エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

プログラムから圏のことばまで,或はモナドについて(前半)

こんにちは,エムスリーエンジニアリングGの榎田(@niflh)です.趣味は数学とテレビゲームです.

以前の記事 で Scala を通して関数型を勉強した話をしましたが,最近社内で Scala with Catsを読む勉強会をしています.この本は,Cats という「Scala で関数型プログラミングをサポートするライブラリ」を勉強しつつ,関数型特有の概念に慣れ親しむことを目的としたものです.

この勉強会も,参加者の皆さんと二人三脚で進み,モナドを扱う章に入りました.そんな中,参加者の一人から,次のような意見が出ました.

モナドをやるとよく圏論が出てくるので,かじってみると面白い一方,よくわからないことも多い.プログラミング上のどの操作が圏論とかのどの操作に紐付いてて,何が嬉しいのか,あたりがまとまってると嬉しい.

尤もだと思ったので,色々調べて考えた結果が本稿です.一記事には長すぎるようにも思うので,数学(特に圏論)控えめの前半と,数学(特に圏論)多めの後半*1に分けます.前半でモナドの説明として一通り完結しているつもりです.後半は前半を読んでいることを前提とします.

前半の梗概は以下のとおりです.

  • Cats における型クラス Monad の構成要素と具体例を述べる.
  • 数学的な関数と(副作用や外部との相互作用を含む)プログラムの橋渡しとしてMonadが機能することを見る.

後半の梗概は以下のとおりです.

  • 圏論に現れることばを,プログラムに即した仕方で理解する一例を示す.
  • モナドの構成要素を圏論のことばで書き直す.

対象読者は次のような人を想定しています.

  • プログラミングに現れるモナドに興味はあるがよくわからない.
  • mapは知っている.
  • (特に後半の記事については)数学が好き.

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]
}
  • pureA 型の値を 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)

他にもFutureEitherなど,基本的な型の多くが cats.Monad にあります.

モナドの目指すもの:値と計算の区別

様々な型が Monadの具体例たることから,Monadはプログラミング上の極めて重要な何かを抽象した概念のようです.他方,Monadが要求するメソッドからはそれが何なのかはっきりしないなぁ,というのがMonad初見時の感想でした.

気になり調べていると,Eugenio Moggi “Computational lambda-calculus and monads” という論文を見つけました.この論文がモナドを計算機科学に持ち込んだようです *3 .この論文には以下のようにあります.

Intuitively  \eta_A \colon A \to TA gives the inclusion of values into computations, while  \mu_A \colon T^2 A \to TA flatten a computation of a computation into a computation.

値 value と計算 computation の定義はなかったのですが,ともかく両概念を区別しています.また,前後まで読むと \eta_Apure に,  \mu_AflatMap に概ね対応するようにも見えました*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つの操作が融合しています.これらは分離できないのでしょうか?

実際には分離できます.以下のようにすれば,pureflatMapからmapflattenを作ることが可能です.

  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)))

とすればよいです.逆にflattenmapからflatMapも作れます.従い,Monadを「pureflatMapを持つ」としても,「pureflattenmapを持つ」としても等価な定義です.本稿後半では,後者の定義から出発して,モナドと密接に関わる圏論のことばを,プログラムのことばに即して理解する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]に変換する
  • pureTで包んで value から computation への橋渡しA=>T[A]を担う
  • flattenTの入れ子を剥ぐT[T[A]] => T[A]
  • flatMapTの絡む計算 computation を実行しつつ入れ子を剥ぐT[T[_]] => T[_]

役割を持ちます*7Monadを作るには,pureflatMapを用意する以外にも,puremapflattenを用意する方法があります.

モナド則に関する私見

モナドの話で「モナド則」はよく話題に挙がるトピックの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]]の入れ子を先に剥がすと読めます.つまりflatMapTの入れ子を複数回剥がす場合,結果は剥がす順番に依らない.

あくまで個人の感想ですが,「剥がして包むと元通り」「包んで剥がすと元通り」「剥がす順番はご自由に」を仰々しく述べているだけに見えます.これらなしには挙動が非直感的になり極めて困りますが,とりあえず一番最初に気にするものではないと思います*8

We are hiring!

本稿のきっかけとなった Scala with Cats 読み会をはじめ,社内では複数の勉強会をしています.ご興味のある方はぜひ話を聞きに来てください.

jobs.m3.com

後半は近日公開予定です.

*1:近日公開予定です.

*2:本当はモナド則も充たす必要がありますが,ここで出しても理解の助けになる気がしないので後回しにします.

*3:が,確証を得るには至っていません.専門の方々の見識を待ちたいです.

*4:事情をご存知の方向けにネタバレしておくと, \mu_A は flatMap ではなく flatten に対応します.

*5:ここで具体例に List がないのはわざとです.非決定計算のモデルとしての List は「入れ子になっていても嬉しくない」例です.しかし,現実の List は非決定計算の文脈を離れた unordered tuple としての使途が多数派でしょう.

*6:私が後者の定義を採用する理由は,pure と flatten が似た形の自然変換で書けるからです.包み役の pure には剥がし役 flatten を相方として用意したいからでもあります.

*7:入れ子を剥がれると困るなら Monad として扱うな,という示唆が見えます.そのために Applicative があるのでしょうが,中身を知らないので例を挙げることができません.

*8:モナドを自作する際はこのルールを壊していないか(ひどく使いづらい型でないか)チェックすることは役に立つと思います.また,とある友人に教えてもらったのですが,Haskell の ListT がモナド則に従わない振る舞いで実装され,大変なことになったことがあるようです.