Skip to content

第09章: モナディック・オニオンズ

ポイント付きファンクターファクトリ

先に進む前に告白が必要です。これまで各型に追加してきたofメソッドについて完全に正直に説明していませんでした。実はこれはnewキーワードを避けるためではなく、デフォルト最小コンテキストに値を配置するためのものです。ofはコンストラクタの代わりではなく、Pointedと呼ばれる重要なインタフェースの一部なのです。

ポイント付きファンクターとはofメソッドを持つファンクターのことです

ここで重要なのは、任意の値を型に投入してマッピングを開始できる能力です。

js
IO.of('tetris').map(concat(' master'));
// IO('tetris master')

Maybe.of(1336).map(add(1));
// Maybe(1337)

Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])

Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')

IOTaskのコンストラクタは関数を引数に取りますが、MaybeEitherはそうではありません。このインタフェースの目的は、コンストラクタの複雑さや特殊な要求なしに、共通で一貫した方法で値をファンクターに配置することです。"デフォルト最小コンテキスト"という用語は不正確ですが、ファンクターの期待される振る舞いで通常通りmap操作を行うために値を型内にリフトするという考えを適切に表現しています。

ここで重要な修正が必要です。Left.ofは意味を持ちません。各ファンクターは値を内包する単一の方法を持ち、Eitherの場合それはnew Right(x)です。ofRightで定義するのは、型がmap可能であればmapすべきだからです。Leftはこのパターンを破っています。

purepointunitreturnといった関数をご存知かもしれません。これらはofメソッドの様々な呼称です。モナドを使用する際、ofは手動で値を型に戻す責任があるため重要になります。

newキーワードを回避するために、JavaScriptには標準的なトリックやライブラリが存在します。責任ある開発者としてfolktaleramdafantasy-landのファンクターインスタンスを使用することを推奨します。これらは正しいofメソッドとnewに依存しない適切なコンストラクタを提供します。

メタファーの混合

onion

スペース・ブリト(宇宙食ブリトーの比喩)に加え、モナドは玉ねぎに例えられます。一般的な状況で実演しましょう:

js
const fs = require('fs');

// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));

// print :: String -> IO String
const print = x => new IO(() => {
  console.log(x);
  return x;
});

// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))

printmap中に2つ目のIOを導入したため、IOが別のIOに閉じ込められています。文字列を操作し続けるにはmap(map(f))が必要で、効果を観測するにはunsafePerformIO().unsafePerformIO()が必要です。

js
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);

catFirstChar('.git/config');
// IO(IO('['))

2つの効果をパッケージ化できるのは良いですが、二重の防護服を着用しているようで、APIが不自然に複雑になります。別の事例を見てみましょう:

js
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));

// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);

// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
  map(map(safeProp('street'))),
  map(safeHead),
  safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

ネストしたファンクター状況が再発生しています。3回もmapを要求するのは乱暴です。このパターンは繰り返し発生し、モナドの力を発揮する主な場面です。

モナドが玉ねぎに似ているのは、mapでネストしたファンクターの層を剥がすたびに涙が出るからです。joinメソッドを使えばこの問題を解決できます。

js
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))

mmo.join();
// Maybe('nunchucks')

const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))

ioio.join();
// IO('pizza')

const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));

ttt.join();
// Task(Task('sewers'))

同じ型の2つの層が存在する場合、joinで結合できます。この結合能力こそがモナドをモナドたらしめます。より正確に定義を探りましょう:

モナドとは平坦化可能なポイント付きファンクターです

joinメソッドを定義し、ofメソッドを持ち、いくつかの法則に従うファンクターがモナドです。Maybejoinを実装してみましょう:

js
Maybe.prototype.join = function join() {
  return this.isNothing() ? Maybe.of(null) : this.$value;
};

Maybe(Maybe(x))の場合、.$valueが余分な層を除去します。そうでなければ、元々マップされていない単一のMaybeが残ります。

joinメソッドを手に入れたので、firstAddressStreetの例に適用してみましょう:

js
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();

// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead), safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})

ネストしたMaybeが発生する箇所にjoinを追加し、制御を維持します。同様にIOにも適用してみます。

js
IO.prototype.join = function() {
  const $ = this;
  return new IO(() => $.unsafePerformIO().unsafePerformIO());
};

外側と内側のIO層を順次実行します。純粋性は保持したまま、過剰なラッピングを解消します。

js
// log :: a -> IO a
const log = x => new IO(() => {
  console.log(x);
  return x;
});

// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
  curry((sel, props) => new IO(() => jQuery(sel).css(props)));

// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));

// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>

getItemIO Stringを返すためmapで解析します。logsetStyleIOを返すためjoinでネストを制御します。

チェインの連鎖作用

chain

map直後のjoin呼び出しパターンに気付いたでしょう。これをchain関数に抽象化します。

js
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());

// or

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));

mapjoinの組み合わせを単一関数に集約します。chain>>=(バインド)やflatMap(フラットマップ)とも呼ばれます。JSではchainが一般的です。リファクタリング例:

js
// map/join
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead),
  safeProp('addresses'),
);

// chain
const firstAddressStreet = compose(
  chain(safeProp('street')),
  chain(safeHead),
  safeProp('addresses'),
);

// map/join
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

// chain
const applyPreferences = compose(
  chain(setStyle('#main')),
  chain(log),
  map(JSON.parse),
  getItem,
);

map/joinchainで置換し整理しました。chainは効果のネストを容易にし、純粋関数型で順次処理と変数代入を実現します。

js
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
  .chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);

// querySelector :: Selector -> IO DOM
querySelector('input.username')
  .chain(({ value: uname }) =>
    querySelector('input.email')
      .chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`))
  );
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Maybe.of(3)
  .chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);

Maybe.of(null)
  .chain(safeProp('address'))
  .chain(safeProp('street'));
// Maybe(null);

自動導出可能なchain: t.prototype.chain = function(f) { return this.map(f).join(); }。パフォーマンスのために手動定義も可能ですが、機能の正確性を維持する必要があります(Texas Hold'em とラメのマジシャンの比喩のように突拍子もなく見えても、数学的構造は相互に関連しています)。chainからmapjoinを相互導出可能です。fantasylandで詳細が規定されています。

最初の例では非同期処理をチェインし、Task(Task([Friend]))を回避します。2番目の例ではIOを使った変数代入を示します。

IO.ofで値を元通りに配置します。mapも使用可能ですが、適切な型を返す必要があります。

js
querySelector('input.username').chain(({ value: uname }) =>
  querySelector('input.email').map(({ value: email }) =>
    `Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Maybeの例では、nullの場合に計算を停止します。

理解に時間がかかっても心配無用です。試行錯誤を重ねてください。通常値はmap、ファンクター返却時はchainを使い分けます。次章ではApplicative Functorを扱います。

異なるネスト型への対応にはファンクター合成やモナド変換子が必要です。

パワートリップ

コンテナスタイルのプログラミングは混乱を招くことがあります。デバッグにはinspect実装が有効ですが、手間がかかる場合もあります。

モナディックな手法の威力を実演します。

ファイル読み込み後、直ちにアップロードする例:

js
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);

3つのエラーを防止します。Eitherによる検証、Taskのエラー処理、httpPostのエラー処理をチェインで実現します。

線形的で宣言的なコードが実現されます。一般的なAPIではなく汎用インタフェースに依存します。

手続き型との比較例:

js
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
  if (!filename) {
    throw new Error('You need a filename!');
  } else {
    readFile(filename, (errF, contents) => {
      if (errF) throw errF;
      httpPost('/uploads', contents, (errH, json) => {
        if (errH) throw errH;
        callback(json);
      });
    });
  }
};

複雑でエラーが発生しやすい構造です。変数が変更される従来のアプリを想像すると危険です。

理論

結合則について検討します。ただし、通常とは異なる解釈です。

js
// associativity
compose(join, map(join)) === compose(join, join);

モナドのネスト性において、結合順序が結果に影響しないことを示します。図解すると理解しやすいです。まず外側2つのMを結合し、その後全体を平坦化する場合と、内側をマップで平坦化する場合に最終結果が一致します。

monad associativity law

map(join) ≠ joinですが、最終的なM aは同じです。

第二法則同様:

js
// identity for all (M a)
compose(join, of) === compose(join, map(of)) === id;

三角形恒等式と呼ばれます。ofによるラップとjoinが恒等関数と等価であり、内部からのof操作も結合で元に戻ります。

monad identity law

ここで記述したofは、使用するモナドに対応する特定のM.ofでなければなりません。

これらの法則は圏論における結合則と恒等則です。モナドは"Kleisli圏"を形成します。

体系的な説明は控えますが、これらの法則はまさに圏の法則そのものです。モナドは「クライスリ圏」を形成し、すべての対象がモナドで射がチェインされた関数となります。実用的な観点から理論的背景の詳細説明は最小限に留めつつ、日常的な利用に役立つ特性に焦点を当てます。

js
const mcompose = (f, g) => compose(chain(f), g);

// left identity
mcompose(M, f) === f;

// right identity
mcompose(f, M) === f;

// associativity
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));

具体的には、数学的なパズルの組み立て方を詳細に説明せずに圏論の断片を示す形となりました。実用上有益な特性を理解し興味を喚起することを目的としています。

まとめ

モナドはネストした計算を掘り下げます。変数代入、順次処理、非同期タスクをピラミッド構造なしに実現します。ポイント付きファンクターがボックス化解除と再配置を支援します。

複数のAPI呼び出しを並列処理したり、検証エラーを収集する場合など、モナドでは不十分なケースもあります。次章でアプリカティブファンクターを見ていきます。

アプリカティブファンクターが適するケースが多い理由を解説します。

第10章: アプリカティブファンクター

演習

以下のUserオブジェクトを想定:

js
const user = {
  id: 1,
  name: 'Albert',
  address: {
    street: {
      number: 22,
      name: 'Walnut St',
    },
  },
};

練習開始!

safePropmap/joinまたはchainを使用し、ユーザーが与えられた時に安全に住所の道路名を取得せよ // getStreetName :: User -> Maybe String const getStreetName = undefined;


以下のアイテムを考慮:

js
// getFile :: IO String
const getFile = IO.of('/home/mostly-adequate/ch09.md');

// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));

練習開始!

getFileでファイルパスを取得し、ディレクトリを除去してベース名のみをログ出力。ヒント:splitlastを使用 // logFilename :: IO () const logFilename = undefined;


以下のシグネチャのヘルパーを想定:

js
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()

練習開始!

validateEmailaddToMailingListemailBlastを使用し、有効なメールを登録後、リスト全体に通知する関数を作成せよ // joinMailingList :: Email -> Either String (IO ()) const joinMailingList = undefined;