純粋な振りして例外投げる関数滅べばいいのに
部分関数ダメゼッタイ
お行儀の悪い関数
Haskellにはいくつか「お行儀の悪い関数」があります。ここで言う「お行儀の悪い」とはIOが付いているわけでもないのに例外を投げる関数のことです。数学やhaskellでは「部分関数(partial functions)」と呼ばれているようです。今回はその「お行儀の悪い関数」をどう扱うかという話をします。はっきりと言ってバッドノウハウの類いの話です。
そもそもなぜ部分関数が問題であるのかはPartial Function Considered Harmful - 純粋関数空間で詳しく説明されているのでこちらも併せて読むとより良いと思います(丸投げ
haskellにおける部分関数で有名なものにhead
, last
, tail
, init
がありますね。これらの関数は空リストを渡すと例外を投げます。div
は第二引数に0
を渡すと、また!!
はリストの長さ以上のインデックスを指定すると例外を投げます。あとはread
やfold(l|r)1
などなど。
また、undefined
やerror
や網羅的ではないパターンマッチを使った関数も部分関数になるでしょう。
> head []
> last []
> tail []
> init []
-- *** Exception: Prelude.head: empty list
> 10 `div` 0
-- *** Exception: divide by zero
> [1] !! 1
-- *** Exception: Prelude.!!: index too large
unknown = undefined
> unknown
-- *** Exception: Prelude.undefined
data Piyo = Foo | Bar
partial :: Piyo -> Int
partial Foo = 1
> partial Bar
-- *** Exception: Non-exhaustive patterns in function
このような関数は型を見ても部分関数であるか判断できず、突然例外を投げるため非常にやっかいです。
対策
例外を投げる値を弾く関数でラップする
一番シンプルで、おそらくベストな方法は例外を投げる値かどうかを判断する方法です。
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead xs = Just . head $ xs
すでにいくつかの関数ではsafeに例外を投げないような関数が定義されているのでこちらを使うと良いでしょう。
IOで包む
問題はここからです。
どのような値で例外を投げるか知ることができれば上記のような方法で解決できますが、何らかの理由でどのような値で例外を投げるか知ることができなかったり、あるいは例外を投げる条件が非常にややこしかったりするとどうしましょう。
今回はhead
がそのような面倒な関数だとします。
こういう時はdeepseq + return
を使うか、evaluate
を使ってIO
で包むとControl.Exception
のcatch
によってキャッチすることができるようになります。
import Control.Exception
bomb :: Int
bomb = head [] --WHNFの時点で例外
deepseq bomb (Just `fmap` return bomb) `catch` \(SomeException _) -> return Nothing
-- IO Nothing
Just `fmap` evaluate bomb `catch` \(SomeException _) -> return Nothing
-- IO Nothing
IO
の伝播が問題にならない場合はこれで解決です。
ちなみにevaluate
の例で(evaluate . Just) bomb
とするとJust bomb
の時点で弱頭部正規形(weak head normal-form, WHNF)になるため、例外は捕捉できません。
理由はdeepseq
はNFまで評価します(従ってNFData
のインスタンスである必要があります)が、evaluate
はWHNFまでしか評価しない点です。従ってevaluate
を使う場合は、WHNFまで評価された時点で例外を投げるかどうか気をつける必要があります。
unsafe
上記のようにIO
で包めば例外をキャッチすることができるようになりますが、今度はIO
を引きずり回すことになります。IO
を扱うにはIO
の文脈でなければならないので、それを使う関数にもIO
が伝播していきます。
どうしてもIO
を伝播させたくない場合、最後の手段であるunsafePerformIO
を使えばなんとかなります。部分関数が内部でunsafeを使っていない限りは例外を投げること以外は純粋なはずなので、実装的には変なことは起こらないはず・・・多分・・・きっと・・・。
import Control.Exception
import System.IO.Unsafe
bomb :: Int
bomb = head []
unsafePerformIO $ Just `fmap` evaluate bomb `catch` \(SomeException _) -> return Nothing
-- Nothing
spoon
やっとspoonの登場です。spoonはこのような部分関数&&例外を投げる値を弾けない&&IO
をどうしても伝播させたくない時に使います。
spoon
は内部でdeepseq + return
を、teaspoon
は内部でevaluate
をしてからunsafePerformIO
をしています。
import Control.Spoon
spoon $ head [1]
-- Just 1
spoon $ head []
-- Nothing
前述のようにevaluate
ではWHNFまでしか評価しないので使うときは注意しましょう。
spoonも内部ではunsafePerformIO
を使っているので根本的解決にはなっていませんが、部分関数をどうにかするためという目的が分かる分、直接unsafePerformIO
を使うよりも心理的抵抗は小さいのではないでしょうか。
まとめ
今回は「部分関数をどう扱うか」ということについて考えてみましたが、先ほど書いた通り「事前に入力値をチェックする関数で包む」のがベストというのは変わらないでしょう。
チェックすることが現実的でない時は、もうしょうがないです。「こんな関数作ったやつが悪いんだー」と思いながらunsafe
を使うことも考えます。せめてもの足掻きとして、spoonを使って心の平穏を保ちましょう。決してunsafePerformIO
やspoonを使うことを推奨するわけではありません。「どうにもならない時の最後の手段がちょっとマシになるよ」という思いでこの記事を書きました。バッドノウハウ。
また、このような悲劇を生まないためにも部分関数作るのはかなり強い理由がない限り避けましょう。
参考
Partial Function Considered Harmful - 純粋関数空間
safe: Library of safe (exception free) functions
spoon: Catch errors thrown from pure computations