Haskell以外の言語に慣れ親しんでいる人は,プログラムがIOモナドに閉じこめられてしまうHaskellのプログラミングを窮屈に感じるかもしれません。そうした人にとって,unsafePerformIOはこの窮屈さを取り除いてくれる頼もしい味方に見えるでしょう。しかし,unsafePerformIOを気軽に使用すべきではありません。unsafePerformIOは,決してHaskell初心者が期待するようには動作しないからです。
今回は,unsafePerformIOとは一体何なのかを掘り下げて考えてみたいと思います。GHCのunsafePerformIOの実装を例に取り,「unsafePerformIOが実際にはどのように振る舞うか」「unsafePerformIOの使用が問題を起こさないのはどのような場合か」といったことを説明します。
最適化によって振る舞いが変わる
なぜunsafePerformIOは危険なのでしょうか? 第29回では,unsafePerformIOが危険な理由について非同期処理の観点から説明しました。ほかに,より本質的な問題も存在します。「unsafePerformIOの振る舞いは,最適化によって大きく変わる可能性がある」という点です。最適化によって振る舞いが異なるということは,「一度書いたプログラムが,最適化オプションの変更や処理系のバージョンアップといったソースコードの外部の要因によって,期待通りに動作しなくなる可能性がある」ことを意味します。
例を見てみましょう。
module UnsafeIO where import System.IO.Unsafe unsafeVal x = unsafePerformIO $ do print x return x main = print $ (unsafeVal 10) * (unsafeVal 10)
unsafeValは,引数として受け取った値を表示し,それから値をそのまま返す関数です。この関数を使用したプログラムでは,当然,unsafeValを使用したのと同じ回数分の文字列が表示されることを期待するでしょう。このプログラムでは,main変数でunsafeValを2回使用しているため,10という値が2回表示されるはずです。
試してみましょう。最適化オプションを指定しない場合,プログラムはそうした期待通りに動作します。
$ ghc --make -main-is UnsafeIO UnsafeIO.hs [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 10 100
次に,最適化オプション「-O」を使用してプログラムを最適化してみましょう。
$ ghc --make -main-is UnsafeIO UnsafeIO.hs -O [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 100
期待に反して,10は1回しか表示されなくなります。これは,GHCの最適化機能の一つである「共通部分式の削除(CSE:Common Subexpression Elimination)」によって,共通する式「unsafeVal 10」がメモ化されたためです。これにより「unsafeVal 10」は1回しか評価・実行されなくなってしまいます。
一方,unsafeValに異なる値を与えた場合には,共通部分式ではなくなるので,それぞれが別々に評価・実行されます。
main = print $ (unsafeVal 10) * (unsafeVal 11)
$ ghc --make -main-is UnsafeIO UnsafeIO.hs -O [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 11 110
このような「同じ値を与えるかそうでないかで動作が異なる」という振る舞いは,プログラマが意図したものではないでしょう。こうした振る舞いの違いを回避するには,共通部分式の削除を無効にする必要があります。GHCには共通部分式の削除を無効にするためのオプション「-fno-cse」が用意されています。これを使えば当初の意図通りの動作を実現できます(参考リンク)。
$ ghc --make -main-is UnsafeIO UnsafeIO.hs -O -fno-cse [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 10 100
10が2回表示されているのがわかりますね。
もう一つ例を見てみましょう。今度は,「unsafeVal 10」という式を陽に共有するために,unsafeGetVal10という変数を定義して使用します。
unsafeGetVal10 = unsafeVal 10 main = print $ unsafeGetVal10 * unsafeGetVal10
このプログラムでは,先ほどの例とは逆に,10が1回だけ表示されることを期待するでしょう。たしかに「最適化なし」「-Oオプションによる最適化」「-fno-cseオプションによる共通部分式削除の無効化」のいずれの場合でも,このプログラムは期待通りに動作します。Haskellには第8回で説明した「メモ化」という機能があるため,同じ式が複数回,評価・実行されることはありません。
$ ghc --make -main-is UnsafeIO UnsafeIO.hs [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 100 $ ghc --make -main-is UnsafeIO UnsafeIO.hs -O [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 100 $ ghc --make -main-is UnsafeIO UnsafeIO.hs -O -fno-cse [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 100
しかし,最適化によってunsafeGetVal10の呼び出しがインライン化されると,期待通りに動作しない可能性があります。unsafeGetVal10変数がインライン化によって元の式「unsafeVal 10」に展開されることで,共通部分式の削除を行わない場合には,二つの「unsafeVal 10」が別々に評価・実行されてしまいます。
実際に試してみましょう。GHCはINLINE指示文に対応しているため,インライン化すべき関数をプログラマが指定できます(参考リンク1,参考リンク2)。これを使ってunsafeGetVal10を明示的にインライン化してみましょう。
{-# INLINE unsafeGetVal10 #-} unsafeGetVal10 = unsafeVal 10 main = print $ unsafeGetVal10 * unsafeGetVal10
$ ghc --make -main-is UnsafeIO UnsafeIO.hs -O -fno-cse [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 10 100
10が2回表示されてしまいました。-fno-cseによる共通部分式削除の無効化とインライン化が組み合わされることで,unsafeGetVal10内の式「unsafeVal 10」が別々に評価・実行されたのです。
ここではインライン指示文を使用しましたが,インライン化が行われるのはこのように明示的に指定した場合だけではありません。最適化の過程で,処理系がソースコードの中からインライン化すべき部分を検出し,暗黙のうちにインライン化することもあります。インライン化によるこうした問題を防ぐには,NOINLINE指示文を使用してインライン化が行われないことを保障する必要があります。
{-# NOINLINE unsafeGetVal10' #-} unsafeGetVal10' = unsafeVal 10 main = print $ unsafeGetVal10' * unsafeGetVal10'
$ ghc --make -main-is UnsafeIO UnsafeIO.hs -O -fno-cse [1 of 1] Compiling UnsafeIO ( UnsafeIO.hs, UnsafeIO.o ) Linking UnsafeIO.exe ... $ ./UnsafeIO 10 100
インライン化を無効にすることで,同じ式が再度,評価・実行されなくなっていることがわかります。
このように,unsafePerformIOの振る舞いは最適化によって変わります。プログラムを期待通りに動作させるには,処理系がどのような最適化を行うかを常に把握しておく必要があります。また,unsafePerformIOの振る舞いの問題を解決しようと最適化を制御することで,unsafePerformIOには関係ない他の部分のコードの処理速度が左右されてしまう可能性もあります。unsafePerformIOは気軽に使えるように見えますが,意図した通りの動作を実現するのは,それほど簡単ではありません。