Edited at

(非Haskellerのための)わかったつもりなモナド

関数型プログラミングについて調べるとよく出てくるのが「モナド」の概念です。


  • モナドはなんかしらんが最強らしい

  • モナドはHaskellとなんか関係あるらしい

  • モナドがあると純粋な関数型言語(参照透過な言語)でも副作用が扱えるらしい

でも


  • 適当に調べてもモナドってなんなのかよくわからん……

  • あとHaskell難しいしよくわからん……

  • (Haskell仕事で使わないので)役に立つのかもわからん……

そんなもやもやを抱えつつ生きていたところ、最近『関数プログラミング実践入門』とか『圏論の歩き方』とかの本でなんとなくモナドがわかったつもりになったので、勉強メモ的な意味でも記事にしてみます。

あと、Haskellを使わない人向けに、主にJavaを使って説明していきます。


モナドを理解するとはどういうことか

プログラミングの概念としての「モナド」を理解するには、おそらく以下の三つを理解することが必要です。


  • 何かが「モナド」と呼ばれるための必要十分条件

  • 「モナド」という概念がなにを表現しようとしているのか。↑の条件の「気持ち」「アイデア」「モチベーション」

  • 「モナド」という概念を生かしたプログラミングをするための言語機能


    • Haskellが特に関係してくるのはここ。型クラス、do記法



この記事ではまず「気持ち」から説明して、その後「必要十分条件」、「Haskellの機能」を説明します。

最後に「モナドを(Haskellを使わない私たちの)日々の業務に活かせるか」を議論します。


「モナド」の気持ち

「モナド」は「あるタイプの型」を表現する概念、と考えておけばいいと思います。

そこで、Javaから「モナド」の具体例を挙げ、その共通点を探すことからその「気持ち」を導入します。


Java Optional<T>

Java Optional<T>は「Nullかもしれない」値を簡単に扱うためのクラスです。

今、データベースに問い合わせて値を返すクエリ用メソッドが二つ、「String Hoge.query1()」「String Hoge.query2(String)」が存在したとします。見つからない場合はnullが返って来ます。

これらのメソッドで次のような処理を実現したいと思います。


  • 以下の処理を順に行う。


    1. Hoge.query1で問い合わせ結果を得る

    2. その結果をもとにquery2で問い合わせる

    3. その結果を"result: hogehoge"という形式の文字列にしてreturn



  • ただし、query1かquery2のどちらかでnullが出たら最終結果もnullにする。

これを素直に書くとこうです。

var result1 = Hoge.query1();

String out;
if (result == null) {
out = null;
} else {
var result2 = Hoge.query2(result1);
if (result2 == null) {
out = null;
} else {
out = "result: " + result2;
}
}

いちいちnullチェックが入るのが面倒ですね。

さて、ここで、次のような夢の言語を考えてみます。その言語では、関数を呼び出すたびにnullチェックを行い、「ただし〜最終結果もnullにする」という但し書きを言語自体が勝手に実装してくれます。プログラマは1〜3の処理をただ順番に書いていくだけです。

// 関数を連続して呼び出していくと間に勝手にnullチェックが挿入される夢の言語

var result1 = Hoge.query1();
var result2 = Hoge.query2(result2);
var out = "result: " + result2;

もしこういう風に書けるとすごく楽ですよね?でも残念ながらJavaはこういう言語じゃないです。GAME OVER

でもそのかわりにOptionalがあります。Optionalを使うと、夢の言語に近い書き方が実現できます。

Optionalは「値が入っているか入っていないかがよくわからない」コンテナ型です。Optional.empty()で「値のないコンテナ」、Optional.of(x)で「値の入ったコンテナ」が手に入ります。Optional.ofNullable(x)と書くと、xがnullならOptional.empty()、それ以外ならOptional.of(x)を自動的に判定して作ってくれます。

そこで、Hoge.query1やHoge.query2の返り値の型をOptional<String>にして、nullのかわりにOptional.empty()を返すことにします。すると、Optionalに入っているメソッドを使って、こんな風に同じ処理を書くことができます。

// flatMapやmapを使うと自動でnullチェック相当のことをやってくれる。

var out = Hoge.query1()
.flatMap(Hoge::query2)
.map(i -> "result: " + i);

短い!スッキリしてる!

但し書きをわざわざ書くことなく、処理をつなげていくだけという、夢の言語に近い書き方になっていますね。


Java CompletableFuture<T>

今、非同期な操作「Future<String> Hoge.async1()」「Future<String> Hoge.async2(String)」があるとします。

これらを組み合わせて以下の処理を実現するとしましょう。


  • 以下の処理を順に行う。


    1. Hoge.async1で非同期処理を起動

    2. その結果をもとにasync2の非同期処理を起動

    3. その結果を"result: hogehoge"という形式の文字列にして表示



  • ただし、前の非同期処理が終わるのを非同期に待ってから次の処理を起動する

さて、これを明示的に書こうとするとやはりいろいろ面倒です。例えばこんな感じでしょうか。

var executor = Executors.newSingleThreadScheduledExecutor();

var future1 = Hoge.async1();
// 非同期に待ちたいのでこれ以降はexecutorに入れる
executor.submit(() -> {
var future2 = Hoge.async2(future1.get()); // getで前の非同期が終わるのを待つ
System.out.ptintln("result: " + future2.get());
});

ここで、「ただし〜非同期に行われる」という部分を勝手に言語が面倒見てくれるような夢の言語を考えてみましょう。その言語では全ての関数は原則的に非同期で動くので、Futureのような「非同期な結果」を特別に表現するコンテナは必要ありません。async1もasync2もStringを受け取ってStringを返すだけの関数として書けます。

その言語では、同じ処理がこんな風に書けるはずです。

var result1 = Hoge.async1();

var result2 = Hoge.async2(result1);
System.out.println("result: " + result2);

楽ですね。しかしJavaはこんな言語ではりません。

こんな言語ではありませんが、しかし近い書き方を実現できるCompletableFutureという型があります(C#だとTask、JavascriptだとPromiseが似たような型です)。この型をasync1やasync2から返すことにすると、同じ処理を次のように書くことができます。

Hoge.async1()

.thenCompose(Hoge::async2)
.thenApply(i -> "result: " + i)
.thenAccept(System.out::println);

但し書き部分を明示的に書くことなく、処理の流れを淡々とつなげる夢の言語のような書き方になります。


Java Stream<T>

今、[1, 2, 3...]というような連番リストを作り、これを「"n times"」(n=1, 2,...)という文字列がn回繰り返されるようなリストに変換したいとします。

やりたいのはこういうことです。


  • 以下の処理を順に行う。


    1. 連番リストを作る

    2. リストの各整数から、iがi回繰り返されるリストを生成する

    3. リストの各整数を、"i times"という文字列に変える



  • ただし、途中でリストとして作られた複数の値は、全て最後にフラットなリストにまとめる

この処理を簡単に書くことができる夢の言語は、「個々の値だけを相手にしていれば、勝手にリストにまとまっている」ような言語です。リストの各値に対する処理だけを書くだけで、最後に結果が勝手にリストにまとまります。

いま、xをy回繰り返すリストを生成する関数Hoge.repeat(int x, int y)があるとして、夢の言語ならこう書くことができます。

var a = Arrays.asList(1, 2, 3);

var b = Hoge.repeat(a, a);
var out = b.toString() + " times";

そして、この夢の言語に近い記述をJavaで提供するのがStreamです。C#だとIEnumerableになります。

var out = Arrays.asList(1, 2, 3).stream().

.flatMap((i) -> Hoge.repeat(i, i).stream())
.map(i -> i.toString() + " times");
// 1 times, 2 times, 2 times, 3 times, 3 times, 3 times

各値に対する処理を連結していくような書き方が可能です。


眺めてみて

さて、Optional<T>, CompletableFuture<T>, Stream<T>を眺めてみて、「なんか似てるな」と思いませんか?

この三つのクラスは次の点が共通します。



  • 但し書きにあたるような内容をプログラマが書く必要がなく、関数をただ繋げて書いていけば欲しい処理が手に入るような夢の言語があり、

  • そんな夢の言語に近い記述をJavaの中で再現できるようなインターフェースが用意されている

Optionalの但し書きはnullチェック、CompletableFutureの但し書きは非同期な結果待ち、Streamの但し書きは結果をフラットなリストにまとめるというものでした。そして、実はこの三つの型はモナドになっています。

「モナド」とは、まさしく夢の言語を手元の言語で再現する仕組みを持った型なのです(少なくともそう解釈できるようなものです)。


モナドの条件

いま、あるジェネリックな型M<T>があったとして、これを↑の気持ちで見るとしたとき、どんな操作が欲しいか考えてみましょう。

いま、型Tの引数を受け取って型Sの値を返す関数の型をT -> Sと書きます。何も受け取らずに値を返す関数は() -> Sと書くことにして、T -> Sという一般的な書き方の一部に含めることにします。

さて、夢の言語とモナドによる書き方を比べて見ると、夢の言語でT -> Sという型を持っていた関数が、手元の言語でモナドを使って書くときはT -> M<S>という型を持っていることに気づきます。例えば、Hoge.query2は夢の言語ではString -> Stringですが、モナドを使った書き方ではString -> Optional<String>になってますよね。また、Hoge.query1は夢の言語では() -> Stringですが、モナドを使った書き方では() -> Optional<String>です。

そこで、次のようなルールを前提することにしましょう。

夢の言語でT -> Sの関数を使う場合、手元の言語ではT -> M<S>の関数で再現する。

そうすると、夢の言語ではquery1の結果をそのままquery2の引数に平然と入れることができ、淡々と処理を連接していくことができます。でもモナドを使って書くとOptional<String>とStringで型のミスマッチが起き、できません。そこで、T -> M<S>の関数をM<T>に適用できる操作をM<T>に定義してやることで、T -> M<S>のような型の関数を連接できるようにして、夢の言語を再現します。

Hoge.query1()

.flatMap(Hoge::query2);

Hoge.async1()
.thenCompose(Hoge::async2);

Arrays.asList(1, 2, 3).stream().
.flatMap((i) -> Hoge.repeat(i, i).stream());

インスタンスメソッドをインスタンスを引数として受け取る関数と考えれば、上の例で使われているのは全てM<T> -> ((T -> M<S>) -> M<S>)の関数です。これをflatmapと呼ぶことにします。

次に、夢の言語は妄想上の言語なので、便利なことはなんでもできてほしいです。具体的には、手元の言語にある関数は、夢の言語でも使えることが望ましい。例えばOptionalの例でも、ある文字列を"result: "という文字列と連結するJavaの操作が使われていますが、これを、「受け取った文字列に"result: "を連結する」String -> Stringの関数と捉えるのであれば、この関数は夢の言語でもあってほしいわけです。

手元の言語にあるT -> Sの関数は、夢の言語でもT -> Sの関数として使いたい。でも、先ほど前提したルールによると、夢の言語でT -> Sの関数を使う場合、手元ではT -> M<S>で再現することになっていました。

よって、夢の言語で手元の言語のT -> S関数fを使う様子を、モナドを使って手元の言語で再現するには、fとほとんど同じことをするT -> M<S>の関数が必要です。そこで、「値を何もせずMに包む」ようなT -> M<T>なる関数ofを用意し、引数xに対してof(f(x))を対応させるような関数を作れば、T -> M<S>の関数が手に入り、夢の言語でのfの使用を再現できるようになります。ここでいうgにあたる関数は、OptionalやCompletableFutureやStreamにも用意されています。

Optional.ofNullable(3);

CompletableFuture.completedFuture(3);
Stream.of(3);

まとめると、


  1. T -> M<S>の関数を連続して適用するための関数flatmap

  2. 値を何もせずMに包む関数of

以上二つの操作が欲しいです。なお、Javaのコード例で使ったmapやthenApplyといった操作は、flatmapとofを使って次のように定義できます。

m.map(f) = m.flatmap((x) -> of(f(x)))

実は、ある型M<T>がモナドであるための条件は、まさしくflatmapとofが定義されていることです。

形式的に表現すると次のようになります。


  1. M<T> ->((T -> M<S>) -> M<S>)のflatmapが定義されている

  2. T -> M<T>のofが定義されている

  3. flatmapとofが「モナド則」という法則を満たす


    1. flatmap(of(x))(f) = f(x)

    2. flatmap(m)(of) = m

    3. flatmap(flatmap(m)(f))(g) = flatmap(m)(f $\circ$ g)



一つ目と二つ目の条件がflatmapとofの型を指定しています。その上で、「モナド則」という法則が、「ofは何もしない」「flatmapが計算をつなげる」という気持ちを形式的に表現しているのですが、まあここはそこまで真面目に理解しなくても当座は大丈夫です。

ちなみに、実は上の条件がすべて満たされているなら、ある数学的に定義された意味で、モナドに夢の言語(クライスリ圏)が対応することが示せます。


Haskellとモナド

Haskellとモナドは切っても切れない関係があります。なぜかというと


  • Haskellで副作用を扱うためにモナドが大活躍する

  • Haskellはモナドという概念を使った抽象化を強力にサポートしている

からです。


Haskellでモナドが活躍する理由

Haskellは純粋関数型言語であり、あらゆる関数が参照透過です。そのため、副作用(引数と返り値に表現されない作用、という意味での)が作れません。

一方で、世の中には「純粋?何それ?」とばかりに、引数にも返り値にも表現されない作用を起こしまくる言語がたくさんあります。Haskellで副作用を扱いたい、と思った時には、こうした言語は夢の言語に見えてくるでしょう。

そこで、副作用を起こせる言語をHaskellの中で再現するためにモナドが使われるのです。例えばHaskellではこんなモナドが登場します。


  • Configの読み出しを行う言語を再現するReaderモナド

  • 状態変化を起こせる言語を再現するStateモナド

  • 入出力を起こし、中心的な値の計算の背後でデバイスの状態を変化させられる言語を再現するIOモナド

重要なポイントは、こうしたモナドは副作用をHaskellの枠内で再現するためのものであって、Haskellの参照透過性を崩すものではない、ということです。実際、例えばIOを含む関数はここまでの表記で書くとT -> IO<S>のような型を持ち、デバイスとの交渉の必要性は返り値に表現されます。引数にも返り値にも出てこない作用はありません。


Haskellのモナドサポート

多分僕の知らない色んな機能があると思うんですが、とりあえず二つ例を挙げます。


より抽象的な型付け

Javaではモナドをインターフェースとして宣言することはできません。無理にやろうとするとこうなります。

interface Monad<M, T> {

M<S> flatmap(Function<T, M<S>> f); // M<S>って何?
static M<T> of(T t); // staticメソッドだからインターフェースで抽象的に宣言できない
}

ジェネリクスの型変数を受け取る側である「M」を変数にできないこと、インスタンスメソッド以外の実装を強制できないことがネックになります。

これに対してHaskellにおいてインターフェースに対応する機能「型クラス」では、Mにあたる「型コンストラクタ」もジェネリックに表現できます。またインスタンスメソッドとみなせるような、引数にM<T>を受け取る関数に限らずとも実装を強制できます。だから「モナド」という概念を型として定義できます(正確には型への制限を表現する「型クラス」として定義しています)。実際、標準で定義されています。簡易的に書くとこんな感じです。

class Monad m where

-- これがflatmap
(>>=) :: m a -> (a -> m b) -> m b
-- これがof
return :: a -> m a

ゆえに、「任意のモナドに関する一般的な関数」とかがHaskellでは書けます。


do記法

Optionalで見せたコード例を思い出してください。

var out = Hoge.query1()

.flatMap(Hoge::query2)
.map(i -> "result: " + i);

Haskellには「do記法」という糖衣構文があり、上を次のように書き直せます(わかりやすいようにHaskellとJavaのちゃんぽんコードで書きます)。

var out = do

x <- Hoge.query();
// Hoge.queryの戻り値は本当はOptional<String>だったが、
// 「型Stringの値をxに代入した」と考えて構わない
y <- Hoge.query2(x);
// xの型はStringなのでquery2に渡せる
return("result: " + y);

do記法を使うと、あるモナドに包まれたM<T>の値を、あたかもTの値であるかのようにプログラミングできます。なにか気づきませんか?そう、これは欲しかった夢の言語ほぼそのものです。do記法は、モナドが再現しようとしている夢の言語の中に入ってしまうための文法なのです。

ちなみに、モナドだったらなんでも使えるので、CompletableFutureやStreamにもdo記法は使えます(Javaにdo記法があればという話ですが)。

do

x <- Hoge.async1();
// 戻り値は本当はCompletableFuture<String>だったが
// 「型Stringの値をxに代入した」と考えて構わない
y <- Hoge.async2(x);
System.out.println("result: " + y);

var out = do

x <- list.stream();
// 戻り値は本当はIntStreamだったが
// 「型Intの値をxに代入した」と考えて構わない
y <- Hoge.repeat(x, x).stream();
return(y.toString() + " times");

さらに、(特にC#やJavascriptを触ったことのある方は)この二つを眺めて思い出すことがありませんか?

そう、これはasync/awaitとfor文です!

x <- Hoge.async1()は x = await Hoge.async1()ですね。

x <- list.stream()はfor(var x: list.stream())みたいなもんです。

つまりasync/awaitやfor文は、非同期処理やコレクション処理を簡単に書くための夢の言語を再現できる構文なわけです。

もちろん、「async/awaitとfor文があるからいいや」という風に話は終わりません。do記法のパワーは、夢の言語が欲しくなるたびにいちいち新しい構文を用意しなくても、夢の言語を再現するモナドさえ定義できれば、do記法一発でそれを再現できる、という点にあります。

つまり今後プログラミングが進化して、夢の言語とそれを再現するモナドが次々考え出されたとしても、do記法さえあれば、Haskellの中で夢の言語を書けるのです!


モナドと私たちの人生

Haskellを業務で使わない私たちは「モナド」を理解してどんな得があるでしょうか。

若干強引に考えてみますが、コーディングに直接型として登場させられなくても、「モナド」という抽象的視点でコードをプログラマが捉えることでコーディングに活かせる部分はあると思います。


  • ある場所でOptionalについてこんな書き方をしたら読みづらかったので、他のモナドでもやめておこう

  • 自分が作った新しいクラス、flatmapやofを実装してモナドにすると便利かも

  • なんか無駄に面倒なコードになっているが、モナドとしてコーディングすれば簡単に書けるかも?

こういう発想をより抽象的な視点から持てることで良いコードが書けるってことはまあないとは言えないんじゃないでしょうか(自信なし)。

あと茫漠とした話ですが、より抽象的な概念を持ってコードやクラスや言語機能を眺めることで、「見通しの良さ」みたいな感覚を得ることができ、考えがスッキリする実感はあります。思考のストレスが減るというか。


まとめ


  • モナドは


    • flatmap/of/モナド則で定義される

    • 「夢の言語を再現する」という気持ち

    • 抽象的な型付けやdo記法など、Haskellにはモナドをサポートする機能がいろいろある