Haskell でバグの出にくいプログラミング (2)

前回のエントリ では、副作用を分離することで、バグを減らすことができる、という話から、Haskell がそのような副作用の分離に向いているということについて書きました。
今回は、IO 制御の分離について、Haskell のコード 2 つを比較しながら書いてみようと思います。

悪い例

import Data.Char
readIdentifier :: IO Bool
readIdentifier =
do str <- getLine
let identifier = tail $ dropWhile (/= ' ') str
return $ isValidIdentifier identifier
where
isValidIdentifier (c:cs) = isIdHead c && and (map isIdChar cs)
isIdHead c = isAlpha c || c == '_'
isIdChar c = isIdHead c || isDigit c
main =
do res <- readIdentifier
print res

これは、インタプリタを初めから丁寧に 第02回で掲載されたコードを、今回の説明に必要な部分のみを抜き出して Haskell で書き直したコードです。 "int hoge" のような変数定義式から、識別子である "hoge" を取り出し、識別子として正しい文字を使っているかを判定しているコードです (Parserc 使えよ、という尤もなツッコミはいりません。コードをあまり複雑にしたくないなどの理由です。 > Haskeller の方々)。

readIdentifier では、getLine 関数を用いて標準入力から 1 行読み出し、それの識別子部分を取り出し identifier という名前をつけて、それが正しいかを判定した結果を返しています。 main ではその返ってきた結果を標準出力に出力しています。 readIdentifier は 1 行読み出し (IO 制御) を行っているので、その返り値の型 BoolIO が付いています。

この readIdentifier を使う際は、関数使用者が常に、標準入力を破壊的に更新するということに気をつける必要があります。 この関数を使用した後の標準入力を取る処理は影響が無いのか? その前にあった標準入力を使う処理とも大丈夫か? EOF がきたらどうなる? などなど。

良い例

そして、このコードはコード添削という記事で添削を受けています。 この添削されたコードを Haskell で書き換えたものが、以下のコードです。

import Data.Char
readIdentifier :: String -> Bool
readIdentifier str =
isValidIdentifier identity
where
identity = tail $ dropWhile (/= ' ') str
isValidIdentifier (c:cs) = isIdHead c && and (map isIdChar cs)
isIdHead c = isAlpha c || c == '_'
isIdChar c = isIdHead c || isDigit c
main =
do l <- getLine
print $ readIdentifier l

getLine が、readIdentifier の外に出て、main で使われていますね。 そして、readIdentifier の型からは IO が消えています。 その分、引数を関数の外から与える必要がありますが、そうすることで引数を関数に渡す前に加工することができます。 こちらの方が、関数の粒度が細かく、より汎用的ですよね。 重要なことは、IO 制御を内部で行わないので、この関数を使うことで生じる IO に関するエラーは一切無いということです。

まとめ

この IO 制御の分離というのは、熟練したプログラマは自然と体得している場合が多い気がします。 IO 制御をコードのあちこちにばら撒くことでその副作用が後のコードに影響することがある、また汎用的でなくなるのを経験的に知っているからだと考えられます。 ある特定のコードを消すと、コードが動かなくなる (または動き出した) といった話はソフトウェア開発の現場ではよくあることと聞きます。 それはそのコードによる副作用が後のコードに影響を与えたために生じたバグです。 憂うべきは、多くのプログラミング言語は、その関数が副作用を持っているかどうかはドキュメントを見なければ分からない、ということです。

Haskell において、IO 制御をする関数は、IO 制御をする関数からしか呼べないので、IO 制御の分離は、関数の型からできるだけ IO を取り除くようにすることで実現できます (無闇やたらにやれということではありません)。 そのようにすることで、相互作用の範囲を狭めることができ、結果としてバグが起こりにくくなり、かつ起こったとしてもデバッグ範囲を狭めることができるのです。

担当: 齋藤 (静的な型のチェックはとてもいいデバッガだと思う)