こんにちは,エムスリーエンジニアリングGの榎田(@niflh)です.趣味は数学とテレビゲームです.
以前の記事 で Scala を通して関数型を勉強した話をしましたが,最近社内で Scala with Catsを読む勉強会をしています.この本は,Cats という「Scala で関数型プログラミングをサポートするライブラリ」を勉強しつつ,関数型特有の概念に慣れ親しむことを目的としたものです.
この勉強会も,参加者の皆さんと二人三脚で進み,モナドを扱う章に入りました.そんな中,参加者の一人から,次のような意見が出ました.
モナドをやるとよく圏論が出てくるので,かじってみると面白い一方,よくわからないことも多い.プログラミング上のどの操作が圏論とかのどの操作に紐付いてて,何が嬉しいのか,あたりがまとまってると嬉しい.
尤もだと思ったので,色々調べて考えた結果が本稿です.一記事には長すぎるようにも思うので,数学(特に圏論)控えめの前半と,数学(特に圏論)多めの後半*1に分けます.前半でモナドの説明として一通り完結しているつもりです.後半は前半を読んでいることを前提とします.
前半の梗概は以下のとおりです.
- Cats における型クラス
Monad
の構成要素と具体例を述べる. - 数学的な関数と(副作用や外部との相互作用を含む)プログラムの橋渡しとして
Monad
が機能することを見る.
後半の梗概は以下のとおりです.
- 圏論に現れることばを,プログラムに即した仕方で理解する一例を示す.
- モナドの構成要素を圏論のことばで書き直す.
対象読者は次のような人を想定しています.
- プログラミングに現れるモナドに興味はあるがよくわからない.
map
は知っている.- (特に後半の記事については)数学が好き.
- Cats における Monad
- モナドの目指すもの:値と計算の区別
- 値と計算の橋渡し
- flatMap から flatten へ
- 前半まとめ:プログラムからモナドを見上げて
- モナド則に関する私見
- We are hiring!
Cats における Monad
Cats において,型引数をひとつ取る型T[_]
のMonad
とは,以下2つのメソッドをもつ型クラスのことを言います*2.
trait Monad[T[_]] { def pure[A](a: A): T[A] def flatMap[A, B](ta: T[A])(f: A => T[B]): T[B] }
pure
はA
型の値をT
に包んで返します.flatMap
は map して flatten するメソッドです.正確には,f: A => T[B]
をmap
することでTf: T[A] => T[T[B]]
に変換し,Tf(ta) : T[T[B]]
を計算する.Tf(ta): T[T[B]]
をT[B]
型にflatten
する.
Monad
の代表的な例は Option
および List
でしょう.
import cats.Monad import cats.instances.option._ // for Monad import cats.instances.list._ // for Monad val opt1 = Monad[Option].pure(3) // opt1: Option[Int] = Some(3) val opt2 = Monad[Option].flatMap(opt1)(a => Some(a + 2)) // opt2: Option[Int] = Some(5) val none :Option[Int] = None // none: Option[Int] = None val opt3 = Monad[Option].flatMap(none)(a => Some(a + 2)) // opt3: Option[Int] = None val list1 = Monad[List].pure(3) // list1: List[Int] = List(3) val list2 = Monad[List]. flatMap(List(1, 2, 3))(a => List(a, a*10)) // list2: List[Int] = List(1, 10, 2, 20, 3, 30)
他にもFuture
,Either
など,基本的な型の多くが cats.Monad
にあります.
モナドの目指すもの:値と計算の区別
様々な型が Monad
の具体例たることから,Monad
はプログラミング上の極めて重要な何かを抽象した概念のようです.他方,Monad
が要求するメソッドからはそれが何なのかはっきりしないなぁ,というのがMonad
初見時の感想でした.
気になり調べていると,Eugenio Moggi “Computational lambda-calculus and monads” という論文を見つけました.この論文がモナドを計算機科学に持ち込んだようです *3 .この論文には以下のようにあります.
Intuitively gives the inclusion of values into computations, while flatten a computation of a computation into a computation.
値 value と計算 computation の定義はなかったのですが,ともかく両概念を区別しています.また,前後まで読むと が pure
に, が flatMap
に概ね対応するようにも見えました*4 .これらの情報をヒントに,Monad
が何を捉えているのかを以下では探ってみます.
上論文に即せば,どうやら単なる A
型のインスタンスを value と,それをT
に包んだ T[A]
のことを computation と呼ぶ区別は悪くなさそうです.さて,T[A]
の具体例を見ると,A
型の値を扱いつつ追加でナニカしたい状況を作っています.
- null かもしれない
A
型のためのOption[A]
- 細かなエラーハンドリングに資する
Either[_,A]
- 非同期計算を目的とした
Future[A]
というように.この様子を見るに,中身にこれ以上踏み込まないアトミックなものを value と,それ以上の「追加の文脈」ないしは「追加でやりたいナニカ」が付随したものを computation と呼ぶ,ということもできそうです.本稿では今後,このようなゆるい区別で値 value と計算 computation を呼び分けます.
値と計算の橋渡し
プログラム上の全ての関数が value しか考えなくて済むなら,プログラミングでのつらみは減るでしょう.しかし現実はそうではないです.多分 null は混ざるし,エラーは適切にハンドルすべきだし,外部 API の応答は待たないといけない.すなわち value から computation に橋渡しをする必要があります.その橋掛け役がpure
にほかなりません.
ところで,これら computation の抽象としての型は,入れ子になりやすい一方で入れ子の意味がないものが多いです.例えば Scala を書くとOption[Option[Int]]
のような型をよく見ます.けれども,やりたいことは nullable のハンドリングに過ぎません.入れ子構造は型パズルの帰結という性格が強く,入れ子ゆえの嬉しさはないでしょう.Future[Future[A]]
も,やりたいことは「A
型の値が来るまで待つ」なので,入れ子のおかげで現れる恩恵はなさそうに見える一方,例えば外部 API に問い合わせるためのパラメタが,他の外部 API の出力を参照していれば Future
の入れ子の出来上がりです.flatMap
は,この事情を汲み取って解決しています*5.
def flatMap[A, B](ta: T[A])(f: A => T[B]): T[B]
上記flatMap
のやりたいことは,関数f: A => T[B]
にA
型の値を通してT[B]
型の出力を取りたいだけです.が,現実にはT[A]
型の入力がくることは珍しくありません.そこでmap
してf
の入力も出力も単純にT
で包んでしまうと,Tf: T[A] => T[T[B]]
というT
の入れ子ができてしまいます.flatMap
はこのT
の入れ子を剥がす役割も担っていると見ることができます.
val opt1 = Monad[Option].pure(3) // opt1: Option[Int] = Some(3) val opt2 = Monad[Option].flatMap(opt1)(a => Some(a + 2)) // opt2: Option[Int] = Some(5)
上の例では,a => Some(a + 2)
をmap
すると出力はOption
の入れ子ですが,flatMap
は入れ子を回避しています.
flatMap から flatten へ
ここまでは Cats でのMonad
の定義に即し,flatMap
を主軸に議論しました.が,このやり方は見通しの悪い点があると感じます.それは取りも直さずflatMap
の複雑さです.既に述べたように,flatMap
は
f: A => T[B]
をmap
することでTf: T[A] => T[T[B]]
に変換し,Tf(ta) : T[T[B]]
を計算する.Tf(ta): T[T[B]]
をT[B]
型にflatten
し,T
の入れ子を剥ぐ.
という2つの操作が融合しています.これらは分離できないのでしょうか?
実際には分離できます.以下のようにすれば,pure
とflatMap
からmap
とflatten
を作ることが可能です.
def flatten[A](tta: T[T[A]]): T[A] = flatMap(tta)(ta => ta) def map[A,B](ta: T[A])(f: A => B) = flatMap(ta)(a => pure(f(a)))
とすればよいです.逆にflatten
とmap
からflatMap
も作れます.従い,Monad
を「pure
とflatMap
を持つ」としても,「pure
とflatten
とmap
を持つ」としても等価な定義です.本稿後半では,後者の定義から出発して,モナドと密接に関わる圏論のことばを,プログラムのことばに即して理解する1つの試みを提示したいと思います *6 .
前半まとめ:プログラムからモナドを見上げて
プログラミングは,性質の良い関数だけで閉じるとは限りません.値 value だけを考慮するのではなく,周囲の状況(欠損値があるかも,内部で呼び出した関数が例外を返すかも,外部 API への非同期問い合わせがある,等々)をも考慮した計算 computation を扱う必要があります.この様子を抽象したものがMonad
です.
Monad
をめぐっては,型T
および4つのメソッドが登場しました.それぞれは大雑把には
- 型
T
は「どんな計算 computation がしたいのか」を示す map
は value to value の関数f: A => B
を computation to computation の関数Tf: T[A]=>T[B]
に変換するpure
はT
で包んで value から computation への橋渡しA=>T[A]
を担うflatten
はT
の入れ子を剥ぐT[T[A]] => T[A]
flatMap
はT
の絡む計算 computation を実行しつつ入れ子を剥ぐT[T[_]] => T[_]
役割を持ちます*7.Monad
を作るには,pure
とflatMap
を用意する以外にも,pure
とmap
とflatten
を用意する方法があります.
モナド則に関する私見
モナドの話で「モナド則」はよく話題に挙がるトピックの1つだという印象を持っています.本稿でも簡単に述べておくことにします.
Scala with Cats によれば,モナド則は以下の3つからなります.
ta: T[A]
に対してflatMap(ta)(pure) == ta
が成り立つ.flatMap
で入れ子を剥がすと決めてから,実際の計算でpure
で包むだけなのは何もしないのと同じ.
a: A
およびf: A => T[B]
に対してflatMap(pure(a))(f) == f(a)
が成り立つ.pure
で包んでからf
の計算に通してflatMap
で入れ子を剥がすのは,何もせずにf
に通すのと同じ.
ta: T[A]
,f: A => T[B]
,g: B => T[C]
に対してflatMap(flatMap(ta)(f))(g) == flatMap(ta)(x => flatMap(f(x))(g))
が成り立つ.- 括弧の中を先に評価する評価戦略のもと,左辺は
T[A]=>T[T[B]]
の入れ子を先に,右辺はT[B]=>T[T[C]]
の入れ子を先に剥がすと読めます.つまりflatMap
でT
の入れ子を複数回剥がす場合,結果は剥がす順番に依らない.
- 括弧の中を先に評価する評価戦略のもと,左辺は
あくまで個人の感想ですが,「剥がして包むと元通り」「包んで剥がすと元通り」「剥がす順番はご自由に」を仰々しく述べているだけに見えます.これらなしには挙動が非直感的になり極めて困りますが,とりあえず一番最初に気にするものではないと思います*8.
We are hiring!
本稿のきっかけとなった Scala with Cats 読み会をはじめ,社内では複数の勉強会をしています.ご興味のある方はぜひ話を聞きに来てください.
後半は近日公開予定です.
*1:近日公開予定です.
*2:本当はモナド則も充たす必要がありますが,ここで出しても理解の助けになる気がしないので後回しにします.
*3:が,確証を得るには至っていません.専門の方々の見識を待ちたいです.
*4:事情をご存知の方向けにネタバレしておくと, は flatMap ではなく flatten に対応します.
*5:ここで具体例に List がないのはわざとです.非決定計算のモデルとしての List は「入れ子になっていても嬉しくない」例です.しかし,現実の List は非決定計算の文脈を離れた unordered tuple としての使途が多数派でしょう.
*6:私が後者の定義を採用する理由は,pure と flatten が似た形の自然変換で書けるからです.包み役の pure には剥がし役 flatten を相方として用意したいからでもあります.
*7:入れ子を剥がれると困るなら Monad として扱うな,という示唆が見えます.そのために Applicative があるのでしょうが,中身を知らないので例を挙げることができません.
*8:モナドを自作する際はこのルールを壊していないか(ひどく使いづらい型でないか)チェックすることは役に立つと思います.また,とある友人に教えてもらったのですが,Haskell の ListT がモナド則に従わない振る舞いで実装され,大変なことになったことがあるようです.