NULL安全
(NULL安全というか、型安全の話です)
https://gyazo.com/4a78b954afb2a3387cf9ded7680406e8
「HaskellユーザがMaybeを使うとき、みんな称賛するのに、私が "if (x == null) return null else return x.method()" をつかうと、みんな私を嘲り笑う。」
安易に「Nullableな値はMaybeにしろ」では、型安全かもしれないが、そのハンドリング責務を別の誰かに押し付けてるに過ぎない。そうではなくnullは基本的に伝播させるべきではなく、特にコアドメインはnullが無いことを保証して、防御的プログラミングをする必要性を極力なくせ、という主張。
HaskellのMaybe型は失敗の可能性があるものを扱うものである。
code:maybe
data Maybe a
= Nothing
| Just a
Maybeは失敗する可能性がある関数であることを明示し、使う側がNothingの場合をハンドリングする必要がある。
そうすることでゼロ除算安全な割り算関数を次のように書くことができる。
code:safeDivide.hs
safeDivide :: Int -> Int -> Maybe Int
safeDivide i 0 = Nothing
safeDivide i j = Just (i div j)
これは失敗の責任を前に押し出すことだ。コードの呼び出し側は好きにIntを与えることができるが、なんらかの条件で失敗する可能性があるので、後からちゃんとハンドリングしてね、ということを伝えるインタフェースである。
この失敗の責任を前に押し出すのではなく、後に押し出すこともできる。責務を前に押し出すことがどんなパラメータでも受け入れ、コードの呼び出し側に失敗の可能性を処理させることだとしたら、責務を後ろに押し出すことは、より厳しいパラメータを受け入れて、失敗できないようにすることになる。
code:safeDivide.hs
safeDivide :: String -> String -> Maybe Int
safeDivide iStr jStr = do
i <- readMay iStr
j <- readMay jStr
guard (j /= 0)
pure (i div j)
失敗を後ろに追いやる。
code:safeDivide.hs
safeDivide :: Int -> NonZero Int -> Int
safeDivide i (NonZero j) = i div j
これは、呼び出し側にNonZero Intでなくてはならない制約を課している。
波及効果
あるコードが私たちに責務を負わせるとき、私たちには2つの選択肢がある。
1. その責務を処理する
2. その責務を他の人に渡す
ある関数がMaybeを返す場合、呼び出し元の開発者が作る関数もMaybeを返す設計にしがちだ。
また、ある関数がNonEmpty Intを必要とするなら、呼び出し元の開発者が作る関数もNonEmpty Intを引数として受け取る設計にしがちだ。
多くのアイテムを持つ注文を表す型を以下のように設計していた。
code:data.hs
data Order = Order { items :: Item } アイテムには、注文の情報のほとんどすべてが含まれるので、注文で行うほぼ全てのことは、空のリストの場合を処理するために、Maybe型の値を返す必要があった。これは大変な作業で、多くのMaybeが必要になった。
型の許容範囲が広すぎる。実際のところOrderは少なくとも、1つのItemがなければ存在しえない。そこで、この型をもっと制限してみる。
code:data.hs
data Order = Order { items :: NotEmpty Item }
からのリストに関するすべてのMaybeはパージされ、すべてのコードは純粋で自由なものになった。注文が空のリストであるエラーケースを扱う箇所は、2つの場所に移動した。
1. JSONのデコード
2. データベース行のデコード
JSONのデコードはAPI側で行われ、様々なサービスが更新情報をPOSTしてくる。これで、400エラーで応答し、APIクライアントに「無効なデータを提供した」と伝えることができるようになった。
データベースの行をデコードするのはもっと簡単だ。注文とアイテムを取得するのに、INNER JOINを使う。そうすることで、各注文には少なくとも1つのアイテムが結果セットに含まれることが保証される。外部キーは、各Itemの注文がデータベースに実際に存在することを保証する。
型の安全性を高めるためには、それを後ろに追いやる必要があります。最終的には、システムの端まで型の安全性を高めることになります。これにより、システム内部のコードやロジックがすべてシンプルになります。型を活用することで、コードをよりシンプルに、より安全に、よりわかりやすくすることができる。
必要なものだけをもとめる
多くの意味で、型安全性を考慮してコードを設計することは、可能な入力に対してできるだけ厳密になることだ。Haskellは他の多くの言語に比べてこれが容易であるが、文字通りどんなバイナリ値でも受け取り、どんな副作用を伴う処理でも行い、どんなバイナリ値でも返すことができる関数を書くことを妨げるものは何もない。
code:foobar.hs
foobar :: ByteString -> IO ByteString
ByteStringは、全く制限のないデータ型だ。任意のバイト列を格納することができる。任意の値を表すことができるため,実際に何が含まれているかはほとんど保証されず、これを安全に処理する方法は非常に限られている。
過去を制限することで、未来の自由を得ることができる。
型駆動設計の本質
可能性の領域
code:foo.hs
foo :: Integer -> Void
このfooを実装することができるだろうか? Voidは値を含まない型なので、どんな関数でもVoid型の値を生成できない。もう少し現実的な例を見てみよう。
code:head.hs
この関数はリストの先頭要素を返す。これは実装可能だろうか? それほど複雑なことはしてないが、これを実装しようとするとコンパイラが許してくれない。
code:head.hs
head (x:_) = x
Pattern match(es) are non-exhaustive
In an equation for ‘head’: Patterns not matched: []
このメッセージは、関数が部分的であること、つまり、すべての可能な入力に対して定義されていないことを指摘している。具体的には、入力がからのリストである[] が定義されていない。
部分関数を全体化する
前述のheadは、リストがからの場合に返す要素がないため、部分的なものになる。幸いなことに、このジレンマには簡単な解決策がある。出来る限り要素を返すように務めるが、何も返さない権利も留保しておく。Haskellではこの可能性をMaybe型で表現する。
code:head.hs
aの型を生成できないことが分かったらNothingを返すことが出来る。
code:head.hs
head (x:_) = Just x
head [] = Nothing
問題は解消したようにみえるが、隠されたコストがある。
Maybeを返すのは、headを実装しているときには便利だが、実際使う側の利便性は著しく低下する。headは常にNothingを返す可能性があるので、その処理は呼び出し側に負担がかかり、その責任転嫁が信じられないほどイライラすることがある。
code:main.hs
getConfigurationDirectories :: IO FilePath getConfigurationDirectories = do
configDirsString <- getEnv "CONFIG_DIRS"
let configDirsList = split ',' configDirsString
when (null configDirsList) $
throwIO $ userError "CONFIG_DIRS cannot be empty"
pure configDirsList
main :: IO ()
main = do
configDirs <- getConfigurationDirectories
case head configDirs of
Just cacheDir -> initializeCache cacheDir
Nothing -> error "should never happen; already checked configDirs is non-empty"
getConfigurationDirectoriesが環境からファイルパスのリストを取得するときは、リストがから出ないことを積極的にチェックする。だが、リストの最初の要素を取得するので、mainでheadを使うと、Maybe FilePathの結果は、絶対に発生しないとわかっているNothingのケースを処理する必要がある。これは以下の理由で良くないことだ。
1. 既にリストがから出ないことをチェックしているのに、また冗長なチェックをしてコードを混乱させる。
2. 性能上の問題にもつながる。もっと複雑なシナリオでは、ループの中で冗長なチェクが行われると、そのコストが増大する可能性がある。
3. getConfigurationDirectoriesが変更されて、リストがからであるかどうかをチェックしなくなったら、プログラマはmainの変更を忘れてしまうかもしれないし、突然「ありえない」エラーが発生する元になる。
冗長なチェックが必要になると、本質的に型システムに穴が開く。
実際にはバリデーションではなくパースだ
例えば、キーと値のペアを表すタプルのリストを受け取る関数を書いているときに、リストに重複したキーがある場合にどうすればいいかわからないことに気づいたとする。1つの解決策は、リストに重複したキーがないことを保証する関数を書くことだ。
code:checkNoDuplicateKeys.hs
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => (k, v) -> m () だが、このチェックは脆弱だ。戻り値が使われないので、いつでも省略することができる。より良い解決策は、Mapのように構造上重複したキーを許さないデータ構造を選択することだ。関数の型シグネチャを調整して、タプルのリストの代わりにMapを受け入れるようにする。
そうすると、新しい関数の呼び出し元では、まだタプルのリストが渡されているので、型チェックに失敗する可能性が高くなる。
code:checkNoDuplicateKeys.hs
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => (k, v) -> m (Map k v)