Edited at
WantedlyDay 25

難しいのは見た目だけ!?Haskellのモナドの「たった2つのルール」を簡単に理解する!

More than 3 years have passed since last update.

この記事は Wantedly Advent Calendar 25日目の記事です。

最終日です!気合いが入りますね!!


Introduction

今回は Haskell のモナドの話をしたいと思います。

Haskell を学び始めた時、誰もが一度は経験するのが「モナドって何だ?」という疑問です。「Haskell モナド」で検索してみても、圏論を絡めた小難しい説明ばかりが出てきて、よく分からない事が往々にしてあります。

ところが、実は「Haskell におけるモナド」を理解する為に、圏論のモナドを理解する必要はありません。何故なら、「Haskell においてモナドである」為に必要なのは、「たった2つのルールを満たす事」だけだからです!

この記事では、「モナドとは何か」を簡単に説明したいと思います!!


Haskell におけるモナドとは?

Haskell におけるモナドとは、誤解を恐れずに言えば「いわゆる flatmap っぽいものが使える、リストっぽい型」を指します。この、


  1. リストっぽい型である

  2. flatmap っぽいものが使える

という2つが、先ほど述べた「Haskellにおけるモナド」の条件です。それぞれの条件について詳しく見てみましょう。


ミニコラム: flatmapとは

flatmap というのは 「コレクション A とコレクションを返す関数 f を受け取って、Aの各要素に f を作用させて得られた結果をフラットなコレクションにして返してくれる」関数です。 言葉だけで説明してもよく分からない感じですが、コードを見たら理解出来ると思います。Ruby や Scala, Swift なんかで使われていて、以下の様な感じになります。

irb> [1, 2, 3, 4].flat_map { |e| [e, e * 10] }

=> [1, 10, 2, 20, 3, 30, 4, 40]

scala> List(1, 2, 3, 4).flatMap(x => List(x, x * 10))

res1: List[Int] = List(1, 10, 2, 20, 3, 30, 4, 40)

> [1, 2, 3, 4].flatMap { [$0, $0 * 10] }

[1, 10, 2, 20, 3, 30, 4, 40]

flatmap を Ruby で雑に実装すると、以下の様な感じになります。 map してから flat するから、flat_map という訳ですね。

irb> def my_flat_map(arr, &block)

irb* arr.map { |e| block.call(e) }.flatten
irb* end
=> :my_flat_map

irb my_flat_map([1, 2, 3, 4]) { |e| [e, e * 10] }
=> [1, 10, 2, 20, 3, 30, 4, 40]


Haskellにおけるモナドの条件1: 「リストの様に、1つの型引数を受け取ってつくられる型である」

Haskell におけるモナドとは、リスト型やMaybe型、IO型の様な「型」を意味します(より厳密には、Monadとは型クラスであり、「Monadという型クラスに List型や Maybe型, IO型が属する」 と表現します。型クラスについては後ほど説明します。)

これらの型に共通するのは、どれも「1つの型引数を受け取る」という事です。。。。と、また新しく「型引数」という言葉が出てきたので、一度その意味を説明したいと思います。


型引数とは

Haskell には、「型を引数として受け取ってつくられる型」が多数存在しており、その時に渡される型が「型引数」です。

例えば、リストを考えてみましょう。Haskell におけるリストは同じ型の要素だけを含むコンテナ型として定義されており、その型は要素の型をaとして[a]で表されます。ここでは、任意のa型に対する[a]型を総称して、「リスト型」と呼ぶことにします。

Intだけを要素として持つリスト[1, 2, 3, 4][Int]型ですし、文字型Charの要素だけを持つリスト['a', 'b', 'c']の型は[Char]です。これらのリスト型は、[]という「型を引数としてとるもの」に対して、IntCharのような型を渡したものと捉える事ができます。そのため、リスト型は「1つの型を型引数として受け取ってつくられた型」となります。

ちなみに、[]は「型コンストラクタ」と呼ばれます。型を引数として受け取って型をつくりだすので、コンストラクタというわけです。


モナドは型引数を1つだけ受け取ってつくられる型

リストは型引数を1つだけ受け取ってつくられる型でした。リスト以外のモナド型についても、その型がどういった構造なのかを見てみましょう。


Maybe型

Maybe型というのは、「値が存在するかどうか分からない時」に使われる型です。その型はMaybe a と表され、aが任意の型となります。aという型引数を受け取るので、Maybeは型コンストラクタとなります。ここでは、Maybe a型を総称して、「Maybe型」と呼ぶことにします。

例えば、何かしら失敗する可能性のある計算を行いたい時、失敗した事を明示的に示すために Maybe型が使えます。

例として、「要素とリストを受け取って、要素がリスト中に現れる位置を返す」関数を考えてみましょう。 elemIndex 2 [1, 2, 3] を評価した時、 1 を返す様な関数elemIndexです。この関数の振る舞いを考えた時、問題になるのは「要素がリストの中に存在しなかった」場合です。この時、elemIndexは何を返したら良いのでしょうか?

1つの答えとして、「-1の様なリストのindexにはなりえない値を返す」という方法もあり得ます。しかし、これは「-1は失敗した時の値だ」という前提知識を利用者に強いる事になります。また、似た様な関数なのにライブラリによって失敗の表現の仕方が違うと言った状況も起こり得ます。「失敗は失敗」として明示出来る方が良い場合は多いのです。

こういった状況で活躍するのがMaybe型です。実はHaskellの標準ライブラリにはまさに「リスト中の要素の位置を返す」関数としてData.List.elemIndexが用意されており、その関数の返り値はMaybe Int型となっています。

Maybe Int型の値はJust (Int型の値)またはNothingです。Data.List.elemIndexは要素の発見に成功した時にはその「位置」をJustでくるんで(Just 位置という値で)返し、失敗した時にはNothingを返します。成功や失敗が返り値から自明であり、さらに成功の時には欲しかった情報がその値の中に含まれています。理想的な振る舞いですね。

ghci> Data.List.elemIndex 1 [1, 2, 3]

Just 0 -- 1はリストの先頭(位置0)で見つかるので Just 0 が返る。Maybe Int型。
ghci> Data.List.elemIndex 10 [1, 2, 3]
Nothing -- 10はリスト中に存在しないので Nothing が返る。これもMaybe Int型。

この様に、任意の型の値を成功時に含むために、Maybe型は1つの型引数aを使ってMaybe a型と表されます。ちなみに興味深いのはNothingで、これは任意のMaybe a型に属する値となっており、文脈によってその型は変わります。例えばMaybe Int型が期待される場面でNothingが返ってきたらNothingの型はMaybe Intですし、Maybe Char型が期待される場面ではMaybe Char型として振る舞います。見た目は同じでも、実は違うものなのです。


余談: Just, Nothingなどのデータコンストラクタについて

ちなみに余談ですが、慣れないとっつきにくいのがJust 3の様な値の表現で、Justに違和感を感じるかもしれませんがこれは立派な「値」です。Justは値を受け取って値を作る役割を果たしており、「データコンストラクタ」と呼ばれます。作られた値の表現にJustという文字が入ってくるのが特徴的ですね。

同じく、Nothingもデータコンストラクタですが、これは引数を受け取らないデータコンストラクタとなっています。引数を受け取らないデータコンストラクタとしては、Bool型のTrueFalseがあると聞くと、なんとなくイメージがつかめるのではないでしょうか。


IO型

もう1つモナド型としてよく知られているのが、IO型です。例によって、任意の型aに対してIO a型が存在し、これらのIO a型を総称してIO型と呼びます。IO型は、「入出力を伴う操作」の型を表現するために使われます。

例えば、標準出力にHello, World!と出力するHaskellプログラムを書きたいとしましょう。実はHaskellには標準出力へ文字列を出力する為に使われる関数printが標準で用意されています。この関数は文字列化が可能な値を受け取って、IO ()型の値を返します。そしてそのIO ()型の値が持つ「副作用」によって、標準出力へ文字列の出力がなされます。使い方イメージは以下の様な感じです。IO型の値であるからこそ、「標準出力への出力」というIO操作が可能となっています。

$ echo 'main = print "Hello, World"' > HelloWorld.hs

$ ghc --make HelloWorld.hs
$ ./HelloWorld
"Hello, World"

()というのは「unit型」と呼ばれる型で、意味のある値が無い事を示す為に使われます。今回のケースでは「標準出力へ出力する」という副作用自体が重要であった為、IO ()型が使われていました。

IO型の値の中でも、意味のある値を持ちたいケースは多々あります。例えば、標準入力から文字を入力として受け取ってプログラムの中で使いたい時、HaskellプログラムではgetCharという関数が使われます。この関数は引数を受け取らず、IO Char型の値として評価されます。ここでIO ()型ではなくIO Char型となっているのは、「標準入力から受け取った文字」がChar型だからです。つまり、「標準入力から受け取った文字をIOでくるんだ値」を表現する為にIO Char型が使われるのです。

IO型には他にもIO Stringなどが存在していて、IOは1つの型を引数として受け取って型をつくり出す型コンストラクタとなっています。


条件1まとめ

ここまで見てきた様に、モナド型はどれも「型引数を1つだけ受け取ってつくられる型」となっています。つまり何かしらの型の値をくるんだ構造となっていて、この「くるまれた値をどう取り出すのか」という問題が次の条件2に繋がってきます。


Haskellにおけるモナドの条件2: 「他の言語でいうflatMapと同様の役割を果たす、>>=(バインド)という関数が定義されている」

モナド型の値に対しては、>>= という関数が必ず定義されています。これは「バインド」と呼ばれる関数で、特にリストに対しては、他の言語でいう flatMap と同じ動作をします。

例を見てみましょう。以下のコードでは、1から5までの数字のリストである list と、値を受け取ってその2倍の値を2つ並べたリストを作る関数である makeDoublePair を定義しています。そして >>=listmakeDoublePair に対して適用させると、元のリストの各要素が2倍の値で2つずつ並んだリストが作られます。まさに flatMap の様な動作になっているのが分かるでしょうか?

Prelude> let list = [1, 2, 3, 4, 5]

Prelude> let makeDoublePair = \x -> [x * 2, x * 2]
Prelude> list >>= makeDoublePair
[2,2,4,4,6,6,8,8,10,10]

>>= を理解する際、注目するべきなのはその「型」です。>>=+ などと同じ様に中置演算子(引数の間に書く関数)であり、listmakeDoublePair の2つを受け取って [2,2,4,4,6,6,8,8,10,10] を返す関数となっています。つまり、 Num t => [t]Num t => t -> [t] を受け取って、Num b => [b] を返す関数となっています。これは、Haskell の REPL である gchi で :type コマンドを打って実際に確認してみると分かりやすいかもしれません。(:type はその後に書いた値の型を表示してくれるコマンドです。値の後に :: を印字した後、型が表記されます。)

Prelude> :type list

list :: Num t => [t]
Prelude> :type makeDoublePair
makeDoublePair :: Num t => t -> [t]
Prelude> :type (list >>= makeDoublePair)
(list >>= makeDoublePair) :: Num b => [b]

Num t => [t] 型というのは、Num 型クラスに属する任意の型を「 t 」と読んだ時に、その t のリスト型を表しています。「型クラス」というのは Haskell において型をグループ化する為の仕組みで、数字を表す型は Num 型クラスに属しています。Haskell では 12 のような数値リテラルだけではどの型になるかわからない(Int32, Int64, Integer, Float, Double など様々な型となりうる)為、型が確定するまでは Num型クラスに属する型のどれか として扱われます。そして、:type コマンドなどで型を印字する際は、Num 型の任意の型a という意味で Num a という表記を使い、それを => の左側に印字し、 => の右側に a を用いて表現した型を記載します。(ab, t など型を表す変数としては様々なものが使われますが、意味は無いので気にしなくて良いです)

Prelude> :type 1

1 :: Num a => a

また、a -> b という表記は 「a 型の値を受け取って b 型の値を返す関数の型」を表していて、例えば makeDoublePair の型である Num t => t -> [t] は 「Num 型クラスに属する t 型の値を受け取って、t 型のリストを返す関数の型」を表しています。

これらの表記を用いて >>= の型を記載すると、 Num t => [t]Num t => t -> [t] を受け取って、Num b => [b] を返す関数であることから Num t => [t] -> (t -> [t]) -> [t] 型である事がわかります。(Haskell では、a 型と b 型の値を受け取って c 型の値を返す関数の型を a -> b -> c と表現します。これは厳密には 「a 型の値を受け取って b -> c 型の関数を返す関数の型」なのですが、2つの値を関数に適用するという操作が「1つの値を適用した返り値の関数にもう1つの値を適用する」操作と同一視出来るため、Haskell ではこのような表記を行います。なお、このような「複数引数関数を単一引数関数をネストしたものとみなす」操作は、カリー化と呼ばれます)

では、実際に ghci で >>= の型を調べてみましょう。

Prelude> :type (>>=)

(>>=) :: Monad m => m a -> (a -> m b) -> m b

Monad というキーワードが出てきました。これは、Monad という型クラスに属する m am b 型が使われる関数という事を意味しています。

ここで、「Haskellにおけるモナドの条件1」の「モナド型は型引数を1つだけ受け取ってつくられる型」という文章を思い出してみましょう。モナド型は、[]IO の様な型コンストラクタを持ち、1つの型を引数として受け取る事でつくられます。>>= の型に出てきた Monad mm はこういった型コンストラクタを表しています。

>>= の型である Monad m => m a -> (a -> m b) -> m b という表記は多少面喰らうかもしれませんが、m をリストの型コンストラクタである [] で、 abNum t => t で置き換えると、まさに Num t => [t] -> (t -> [t]) -> [t] となっている事がわかると思います。

リスト型に対して >>= を適用した際にも、確かに Monad m => m a -> (a -> m b) -> m b という型になっている事がわかりました。


Monad 型クラス

Haskell におけるモナド型とは、 「Monad 型クラスに属する型」の事を指します。つまり、Haskell においてモナド型が欲しければ、 Monad 型クラスに属する為の条件を満たせば良い事になります。

では、Monad 型クラスに属する為の条件とはなんでしょうか?それこそがまさに、>>= という関数が定義されている事(それに加えて、return という関数が定義されている事)なのです。

「型クラス」は Haskell における型のグループ化の仕組みと述べましたが、グループに属する条件は「指定された特定の関数を定義している事」になります。例えば、順序付け可能な型が属する Ord という型クラスは、以下の順序付けに使われる関数の定義を要請します。<<=, >>= の定義が必要な点が、特徴的です。

compare :: a -> a -> Ordering

(<) :: a -> a -> Bool
(<=) :: a -> a -> Bool
(>) :: a -> a -> Bool
(>=) :: a -> a -> Bool
max :: a -> a -> a
min :: a -> a -> a

Monad 型クラスを定義する Haskell コードは以下のようになっていて、Monad 型クラスにおいては、この「定義されている必要のある指定された関数」が >>=return になります。

class Monad m where

(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a

>>= はリスト型の例でも見たように、モナド型の値 m a と、モナド型の値を返す関数 a -> m b を受け取って、モナド型の値 m b を返す関数です。その定義の中には、Haskellにおけるモナドの条件1の 「m が1引数の型コンストラクタである」という条件が入ってきています。また、この関数を定義することがHaskellにおけるモナドの条件2そのものでした。

return はいわゆるデータコンストラクタで、任意の型の値を受け取って、モナド型の値を作り出します。例えば、リスト型のコンテキストで return 3 を評価すれば [3] になりますし、 Maybe 型のコンテキストで return 3 を評価すれば Just 3 になります。本当は return を定義する事も「Haskellにおけるモナドの条件」に加えるべきなのですが、1引数のデータコンストラクタの定義は大抵サクッと出来ちゃうので省略していました。

また、より厳密に言うと現在は FunctorApplicative 型クラスに属する事も Monad 型クラスに属する為の必要条件となっている(なおかつ Minimal complete definition は >>= の定義だけになっている)のですが、 https://wiki.haskell.org/Functor-Applicative-Monad_Proposal を見ると分かるように、 >>=return の定義さえ存在していれば、お決まりのコードを書くだけで FunctorApplicative 型クラスの条件を満たす事が出来ます。

ここまでで、「Haskellにおけるモナドの条件1, 2」を満たせば Monad 型クラスに属する事ができる、つまりHaskellにおける「モナド型」となれる事が分かりました。


モナド型の便利さ

Haskell においてモナド型である事、つまり Monad 型クラスである事は、>>= が定義されている事とほぼ同義である事が分かりました。では、なぜ「モナド型」がこれほどまでに持て囃されるのでしょうか?>>= がそれほど偉いのでしょうか?

実は、>>= の凄さはその「便利さ」にあります。例として、Maybe 型における >>= の利用を考えてみましょう。Maybe 型は、計算が成功するか失敗するかが分からない時に返り値を表現する為に使われる型です。失敗の可能性のある計算を連続して行いたい時に、>>= は便利な記法となります。

以下のコードでは、文字列から数字の読み取りを Text.Read.readMaybe で行い、その計算が成功した時にだけ、得られた数字を使って Data.List.elemIndex を評価しています。Text.Read.readMaybe は文字列からの読み取りに成功すれば Just で包んだ値を、失敗すれば Nothing を返す関数であり、Data.List.elemIndex は要素とリストを受け取って「要素が何番目に現れたか」を Just で包んで返す関数です。どちらも、引数によっては計算に失敗する(Nothing を返す可能性のある)関数です。

*Main> let list = [1, 2, 3, 4, 5]

*Main> let string = "2"
*Main> (Text.Read.readMaybe string) :: Maybe Int
Just 2
*Main> (Text.Read.readMaybe string) >>= (\x -> Data.List.elemIndex x list)
Just 1

>>= を使ってこういった計算を繋げると、失敗しない限りはそのまま計算を続けてくれて(Just でくるまれた値を取り出して使ってくれて)、失敗した瞬間に全体の計算結果を Nothing にしてくれます。この際、計算と計算の間でわざわざ Nothing かどうか確認するためのコードを記述していないのがポイントです。その役割は >>= が担ってくれる為、コードはロジックの記述に集中したシンプルなものになります。

実際、Maybe型における >>= の定義は以下のようなものになっていて、Nothing かどうかの判定は >>= が行ってくれています。

(Juxt x) >>= f = f x

Nothing >>= f = Nothing

IO 型でも同様で、例えば外部から読み取った値を使って計算を行う場合に >>= を使います。例として、以下のコードを考えてみましょう。

main = getLine >>= (\name -> putStrLn ("Hello, " ++ name))

getLine を評価した値の型は IO String で、外部からの標準入力を IO でくるんだ値となっています。ここから値を取り出して計算を行うために、 >>= が使われます。

いくつもの入力を読み込むことももちろん可能で、その為には >>= をネストさせます。

main = getLine >>= (\firstName -> (getLine >>= (\lastName -> putStrLn ("Hello, " ++ firstName ++ lastName))))

これで、firstNamelastName の2回分読み込みを行うことができます。こうやって簡単に IO の数を増やしていけるのも、>>= の便利ポイントです。

ちなみに、>>= の数が増えるとどんどんコードが読みづらくなっていくので、それを緩和する為に do構文 と呼ばれる記法がよく使われまます。先ほどのコードを do構文 を使って書き直すと以下のようになります。

main = do

firstName <- getLine
lastName <- getLine
putStrLn ("Hello, " ++ firstName ++ lastName)

格段に読みやすくなりました。この様に do構文 を使えるのも、モナド型の大きな特徴です。


モナド則って何?

最後に、モナド則についても触れておきたいと思います。モナド則とは、Monad 型クラスとして定義したモナド型が「圏論の意味でのモナド」となる為に満たすべきルールであり、また実際に扱いやすい(想定通りの振る舞いをする)型である為に満たしておいて欲しいルールでもあります。

モナド則は以下の3つの等式で表現できます。

Left identity: return a >>= f と f a が等価

Right identity: m >>= return と m が等価
Associativity: (m >>= f) >>= g と m >>= (\x -> f x >>= g) が等価

これらの意味について興味が湧いた方は Haskell Wiki の Monad laws を読んでみてください。ここではざっくりと、モナド則を満たさない場合になぜ扱いづらいモナド型となってしまうかを例を通して見てみます。

>>=Monad m => m a -> (a -> m b) -> m b という型になっていれば良い為、様々な定義がありえます。例えば、Maybe 型によく似た MyMaybe 型を考えてみます。MyMaybe 型の値には MyNothingMyJust a が存在し、Maybe 型とよく似た動作をするものの、>>= が常に MyNothing を返すとします。

instance Monad MyMaybe where

return a = MyJust a
_ >>= _ = MyNothing

>>= の型はちゃんと Monad m => m a -> (a -> m b) -> m b となっている為、MyMaybe 型は Monad 型クラスに属しています。

ただし、Monad 則は満たしていません。試しにRight identity を考えてみると、任意のMyJust aMyNothing となりうる m に対して m >>= return は常に MyNothing となる為、Right identity は成り立っていない事が分かります。

そして、実際に MyMaybe 型における >>= は使い物になりません。常に MyNothing を返す為、意味のある計算を行う事が出来ないからです。モナド則をちゃんと満たすことで、こう言った状況は避ける事ができます。


まとめ

Haskell におけるモナドとは、「Monad 型クラスに属する型」の事で、その条件は「1つの型引数を持つ型コンストラクタ」を持ち、「return>>= が定義されている」事です。また、モナド則と呼ばれるルールを満たす事で、圏論の意味でも正しい「モナド」となり、実用上も扱いやすい型となります。

なにやら小難しく思える「モナド」ですが、これらの簡単なルールを満たすだけでモナドになってしまいます。難しく考えず、モナドと戯れてみましょう!

皆で楽しく、Let's Haskell!!