この記事の執筆背景(読み飛ばし可能)
モナドの解説記事って、具体例を並べて説明した気になったり、難しい数学用語を並べて初心者狩りをするようなモノばかり!!
こんなんじゃ僕らの素晴らしいモナドが理解されないよ!
下手くそ!
貸せ!!
モナド解説はこうやる!!!
モナドは共通化のために導入する
モナドは共通化のためのツール。
モナドを導入することで、 共通の性質を持った型を同じように扱う ことができます。
モナドを導入するモチベーションは、言語によって「インターフェイス」「トレイト」「プロトコル」などと呼ばれるような、ふるまいを定義する仕組みと同じです。
Dog
と Cat
を共通化して Animal
として扱うように。
Teacher
と Student
を共通化して Human
として扱うように。
配列 や 非同期処理 などを共通化するためのツールが モナド です。
モナドは「構造や性質を付与するもの」を共通化する
以降の解説では具体例として C# や F# を使うことがありますが、言語に特有の知識がなくても読めます。
概観 - モナドによる共通化の威力
突然ですが次の2つのコードを見てください。C#が読めなくても何となく雰囲気は感じられると思います。詳細は次の項で説明します。
int[] SumAll(int[] array1, int[] array2)
{
var result = new List<int>();
foreach (var x1 in array1) // [1] 1つ目の引数から値を取り出す
{
foreach (var x2 in array2) // [2] 2つ目の引数から値を取り出す
{
result.Add(x1 + x2); // [3] 足し算を行う
}
}
return result.ToArray();
}
Task<int> SumAsync(Task<int> task1, Task<int> task2)
{
return task1.ContinueWith(x1 => // [1] 1つ目の引数から値を取り出す
{
return task2.ContinueWith(x2 => // [2] 2つ目の引数から値を取り出す
{
return x1 + x2; // [3] 足し算を行う
});
});
}
配列の処理と非同期処理で扱っているものは違いますが、コメント[1]〜[3]で示したような共通点があります。2つのコードは「配列」「非同期」という違いはあれど、両方とも「構造や性質の中で足し算を行う」処理を書いているからです。
モナドをうまく扱う仕組みを持つF#では、上2つのコードをほとんど同じように書くことができます。これができる理由は、配列や Task をモナドとして共通化し、 let!
や return
といったモナド用の構文が使えるからです。1
let SumAll(array1: int[], array2: int[]) : int[] =
[|
let! x1 = array1
let! x2 = array2
return x1 + x2
|]
let SumAsync(task1: Task<int>, task2: Task<int>) : Task<int> =
task {
let! x1 = task1
let! x2 = task2
return x1 + x2
}
こいつぁすげえ!!配列処理と非同期処理のコードが一緒になっちまった!!
モナド、いいでしょう?
使ってみたいでしょう?
……とはいえ、配列と非同期処理の共通化と聞いてピンと来る人は少ないでしょう。
次の項ではそれを説明します。
配列と非同期処理の共通点
C#では整数型 int
の配列を int[]
と表します。これは「整数がいくつか並んでいる」ようなデータ構造を表します。もちろん文字列の配列 string[]
なども考えることができます。
ここから、 []
は整数型 int
に 「いくつか並んでいる」という構造を付与するもの と考えることができます。
// 整数 int
int x = 1;
// 整数の配列 int[] は、intに「いくつか並んでいる」という構造を付与したもの
int[] xs = [1, 3, 9];
C#には非同期処理用に「未来のある時点で値が返ってくる」ことを表す Task
という型があります。例えば「未来で整数が返ってくる」ような型は Task<int>
と表します。
ここから、 Task< >
は整数型 int
に 「未来のある時点で返ってくる」という性質を付与するもの と考えることができます。
// 文字列 string
string x = "ABC";
// 特定のURLにリクエストを投げて文字列を取得する関数
// xsには取得した文字列ではなく「レスポンスが返ってきたら文字列が入る」という概念 Task<string> が入る
Task<string> xs = httpClient.GetAsync("https://jsonplaceholder.typicode.com/todos");
ここで示した []
と Task< >
には
【元になる型】に【ある構造や性質】を付与する
という共通点を考えることができます。あまりにも抽象的ですが、この共通化がモナドの本質です。
付与される具体的な構造や性質にとらわれずに「何らかの構造や性質を付与するもの」として共通化したい。これこそがモナドを導入するモチベーションです。
モナドが満たす「中の値を維持する」性質
【元になる型】に【ある構造や性質】を付与する概念がなんでもモナドになるわけではなく、モナドとして扱うためにはいくつかの関数が必要です。それらをC#のインターフェイス風2に表すと次のようになります。
interface IMonad<TMonad>
{
// Unit : 【元になる型】の値に【構造や性質】を付与する関数
static TMonad<T> Unit<T>(T value);
// Map : 【構造や性質】の中で関数を実行する関数
static TMonad<T2> Map<T1, T2>(TMonad<T1> monad, Func<T1, T2> mapping);
// Join : 多層化した【構造や性質】を一層に「ならす」関数
static TMonad<T> Join<T>(TMonad<TMonad<T>> monad);
}
モナドの実装者は、これらの関数が モナド則 と呼ばれる特定の性質を満たすように実装する必要があります。モナド則をめちゃくちゃ端的に言えば、【構造や性質】が付与された 「中の値」を維持する という規則です。
大半のエンジニアはモナドを使う側でしょうから、モナド則を詳しく把握している必要はありません。ただし、 これらの関数がどのモナドでも同じように振る舞う ことは覚えておきましょう。これで、新しいモナドに出会った時にその動作を推測できるようになります。
具体例を交えながら、ひとつずつ見ていきます。
Unit : 【元になる型】の値に【構造や性質】を付与する関数
Unit は単純な関数です。【元になる型】の値に対して【構造や性質】を付与します。
配列における例を見てみます。
// ここで == は「左右の値が等しい」ことを表す
Unit(12) == [12]
Unit("ABC") == ["ABC"]
配列の Unit 関数は「何らかの値を受け取り、それを長さ1の配列に入れて返す」という動作をします。受け取った値に配列という構造を付与しているわけです。
やや強引ですが、整数 12
と配列 [12]
は、 配列という構造が付与されている点を除けば「実質的に同じ」だと考えることができます。つまり 12
という「中の値」は維持されています。
未来で値が返ってくる Task の Unit 関数の動作も同じように考えることができます。
// Task.FromResult は「現在 = 0秒後の未来に値を返す」Taskを作る関数
Unit(12) == Task.FromResult(12)
Unit("ABC") == Task.FromResult("ABC")
Unit 関数は 12
に Task という性質を付与するだけで、両者は実質的に同じものです。
Map: 【構造や性質】の中で関数を実行する関数
Mapはモナドの値(構造や性質が付与された値)と「【元になる型】を引数にとる関数」を引数にとり、【構造や性質】の中で関数を実行します。関数の戻り値は、元と同じ【構造や性質】の中に入れられます。
(C#では LINQ の Select メソッドが挙動的に Map に相当します)
// x => x * 2 は「整数を2倍する」という関数を表す
// [1, 3, 5]という構造の中で「整数を2倍する」関数を実行し、結果を配列に入れ直す
Map([1, 3, 5], (x => x * 2)) == [2, 6, 10]
// x => x.ToString() は「整数を文字列に変換する」という関数を表す
// [1, 3, 5]という構造の中で「整数を文字列に変換する」関数を実行し、結果を配列に入れ直す
Map([1, 3, 5], (x => x.ToString())) == ["1", "3", "5"]
Map の呼び出し前後でも「中の値」は維持されるように動作します。 Map(Uint(12), x => ...)
というコードを書いたら、x には当然12が渡されて来ることが期待できます。同様に ...
の計算結果は次の Map で引数で受け取れることが期待できます。
Task についても考えてみます。
// リクエストを投げて「レスポンスが返ってきた未来で文字列が入る」xs
Task<string> xs = httpClient.GetAsync("https://jsonplaceholder.typicode.com/todos");
// 未来という性質の中で「文字列の先頭に★を追加する」関数を実行する
// これはどんな動作になるだろう?
Task<string> mapped = Map(xs, (x => "★" + x))
仮に上記のURLから "ABC"
という文字列が返ってくると考えてみましょう。
Taskの Map 関数は、未来で返ってくる値 "ABC"
に対して「文字列の先頭に★を追加する」関数の実行を予約します(未来という性質の中で関数を実行します)。
結果的に、 mapped
が未来で返す値は "★ABC"
になることが分かるでしょうか?
Join : 多層化した【構造や性質】を一層に「ならす」関数
言葉だと難しそうに見える Join ですが、これは具体例を見た方が早いと思います。
// 「配列の配列」を引数にとり、それと「実質的に同じ」である配列を返す
Join([[2, 3], [1, 5, 4], [9]]) == [2, 3, 1, 5, 4, 9]
配列のJoin関数は「配列の配列」を単なる配列にならします。
同様にして、 Task の Join 関数は「未来Aで返ってくる「未来Bで返ってくる整数」」をならして「未来Bで返ってくる整数」にします。
余談 / Bind
Bindはどこだ!Bindを出せ!という方へ(読み飛ばし可能)
モナドの解説において、 Map と Join のかわりに下記のような Bind でモナドを定義することがあります。
// Bind : 構造や性質の中で関数を実行し、一段階にならす
static TMonad<T2> Map<T1, T2>(TMonad<T1> monad, Func<T1, TMonad<T2>> mapping)
{
return Join(Map(monad, mapping));
}
上記のように、 Join と Map があれば Bind 関数を作ることができます。逆に、 Bind と Unit があれば Join と Map を作ることができます。
このように (Unit, Bind) と (Unit, Map, Join) のうち、どちらか片方があればもう片方を作ることができるので、定義にどちらを採用しても構いません。
プログラミングにおいては Bind 側を定義とすることが多いですが、圏論におけるモナドではむしろ Map と Join に相当するものが定義になっています。
まぁつまり、 目くじら立てないで。流派によるよ。 ということです。
モナドを使って共通化してみよう
これであなたの脳内にモナドが導入されました。
配列やTaskがモナドであることを知ったことで、未知のモナドに出会ってもその挙動を類推できるようになります。
さっそく、冒頭で示した「構造や性質の中で足し算するコード」を共通化し、未知のモナドを受け入れられるようにしましょう。
共通化するのは次のコードです。
int[] SumAll(int[] array1, int[] array2)
{
var result = new List<int>();
foreach (var x1 in array1) // [1] 1つ目の引数から値を取り出す
{
foreach (var x2 in array2) // [2] 2つ目の引数から値を取り出す
{
result.Add(x1 + x2); // [3] 足し算を行う
}
}
return result.ToArray();
}
Task<int> SumAsync(Task<int> task1, Task<int> task2)
{
return task1.ContinueWith(x1 => // [1] 1つ目の引数から値を取り出す
{
return task2.ContinueWith(x2 => // [2] 2つ目の引数から値を取り出す
{
return x1 + x2; // [3] 足し算を行う
});
});
}
これらのコードの「値を取り出す」部分を Map に置き換え、【構造や性質】が付与されすぎてしまったら適宜 Join を使ってならします。こうして完成した「モナドによる共通化」コードは↓のようになります。
TMonad<int> SumMonad<TMonad>(TMonad<int> monad1, TMonad<int> monad2)
{
return Join(Map(monad1, x1 => // [1] 1つ目の引数から値を取り出す
{
return Map(monad2, x2 => // [2] 2つ目の引数から値を取り出す
{
return x1 + x2; // [3] 足し算を行う
});
}));
}
残念ながら C# では「型引数を取る型」を型引数にできないので、↑のコードはコンパイルできません。このコードは「概念的に共通化できてうれしい!」程度に捉えてください。
C#どころか、ほとんどのプログラミング言語はモナドをインターフェイス(トレイト、プロトコル、etc)として書くことができません。そのため、モナドは大抵の場合「異なる構造や性質を同じように扱うためのデザインパターン」として導入されます。
モナドは【構造や性質】を隠蔽する
前の項では配列とTaskをモナドとして共通化したコードを書きました。共通化後のコードは、配列が持つ構造や、Taskが持つ性質に依存しない実装となっています。扱うモナドがどんな【構造や性質】を付与するか知らなくても、このコードを使えば【構造や性質】の上で足し算ができます。
これをモナドを作る側の立場で言い換えると、 モナドとしての振る舞いを提供することで、付与する【構造や性質】を隠蔽することができます。
モナドは、誰もがオブジェクト指向でやったであろう「実装の隠蔽 / カプセル化」を行うための仕組みでもあるのです。
そもそも、インターフェイスを使って共通化をすれば、個々の具体的な実装は自動的に隠蔽されます。これはモナドに限った話ではありません。
モナドが強力なのは、実装がオブジェクトの内側に隠蔽されるのではなく、 ロジックの間や外側に隠蔽される ように見えるところにあります。
ロジックの外側に隠蔽される【構造や性質】
モナドによって共通化したコードを再掲します。
TMonad<int> SumAsync<TMonad>(TMonad<int> monad1, TMonad<int> monad2)
{
return Join(Map(monad1, x1 => // [1] 1つ目の引数から値を取り出す
{
return Map(monad2, x2 => // [2] 2つ目の引数から値を取り出す
{
return x1 + x2; // [3] 足し算を行う
});
}));
}
このコードの Map(monad1, x1 => ...)
の部分は、モナドに「中の値を維持する」規則があるおかげで、実質的に モナドから値を取り出している と考えることができます。値を取り出すのはそれほど重要な操作ではないので、一時変数を定義するくらいの重要度だと考えてよいでしょう(そういうことにして下さい)。
そのため、よく訓練されたモナド使いには、↑のコードは↓のコードと同じように見えます。
TMonad<int> SumMonad<TMonad>(TMonad<int> monad1, TMonad<int> monad2)
{
int x1 = monad1; // [1] 1つ目の引数から値を取り出す
int x2 = monad2; // [2] 2つ目の引数から値を取り出す
return x1 + x2; // [3] 足し算を行う
}
「同じように見える」どころではありません。実際、モナドを扱う機能がある言語(例として↓のコードはF#)では、値を取り出すのとほぼ同じように書くことができ、Join や Map に相当する関数が自動で挟まれるようになっています。
let SumMonad(monad1: TMonad<int>, monad2: TMonad<int>) : TMonad<int> =
monad {
let! x1 = task1
let! x2 = task2
return x1 + x2
}
そうすると、このコードの中でモナド使用者にとって最も重要なロジックは x1 + x2
ということになります。
しかし実際の処理では、 int x1 = monad1;
や int x2 = monad2;
の裏側、使用者の「目に映らない」ところで【構造や性質】に応じて様々な処理が行われます。例えば配列であれば裏側に foreach ループが隠されますし、 Task であれば裏側に未来で返ってくる値を待つ処理が隠されます。
これが ロジックの間や外側にモナドの実装が隠蔽される ということです。
共通化前の配列を処理するコードを再掲するので、どの部分が「モナド使用者の目に映らない」のか確認してみましょう。
int[] SumAll(int[] array1, int[] array2)
{
var result = new List<int>();
foreach (var x1 in array1) // [1] 1つ目の引数から値を取り出す
{
foreach (var x2 in array2) // [2] 2つ目の引数から値を取り出す
{
result.Add(x1 + x2); // [3] 足し算を行う
}
}
return result.ToArray();
}
x1 + x2
が最重要ロジックとして見えている他は、 var x1 in array1
と var x2 in array2
がかろうじて見える程度です。
ほとんど見えてないじゃねぇか!!
具体的なモナドの例
Option - 「ないかもしれない」性質を付与する
付与するもの | Unit | Map |
---|---|---|
「ないかもしれない」性質 | 「値がある」状態を作る | 値があるなら関数を実行し、値がないなら何もしない |
Option (Optional, Maybe とも) は【元になる型】に「ないかもしれない」という性質を付与します。
Unit 関数は「値がある」状態を作ります。Unit の他に None や Nothing と呼ばれる値を持ち、これは「値がない」状態を表します。
Map は値がない場合は何もしませんから、途中で失敗するかもしれない処理に対して「失敗したら処理を中断する」という処理をロジックの外側に隠蔽することができます。
Observable - 未来で複数の値を返す
付与するもの | Unit | Map |
---|---|---|
「時間軸に沿って複数の値を返す」性質 | 現在で単独の値を返す | 未来で値が返されるたびに実行される |
Reactive Extensions (RX) として有名になった Observable パターンもモナドになっています。Observable は Task が持つ「未来」の性質と配列が持つ「複数」の構造をあわせ持っており、時間軸に沿って何度でも値を返すことができます。
State - 副作用を隠蔽する
付与するもの | Unit | Map |
---|---|---|
中の値と書き換え可能な変数とセットにする構造 | 通常の値として処理 | 書き換え可能な変数を維持しつつ、通常通り関数を実行 |
出た!副作用!!
「モナドは関数型言語で副作用を扱うために導入する」と説明される理由のひとつです。構造が複雑なのでここでは示しませんが、 State モナドは巧みなトリックを使っており、ロジックそのものに「書き換え可能な変数」をひとつ付与します。3
純粋関数型言語では書き換え可能な変数を定義できませんから、State モナドを使うことで書き換え可能な変数を扱います。副作用が許されるプログラミング言語では、わざわざ使うものではないかもしれません。
まとめ
- モナドは 【元になる型】に【ある構造や性質】を付与する 型を共通化する
- モナドは「中の値」を維持する
- モナドは ロジックの間や外側に処理を隠蔽する
何のために導入するかをよく理解して、楽しくモナドを使いましょう!!