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();
これはこれでいいのですが, 以下のような問題があります。
- 使い回しができない。 3D6 を何度も振るたびに上の式を書くのは少し面倒
- ほかのサイコロに対応していない。 同様に 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[]
を返します。
同様に,
作用
()=>T
のflatMap
は(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
によっていい感じに合成できるもの, がモナドなのです。 クッソ抽象的なので誰も簡潔に説明できません。 でも, そもそも「いい感じ」なので使うのはめっちゃ簡単です。
図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. モナドは決して純粋関数型言語の暗黒面ではありません。 たぶん。 ごめんちょっと自信ないや。
-
まあ私も初学者なんですが。 ↩
-
この説明自体は理論的には正しいはずですが, まず前提として
State s
の理解が必須ですし, やっぱ普通の人はRealWorld
なんていうのは受け入れがたいと思うので本記事のような泥臭い解釈のほうが通りがいいんじゃないかなぁと思います。 ↩ -
厳密に言うと, JavaScript の場合は足すことはできますが, 和が定義されていないため勝手に文字列にキャストされて結合されるだけですので違うよクソってなります。 ↩ ↩2