LoginSignup
16
8

More than 3 years have passed since last update.

モナド初学者の鬼門, Effect / IO モナドを攻略する

Last updated at Posted at 2020-11-17

PureScript におけるEffect, Haskell におけるIOは, Maybeモナドあたりを勉強して「モナド完全に理解したわ」となった初学者1を殺す機械として有名ですが, ヤツをぶち殺すことを目標にした記事です。

この記事は誰のためのものか

以下のような方に向いています。

  • 参照透過な純粋関数しか扱えない PureScript や Haskell でどうやって作用を扱っているのか知りたい
  • 世界すべてを引数に渡すとかいうファンタジックな説明2を見てしまいブラウザバックしてしまった
  • モナドは箱であるみたいな説明を見て「あーそーゆーことね完全に理解した」と自惚れた経験がある

また, 説明の都合上基本的な JavaScript の知識があるととっつきやすいと思います。 べつに JavaScript に拘る必要はないので,

  • 関数を第一級オブジェクトとして扱えて(関数を変数に代入したり, 関数の引数や返り値に関数を使えたりできるくらいの意味)
  • 配列やイテレータにflatMapみたいなメソッドがある

言語に親しんでいるならフィーリングで読めるかと。 たとえば Rust や Swift, Python なら文句ないですし, ナウな Java も関数型オブジェクトで似たようなことができて Stream API にflatMapがあるのでいけるはずです。

(追記) しばらく触っていなかったのですっかり忘れていたのですが Python にflat_mapはありませんでした。 お詫びに自分で実装したので許してください。

def flat_map(f, arr):
  return sum(map(f, arr), [])

純粋な関数と純粋でない関数

関数には純粋なものとそうでないものがあります。 純粋な関数というのは簡単に言えば, 入力から出力が一意に決まり, 実行の前後で外部の環境に影響がないようなものです。

たとえば, 以下のふたつの値を足し合わせるような関数は純粋です。

function sum(a, b) {
  return a + b;
}

一方で, 以下の乱数を取得するような関数は純粋ではありません。

function rand(a, b) {
  return Math.floor(Math.random() * (b + 1 - a) + a);
}

純粋な関数において, 「引数がない関数」なんていうのはナンセンスです。 それは定数にしかなりえませんからね。 しかし, 純粋でない関数においてはそうではありません。 たとえば以下の関数は 6 面体のサイコロをシミュレートしますが, 引数が一切必要ないことがわかるかと思います。

function dice6() {
  return rand(1, 6);
}

JavaScript では関数は第一級オブジェクトです。 普通の用途ではすぐに実行されて何らかの値が取り出されるので, これそのものを取り回すことはあまりないので意識されづらいですが,dice6()dice6は別物で, 前者はnumberであり,後者はnumberを返す関数()=>numberです。

Effectの正体

なんでいきなりこんな話を始めたのか? それは, これこそがEffectの正体だからです。 Effectとは, 要するに「実行される瞬間を待っている純粋でない関数」を, ひとつの値として扱っているようなものなのです。 いかに副作用をもつ関数といえど, 実行されなきゃただの値です。

あなたの知らない真実: レシピが勝手に料理を作ることはない。

JavaScript でいう()=>numberは PureScript ではEffect Numberと表記されます。 これをこの記事では「numberの作用」などと呼ぶことにしましょう。

配列はモナドなにそれな人にも身近なモナドの一種ですが, 以下のように好対照なのがわかります。

配列 作用
JavaScript での型表記 T[] ()=>T
PureScript での型表記 Array a Effect a
Tの値を取り出す x[n] x()

作用を引数にとる関数

さて, ここで 3 個のサイコロをふった合計――専門用語(?)で 3D6――を考えるとします。

関数を見ればすぐ実行したがるプログラマは以下のようなコードを書こうとするかもしれません。

const result3d6 = dice6() + dice6() + dice6();

これはこれでいいのですが, 以下のような問題があります。

  1. 使い回しができない。 3D6 を何度も振るたびに上の式を書くのは少し面倒
  2. ほかのサイコロに対応していない。 同様に 3D4 や 3D20 を振る処理が別になってしまう

関数が第一級オブジェクトであることを利用すれば, 以下のような関数が書けます。

function roll3d(d) {
  return function() {
    return d() + d() + d();
  }
}

const roll3d6 = roll3d(dice6); // サイコロの種類ごとに定義できる

const result3d6 = roll3d6(); // 使いまわして実行できる

この関数roll3dは PureScript の世界観から言えばnumberの作用を引数にとってnumberの作用を返す関数といえます。

ここで注目してほしいのは, roll3d自身は「純粋な関数」であるということです。 roll3dは副作用を起こしません。 だってroll3dが実行されてroll3d6に代入された時点では純粋でない関数を実行しませんからね。 副作用を起こしているのはroll3d6の実行時です。

ここまでで「純粋関数型言語で作用を扱う」仕組みというのが見えてきたのではないでしょうか。 実際にサイコロを振らなければ, 「サイコロを振った合計を出す」ことはできません。 しかし, サイコロを振らなくたって「サイコロを振って合計してね」という指示を伝えることはできるのです。

配列をflatMap, 作用もflatMap

いま, サイコロを 1 個振った結果に 2 点上方修正する――専門用語(?)で 1D6 + 2――ことを考えます。 普通なら以下でよいでしょう。

const revisedResult = dice6() + 2;

ですが, 純粋な計算のバージョンを考えると以下のようには書けません。

const revisedRoll = dice6 + 2; // Oops!

number同士を足すことはできてもnumberの作用とnumberを勝手に足すことはできません3。これはnumberの配列とnumberを足せない3ことに似ています。 こういうミスはみんな初学者のころにやりましたよね。

const arr = [1, 2, 3, 4, 5, 6];

const arrPlus2 = arr + 2; // Oops!!

初学者のころやらかしたということは, 「はー? 俺がどうしてほしいかくらい予想つくだろバチボが」という気持ちが心のドコかにあったということです。 実際, 配列に対してやるべきことなんて決まりきってます。 決まりきっているので, flatMapメソッドがあります。 flatMapメソッドは私たちの気持ちを汲み取ってくれるのです。

const arrPlus2 = arr.flatMap(x => [x + 2]); // [3, 4, 5, 6, 7, 8]

配列と作用が好対照なのは示しました。 なら, 私たちの気持ちを汲み取ってくれる作用版のflatMapを考えることはできないのでしょうか? この感情を抽象化といいます。

世間的には「抽象的すぎる」だの「具体的な話をしろ」だのと抽象的というのはたいてい悪いことのように扱われてしまいます。 ですが JavaScript で文字列の結合と配列の結合がどちらもconcatメソッドという名前になっているように, 似たようなことは中身が違っても同じインタフェースでできたほうがみんな幸せです

すっげぇワルの敵が使ってくるヤツじゃ~ん!!
図1. 「抽象化」はすっげぇワルの敵が使ってくるヤツではありません。

JavaScript の場合プロトタイプを利用してかなり自由に既存のオブジェクトをいじれますので, 実際に作用版のflatMapを実装してみましょう。

型は方針の強力なヒントになります。

配列T[]flatMap(x: T)=>R[]な関数を引数にとってR[]を返します。

同様に,

作用()=>TflatMap(x: T)=>()=>Rな関数を引数にとって()=>Rを返すべきです。

ここで作用()=>Tは実行すればTが得られるのですから, 一度実行して得られる値を引数の関数に与えてみましょう。 そうしたならば当然()=>Rを返しますよね。 これを返せばいいでしょうか?

Function.prototype.flatMap = function(f) {
  return f(this()); // ()=>R
};

ちょっと違います。 だって, 本当に実行してしまったらその時点で純粋性は壊れてしまいます。 じゃあ実行待ちにするために全体を関数で囲んでみましょうか。

Function.prototype.flatMap = function(f) {
  const e = this;
  return function() { // ()=>(()=>R)
    return f(e());
  }
};

今度は型が合いません。 先に示したとおり, 外側を実行するわけには行かないので, 内側を実行します。

Function.prototype.flatMap = function(f) {
  const e = this;
  return function() { // ()=>R
    return f(e())();
  }
};

いい感じです! ぶっちゃけ, やってることはほぼほぼただの関数合成なのがわかると思います。 関数合成というのは $g \circ f (x) = g(f(x))$ という数学でやったかもしれないしやってないかもしれないアレです。

配列でいう[hoge]の作用版として() => hogeを使えば, 配列と同じように記述できます。

const revisedRoll = dice6.flatMap(x => () => x + 2);

モナドのパワは素晴らしいぞ

実のところ, これがモナドのすべてです。 flatMapによっていい感じに合成できるもの, がモナドなのです。 クッソ抽象的なので誰も簡潔に説明できません。 でも, そもそも「いい感じ」なので使うのはめっちゃ簡単です。

モナドはいい感じに flatMap できるやつ
図2. 「ソフトウェアエンジニアとしてモナドを完全に理解する」より。 執筆後にこのスライドを見つけたのですが完全に表現がかぶっててパクリ疑惑で炎上が避けられませんね。 ふえぇ。

こんなに単純なことだ!僕と同じような考えなら、以下の疑問を持っていることだろう: もしそんなに単純なら、なんで皆大騒ぎしてるの?何で単に「一つの物を使って別の物を計算する」パターンって呼べばいいんじゃないの?

取り敢えず言っておくと、その名前は長過ぎるのでダメ。次に、モナドは最初に数学者によって定義された。数学者は何にでも名前をつけるのが好きなのだ。数学はパターン探しの専門だが、探した後で何でもいいから名前を付けないと、そのパターンを後で探すのが難しくなるからだ。

  ―― 「モナドはメタファーではない」, Daniel Spiewak, e.e d3si9n

PureScript / Haskell ではflatMap>>=という演算子として定義されています。 また, [hoge]() => hogeも抽象化され, pure / returnという名前の共通した関数になっています。

hoge = fuga >>= (\x -> pure $ x + 2) -- 配列でも作用でもこの式でOK

flatMap / >>=を使えば「いつもの処理」はだいたいなんでもできます。 たとえば 3D6 のやつは以下のようにできます。

const roll3d6 = dice6.flatMap(x => dice6.flatMap(y => dice6.flatMap(z => () => x + y + z)));

とはいえ流石にここまで来るとややこしいですよね。 PureScript や Haskell の場合, >>=演算子の糖衣構文であるdo式があるため, より簡潔に書くこともできます。 これはモナドそのものの強みというよりかは, モナドという形で「いつもの操作」を抽象化したことによって実現できた「言語」の強みですが, 直観的でかなり強力です。

roll3d6 = do
  x <- dice6
  y <- dice6
  z <- dice6
  pure $ x + y + z

今回は配列モナドと対照する形で作用モナドを扱いましたが, モナドでいい感じにできるパターンというのはかなり多いです。

たとえば例外機構は「失敗しない前提で処理を書くが, 失敗してたら一気に大域脱出する」のがモナドでいい感じにできますし, 非同期処理は「非同期に得られる結果を使った処理を書いて, 実際に得られたタイミングで計算する」のがモナドでいい感じにできます。

一般的な言語では「失敗するかもしれない値」「非同期に得られる値」を普通の値のように記述するために専用の文法(try / async)を用意しますが, flatMapをいい感じに定義してモナドにしてしまえば, ひとつのdo式で全部対応できるのでかなりの強みです。 今後「あのモダンな機能がほしい!」となったときに言語の拡張が必要ないのですから。

暗黒面のパワーはすばらしいぞ
図3. モナドは決して純粋関数型言語の暗黒面ではありません。 たぶん。 ごめんちょっと自信ないや。


  1. まあ私も初学者なんですが。 

  2. この説明自体は理論的には正しいはずですが, まず前提としてState sの理解が必須ですし, やっぱ普通の人はRealWorldなんていうのは受け入れがたいと思うので本記事のような泥臭い解釈のほうが通りがいいんじゃないかなぁと思います。 

  3. 厳密に言うと, JavaScript の場合は足すことはできますが, 和が定義されていないため勝手に文字列にキャストされて結合されるだけですので違うよクソってなります。 

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8