Admittedly something small.

ちょっと小さいのはたしかですが。

AffですべてのPromises/Generatorsを過去にする/そして何故我々は作用をモナドで抽象化すべきなのか

2015年9月17日 ( 9年前に投稿 )

JavaScript Haskell promise generator purescript

PromiseやGeneratorのような機構を使ってもなお非同期処理は厄介だ、そしてもっとシンプルで便利な方法があるよ、という話です。前半の議論を元に、後半ではなぜプログラミング言語の作用をモナドで抽象すると便利なのかということの説明をしています。

関数型プログラミング言語は「副作用をなるべく減らすことで安全性を高めた言語」というように説明されることがありますが、すべての式が副作用を持たないという『純粋』関数型プログラミング言語が言語を参照透明にしてモナドを導入するのは決して「副作用はなるべく避けたほうが安全だから」という理由だけではないのです。長いですが、これでも結構削りました。

序盤戦・Promises/Generatorsの光と影

Promises/Generatorsで世界はちょっと平和になる

かつてはJavaScriptの非同期処理はコールバック地獄に陥ったり様々なパターンが混在したりして、『地獄』と呼ぶのに似つかわしい混沌とした状態にありましたが、最近になってPromiseとGeneratorが導入され、ようやくうまく書ける環境が整いつつあります(脚注1)。

簡単な例として、Nodeで3つのファイルfoo.txtbar.txtbaz.txtを読み込んでつなげて出力したいとします。まずはNode形式の非同期処理関数をPromise化する関数promisifyを次のように適当に定義しておきます。

function promisify(f){
    return function(){
        return new Promise((resolve, reject)=>{
            f.apply(undefined, Array.prototype.slice.call(arguments).concat([(err, data)=>{
                err ? reject(err) : resolve(data);
            }]));
        });
    };
}

この関数の定義の詳細はどうでもいいのですが、とにかくこれでpromisify(fs.readFile)とすれはPromiseを返すような非同期な関数になるので、ジェネレータ関数をうまいこと処理してくれるtj/coを組み合わせて次のように書くことができるようになります。

co(function*(){
    try{
        var foo = yield promisify(fs.readFile)("foo.txt");
        var bar = yield promisify(fs.readFile)("bar.txt");
        var baz = yield promisify(fs.readFile)("baz.txt");
        console.log(foo + bar + baz);
    }catch(err){
        console.log(err);
    }
});

ネストが深くなっていかないし、例外処理もtry-catchで同期処理の時と同じように書くことができています。これでJavaScriptの非同期処理におけるコーディングの問題は概ね解決したといっていいでしょう。Generatorsを使えない環境もあるので、現実的にはBabelでバベることになるかもしれませんが、解決の道筋は与えられたといえます。ここまではJavaScript基礎編ですが、大丈夫でしょうか。

何かがおかしい

さて、これで構文としては悪くないとは思うのですが、yield promisify(fs.readFile)という部分が3度も繰り返しているのが少し気になります。promisify(fs.readFile)の繰り返しについては、以下のように関数として括りだすことで重複を防げます。

function readFilePromise(path){
    return promisify(fs.readFile)(path);
}

しかし、yieldの繰り返しについては、他の関数に括りだすというわけにはいきません。promisifyはただの関数ですが、yieldは関数ではないからです。当たり前だろ!と思うかもしれませんが、なぜそんな指摘をするのかはあとでわかります。

それにまだ問題があります。たとえば、Array.prototype.forEachを使った繰り返しの中ではyieldが使えないのです。

["foo.txt", "bar.txt", "baz.txt"].forEach(function(path){
   var content = yield readFilePromise(path);  // SyntaxError: Unexpected identifier
   console.log(content);
});

これは、forEachに渡すコールバック関数の中では、ジェネレータ関数とは別の関数の文脈になってしまうからです。次のようにもう一度coを呼び直せばうまくいくかと思いましたが、何も出力されませんでした。よくわかりません。

["foo.txt", "bar.txt", "baz.txt"].forEach(function(path){
    co(function*(){
        var content = yield readFilePromise(path);
        console.log(content);
    });
});

ここまできてyieldと引き換えにforEachを失うとか悲しすぎです。もっとも、回避策がまったくないわけではなく、coは配列も扱えるので次のように書く手があります。

var contents = yield ["foo.txt", "bar.txt", "baz.txt"].map(path => promisify(fs.readFile)(path));
console.log(contents.map(s => s.toString()).join(""));

これでも動きますが、もともとの同期処理とは似ても似つかないコードになりました。同期処理の時と同じような書き方ができているわけではありません。

それに、静的型付けのTypeScriptはyieldをどう扱うつもりなのでしょうか。yieldにはどんなデータでも渡すことができますが、どのようなデータを正しく処理できるかはジェネレータ関数を受け取るcoのような関数次第です。coPromiseだけでなく配列、オブジェクト、Generatorオブジェクト、ジェネレータ関数までいろいろな値を受け付けます。yieldのような構文を持ち込むと、それが関数でない以上例外的に扱って複雑な型付けをするか、anyでガバガバ動的型付けすることになります。

Promise/Generatorで確かに問題は解決に近づいているものの、その場しのぎの奇策を打った結果、あちこちで隠し切れないボロがはみ出ている感じです。仕方ないだろ!それくらい我慢しろ!と思うかもしれませんが……もし、もっと単純で、言語そのものを変更するのではなく単なるライブラリを使うだけで、同期処理と同じように繰り返しを書くことができ、しかも静的型付けにも問題のない、そんな夢のような非同期処理の方法があったらどうでしょうか。

中盤戦: PureScript-affの降臨

どうせ構文に手を入れたりBabelみたいなコンパイラを使うなら、JavaScriptを離れてPureScriptのようなAltJSを導入してしまう手があります。Scriptって名前に入っているからには、PureScriptはスクリプト言語なんでしょう。しかも名前がPで始まるんですよ!こうなるとPureScriptはPerl、PHP、Python、PowerShellとかのPの一族だと考えるのが自然です。もうどう考えてもゆるゆるガバガバ、楽すぎて開発者を人として駄目にする、ぬるま湯のようなスクリプト言語に違いありません。

PureScriptの同期処理

後の非同期処理のコードとの比較のため、まずはPureScriptで同期的な処理のほうがどうなるかの雰囲気を見てほしいと思います。重要でないのでPureScriptの構文の詳細について省きますが、なんとなく意味がわかるように解説を加えておきます。

main = do
    catchException (log <<< show) do
        foo <- FSS.readTextFile UTF8 "foo.txt"
        bar <- FSS.readTextFile UTF8 "bar.txt"
        baz <- FSS.readTextFile UTF8 "baz.txt"
        log (foo ++ bar ++ baz)
  • 簡単のため、このテキストではimport節や型定義は省略しています。
  • catchException関数がJavaScriptのtry-catch節に相当しています。
  • log <<< showのところがcatchに相当していて、例外が起きたときに例外オブジェクトを文字列にしてから標準出力することを指示しています。
  • do以降の一段インデントされているところがコードブロックになっており、fs.readFileSyncに対応するFSS.readTextFileを順次呼び出して結果を得ています。FSSはモジュールNode.FS.Syncの別名です。
  • 最後にconsole.logに対応するlogで結果の文字列を標準出力するだけです。

多少字面が異なるだけで、やっていることはJavaScriptと大体同じようなものです。この中括弧のないスッカスカの見た目はCoffeeScriptやPythonを思わせます。この時点で人を堕落させる誘惑がほとばしっています。

ちなみに繰り返しは、JavaScriptのforEach関数みたいな感じでfor関数を使います。

for ["foo.txt", "bar.txt", "baz.txt"] \path -> do
    content <- FSS.readTextFile UTF8 path
    log content

同期処理はどの言語でもまあ問題はありません。問題は非同期処理です。

夢のような方法はあった

PureScriptにはpurescript-affという便利な非同期処理ライブラリがあります。JavaScriptがfunction*というキーワードのついたジェネレータ関数のうえでだけyieldするように、purescript-affではAffと呼ばれる文脈の上でのみ非同期処理を実行していきます。まずは、先ほどJavaScriptでNode形式の関数をPromise化したのと同じように、Nodeのfs.readFileに相当する非同期なテキストファイル読み込み関数Node.FS.Async.readTextFileAffで使える形式で次のように定義し直します(脚注2)。この定義の詳細は気にしなくて構いません。とにかく、このreadTextFileAffAffという非同期な文脈の上で非同期にファイルを読み込むことができる関数になります。

readTextFileAff encoding path = makeAff (\reject resolve -> Node.FS.Async.readTextFile.readTextFile encoding path (either reject resolve))

これを使うと、先ほどのJavaScriptのコードは、PureScriptで次のように書くことができます。

mainAff = launchAff do
    catchError (do
        foo <- readTextFileAff UTF8 "foo.txt"
        bar <- readTextFileAff UTF8 "bar.txt"
        baz <- readTextFileAff UTF8 "baz.txt"
        liftEff (log (foo ++ bar ++ baz))
    ) (\err -> do
        liftEff (log (message err))
    )
  • launchAffは非同期処理を実際に起動する役割を持つ関数です。JavaScriptのcoと同じような役目です。
  • catchError(脚注3)はtry-catch節に相当する関数です
  • liftEff関数はJavaScriptのyieldと同じように同期と非同期の橋渡しをする役目があります。
  • readTextFileAffを順次呼び出して、最後にliftEfflogで出力しています。

同期的な処理と同じように改行で区切って続けて書いていくだけで、逐次の非同期処理ができています。でもそれだけではありません。liftEffはJavaScriptのyieldのように同期処理と非同期処理の橋渡しをするものなのですが、キーワードであるyieldと違ってliftEffはただの関数です。つまり、liftEff (log (...))というようなコードが重複していますが、次のような関数logAffを定義すると、liftEffの呼び出しごと重複部分を括りだすことができます

logAff text = liftEff (log text)

このlogAffを使うと、先ほどのliftEff (log (foo ++ bar ++ baz))という呼び出しのところで、

logAff (foo ++ bar ++ baz)

と書くことができるのです。繰り返しも問題ありません。Node.FS.Async.readTextFileの代わりにreadTextFileAffを使うだけで、forも同期処理と同じように使うことができます

for ["foo.txt", "bar.txt", "baz.txt"] \path -> do
    content <- readTextFileAff UTF8 path
    logAff content

また、非同期処理と同期処理を何度ネストしてもまったく問題ありません。次のように、launchAffliftEffを使えばいくらでも自由に同期処理と非同期処理をネストして切り替えられます。こういう非同期処理と同期処理のネストは意外と頻繁に発生するので、そのようなややこしい処理でも素直に書けるというのは便利です。

main = do                                                 -- mainは同期処理
    launchAff do                                          -- launchAffで同期処理部分から非同期処理へ切り替える
        foo <- readTextFileAff "foo.txt"                  -- ここは非同期処理
        liftEff do                                        -- liftEffで非同期処理部分から同期処理へ切り替える
            bar <- readTextFile "bar.txt"                 -- ここは同期処理
            launchAff do                                  -- また非同期処理へ
                baz <- readTextFileAff "baz.txt"          -- ここは非同期処理
                liftEff do                                -- またまた同期処理へ
                    bow <- readTextFile "bow.txt"         -- ここは同期処理
                    log foo                               
 

Affには次のような利点があります。

  • function*yieldのような特殊な構文を持ち込む必要がありません。Affで使うのはすべて通常の関数であり、yieldの奇妙な振る舞いについて改めて学ばなくて良いので学ぶのも簡単です。
  • liftEffはただの関数ですから、liftEffごと重複部分を別の関数にくくりだすことができます。
  • forのような繰り返し構造の中でも特に問題はありません。同期的な処理のときとまったく同じように書くことができます。
  • 非同期処理と同期処理を何度ネストしても大丈夫です。coのようにネストしたら動かなくなったりはしません
  • すべての式は厳密に静的型付けされています。TypeScriptにようにときおりanyでガバガバにして誤魔化したりは一切していません
  • もちろんエラー処理も直感的で、throwError関数を呼ぶだけでその非同期処理は中断し、エラーが自然に呼び出し元に伝わります。

非同期処理のコールバック地獄のようなコーディング上の問題を、言語自体に新たな構文を付け加えて解決しようとするのはあまり好ましい方法とは言えません。言語の構文を拡張すると、コンパイラはもちろん、デバッガもエディタもリファクタリングツールも、ありとあらゆるツールで対応が必要になり、言語は複雑さを増し、学習コストは増大していきます。しかも、JavaScriptのように同一のスクリプトが複数の処理系で実行される場合、すべての処理系でその新しい構文が実装されていなければなりません。現実的にはBabelのようなコンパイラで処理系ごとの実装の差を吸収しないとどうにもならないでしょう。ライブラリだけでなんとか解決できれば理想ですが、PureScriptではその理想が本当に実現できてしまうのです。しかも、co/Generatorsにあったような、ネストするとうまく動かないという欠点もまったくありません。

JavaScriptだと、yieldどころか、今後さらにasync/awaitの導入まで検討されています。たかが非同期処理ごときのために、どれだけ言語を複雑にする気なのでしょうか。PureScriptなら、非同期処理をしてもコールバック地獄に陥らないし、ジェネレータ関数やyieldのような異様な構文も学ばなくて大丈夫です。それどころか、for文やtry/catch文すらありません。オブジェクト指向もなくJavaScriptのthisの謎な振る舞いに悩まされる必要も、function式とラムダ式の微妙な違いに頭を抱えることもありません。プログラミングがこんなに簡単でいいのでしょうか。PureScriptでこんなに甘やかされてしまってはプログラミング技術も錆びついてしまいそうです!

Affはなぜこんなことができるのか

JavaScriptでは非同期処理のたびにネストが積み上がっていったりして、言語にGeneratorsでyieldという構文を追加する変更を加えてもなお、Array.prototype.forEachのような繰り返し構造の中でyieldが使えない欠陥を残すという限界がありました。それに対してpurescript-affは、言語そのものに変更を加えることなく、単なる関数群を提供するだけで非同期処理でネストが積み上がる問題を解消し、繰り返しのforも同期処理のときとまったく同じように使えるという一貫性がとれています。

purescript-affはなぜこんなことができるのでしょうか。単なる関数を提供するだけでコールバック地獄が解消できるなら、JavaScriptでもGeneratorsは必要ないのでしょうか。一言で言えば、このようなことが可能なのは、PureScriptはモナドですべての作用(脚注4)を抽象化して直接計算の対象にできるように言語自体が設計されているからなのです。JavaScriptでは作用を直接扱う方法が提供されていないので、構文そのものに手を加えないとうまく書くことができず、ライブラリだけでは解決できないのです。

JavaScriptの式が表すものと、PureScriptの式が表すものの違い

JavaScriptで作用のある式が持つ値は作用の結果です。例えば、次の式

console.log("Hello")

は、標準出力にHelloと出力する作用の結果、つまりundefinedを値として持っています。また、

fs.readFileSync("foo.txt");

という式は、このファイルfoo.txtを読み込むという作用の結果である、そのファイルの中身のテキストを値として持っています。このため、

var fooBar = fs.readFileSync("foo.txt") + fs.readFileSync("bar.txt");

とすると、変数fooBarfoo.txtbar.txtの中身を連結したものになります。当たり前じゃないか、と思うかもしれませんが、PureScriptでは同じような見た目の式がまったく異なる意味を持っています。例えば、関数logに引数"Hello"を与えた式、

log "Hello"

が持っている値は、「Helloという文字列を出力する作用」という作用そのものです。「Helloという文字列を出力した結果」の文字列ではなく、作用そのものを表すEff(作用、Effectの略から)という型の値になっています。同様に、

FSS.readTextFile UTF8 "foo.txt" 

という式は、「foo.txtというテキストファイルを読み込む」という作用そのものを値として持っているのです。

作用どうしを計算する

FSS.readTextFile UTF8 "foo.txt"が表しているのは作用そのものですから、次のようには書くことはできません。

fooBar = FSS.readTextFile UTF8 "foo.txt" ++ FSS.readTextFile UTF8 "bar.txt"        ---- コンパイルエラー!

++は文字列を連結する演算子であって(脚注5)、作用どうしを連結する演算子ではないからです。作用どうしを連結する演算子というものは別にあって、例えば>>という演算子を使うと、次のように書くことができます。

fooBar = FSS.readTextFile UTF8 "foo.txt" >> FSS.readTextFile UTF8 "bar.txt"

このとき、fooBarは「foo.txtを読み込み、それからbar.txtを読み込むという作用」を表しているのです。PureScriptには他にも作用どうしを計算するような関数や演算子が多数定義されています。このような作用の取り扱いは、JavaScriptとPureScriptの決定的な違いになっています。

作用の結果を扱う

それじゃあPureScriptで作用の結果を扱うにはどうしたらいいのかと思うかもしれませんが、もちろん作用の結果の方を扱う直接的な方法も提供されています。最初のJavaScriptの例と同じように、foo.txtbar.txtを読み込んだ結果どうしを連結するには次のように書く方法があります。

fooBar = do
    foo <- FSS.readTextFile UTF8 "foo.txt"
    bar <- FSS.readTextFile UTF8 "bar.txt"
    log (foo ++ bar)

あるいは、<$><*>という演算子を使って次のように書くこともできます。

fooBar = (++) <$> FSS.readTextFile UTF8 "foo.txt" <*> FSS.readTextFile UTF8 "bar.txt" >>= log

<$><*>>>=は演算子ですが、これは言語に組み込みのものではなく、ライブラリで提供されている普通の演算子に過ぎません。後者のほうはなんだか演算子だらけになっていて、見た目上JavaScriptよりややこしくなってることは確かで、この辺りに作用そのものを扱えるようにしたことの代償が見え隠れしています。でも、<$><*>の意味を詳しく知るとわかりますが、上のコードでやっていることは見た目から想像するものよりずっとシンプルな意味を持っていて、書くのは別に難しくはありません。考え方としては、まず単に次のようにreadTextFileを直接++で繋げられたらいいな、と考えます。

FSS.readTextFile UTF8 "foo.txt" ++ FSS.readTextFile UTF8 "bar.txt"

演算子++はカッコで囲んで(++)と書くと、以下のように通常の関数と同じ語順で適用を書くことができます。

(++) (FSS.readTextFile UTF8 "foo.txt") (FSS.readTextFile UTF8 "bar.txt")

気持ちとしては(++)関数にFSS.readTextFile UTF8 "foo.txt" 及び FSS.readTextFile UTF8 "bar.txt"という2つの引数を与えているわけです。この関数と引数の間に、<$>および<*>という演算子を挟みこむように書きます。

(++) <$> (FSS.readTextFile UTF8 "foo.txt") <*> (FSS.readTextFile UTF8 "bar.txt")

こうすると、うまく型が合ってコンパイルが通り、正しく動作します。一般的に言えば、普通の関数適用はf x y zというように引数を渡しますが、引数x,y,zがモナドな値のときは、

f <$> x <*> y <*> z

というように<$><*>を挟むとfに適用することができるというイメージです。このような書き方をアプリカティブスタイルといい、モナドなら必ずこの書き方ができるので、慣れれば迷わずにスラスラ書けるようになります。演算子が多くてちょっと面倒臭いところはありますが、概念そのものはよく整理されているので一貫性はあります。

HaskellやPureScriptは作用そのものと作用の結果の両方をうまく扱えるように最初から設計されており、それゆえ作用そのものを計算することも容易ですし、作用の結果を扱うのも簡単なのです。

Aff

Affという名前はAsynchronous E<span/>ffectから来ていて、Affもまた作用の一種であり、Effと同様に計算の対象にすることができます。たとえば、非同期なファイル読み込み関数readTextFileAffを使って次のようなコード書くことが可能です。モナドで抽象化した作用はどんな作用でも>>という演算子で連結することができるようになっているのです。

fooBarAff = readTextFileAff UTF8 "foo.txt" >> readTextFileAff UTF8 "bar.txt"

このとき、fooBarAffは「foo.txtを非同期に読み込み、それが完了したらbar.txtを非同期に読み込むという非同期な作用」を表しています。作用そのものを直接扱うことができるので、作用どうしの連結のしかたはプログラムで自由にコントロールすることができます。この非同期な処理では裏ではNode形式のコールバックが動いて非同期な作用をつなげているのですが、その実装を完全に隠蔽して、見た目上まるで同期的な処理のように表現することができるのです。

yieldliftEff関数

JavaScriptのyieldはPromiseのような非同期的な処理を表す値を同期的な処理に変換するような役割だとみなすことができます。それに対し、PureScriptのliftEffは、同期的な作用を非同期的な作用に変換するという逆の方向で非同期と同期の橋渡しをしています。これはliftEffが次のようなシグネチャを持っている(脚注6)ことからも一目瞭然です。ここで、関数の型f :: forall a b . a -> b(脚注7)はabに写す関数、プログラミング言語っぽくいえば、型aの引数を受け取り型bの返り値を返す関数のことです。

liftEff :: forall a eff . Eff eff a -> Aff eff a

これをみればEffAffに写す関数であることが一発でわかります。yieldがどのように振る舞うのか理解するのは結構ややこしいですが、liftEffがやっていることは単に値の型を変換しているのだと捉えることができますし、しっかり静的型付けされているので、使い方を間違えようがありません。間違っていればコンパイルが通らず、間違っている行を教えてくれますし、正しくてコンパイルが通れば本当に正しく動きます。さっき筆者がcoを2重にネストして呼び出したらうまくいかず、どこがどう悪かったのかもわからなかったのに比べれば、ずっとシンプルで明白、しかも安全です。

終盤戦・何故我々は作用をモナドで抽象化すべきなのか

PureScriptは「すべての式が参照透明」つまり「どの関数の呼び出しでも副作用がない」という『純粋』関数型プログラミング言語なる超マイナーなジャンルのプログラミング言語です。一見してわかる通りこれは非常に強い制約であり、こんな極端な言語仕様を採用しているのは関数型プログラミング言語というマイナーなジャンルの中でも更にごく一部の言語に限られます。これらの言語について、「副作用のある式も都合に合わせて自由に選んで使えたほうが便利だろうに、なぜわざわざすべての式が参照透明などという極端なことをしなければならないのか」「なぜモナドとかいうややこしいものを導入してまで、参照透明性にこだわるのか」という疑問を抱く人も多いでしょう。

言語全体を参照透明性にする理由としてしばしば挙げられるものに、「下手に状態を書き換えるとバグの元になるから、副作用がない処理を組み合わせたほうが安全だ」というものがあります。でもJavaScriptのような動的な型付けな言語で状態を書き換えまくるようなコーディングを平気でこなしている人もいるわけで、その人に「副作用がないほうが堅牢なのが利点」などと説明しても納得は得難いでしょう。「そんなの下手に状態を変更しないように気をつけて書けばいいだけじゃん」といわれるだけです。そういう利点もあるとは筆者は思いますが、説得力に欠けるように思います。

Haskellの場合はまた特殊で、「評価戦略を遅延評価にしたかったから」というのが言語を参照透明にしてモナドを導入する強い理由になっているようです。遅延評価を前提にするならもう言語を参照透明にする理由は十分なのですが、それに比べると正格評価のPureScriptでは参照透明にしてモナドを導入する大きな理由がひとつ欠けていることになります。

それではPureScriptで言語を参照透明にする理由がないかというと、決してそんなことはないと筆者は思います。これまで見てきたように、作用をモナドとして抽象化し、作用そのものを計算の対象として扱えるととても便利なのです。作用を直接制御できると、同期処理とほとんど同じように非同期処理が書けたり、for文のような制御構造も要らなくなります。何もかもとてもシンプルになるのです。作用を直接扱うなんてことはめったにない、そんなのは特殊な用途のプログラミングだけだろう、などと思う人もいるかもしれませんが、現に我々は非同期な作用の扱いでこれだけ苦労しています。作用をデータとして扱うのは決して特殊な用途だけというものではありません。

JavaScriptのような言語を使っていると気づきませんが、作用をデータとして扱うと便利だという場面は実はあらゆるところに発生しているのです。作用の種類は同期的な作用Effと非同期な作用Affだけではありません。たとえば以前筆者が書いた記事のグラフィックを描画する作用Graphicsもまた作用の一種であり、特定の関数を呼ぶことでEffAffと組み合わせることができます。見方を変えれば、Affは非同期処理に特化した領域特化言語なので便利なんだと捉えることもできるでしょう。

言語を参照透明にすると、関数が直接副作用を起こすことは禁止、変数の書き換え禁止になるなど、一見とても不便そうに見えます。しかし実際に使ってみると、JavaScriptでyieldforEachの中で使えないというような不都合がPureScriptではなくなっていたり、むしろ自由になることも多いのです。逆説的ですが、制約こそが純粋関数型プログラミング言語に自由をもたらしています。

まとめ

purescript-affを使うと:

  • purescript-affは非同期処理のコールバック地獄をうまく解決します。
  • Promises/GeneratorsのときのようにforEachで問題を起こすこともありません。
  • また、単なるライブラリなので新たな構文を導入することもありません。

作用をモナドで抽象化すると:

  • あとから好きなだけ作用の種類を追加することができます。たとえば、ネイティブな作用Effに加えて、あとからAffGraphicsを始めいくらでも作用を追加することができます。これらの作用は一種の領域特化言語として機能します。
  • あとから追加した作用の構文は、ネイティブな作用の構文とまったく同じです。あとから追加されたものだからといって構文が書きにくくなったりしません
  • どの作用についても再利用可能なforなどの関数を定義できるなど、強力な再利用性と一貫性をもたらします。

おまけ:

  • 変数を直接書き換えられなくても、その代わりに「変更可能な状態を扱う作用」STが提供され、それを使えば事実上変更可能な状態を利用することができるので、実際には不便はあまりありません。
  • 副作用をなくすと安全だからという理由だけで言語を純粋にしたりモナドを導入したわけではありません。
  • PureScriptがPerlやPythonみたいなゆるふわスクリプト言語だとでも思ったか!まんまと騙されおって!PureScriptは、あの堅物のHaskellすら甘やかした作用をExtensible Effectsで更にギッチギチに締め上げるような、超ヘビー級静的型付け言語だ!残念だったな!

参考文献

  • とくに何も見ずに書いたので、いい参考文献が思いつきませんでした
  • あえて挙げるならpurescript-affのReadmeを読むと、このテキストで説明しなかった並列処理のやり方や失敗からの復帰のやり方とか書いてあって夢が広がるかもしれません。
  • A History of Haskell: Being Lazy With Class Haskellの開発の中心人物たちがその歴史を語っている第一級の資料。このテキストを書いているときにたまたま見つけました。私はあまり歴史学に興味がないので読んでないのですが、歴史好きの人はお勧めかも。PureScriptはできたてホヤホヤなので実際に使うとなると大冒険になります。実用にしたい人は素直に長年の実績があるHaskellを使いましょう。


脚注1  ところで、Promiseでコールバック地獄が解決できるといわれることがたまにありますが、Promiseのようなライブラリを追加しただけではコールバック地獄は完全に解決できません。Generatorなしで解決している主張しているのは、関数を分割してネストを分解しているか、いわゆるメソッドチェーンで直前の非同期処理の結果だけを触っているかのどっちかでしょう。関数で分割すると深いネストはなくなりますが、本来ひとつの関数にまとまっているべき処理がバラバラになるので大きく可読性を損ないます。また、gulpでgulp.src("foo.txt").pipe(browserify()).pipe(uglify()).pipe(gulp.dest("output"))みたいにpipeでつなげるのは、それぞれの処理が直前の結果だけを扱う場合だけにしかうまくいきません。

脚注2  readTextFileAffの定義に、rejectresolveという引数を伴ったコールバックがあるのに気づいたでしょうか。このresolverejectは、Promiseのコンストラクタ呼び出しnew Promise(function(resolve, reject){ ... })に出てくるあのresolve/rejectに相当するものです!同じような問題を解決しようとしているんですから、同じような構造が出てくるのは不思議ではありません。PromiseではPromiseのコンストラクタにresolve/rejectという引数を持つような関数を渡すことで非同期な処理を作り出すのに対して、AffではmakeAffreject/resolveという引数を持つ関数を渡すことで非同期な処理を作ります。なお、Haskell/PureScriptの習慣で、こういう時は例外→正常の順番で書いていく習慣があり、引数の順番は、Promiseではresolveが先ですが、Affではrejectのほうが先になります。

脚注3  そういえば同期処理のときは例外処理にcatchExceptionを使っていたのに、非同期の時はcatchErrorに変わっているのが気になるひともいるかもしれません。本文では同期の時と非同期の時で書き方が変わらないから便利という話をしているにもかかわらずです。同期処理EffのほうでもcatchErrorを使うことはできるよう実装することはできそうですが、この辺りはPureScriptでは同期処理の例外をExtensible Effectsというのでより厳密に処理していることが関係しているように思います。

脚注4  このテキストの中では「副作用」と言ったり「作用」と言ったりしていますが、同じものを指して言っています。readTextFileAffのような関数にとって、ファイルを読み取るという作用は、計算の副産物どころか関数の目的そのものです。関数の主目的に対して「副」と付けるのはちょっとおかしいので、基本的には「作用」と呼びたいところです。でも「副作用」という言い方のほうがよく使われると思うのでわかりやすいでしょうし、どう呼ぶべきかけっこう迷っています。

脚注5  正確には++は「文字列を連結する演算子」というより「Semigroupを連結する演算子」です。余談ですが、AffEffSemigroupのインスタンスを与えればいいわけで、実はFSS.readTextFile UTF8 "foo.txt" ++ FSS.readTextFile UTF8 "bar.txt"というように++で直接つなげる書き方もできないわけではありません。

脚注6  liftEffMonadEff型クラスのメンバであり、正確にはもっと一般的なシグネチャを持っています。でもここでは話がややこしくなるので特殊化したシグネチャを示しています。

脚注7  数学でいえば $f : \forall {\rm A} {\rm B} . {\rm A} \rightarrow {\rm B} $ みたいな感じでしょうか。Haskellがなぜ数学同様のシングルコロンじゃなくてダブルコロンにしたのかはわかりません。わざわざ数学のものと違える意味はないし、頻繁に使うトークンなのでシングルコロンにすべきだったと思うのですが。

(この記事は同じ筆者が Qiita に投稿した記事の複製です。オリジナル記事)