Haskell

Haskell IOモナド 超入門

More than 1 year has passed since last update.

Haskellではモナドと呼ばれる部品を組み合わせてプログラムを作ります。今までアクションとして取り扱っていたのはIOモナドというモナドの一種です。IOモナドの仕組みを調べることで、IOモナドを組み合わせることの具体的なイメージを説明します。モナドについての一般論へ進む前の準備を目的としているため、IO以外のモナドや圏論には言及しません。

シリーズの記事です。

  1. Haskell 超入門
  2. Haskell 代数的データ型 超入門
  3. Haskell アクション 超入門
  4. Haskell ラムダ 超入門
  5. Haskell アクションとラムダ 超入門
  6. Haskell IOモナド 超入門 ← この記事
  7. Haskell リストモナド 超入門
  8. Haskell Maybeモナド 超入門
  9. Haskell 状態系モナド 超入門
  10. Haskell モナド変換子 超入門
  11. Haskell 例外処理 超入門
  12. Haskell 構文解析 超入門
  13. 【予定】Haskell 継続モナド 超入門
  14. 【予定】Haskell 型クラス 超入門
  15. 【予定】Haskell モナドとゆかいな仲間たち
  16. 【予定】Haskell Freeモナド 超入門
  17. 【予定】Haskell Operationalモナド 超入門
  18. 【予定】Haskell Effモナド 超入門
  19. 【予定】Haskell アロー 超入門

練習の解答例は別記事に掲載します。

IOモナド

今まで出て来たアクションはIO型です。

dice :: IO Int
dice = getStdRandom $ randomR (1, 6)

このIO型はIOモナドと呼ばれます。今までアクションと呼んでいたものの実体がIOモナドです。

※ 正確にはIOモナドによるアクションはIOアクションと呼びます。

モナドへの取っ掛かりとして、IOモナドの内部構造を見て行きます。

※ IOモナドの実装は処理系依存ですが、今回はGHCを前提とします。

値の取り出し

IOモナドは値を取り出すことができます。挙動にいくつか種類がありますが、復習がてら振り返ります。

m_value.png

単純に値が入っている例です。

main = do
    let a = return 1
    print =<< a
    print =<< a
実行結果
1
1

このようなケースではモナドの中に静的に値が入っているようにイメージできます。

しかし、取り出すたびに値が変わるものもあります。

import System.Random

dice :: IO Int
dice = getStdRandom $ randomR (1, 6)

main = do
    print =<< dice
    print =<< dice
実行結果(毎回異なる)
5
3

このようなケースでは、取り出す際に何か処理が行われているため、単純に値が入っているわけではないということが分かります。

値を取り出す以外の影響もあります。

main = do
    let hello = putStr "hello"
    print =<< hello
    print =<< hello
実行結果
hello()
hello()

取り出されるのは()という意味のない値ですが、取り出す際に文字が表示されるという副作用が発生しています。

内部構造

IOモナドから値を取り出す際の挙動は、IOモナドの中に参照透過性が保証されない特殊な関数が隠されていることで実現されています。

mfunc.png

※ 通常はIOモナド内の関数は隠されています。

関数の取り出し

IOモナドから隠された関数を取り出してみます。

※ ここで行うのは通常のプログラミングでは一切行わない特殊な処理です。IOモナドの仕組みを説明することが目的で、実用に供することは意図していません。

GHC.Base.unIO関数によりIOモナドの中に隠された関数を取り出せます。

unIO.png

import GHC.Base

hello1 = unIO $ return 1

ここで注意しないといけないのは、取り出したのは1という値を返す関数で、1というではないということです。

※ 取り出した関数は非常に特殊な仕様のため、簡単には呼び出せません。詳細は後述します。

関数の格納

取り出した関数をIOの中に入れれば、IOモナドを再構築できます。

IO.png

hello2 = IO hello1

IOはコンストラクタ(構築子)です。今回の範囲では型を構築する特殊な関数だと捉えれば十分です。詳細はHaskell 代数的データ型 超入門で説明します。

関数と値

関数と値で出し入れの方法をまとめました。

IO_funcs.png

※ 図中の矢印の向きは<-に合わせました。

種類 取り出し 格納
関数 unIO IO
<- return

ここで注目するのはreturnです。コンストラクタIOを直接使うと、通常は存在を意識する必要のない内部関数が表に出てしまいます。returnにより指定された値を返す関数を自動的に作ることで、IOモナドの内部構造を意識させずに利用できるようにしています。オブジェクト指向でのファクトリメソッドに近い考え方です。

関数や値の出し入れを行う例です。

import GHC.Base

main = do
    let m  = return 1
    print =<< m

    let f  = unIO m
    let m1 = IO f
    print =<< m1

    v <- m
    let m2 = return v
    print =<< m2
実行結果
1
1
1

この例では既存のモナドから関数や値を抜き出して、再度入れることでIOモナドを再構築しています。ただ出し入れしただけなので、元と同じIOモナドが出来るのは当たり前です。

※ 当たり前で済ませていますが、値に関しては出し入れで変化しないことがモナド則というルールによって保証されています。今回の範囲を超えるため詳細は省略しますが、興味があれば次の記事を参照してください。

自作関数

関数を出し入れするだけではブラックボックスのままです。関数の仕様を調べるため、自作関数でIOモナドを構築してみます。

IOモナドの中にある関数は通常は触るようなものではなく、仕様も特殊です。戻り値はアンボックス化タプルと呼ばれる特殊なタプルです。まずそれについて説明します。

アンボックス化タプル

内部的な処理が違うタプルです。違いは表面上分かりません。

通常のタプルは計算結果ではなく式を保持しますが、アンボックス化タプルでは計算結果が含まれます。そのためタプルを大量に使うような用途ではメモリ効率が向上するようです。それによって計算結果が変わるわけではないため、違いが簡単に分かるような例は示せません。

  • 通常のタプルは()で囲むのに対して、アンボックス化タプルは(##)で囲みます。
  • アンボックス化タプルを扱うにはUnboxedTuplesという言語拡張が必要です。
    • 言語拡張はソースの先頭に{-# LANGUAGE#-}と記述します。
  • アンボックス化タプルはprintが受け付けないため、確認するには中身を個別に取り出す必要があります。

例を示します。

{-# LANGUAGE UnboxedTuples #-}

addsub x y = (# x + y, x - y #)

main = do
    let (# a, b #) = addsub 1 2
    print (a, b)
実行結果
(3,-1)

取扱い方法自体は通常のタプルと大差ないため、これ以上は特に説明することはありません。興味があれば次の資料を参照してください(この資料では非ボックス化と訳されています)。

※ ちなみにIOUArrayのUもアンボックス化の意味です。

return

let a = return 1相当のIOモナドを自作します。先ほど述べたようにreturnはファクトリメソッドのようなもので、指定された値を返す関数を作ってIOモナドの中に入れますが、それを手動で行います。

return.png

{-# LANGUAGE UnboxedTuples #-}

import GHC.Base

main = do
    let a = IO $ \s -> (# s, 1 #)
    print =<< a
実行結果
1

ここで見られるIOモナド内の関数の仕様をまとめます。

  • 戻り値は2要素のアンボックス化タプルです。
  • 第1要素は引数を素通ししたものです。
  • 第2要素は今まで「アクションから取り出した値」と言っていた値です。
  • アンボックス化タプルを使うことで、タプルに格納する際に式が評価されるようにしています。

引数はどこから来るのでしょうか。

main

mainの実体もIOモナドのため、関数から自作することが可能です。

main.png

{-# LANGUAGE UnboxedTuples #-}

import GHC.Base

main = IO $ \s -> (# s, () #)
実行結果
(出力なし)

引数sはHaskellの処理系によって渡されたもので、自分で作ることはできません。これを取り回すことでIOモナドは実行されていきます。その様子を見ます。

print

mainに渡された引数をprintに渡してみます。

main_print.png

{-# LANGUAGE UnboxedTuples #-}

import GHC.Base

main = IO $ \s ->
    let (# s1, r #) = unIO (print "hello") s
    in  (# s1, r #)
実行結果
"hello"

引数sprintを通ることでs1に変化して最終的な戻り値となります。

State# RealWorld

IOモナド内の関数に渡される引数の型はState# RealWorldです。#'と同じように名前の一部ですが、扱うには言語拡張MagicHashが必要です。あまり触られたくない型や変数に#が付けられているようです。

処理を連続させるにはsをバケツリレーします。型注釈を明記します。

main_print3.png

{-# LANGUAGE UnboxedTuples, MagicHash #-}

import GHC.Base

main' :: State# RealWorld -> (# State# RealWorld, () #)
main' s =
    let (# s1, _ #) = unIO (print "hello") s
        (# s2, _ #) = unIO (print "world") s1
        (# s3, r #) = unIO (print "!!") s2
    in  (# s3, r #)

main :: IO ()
main =  IO main'
実行結果
"hello"
"world"
"!!"

これは次のように解釈できます。

  • State# RealWorldある時点の実世界の状態を表す型です。
    • 状態が具体的にどんなデータなのかは不明ですが、識別ID以上の意味はないようです。
  • その型を持つ変数がバケツリレーされている様子は、副作用のある関数を通ることで状態が変化していることを表現しています。
  • 最後の状態が関数の戻り値の1つとして返されます。

実世界という表現は大袈裟に感じますが、プログラムは入出力(I/O)により外部とやり取りしているため、実世界と切り離されてはいないという意味合いです。そしてI/Oを取り扱うためのモナドということでIOモナドと呼ばれます。

状態の受け渡しについては、もっと簡単なコードでの例がヒントになるかもしれません。

※ 状態はきちんと順番に受け渡す必要がありますが、言語的に保護されているわけではありません。通常はbindが適切に処理するため、bindによって保護されているとは言えます。他の言語での保護状況や、順番を守らなかったときのことなどに興味があれば、次の記事を参照してください。

ループとの比較

ループを再帰で実装する際に、ループカウンターを引数で表現することで変数の書き換えを避けることができます。

main = do
    let loop i | i <= 3 = do
            print i
            loop $ i + 1
        loop _ = return ()
    loop 1
実行結果
1
2
3

IOモナド内の関数が受け取る状態は、このループカウンターのようなものだと見なせます。更新されたループカウンターが再帰で渡されるように、更新された状態が後続のIOモナドに受け渡されて行きます。

do

3種類の書き方を比較してみます。

{-# LANGUAGE UnboxedTuples #-}

import GHC.Base

test1 = do
    a <- return 1
    print a

test2 =
    return 2 >>= \a ->
    print a

test3 = IO $ \s ->
    let (# s1, a #) = unIO (return 3) s
        (# s2, r #) = unIO (print  a) s1
    in  (# s2, r #)

main = test1 >> test2 >> test3
実行結果
1
2
3

隠された仕組みを2段階で明らかにしています。

  1. test1test2:値の受け渡しを明らかにします。
  2. test2test3:状態の受け渡しを明らかにします。

値と状態の受け渡しを明示的に書くのは面倒です。見せる必要のない仕組みを隠すことで、使いやすくしているわけです。

ここまでのまとめ

  • IOモナドの中には関数が入っています。
  • 関数は状態を受け取って、更新された状態と値を返します。
    • 状態の受け渡しを隠せば、IOモナドから値だけを取り出しているように見えます。
  • 状態をバケツリレーすることでコンテキストが構成されます。
    • doブロックは独自のコンテキストを持つと見なせます。
  • このモデルで入出力(I/O)による状態変化を取り扱えるため、IOモナドと呼ばれます。

練習

【問1】次のコードからdoを取り除いて、bindやreturnは使わずにunIOIOで書き換えてください。

import System.Random

shuffle [] = return []
shuffle xs = do
    n <- getStdRandom $ randomR (0, length xs - 1) :: IO Int
    xs' <- shuffle $ take n xs ++ drop (n + 1) xs
    return $ (xs !! n) : xs'

main = do
    xs <- shuffle [1..9]
    print xs

解答例

bind

bind(>>=)はIOモナドと関数をつなぎます。IOモナドから取り出した値を関数に渡します。関数はIOモナドを返すという縛りがあるため、つないだものは1つの大きなIOモナドになります。

m' = m >>= fのイメージを示します。

m_bind_f.png

複数bindしたm' = m >>= f1 >>= f2のイメージを示します。

m_bind_f1_bind_f2.png

IOモナドはbindすることでどんどん大きく成長して行きます。これが冒頭で言及した「IOモナドを組み合わせることの具体的なイメージ」です。

コードでも括弧で囲めばIOモナドの階層構造が見えて来ます。

test1 = ((return "1" >>= putStr) >>= print)
test2 = return "2" >>= putStr >>= print

main = test1 >> test2
実行結果
1()
2()

test2のようにフラットに書いても左から結合されるため、結果的にtest1と同じ構造が形成されます。

値の動き

m' = m >>= fと定義されるm'から値を取り出す動きを示します。

m_bind_f_value.png

  1. mから値が取り出されてfに渡されます。
  2. fからIOモナドが返されます。
  3. 2のIOモナドから値を取り出します。
  4. 3の値がm'から取り出した値となります。

詳細な動き

これをIOモナド内の関数を含めた形で細かく見ます。

m_bind_f_func.png

  1. m'内の関数(>>=によって作成)に外部から状態が渡されます。
  2. 1で渡された状態はm内の関数に渡されます。
  3. m内の関数から状態と値が返されます。
  4. 3の値がfに渡されます。
  5. fからIOモナドが返されます。
  6. 5のIOモナド内の関数に3の状態が渡されます。
  7. 関数から状態と値が返されます。
  8. 7の状態と値がm'内の関数からの戻り値となります。

カプセル化

>>=の役割は、適切に状態を受け渡す関数を生成することです。IOモナドの中に関数があることも、状態が受け渡されていることも、普通は意識することはありません。>>=が内々に処理しています。

オブジェクト指向でのカプセル化に近い考え方です。

>>=の他に、returnもIOモナドの内部構造を前提に実装されています。

まとめ

  • IOモナドの内部には関数があり、状態の受け渡しを行っています。
  • IOモナドの内部構造を知っているのは>>=returnだけです。
  • >>=returnの使い方さえ知っていれば、内部構造を知らなくてもIOモナドは利用できます。
  • >>=によって、状態は適切に受け渡されることが保証されます。
  • 結果をアンボックス化タプルに入れることで、順番に評価されることが保証されます。

練習

【問2】IOモナドを扱うbindreturn'を実装してください。>>=, <<=, <-, returnは使わないでください。

具体的には次のコードが動くようにしてください。

main = return' "hello" `bind` putStr `bind` print
実行結果
hello()

解答例

参考

IOモナドについての詳細は次の記事が参考になります。