FAIC モナド、パート3
2018-01-19 08:29:18
このシリーズでは、圏論や関数型言語の練習から始める「トップダウン」で行くのではなく、パターンを見出そうとすることによる「ボトムアップ」でモナドを理解しようとしている。前回、私はC#でよく使われている5つのジェネリック型を取り上げた。今回はそれらがジェネリック型であるという以上の共通点を探すことから始める。
それに取り掛かる前に、私は、この解説をよりわかりやすくするために3つの小技を使いたい:
第一に、私が前回述べた型のうちの1つは、オンデマンドで生成することができる T
を表す汎用デリゲート型 Func<T>
だった。問題は、高階関数型プログラミングというモナドの性質を探求し始めると、1変数の関数を表現するために Func<A, R>
を使用する必要が出てきて、そうすると Func<T>
もまたオンデマンドで生成することができる T
を表すのに使用しているせいで、混乱を招くということだ。なのでこれからは、我々はこのような汎用デリゲートを持っていることを前提として進もう。
delegate T OnDemand<T>();
そして、モナド型の例の一つとしてはそっちを使う。
第二に、 Nullable<T>
の型引数がnullを許容しない値型に制限されていることは、歴史の事故であることを前回述べた。ここからこの連載では、その不便な事実を無視して、 Nullable<T>
は任意の型で動作できることにする。
第三に、 Lazy<T>
型には異なるスレッドセーフモードがあり、自由に切り替えることができるという事実もまた無視するつもりだ。古い遅延初期化値から新しい遅延初期化値を導出する場合は、おそらく元の値のスレッドセーフモードを維持しなければならない。私はその問題を無視しよう。
さて、わき道はそのぐらいにして、本題を掘りさげていこう。null許容型、遅延初期化型、オンデマンド型、非同期型、およびシーケンス型について最初に注意すべきことは、それらはすべて、多かれ少なかれ値を生成するものであるということだ。null許容型は値を生成しない可能性があり、シーケンス型は任意の数の値を生成するかもしれないが、それらの値は常に元になる型の値だ。これらの型はすべて、ある意味で、元になる型のいくつかの値に対する「ラッパー」なのだ。そして実際には、それより先に進むことができる。元になる型の特定の値を持っている場合は、その値を生成するラッパー型をいつでも簡単に作ることができる:
static Nullable<T> SimpleNullable<T>(T item)
{ return new Nullable<T>(item); }
static OnDemand<T> SimpleOnDemand<T>(T item)
{ return ()=>item; }
static IEnumerable<T> SimpleSequence<T>(T item)
{ yield return item; }
Lazy<T>
と Task<T>
の単純なラッパーメソッドは、練習問題として残してある。
そして、実はこれがモナドパターンの最初の要件である: M<T>
がモナドの型であれば、 T
型の任意の値を M<T>
型の値に変える簡単な方法がなくてはならない。
さて、要件の2つめは当然「あなたは元の値を再び取得できなければならない」だろうなと思うかもしれない。これから分かることだが、それは 惜しい けれども正解ではない。第2の要件はもっと微妙なものなので、慎重にアプローチしていこう。まずは非常に具体的な質問から始めるとしよう:整数に1を足すことができるとして、ではモナド的な整数のラッパーに「1を足す」にはどうすればいい?
かつて8つのエピソードを全部使って論じた通り、コンパイラはnull許容intにそうする方法をすでに知っている。これはすでに言語に「焼きこまれ」ている。1を足すだけで、コンパイラがすべて面倒を見てくれる。しかし、もしそれがなかったとしても、我々はそのようなコードの書き方を知っている。各ステップを慎重に説明するために、普段ほど簡潔ではないやり方で書いてみよう:
static Nullable<int> AddOne(Nullable<int> nullable)
{
if (nullable.HasValue)
{
int unwrapped = nullable.Value;
int result = unwrapped + 1;
return SimpleNullable(result);
}
else
return new Nullable<int>();
}
OK、簡単だ。元の値はnullだったかもしれないので、null許容整数に1を足す操作では当然別のnull許容整数が得られることに注意してほしい。しかし操作は非常に簡単だった:値があればそれを 開封 し、 開封された値に加算演算を実行 して、 結果の値をラップ する。 これは一般的なパターンだろうか? 別の例を見れば、このパターンに従ったらどうなるかわかるだろう:
static OnDemand<int> AddOne(OnDemand<int> onDemand)
{
int unwrapped = onDemand();
int result = unwrapped + 1;
return SimpleOnDemand(result);
}
これはもっともらしく見える。コンパイルも通る。これは正しいだろうか?だめだ。次のようなデリゲートから始めて
() => DateTime.Now.Seconds
それから1を足したとしても、次の結果は得られない。
() => DateTime.Now.Seconds + 1
実際にはこうなる。
() => SomeFixedValue // 何かの固定値
これは間違っているようだ。 加算の左側にあった「オンデマンド的性質」が消えてしまった。値は AddOne
メソッドが呼び出された時点で固定され、結果としてラップされた値はもはや望みのセマンティクスを実装していない。オンデマンドの整数に1を足す正しい方法は、新しいオンデマンド整数が元のオンデマンド整数自体を必要とするように保証することだ:
static OnDemand<int> AddOne(OnDemand<int> onDemand)
{
return ()=>
{
int unwrapped = onDemand();
int result = unwrapped + 1;
return result;
};
}
(繰り返しになるが、私は明示的にすべてのステップを示している。現実には、もっとずっと簡潔なコードを書くだろう。)
これは望みのセマンティクスを保持する。こうすれば、新たに必要される値が要求されるたびに、元のオンデマンド値が必要とされる。
さて、正しいコードでは「開封し、操作を行う」という仕組みを 使っていた ことに気づいただろう。null値を許可するint型の操作とオンデマンドのint型の操作との違いは、 結果となるラップ型が作成される方法 にある。null値を許可するモナドでは、積極的に値を開封し、計算をして、新しい値の単純なラッパーを生成するだけで済ますことができる。オンデマンドモナドでは、新しいラッパーの作成方法についてもっと巧妙でなければならない。これは値の単純なラッパーではないのだ。むしろ、 これから必要とされる操作のシーケンスをその内部構造にエンコードしたオブジェクトを生成する1。この例では、元のデリゲートを呼び出すデリゲートを生成している。
では続けて、別の整数ラッパーに対して「1を足す」操作の例に進もう。また無駄に冗長な書き方を続ける。
static Lazy<int> AddOne(Lazy<int> lazy)
{
return new Lazy<int>(()=>
{
int unwrapped = lazy.Value;
int result = unwrapped + 1;
return result;
});
}
当然のことだが、遅延初期化の整数に1を足すことは、null許容整数に1を足すことと、オンデマンド整数に1を足すことの組み合わせによく似ている。新しい遅延初期化値が計算されるまで、元の遅延初期化値が計算されていないことに注意してほしい。遅延初期化は保存されている。
async static Task<int> AddOne(Task<int> task)
{
int unwrapped = await task;
int result = unwrapped + 1;
return result;
}
おや、この例は簡単だった!これは、null値を許可する最初の例よりも簡単に見えるが、それは、あなたの代わりに多くのコードをまとめて生成する、C#5コンパイラの機能を活用しているからだ。練習として、非同期的に計算される整数の AddOne
操作をC#4で書いてみよう。
static IEnumerable<int> AddOne(IEnumerable<int> sequence)
{
foreach(int unwrapped in sequence)
{
int result = unwrapped + 1;
yield return result;
}
}
この例でも、あなたに代わって多くのコードを書いてくれるC#コンパイラの能力を活用している。 foreach
や yield
を使わずにこのコードを書くことは勉強
になる2。
さて、ここまでに学んだことは?
- モナドパターンの第1のルールは、「元になる」型の値から「ラップされた」型の値に変えるための簡単な方法が常にあるということだ。
- モナドパターンの第2のルールは、ラップされたint型に1を足すと、何らかの方法で別のラップされたintが生成されるが、それは望みの「増幅」を維持するような方法であることだ。
この第2のルールは、いくつかの作業を使っているかもしれない。 次回のFAIC では、モナドパターンの第2のルールを改良して、「ラップされた整数値」に1を足す例を一般化することから始めよう。