この記事は、Haskell Advent Calendar 2011 25日目の記事として書かれました。

概要

Haskell、あるいはその他のプログラミング言語では 「部分関数(Partial Function)」 と呼ばれるものが標準ライブラリに存在したり、 定義したりすることができます。 今回はそれらが有害であるという考えと、 代替の紹介をしようと思います。

部分関数とは

部分関数(Partial Function)とは、 集合の言葉で言うと、

定義域(domain)の要素に対して、値域(range)の値が高々1つ対応付けられる

ような対応付けのことです。 Haskellでは 「結果の値が引数によっては定義されないことがあり得る」 関数だと言えます。

例えば、整数の割り算を行う関数 div :: (Int, Int) -> Int は、 (1, 0) に対しては定義されません。

Haskellでは部分関数を定義するのに、幾つかの方法があります。 1つ目は、定義されないことを明示するために、undefinedを用いるものです (コードがuncurry化されていることには特に意味はありません)。

div' :: (Int, Int) -> Int
div' (a, b)
  | b /= 0 = a `div` b
  | otherwise = undefined
ghci> div' (1, 0)
*** Exception: Prelude.undefined

undefind の代わりに、errorthrow を用いることも出来ます。

もう1つはパターンマッチを網羅しないものです。

data Hoge = Foo | Bar

test :: Hoge -> Int
test Foo = 0
ghci> test Bar
*** Exception: test.hs:4:1-12: Non-exhaustive patterns in function Main.test

対応するパターンが無い旨がエラーとして表示されます。

部分関数はなぜ良くないのか?

部分関数が良くない理由はただ1つ、

部分関数での未定義をエラーとして補足することが非常に困難

だからです。

実際に XMonad1 のようなクラッシュすることが許されない類のプロダクトでは、 部分関数を排除するスタイルのコーディングをルールにしているものもあります2

なぜ困難なのか?

部分関数の未定義を捕捉するのが困難なのには幾つかの理由があります。

  • 型として現れない

ある関数が部分関数なのか、あるいはそうでないのか、 Haskellでは型の部分に現れません。 つまり部分関数のエラーの捕捉に関しては、 型システムのチェックを利用できないということです。

  • Haskellでの未定義の扱い

Haskellでの未定義は実際には例外によって実現されています。 Haskellでは、throw関数によってpureなコードから 例外を投げることができます。

pureなコードで例外を投げることの意味についてはここでは 詳しく述べませんが、少なくとも幾つかの難しい部分があります。

1つ目は、例外を拾うことができるのはIOモナドの中だけだということ。 2つ目は、pureなコンテクスト下では、例外の発生も遅延されるということ。

簡単な例を見てみましょう。

getNums :: IO [Int]
getNums = do
  line <- getLine
  return $ map read $ words lines

main :: IO ()
main = do
  nums <- getNums `catch` \(SomeException _) -> return []
  print $ sum nums

getNums は標準入力から1行入力してスペース区切りの整数のリストを読み込みます。 整数として正しくない文字列が入力された場合、例外が発生します。 main では getNums を呼び出し、受け取った数の合計を表示します。 getNumsが例外を発生させた場合は、空リストを受け取ったことにします。 つまり不正な入力が来た場合、表示されるものは0になるはずです。

$ runhaskell Test.hs
1 2 3
6
$ runhaskell Test.hs
hello, world!
Test.hs: Prelude.read: no parse

1つ目の実行結果は正しく動いているようです。 問題は2つ目です。さて、望ましくない挙動です。 どうしてこうなるのか。これはまさしく例外の発生が遅延されているからです。 getNums 呼び出しの時点では例外は発生せず、 実際にその値が必要とされるところ、この場合だと print $ sum nums で発生します。 つまり発生場所に catch を記述して例外を捕捉しようとしてもそれをするりとすり抜け、 全く別の場所で例外を起こしてしまうのです。 実際に値がどこで使われるのか、 はたまた今使っているものが例外を起こしうるのか? 例外をpureに投げることのできるHaskellでは知るすべはありません。

これを何とかするためには、 例外を拾う場所で評価を行い、 例外を起こさせるようにする方法があります。 Control.Exception モジュールにそのための関数、 evaluate という関数があります。

main :: IO ()
main = do
  nums <- do
    n <- getNums
    evaluate n
    `catch` \(SomeException _) -> return []
  print $ sum nums

しかし、残念ながらこれもうまくは動きません。 というのも、evaluateはWHNF(Weak Head Normal Form, 弱頭部正規形)までしか評価しない、つまりリストの中身までは評価しないからです。 なぜそのような動作になっているのかというと、Haskellの標準的な機能では データ構造をWHNFまでしか簡約できないからです。

deepseqパッケージにある NFDataクラスは、 NF(Normal Form, 正規形)までの簡約を行うインターフェースを提供しています。 これを利用することによって、先程の例は正しく動作するようになります。

main :: IO ()
main = do
  nums <- do
    n <- getNums
    return $!! n
    `catch` \(SomeException _) -> return []
  print $ sum nums
$ runhaskell Test.hs
hello, world!
0

しかし、プログラムのすべての場所についてこのような注意を払うのは大変困難ですし、 すべての評価したい値が NFData クラスのインスタンスにできるとも限りません。 その最たるものが関数です。

  • 出力がプアー

エラー時のメッセージが、先程の例の

Test.hs: Prelude.read: no parse

のように、非常に質素なものです。 これに加え、Haskellではスタックトレース、 すなわちこの例外が生成された場所を知る術がありません。 それは、実際には例外が作られた場所と評価された場所が異なることに起因するもので、 今のところ簡単な解決方法はありません。 大きなプログラムで突然何の情報もなくエラーで落ちる。 そしてどこが原因か全くわからない。 デバッグは非常に困難です。

ところでこれはいろいろ問題だと認識されていて、 Haskellにスタックトレースを実装して もうすこしまともな情報を得られるようにしようという試みは いろいろなされていて3、 近い将来GHCに実装されるのではないかと思われます。

身の回りの部分関数

エラーとして部分関数を用いている関数が、 身近なところにも実はとてもたくさんあります。

Preludeだけでも、

  • head / tail / init / last
  • foldl1 / foldr1 / maximum / minimum
  • Enumクラスのメソッド全般
  • read

などなど、非常によく使うものが含まれています。 Preludeは一歩間違えれば地雷に足を突っ込んでしまう 超危険地帯なのです。

部分関数ではないもの

一方、エラーをIOモナドの例外として送出するものは 部分関数ではありません。

Haskellの例外には throw によってpureに送出されるものと throwIOによってIOモナドとして送出されるものがありますが、 throwIOの方は、IOモナドのコンテクストにて正しくハンドリングされますので、 部分関数ではありません。

例えば、getLineIO String の型を持ちますが、 これはIOのエラーとして例外が投げられます。

部分関数の除去

さて、あなたのプログラムに潜む地雷を撤去したくなったでしょうか? そのための幾つかのオプションがあります。

既存の部分関数を使うのを避ける

先ほど挙げたPreludeの関数、及び他の標準的な部分関数を使うのを避けましょう。 ある関数が部分関数かどうか見分けるのは、型から判断することはできませんので ドキュメントに頼ることになるでしょう。 しかし逆の、部分関数ではない関数を見分ける方法ならいくつかあります。

  • IOモナドである

throwIO によってエラーが通知される可能性が高いです

  • 返り値がMaybeあるいはEitherである

あえて部分関数にしている可能性は非常に低いです。

  • その他何らかのエラーモナドになっている

MonadError あるいは Failure など、どういうエラーでどういうモナドか明示されている場合、 それによってエラーが通知される可能性が非常に高いです。

このようなエラーの通知手段が明示されている関数を好んで利用するのは 良い習慣です。

標準ライブラリの部分関数の利用を避けるため、 それらの非部分関数(total function、もしくは単に関数)版が用意されているパッケージ、 例えば safeパッケージ のようなものがあります。 こういったものを用いるのも良い代替でしょう。

また標準ライブラリの部分関数には、 IOモナド版が用意されているものもあります。 例えば read :: Read a => String -> a に対して readIO :: Read a => String -> IO a というものがあります。 返り値にIOが付いただけですが、これが非常に重要なことです。 というのも、IOが付くことによって正しいエラー伝達手段が用意されたということ になるからです。readIO を用いると、 先の例はとてもシンプルに書きなおすことができます。

getNums :: IO [Int]
getNums = do
  line <- getLine
  mapM readIO $ words lines

main :: IO ()
main = do
  nums <- getNums `catch` \(SomeException _) -> return []
  print $ sum nums
$ runhaskell Test.hs
hoge--- moge---
0

このようにIO版が用意されている場合、 それを用いるのは有力な選択肢となります。

部分関数を作るのを避ける

部分関数を利用するのを避けたなら、 作るのも避けなければなりません。

  • パターンマッチ漏れを防ぐ

とても単純なことですが、 パターンマッチ漏れを防ぐことは重要です。 パターンマッチに漏れがあるかどうかは GHCの場合コンパイラが自動でチェックして 警告を出してくれるので、 それらを無視せずにきちんと修正しましょう。

あるいはパターンマッチを使わずに済むなら、 なるべく使わないようにするというのも 考慮すべきオプションでしょう。

  • 部分関数にする代わりにIOモナドにする

throwerror そして undefined は部分関数を構築するプリミティブです。 これらを使わないようにしましょう。 単純にそれらを throwIO で置き換えれば問題は解決です。

  • 部分関数にする代わりに MaybeEither あるいはその他のエラー伝達手段を用いる

もともとIOモナドの一部ならthrowIOを用いるのに特に問題はありませんが、 純粋な計算ではIOが混入することは嫌われるかもしれません。 その場合にはMaybeEitherを使うのが良いでしょう。 または MonadErrorあるいはFailureで ポリモーフィックにエラー通知をするのがスマートです。

遅延IOの除去

さて最後にもう一つ、厄介なものを紹介します。 部分関数ではないものの、部分関数と同様に扱いにくいもの、遅延IOです。 遅延IOでよく用いられるものに getContents などがあります。 getContentsは標準入力からの遅延入力を行う関数です。 入力は必要とされるまで実際には行われません。 実際に入力が行われるときにエラーが発生すると、 その入力を処理していたpureなコンテクストで例外が発生します。 部分関数と同様にこれは非常に捕捉しにくいものになります。

あるいは、エラーを起こす可能性のあるIOモナドを unsafeInterleaveIOまたはunsafePerformaIOすることによっても 同様の現象が発生します。

(結局のところ、pureなコンテクストで例外を発生させるということが良くないのです)

Lazy I/O Considered Harmful なる記事も書かれており、エラーハンドリングの困難さはその問題の1つとして挙げられています。

しかし困ったことに遅延IOというものは非常に便利な側面もあり、 堅牢なコードを書くためにこれを避けるのはもったいないものです。

そこで、遅延IOを避けつつ、遅延IOがもたらす利益を享受しようと設計されたのが いわゆる Iteratee(もしくはEnumerator) と呼ばれるものです。

Iteraete に関しては、日本語を含むたくさんの解説記事 4 5 6 7 がありますので、 ぜひそちらをご参照ください。

Iteratee 周りの動きは非常に活発で、 すでに多くのライブラリがこのインターフェースに基づいた実装を行ったり、 最近になって Conduitという 設計を見なおして使いやすくした代替ライブラリなどが出現しています。

まとめ

部分関数を用いるのは堅牢なプログラムを書く上で望ましくないという 話をしてきました。 実際のところ、エラーの発生する関数を部分関数にしてしまうのはとてもお手軽なので、 書き捨てのプログラム等では無理に除去する必要はないでしょう。 しかし、長時間動作する必要のあるサーバのようなプログラム、 あるいは汎用的に用いられるライブラリなどは 必ず代替手段を用意しておくべきです。

堅牢なプログラムのためには、 部分関数を除去し正しくエラーを通知して、 それから正しくエラーを処理する必要があります。 そのために、monad-controlというパッケージが非常に有用なのですが、それは又の機会に。

あなたのHaskellプログラムが正しく動き続けますように。 Happy Haskelling and Happy Holidays!