matarillo.com

The best days are ahead of us.

FAIC モナド、パート5

2018-01-19 09:45:18

原文

私たちは「モナド・パターン」の実際の要件に近づきつつある。これまでのところ、モナド的な型 M<T> では、 T の値を M<T> に変える簡単な方法がなくてはいけないことを見てきた。また、前回AR に変えるどんな関数でも M<A> に適用して M<R> を作り出すことができ、そのときに関数の動作とモナドの「増幅」の両方を保存できるということを見た。これは実にクールだ。もうこれで大丈夫に見える; 他に何が必要なの?

ええと。ここでマサカリを投げさせてほしい。このまま予定通りには行かせない。私は 任意の 1引数を受け取って voidでない任意の型の戻り値 を返す関数を与えられれば、それをモナドに適用して、戻り値として M<R> を生成できると言った。戻り値の型が任意だって?それなら、こんな1引数関数はどうだろう1:

static Nullable<double> SafeLog(int x)
{
  if (x > 0)
    return new Nullable<double>(Math.Log(x));
  else
    return new Nullable<double>();
}

1引数関数として何の問題も内容に思える。そうであれば、この関数を Nullable<int> に適用できないといけなくて、そのときの戻り値は……

なんてこった!

…… Nullable<Nullable<double>> になるじゃないか。ApplyFunction<A, R> ヘルパー関数は1つの Nullable<A> と1つの Func<A, R> を受け取って Nullable<R> を返すわけで、この場合では RNullable<double> なのだ。

さて、ここで前に言ったことを思い出してほしいのだけれど、私は簡単のために Nullable<Nullable<double>> を作ることができないという不便な事実を無視すると言った; CLRは、型引数がnullを許容しない値型であることをここで要求する。私が言いたいのは、これは規則違反だけれど、もし 許可されていた としても、何かが 間違っている ことに変わりはないということだ。

同様に、 int を受け取って Lazy<double> を返す関数があった場合は、それを Lazy<int> に適用したときに Lazy<Lazy<double>> ができるのはどうもおかしい。int を受け取って Task<double> を返す関数を Task<int> に適用して Task<Task<double>> ができるのも変だ。以下同様。これら全ての場合で、ApplyFunction ヘルパー関数は余計な間接性を削除できて当然に思える。ApplyFunction の新しいバージョンを作ってみて、このアイディアを検証してみよう。

static Nullable<R> ApplySpecialFunction<A, R>(
  Nullable<A> nullable,
  Func<A, Nullable<R>> function)
{
  if (nullable.HasValue) 
  {
    A unwrapped = nullable.Value;
    Nullable<R> result = function(unwrapped);
    return result;
  }
  else
    return new Nullable<R>();
}

どう、これ簡単でしょ?これなら、null許容 intSafeLog を適用してnull許容 double を得ることができる。null許容 null許容 double じゃなくてね。ただ、Nullable<T> はモナド五大将の中でも最弱……なのはすでに知っているだろう; 今度は OnDemand<T> 用の関数適用ヘルパーを書いてみよう。前のバージョンを修正するんだ:

static OnDemand<R> ApplySpecialFunction<A, R>(
  OnDemand<A> onDemand,
  Func<A, OnDemand<R>> function)
{
  return () =>
  {
    A unwrapped = onDemand();
    OnDemand<R> result = function(unwrapped);
    return result();
  };
}

またもや朝飯前だった。すべての計算は必要に応じて行われる性質は持ったままだし、 OnDemand<OnDemand<R>> にはならなかった。Lazy<T>Task<T> 用の関数適用ヘルパーも同様にやっつけよう:

static Lazy<R> ApplySpecialFunction<A, R>(
  Lazy<A> lazy,
  Func<A, Lazy<R>> function)
{
  return new Lazy(() =>
  {
    A unwrapped = lazy.Value;
    Lazy<R> result = function(unwrapped);
    return result.Value;
  };
}

static async Task<R> ApplySpecialFunction<A, R>(
  Task<A> task,
  Func<A, Task<R>> function)
{
  A unwrapped = await task;
  Task<R> result = function(unwrapped);
  return await result;
}

パターンが見えた?モナド的な型は常に M<M<R>>M<R> に「展開」する方法を知っている!Nullable<Nullable<R>> を避けるには、外側のNullableが値を持っているかチェックする。もしあったならそれを使うし、なければ値のない Nullable<R> を作ってそれを使う。Task<Task<R>> を避けるには、外側のタスクを待った後で内側のタスクを待つ。以下同様だ。

いけない、1つ忘れてた。シーケンスのシーケンス、つまり IEnumerable<IEnumerable<R>> を、1つのシーケンスである IEnumerable<R> にするにはどうすればいい?実はこれも簡単だ; 内側のシーケンスを全部連結して、1つの大きいシーケンスにするだけだ2

static IEnumerable<R> ApplySpecialFunction<A, R>(
  IEnumerable<A> sequence,
  Func<A, IEnumerable<R>> function)
{
  foreach(A unwrapped in sequence)
  {
    IEnumerable<R> result = function(unwrapped);
    foreach(R r in result)
      yield return r;
  }
}

さて、まとめよう。今のところ以下のルールが見出されたようだ:

モナド・パターンの第1ルールは、型 T の値を M<T> のインスタンスに「ラップする」方法が常にあること。連載記事の最初の方で、次のようなヘルパーメソッドを必要とするというようにこのルールを表現した:

static M<T> CreateSimpleM<T>(T value)

モナド・パターンの第2ルールは、型 A を型 R に変える関数がある場合は、その関数を M<A> に適用して M<R> のインスタンスを得ることができること。次のようなヘルパーメソッドを必要とするというようにこのルールを表現した:

static M<R> ApplyFunction<A, R>(
  M<A> wrapped, 
  Func<A, R> function)

モナド・パターンの第3ルールは、型 A を型 M<R> に変える関数がある場合は、その関数を M<A> に適用して M<R> のインスタンスを得ることができること。次のようなヘルパーメソッドを必要とするというようにこのルールを表現した:

static M<R> ApplySpecialFunction<A, R>(
  M<A> wrapped, 
  Func<A, M<R>> function)

本当にこれらがモナド・パターンのルールなんだろうか?ちょっとした問題点があるようだ: この3つのルールには重複がある。ルール2はルールに含めなくてもいい; これはルール1と3から導き出せるからだ!理由はすぐ分かる。モナド的な型 M<T> に対して、CreateSimpleMApplySpecialFunction がすでに書かれているとしよう。ApplyFunction はすぐに実装できる:

static M<R> ApplyFunction<A, R>(
  M<A> wrapped, 
  Func<A, R> function)
{
  return ApplySpecialFunction<A, R>(
    wrapped,
    (A unwrapped) => CreateSimpleM<R>(function(unwrapped)));
}

そうすると ApplyFunctionApplySpecialFunction は名前付けがよくなかった; 実際には前者が後者の特別な場合だからだ!

こうして、ルール2は取り除くことができた。ルール1と3から導けるからだ。モナド・パターンの新ルールはこうなる:

ルール1: 次のようなヘルパーメソッドが存在する。

static M<T> CreateSimpleM<T>(T value)

ルール2: 次のようなヘルパーメソッドが存在する。

static M<R> ApplySpecialFunction<A, R>(
  M<A> wrapped, 
  Func<A, M<R>> function)

そしてこのメソッドは、与えられたモナド型の「増幅」を保存し、適用された関数の意味を保存する。さらに、通常は ApplySpecialFunction は次のように動作する: ラップしているオブジェクトから元の値の集合を取り出し、関数に適用してラップされた結果の集合を作り、そして何らかの方法で1つのラップされたオブジェクトに結合する。 「抽出」と「結合」のステップの詳細によって、モナドがどのような「増幅」を実装するかが決まるのだ

モナド・パターンのルールは本当にこれでいいのか???基本的にはこれでいい3。 (ようやく!) しかし、モナド・パターンを正しく実装するためには、ApplySpecialFunction がいくつかの不変条件を保証しないといけない。

次回のFAIC は、脱線して 金曜のお楽しみ・再放送 をやる。来週は モナド・パターンの調査を再開して、残りのルールを導き出す。


インデックスへ戻る


  1. しつこいようだが、説明のために普段よりずっと冗長なコードを書いている。そしてもちろん、double にはすでに NaN という「存在しない」値があることを無視している。 ↩︎

  2. もしこの後注意してみていれば、シーケンス用の ApplySpecialFunctionSelectMany 拡張メソッドの一種として知られていることに気づくことができるだろう。この興味深いけれどもぜんぜん偶然じゃない事実については、連載の後ろの方で取り上げる予定だ。 ↩︎

  3. モナドのルールを説明するにはもう1つの等価な方法がある: 元の ApplyFunction 操作が存在することと、M<M<R>>M<R> にたたむ方法が存在することだ。そういう説明をしたとしても仕組みは十分分かりやすかっただろうけれど、モナド・パターンの説明では普通は使われない方法なので、この連載の中ではこれ以上とりあげることはない。 ↩︎