非ボックス化型(unboxed type)

 モナドによって複数のI/Oアクションがきちんと順序づけられるとして,I/Oアクションの呼び出し自体はどうなっているのでしょうか? Haskellでの値が遅延評価されるのに対し,Cの標準ライブラリやシステムコール(system call)などのAPIは,遅延評価を行わない一般的な言語から呼び出すことを前提に提供されています。そのままではI/OアクションをHaskellから利用することはできません。

 実はHaskellの値は,遅延評価を可能にするために,マシン表現(ビット・パターン(bit-pattern),非ボックス化型)を直接扱うのではなく,「未評価の中断(suspension)状態,あるいはデータ構成子に格納された値のマシン表現のいずれか」を表現するヒープ上のオブジェクト(heap-allocated object)へのポインタ(ボックス化(boxed)された型)として扱われるようになっています。だとすれば,I/Oアクションの呼び出しを行う前にHaskellの値を評価し,APIにマシン表現を渡してI/Oアクションを実行し,返値のビット・パターンを改めて(IO型というコンテナに包んだ)Haskellの値として格納すればよいのではないでしょうか?

 これができれば,あとは実行環境で外のAPIを呼び出すようにすることで,I/Oアクションを実現できます。以下のコードは,現在の標準Haskellの補遺(Addenda,追補)という形で定義されている「FFI(Foreign Function Interface,外部関数インターフェイス)」を使用した例です(次期標準のHaskell'では,正式に仕様の一部に取り込まれる予定です。参考リンク)。

foreign import ccall "srand" srand :: Int -> IO ()
foreign import ccall "rand" rand :: IO Int

 foreign宣言で使われている「import」は外部関数をインポートして使用すること,「ccall」はCの関数であることを示しています。""内の関数名が外部から取り込む関数の名前,右側の関数名及び「型シグネチャ(type signature,型指標)」がHaskellで使用するときの名前と型です。

 今回はFFIを解説するのが目的ではないので,詳細は特に説明しません。だいたいの雰囲気と,副作用のある関数(I/Oアクションになる関数)を呼び出す場合には必ずIO型にくるまなければならないという規約があることを理解すれば,それで十分です。

 と,ここまでHaskellでI/Oアクションを実現するための方法について説明してきましたが,上のコードからもわかる通り,実際にはそのような処理はすべて処理系が裏方でやってくれます。普段はこのような詳細な処理を気にする必要はありません。

 とはいえ,本当にこうした理屈通りに実装されているのかどうか気になる方もいるでしょう。GHCには,最適化を助けるために,非ボックス化型にアクセスするための拡張機能が用意されています。そこで,この拡張機能を使って実際の実装を確かめてみましょう(参考リンク)。

 まずは,Intなどの定義済みの型を:infoコマンドで見てみましょう。非ボックス化型を扱えるGHCではどのように定義されているかを調べるためです。

Prelude> :i Int
data Int = GHC.Base.I# GHC.Prim.Int#    -- <wired into compiler>
instance Bounded Int -- Defined in GHC.Enum
instance Enum Int -- Defined in GHC.Enum
instance Eq Int -- Defined in GHC.Base
instance Integral Int -- Defined in GHC.Real
instance Num Int -- Defined in GHC.Num
instance Ord Int -- Defined in GHC.Base
instance Read Int -- Defined in GHC.Read
instance Real Int -- Defined in GHC.Real
instance Show Int -- Defined in GHC.Show
Prelude> :i Char
data Char = GHC.Base.C# GHC.Prim.Char#  -- <wired into compiler>
instance Bounded Char -- Defined in GHC.Enum
instance Enum Char -- Defined in GHC.Enum
instance Eq Char -- Defined in GHC.Base
instance Ord Char -- Defined in GHC.Base
instance Read Char -- Defined in GHC.Read
instance Show Char -- Defined in GHC.Show
Prelude> :i Integer
data Integer
  = GHC.Num.S# GHC.Prim.Int#
  | GHC.Num.J# GHC.Prim.Int# GHC.Prim.ByteArray#
        -- Defined in GHC.Num
instance Enum Integer -- Defined in GHC.Num
instance Eq Integer -- Defined in GHC.Num
instance Integral Integer -- Defined in GHC.Real
instance Num Integer -- Defined in GHC.Num
instance Ord Integer -- Defined in GHC.Num
instance Read Integer -- Defined in GHC.Read
instance Real Integer -- Defined in GHC.Real
instance Show Integer -- Defined in GHC.Num

 State#と同じように,組み込みの型(GHC.Prim)として定義された,最後に#が付く名前の型があるのがわかりますね。これが非ボックス化型です。I#などのデータ構成子の名前の最後にも#が付いていますが,Intなどのデータ型が非ボックス化型でないことからわかるように,最後に#が付いていても,こうしたデータ構成子自体は非ボックス化型ではありません。非ボックス化型を対象とする関数やデータ構成子は,その事実を明示するため,慣習的に名前の最後に#を付けるのだと理解すればよいでしょう(ただし,IOやSTの定義に使われているState#や(# e_1, ..., e_n #)という形の「非ボックス化タプル(unboxed tuple)」は,不要なヒープ確保を防ぐという例外的な役割を持っています。参考リンク)。

 次に,Int#についての情報を見てみましょう。

Prelude> :m GHC.Base
Prelude GHC.Base> :i Int#
<interactive>:1:3: parse error on input `#'

Prelude GHC.Base> :k Int#
<interactive>:1:4: parse error (possibly incorrect indentation)

 parse errorと出ました。あまり良いエラー・メッセージではないため,何が起こったのかすぐにはわからないと思います。要するに,現在のGHCはHaskell98モードで動いているため拡張機能である非ボックス化型を使用することはできない,ということです。非ボックス化型の場合,#を名前に使用できるようにするためにパーサー(parser,構文解析器)自体を拡張する必要があるので,このような形でエラーが出てしまうのです。

 このエラーを回避するには,-fglasgow-extsオプションを使って拡張機能を有効にする必要があります。拡張機能を有効にした状態で起動するか,:setコマンドを使ってオプションを有効にしてください。

Prelude GHC.Base> :s -fglasgow-exts
Prelude GHC.Base> :i Int#
data Int#       -- <wired into compiler>
Prelude GHC.Base> :k Int#
Int# :: #
Prelude GHC.Base> :k Int
Int :: *

 Int#はボックス化された通常の型とは種が違うため,型クラスのインスタンスが存在しないのがわかります。

 それでは,いよいよ非ボックス化型を使ってみることにしましょう。数字などのリテラルは型クラスで定義されているため,パターン照合を使ってデータ型から値を取り出してやる必要があります。+#は,見ての通り,Int#を対象にした加算演算子です。

Prelude GHC.Base> case 23 of I# x -> case 24 of I# y -> I# (x +# y)
47
Prelude GHC.Base> case ([0..]!!23) of I# x -> case 24 of I# y -> I# (x +# y)
47
Prelude GHC.Base> :t case 23 of I# x -> case 24 of I# y -> I# (x +# y)
case 23 of I# x -> case 24 of I# y -> I# (x +# y) :: Int
Prelude GHC.Base> :t case 23 of I# x -> case 24 of I# y -> x +# y
case 23 of I# x -> case 24 of I# y -> x +# y :: Int#

 I/Oアクションの呼び出しを実現する方法として説明したのと同じように,「ボックス化された型を評価した値から,非ボックス化された値を取り出し,関数を適用した後,再びボックス化された値を返す」という形になっていることがわかりますね。

遅延I/O

 ここまでで,I/Oアクションを順番に評価・実行するという一つ目の目的は達成できました。しかし,一つ問題があります。IOモナドは,通常,各I/Oアクションを呼ばれたら値を直ちに評価する「正格(strict,遅延評価にならないこと)」な関数の値として直列化(serialize)するため,IOモナドによって合成されたI/Oアクションは一枚岩(monolithic,モノリシック)になります。その結果,例えば数百MBから数GBの巨大なファイルがある場合,最初の数KBだけしか必要ではない場合でも,ファイル全体を読み込むことになってしまいます。

 これは,遅延評価という言葉から一般的に連想されるイメージとは異なります。「遅延評価のもとで行われるI/O」という言葉からイメージされるのは,「最初は必要な数KBだけ読み込み,ファイルの内容がさらに必要になったときに初めて必要な分を追加的に読み込む」という性質ではないでしょうか? そのような漸進的(incremental,インクリメンタル)に呼ばれた分だけ評価・実行するという「非正格(non-strict)」な特徴を保持するI/Oアクションのことを,遅延I/Oと呼びます。遅延I/Oの例としては,PreludeのreadFileやgetContentsがあります(参考リンク)。

 これを可能にするにはどうすればよいでしょうか? 単純に考えて,環境State# RealWorldを直接次のアクションに受け渡さないアクションがあれば,そのアクションは次のアクションに要求されない ― つまり,値が要求されるまで評価・実行されないものになりそうです。ならば,そのようなアクションを作り,IOモナドを使って合成してやれば,必要なときに必要なだけ評価・実行される遅延I/Oを実現できるのではないでしょうか?

 これを行うのが,System.IO.UnsafeモジュールにあるunsafeInterleaveIOです。GHCでのunsafeInterleaveIOの実装は以下の通りです。

unsafeInterleaveIO :: IO a -> IO a
unsafeInterleaveIO (IO m)
  = IO ( \ s -> let
                   r = case m s of (# _, res #) -> res
                in
                (# s, r #))

 unsafeInterleaveIOで行ったアクションの結果発生する環境は捨てられ,前の環境が次のアクションに渡されているのがわかると思います。

 unsafeInterleaveIOを使うと,ファイルを必要なだけ読み込むreadFileは以下のように書くことができます(実際のライブラリでは,もっと複雑な定義が使われています。参考リンク1参考リンク2)。

import System.IO
import System.IO.Unsafe

readFile :: String -> IO String
readFile f = openFile f ReadMode >>= \h ->
             unsafeInterleaveIO (lazyRead h)

lazyRead :: Handle -> IO String
lazyRead h
  = hIsEOF h >>= \b ->
    if b then
       hClose h >>
       return []
    else
       hGetChar h >>= \a ->
       unsafeInterleaveIO (lazyRead h) >>= \as ->
       return (a:as)

 ところで,unsafeInterleaveIOにunsafeという接頭辞(prefix)が付いているのはなぜでしょうか? それは,この関数を使って行う操作は安全でない可能性があるためです。もともとIOモナドを導入したのは,処理を順番通りに行わせたかったからでした。ところが遅延I/Oは必要なときに処理を行うという性質上,そうした順番の制御から外れてしまいます。この結果,通常のI/Oと遅延I/Oのアクションが衝突(conflict)し干渉しあってしまうことがあるのです。その場合,ユーザーが何らかの方法で評価・実行順序を制御しない限り,プログラムを期待通りに動作させることはできません(参考リンク1参考リンク2)。なので,unsafeInterleaveIOを使って遅延I/Oを作る場合には,できるだけそういった衝突が生じないよう,または生じてもあまり致命的な問題にはならないよう,注意を払う必要があります。

命令型スタイルとモナド

 上のreadFileのコード例からもわかる通り,IOモナドを使ったプログラムは一般的な命令型言語(手続き型言語)と同じような形をしています。

 実は,このスタイルを実現するのに,Monadクラスのある性質がかかわっています。第3回でMonadクラスについて説明していたとき,一つ疑問を覚えなかったでしょうか? これまで軽く流してきましたが,FunctorクラスのfmapとMonadクラスの>>=は,flipを使って第1引数と第2引数の順序を入れ替えた形になっています。

Prelude> :i Functor
class Functor (f::* -> *) where fmap :: (a -> b) -> f a -> f b
        -- Imported from GHC.Base
instance Functor []     -- Imported from GHC.Base
instance Functor IO     -- Imported from GHC.IOBase
instance Functor Queue  -- Imported from Data.Queue
instance Functor Tree   -- Imported from Data.Tree
instance Functor Maybe  -- Imported from Data.Maybe
Prelude> :i Monad
class Monad (m::* -> *) where
  (>>=) :: m a -> (a -> m b) -> m b
  (>>) :: m a -> m b -> m b
  return :: a -> m a
  fail :: String -> m a
        -- Imported from GHC.Base
instance Monad []       -- Imported from GHC.Base
instance Monad Maybe    -- Imported from Data.Maybe
instance Monad IO       -- Imported from GHC.IOBase

 この形式は,別にモナドの要請によって決められているものではありません。>>=はfmap+joinなので,(=<<) :: (a -> m b) -> m a -> m bであってもかまわないはずです(実際,Preludeには,=<<という関数も用意されています。参考リンク)。

 では,この引数の入れ替えによって何が得られるのでしょうか? その答えがまさに「IOモナドを使ったアクションの生成のような手続き的な性質を持つプログラムを,あたかも命令型言語を使っているかのようなスタイルで記述できる」ということです。

 命令型スタイルでプログラムを書く利点は何でしょうか? ある種のプログラムは,関数型スタイルよりも命令的なスタイルを使って書いたほうが素直に表せます。例えば,GUIやOpenGLなどの状態を持つAPIの呼び出しに対しては命令型スタイルのほうが素直にコードを記述できるでしょう。

 だからといって,そうしたプログラムが関数型スタイルになじまないと悲観する必要はありません。モナドの強力さは,計算の合成性(compositionality)を尊重しているところにあります。モナドを利用することで,任意の制御構造を高階関数という形で新たに導入できるのです。Haskellでよく行われる正統(orthodox,オーソドックス)なやり方は,関数型スタイルでコンテナを作成するための計算を行い,モナドによってそれを解釈して命令型スタイルの評価・実行に変換するというものです。Preludeのsequense*やmapM*(参考リンク),Graphics.SOE(参考リンク),STM(Software Transactional Memory)モナド(参考リンク)などがこうした形になっています。

 今回はこれらの中から,リストを命令型スタイルで順に評価・実行するsequense*とmapM*を見ることにしましょう。標準Haskellでは,sequense*とmapM*は以下のように定義されています。

sequence    :: Monad m => [m a] -> m [a] 
sequence    =  foldr mcons (return [])
                 where mcons p q = p >>= \x -> q >>= \y -> return (x:y)

sequence_   :: Monad m => [m a] -> m () 
sequence_   =  foldr (>>) (return ())

mapM        :: Monad m => (a -> m b) -> [a] -> m [b]
mapM f as   =  sequence (map f as)

mapM_       :: Monad m => (a -> m b) -> [a] -> m ()
mapM_ f as  =  sequence_ (map f as)

 sequenceの定義に使われているfoldrの定義は,以下の通りです。

foldr            :: (a -> b -> b) -> b -> [a] -> b
foldr f z []     =  z
foldr f z (x:xs) =  f x (foldr f z xs)

 すぐには意味が取れないかもしれませんね。何段階か再帰を展開してみましょう。

   foldr f z (x:xs)              =  f x (foldr f z xs)
=> foldr f z (x1:x2:xs)          =  f x1 (f x2 (foldr f z xs))
=> foldr f z (x1:x2:x3:xs)       =  f x1 (f x2 (f x3 (foldr f z xs)))
=> foldr f z (x1:x2:x3......:xs) =  f x1 (f x2 (f x3 ... (foldr f z xs) ... ))

 このように,foldrはリストの値に関数fを再帰的に適用し,リストを別の値に変えるという役割を持っています。このような関数を「畳み込み演算子(fold operator)」や「畳み込み関数(fold function)」と呼びます(あるいは,「折り紙関数」などと呼んでもかまわないかもしれません。参考リンク1参考リンク2)。適用する関数fの内容にもよりますが,多くの場合,foldrはリストのすべての要素を要求する正格な性質を持ちます(もちろん,foldrの内部で行われる計算は遅延評価になっていてもかまいません)。

 foldrがわかれば,sequense*やmapM*がやっていることも自然にわかるでしょう。

 sequenceは,リストになっているそれぞれのコンテナを,前から順に一つの大きなコンテナ(=計算)へと合成します。このとき,初期値として与えたreturn [ ]を使って結果をリストとして返すのがsequence,return ()によって単に評価されたという結果だけを伝えるのがsequence_です(値を返さないバージョンであることを表現するために“_”という接尾辞(suffix)を付ける,というのは大事な慣習なので,覚えておいてください)。

 これだけと何のためにあるのか今ひとつわからないかもしれません。計算の生成にListモナドやリストの内包表記を使ったり,あらかじめ別のところで作っておいた計算をリスト操作の関数を使って任意の形で合成できると考えれば,その強力さがわかるのではないでしょうか? このような使い方を可能にするために,IO (IO a)の内部のIOは実行されないというIOモナドの性質が重要になってきます。

 また,mapM*の定義を見れば,mapを使って生成した計算をsequence*によって合成しているのがわかりますね。遅延評価によってmapとsequenceで二回操作するという無駄は自動的に省かれます。