コールバック……駆逐してやる…この世から…一匹…残らず!!

  • 1048
    いいね
  • 21
    コメント
この記事は最終更新日から1年以上が経過しています。

このテキストは JavaScript のコールバック地獄に疲れたひとのためのコールバック駆逐術指南書です。対象読者は JavaScript道初段くらいの人です。このテキストを読むと、以下のそれぞれの手段における非同期処理制御の仕組み、利点および欠点がわかるようになるかもしれません。

  • コールバック地獄
  • jQuery.Deferred
  • async.js
  • Concurrent.Thread
  • generators
  • co
  • fibers
  • Web Workers

(※なぜか『進撃の巨人』の一部ネタバレが含まれるので注意してください)

それは『何故人はコールバックするのか』という話でしょうか?

非同期処理って面倒ですよね。JavaScriptではいわゆる コールバック地獄 というやつにしばしば陥りがちです。たとえば、Ajax でふたつのファイル hoge.txt と piyo.txt を持ってきて、それらを結合してコンソールに出力したいとします。もし同期的にリモートのファイルを読み込む関数 get があるとしたら、次のように書けるはずです。

var hoge = get("hoge.txt");
var piyo = get("piyo.txt");
console.log(hoge + piyo);

でもそんな同期的な関数は JavaScript にはありません(XMLHttpRequest#open には同期的に読み込むオプションもありますが、パフォーマンス上の問題があることから使用は勧められません。Nodeにも同期的なファイル読み込み関数 fs.readFileSync がありますが、XHR と同様の理由であまり使用は勧められません)。たとえば、jQuery で非同期でファイルを取得する関数 $.get を使ったとしても、素直に書くと次のような悲惨なことになるわけです。

$.get("hoge.txt", function(hoge){
    $.get("piyo.txt", function(piyo){
        console.log(hoge + piyo);        
    });
});

もし非同期だけど直列に処理したいモノが3つ、4つと増えていったとすると、それにしたがってさらにネストは深くなっていきます。あまりネストが深くなるとソースコードが読みづらいので、自分などはたまにネストを押しつぶして次のように書いたりします。

// こういうパターンを見たら、心のなかで var hoge = $.get("hoge.txt") と読み替える
$.get("hoge.txt", function(hoge){    

// なんだか var piyo = $.get("piyo.txt") に見えてきたぞ……?
$.get("piyo.txt", function(piyo){

// どうみても var nyan = $.get("nyan.txt") ですね
$.get("nyan.txt", function(nyan){

// var myon = $.get("myon.txt") のどこにコールバックが?
$.get("myon.txt", function(myon){

console.log(hoge + piyo + nyan + nyan);        




});});});}); // ←見なかったことにしよう!(^o^)b

JavaScripter は5重、6重のネストが書けるようになって一人前。熟練した JavaScript 職人は、息をするように20重や30重のネストを書きます。いやそんなことはないです。そして、本当の恐怖はエラー処理をちゃんと書くときに始まります。同期的な処理なら try/catch で囲めばまるごと例外を捕獲できますが、こういう非同期処理だといちいち個別に例外処理しなければなりません。なんとかならないものでしょうか。

有害なコールバックを駆除した!!たまたまjQuery.deferredと恰好が似ていただけだ!!

こうした非同期処理の煩雑さを解決するために実に数多くの手段が考えだされてきましたが、その手段は大きくわけて2つあると思います。

  • ネストが深くならないように、コールバックの構造を変形する(jQuery.Deferred, async.js など)
  • スレッド/ファイバー/コルーチンで処理の流れを複数つくり、同期処理する(generators, fibers, Web Workers など)

前者の方法はあくまで非同期処理であり、現在の JavaScript でも実現可能な方法です。現在ごく一般的に行われているコールバックを駆使する方法の延長線上にあるので比較的理解しやすいでしょう。ただし解決は部分的であることがほとんどで、同期処理ほどの読みやすいコードが実現できることは稀です。

それに対し、後者の方法は現在の JavaScript 環境にまったく新しい機能を追加し同期処理できるようにするというものなので、非同期処理にまつわる煩雑さはほとんど解決しますが、使える環境がまだまだ限られることがほとんどです。また、これまで同期処理ができなかった部分が同期処理できるようになったとしても、その同期処理どうしが並列に処理されるというような複雑なケースも発生しうることがあり、同期処理と非同期処理が混在していてもわかりやすくコントロールする手段はやはり必要になります。

本テキストでは主に前者のカテゴリの手段についてどのようなものであるかを検討していきます。ただし、後者の手段を以ってしも非同期処理の煩雑さは残されることがあり、そういった残された問題をどう解決するかも含めて包括的に検討していきます。

まずは前者のカテゴリの手段を幾つか見ていきましょう。JavaScript の有名なライブラリといえば jQuery はまっさきに挙げられるもののひとつですが、jQuery にもこうした非同期処理の煩雑さを解消しようとする機能、 jQuery.deferred があります。ちまたで話題の jQuery.deferred を使えば、さっきみたいなネスト地獄のコードもきっとおしゃれに書けるはず……!

var hoge;
$.get("hoge.txt")
    .then(function(_hoge){
        hoge = _hoge;
        return $.get("piyo.txt");
    })
    .then(function(piyo){
        console.log(hoge + piyo);        
    });

えっ……確かにネストは増えていかないけど、なんかむしろキモさが増してる気が……!だいたい、いったんコールバックの外の変数に待避しないと次のコールバックで参照できないとか、なんかもう根本から破綻してるとしか思えません。これならさっきのネスト地獄のほうがまだわかりやすいレベル。

は◯ブのほうで『 jQuery.deferred の例のコードがキモいのは、お前の使い方が悪いだけじゃね?』(意訳)という声を見かけたので、もう少し詳しく検討してみます。jQuery.deferred の圧倒的実力を見せつけてやろうではないですか。

上記の例では、$.get("hoge.txt") の結果が then のコールバックの引数 hoge に渡されてくるわけなのですが、このコールバックを抜けるから hoge にアクセスできなくなって、いったん外の変数に退避しなければならなくなったわけでした。したがって、これを抜ける前に続けて $.get("piyo.txt") の結果のほうまで受け取ってしまえばいいのではないでしょうか。コードは次のようになります。

$.get("hoge.txt")
    .then(function(_hoge){
        return $.get("piyo.txt").then(function(piyo){
            console.log(hoge + piyo);
        });;
    });

無駄な退避用の変数がなくなりました!…… って、これさっきのコールバック地獄じゃないですか 。使うファイルが増えるとどんどんネストが深くなっていきます。コールバック地獄を逃れようとして jQuery.deferred にすがったのに、これではあんまり意味がありません。

まだだ……まだ終わらんよ……!あの jQuery がこの程度の実力なわけがない!そうだ!最後にまとめてそれぞれの結果にアクセスしようとするのが良くないのでしょう。このように順番に処理していく場合、直前の処理から受け取れるのは基本的に1個まで。今回の目的だととにかく最終的に全部つながった文字列が得られればいいのだから、それぞれの $.get が終わったその時点で、それまでの文字列を積算してしまえばいいのです。つまり、あらたに次のような Deferred を作る関数 accum を定義しましょう。他人に与えられた deferred を使うだけなんて素人のやること、オリジナル Deferred を作ってこそ真の jQuery 使いといえます。

function accum(path){
    return function(previous){
        var def = new $.Deferred();
        $.get(path).then(function(data){
            def.resolve(previous + data);
        });
        return def.promise();
    }
}

$.get("hoge.txt")
    .then(accum("piyo.txt"))
    .then(accum("pyon.txt"))
    .then(accum("nyan.txt"))
    .then(function(data){
        console.log(data);
    });

よし、これで OK! ネストも深くなっていかない!素晴らしい!……え?今度は piyo.txt のテキストだけ、全部大文字にしてくれだって?accum ではそんな処理はできません。じゃあまた新たにファイルを読み込んでそれを大文字にする deferred を定義して……。

め ん ど く さ い。同期処理なら hoge + piyo.toUpperCase() + pyon + nyan で済むのに、なんでちょっと処理が変わるたびに新たな deferred を定義しなければならないのでしょうか。じゃ、じゃあ、全部配列にして送ってしまえばいい!

function accum2(path){
    return function(array){
        var def = new $.Deferred();
        $.get(path).then(function(data){
            array.push(data);
            def.resolve(array);
        });
        return def.promise();
    }
}

var def = new $.Deferred();
def.then(accum2("hoge.txt"))
.then(accum2("piyo.txt"))
.then(accum2("pyon.txt"))
.then(accum2("nyan.txt"))
.then(function(array){
    console.log(array[0] + array[1].toUpperCase() + array[2] + array[3]);
});
def.resolve([]);

って配列かよ! hoge とか piyo とかの名前はどこにいったんだよ!array[3] とか言われてもどのファイルの中身なのかさっぱりわからんよ!ちゃんと意味のあるデータを適当に配列に突っ込んじゃいけないって、プログラミング初めて習った時に口酸っぱくいわれたよ!

そうか、直列に処理するからいけないんだ。$.when を使って並列に処理すれば……。

$.when($.get("hoge.txt"), $.get("piyo.txt")).then(function(hoge, piyo){
    console.log(hoge[0] + piyo[0]);
});

うーん。さっきよりはずっとマシになったかな。なんで結果の hogepiyo が配列になってるの?[0]とかなんなん?というのはまあ目をつぶるとして、$.get("hoge")hoge がやけに離れた位置になってしまいました。同期的に書いていた時には var hoge = get("hoge"); みたいに一行ごとにまとまっていたのに……。もし必要なファイルが増えていくと、さらに離れ離れになっていって、どれとどれが対応しているのかもはやわからなくなってしまいます。同じような処理を追加しているのに、やけに離れた位置を2箇所づつ編集することになります。

$.when(
    $.get("hoge.txt"), 
    $.get("piyo.txt"), 
    $.get("nyan.txt"),   // 増えた
    $.get("myon.txt"),   // 増えた
    $.get("pong.txt"),   // 増えた
    $.get("chun.txt")    // 増えた
).then(function(
    hoge, 
    piyo,
    nyan,   // 増えた
    pong,   // 増えた
    myon,   // 増えた
    chun    // 増えた
){
    console.log(hoge[0] + piyo[0] + nyan[0] + pong[0] + myon[0] + chun[0]);
});

これだとうっかり非同期処理とその結果を代入する変数の順番を間違えるようなこともありそうです。上記のコード、実は myonpong の変数の順序が入れ替わっています。お気付きになったでしょうか。気付くわけないですよね。

これが同期的だったら、次にように一行づつ編集して増やせるのに……。非同期処理とその結果を代入する変数が一行ごとにまとまっているので、jQuery.Deferred のものより遥かに読みやすく書きやすいと思います。

var hoge = get("hoge.txt");
var piyo = get("piyo.txt");
var nyan = get("nyan.txt");  // 増えた
var pong = get("pong.txt");  // 増えた
var myon = get("myon.txt");  // 増えた
var chun = get("chun.txt");  // 増えた
console.log(hoge + piyo + nyan + pong + myon + chun);

また、今回はたまたま hoge.txt の piyo.txt の読み込みは独立しているので $.when で並列に処理できますが、もし一方の処理がもう一方に依存している場合、たとえば hoge.txt には別のファイルのパスが書かれていて、次にそのパスのファイルを読まなければならない場合には並列には処理できません。その場合はさっきの書きづらいバージョンに戻るしかありません。

いろいろ検討してみましたが、ネストを増やさずに、しかもなるべく自然にそれぞれの非同期処理の結果にアクセスしようとすると、結局のところ外の変数に退避するのが一番手っ取り早いように思いました。もし何かもっといい方法があったら教えて下さい。自分には jQuery.deferred は使いこなせませんでした。でも俺が敗れ去っても、第二、第三の jQuery.deferred 使いが現れ、その真の実力を引き出してくれるでしょう。きっと。

async.jsは調子に乗りすぎた……いつか私が然るべき報いを

非同期処理のフローを制御するライブラリとしては、async.js というものもあります。async.jsでもjQuery.deferredと似たような形式では書けるのですが、async.jsではさらに次のようも書けたりします。

function get(path){
    return function(callback){
        $.get(path).then(function(data){ callback(null, data); });
    };
}

async.parallel({
    hoge: get("hoge.txt"),
    piyo: get("piyo.txt")
},
function(err, results) {
    console.log(results.hoge + results.piyo);
});

paralleljQuery.when と同様に並列に非同期処理する関数なのですが、名前と処理のテーブルとしてオブジェクトを渡すことができ、並列処理後に呼ばれるコールバックの引数のオブジェクトには、同名のプロパティで結果が格納されています。おお!わりとおしゃれだ!非同期で得たデータがそれぞれ直接変数に代入されるんじゃなくて results というオブジェクトにまとめられてるのはまあ許すとして、なんといっても hoge: get("hoge.txt") っていうかんじで hogeget("hoge.txt") がすぐとなりにあるのが(・∀・)イイ!! これなら処理がもっと増えていっても自然に追加できる!

function get(path){
    return function(callback){
        $.get(path).then(function(data){ callback(null, data); });
    };
}

async.parallel({
    hoge: get("hoge.txt"),
    piyo: get("piyo.txt"),
    nyan: get("nyan.txt"),  // 増えた
    pong: get("pong.txt"),  // 増えた
    myon: get("myon.txt"),  // 増えた
    chun: get("chun.txt")   // 増えた
},
function(err, results) {
    console.log(results.hoge + results.piyo + results.nyan + results.pong + results.myon + results.chun);
});

だがちょっとまってほしい。async.parallel の型ってどうなっているんだろう?『 はあ!?なにいってんのおまえ!? 』とおっしゃるかもしれませんが、我々のような静的型付け過激派は動的型の JavaScript を書いている時でさえ型を意識するのです。そんな我々から見れば、こんな動的な API はまさしく粛清の対象。JavaScriptのようなヒトならざる魔性なら、他者の辛苦を蜜の味とするのも頷けます。でも、それは罪人の魂です。罰せられるべき悪徳です。

じゃあネスト地獄やjQuery.deferredはどうなんだよといいますと、実はネスト地獄スタイルやjQuery.deferredスタイルは一応ちゃんと静的型付けできるのです。試しにTypeScriptで型注釈をつけてみましょう(TypeScriptのステマ)。

declare var $: {
    get(path: string, callback: (data: string)=>void): void;
};

$.get("hoge.txt", function(hoge: string){
    $.get("piyo.txt", function(piyo: string){
        console.log(hoge + piyo);        
    });
});

ネスト地獄は型付けという意味に関しては完璧です。一部の隙もありません。で……いや、やっぱりjQuery.deferredは静的型付けできませんでした。$.when に複数の引数を渡した場合、何をトチ狂ったのかコールバック関数の引数に [data, textStatus, jqXHR] という感じで全然違う型のオブジェクトをひとつの配列に押し込んで返してくるのが原因です。これが配列じゃなくてオブジェクトに入れて返してくるならちゃんとした静的型付けできるのですが……。自分に言わせれば、これはスタイルの問題というより単に jQuery の設計ミスだと思います。従って、jQuery.deferredスタイルも本質的には静的型付け可能、ということにしておきます。

それに対して、先ほどの async.parallel の場合では事前にどんなオブジェクトが渡されるかわかりませんから、どうにも静的型付けはできません。やっぱりネスト地獄やjQuery.deferredのほうがマシだったんだ!……いや、やっぱりどっちもどっちです。

俺にはわかる、Concurrent.Threadは本物の化け物だ……『コールバック』とは無関係にな。どんな力で押さえようとも、どんな檻に閉じこめようとも、コイツの意識を服従させることは誰にもできない……!

Concurrent.Thread はJavaScriptで擬似的なマルチスレッドを実現するライブラリです。このライブラリでも yield ができるようですから、コールバック地獄の回避にも応用できそうです。どのようにして擬似的なマルチスレッドを実現しているかというと、JavaScript のソースコードをパースしてぶつ切りにし、setTimeout で適宜制御を譲りながら交互に実行することで擬似的なスレッドを構成するというもの。その発想がないわけではなかったが、まさか本当にやるとは思わなかった、という感想しかでてこない豪快な方法です。Web 上にはあまり情報がありませんが、作者自身による解説記事 JavaScriptによるマルチスレッドの実現‐Concurrent.Threadの裏側 が参考になります。

Web 上で閲覧できるサンプルとしてはConcurrent.Thread exampleがあります。一応Concurrent.Threadが吐き出したコードも眺めてみましたが、とてもヒトに理解できるコードではなかったので内部の理解は諦めました。なんかもうそこまでするなら、俺ならベースの言語として JavaScript じゃなくて静的型付けできる他の言語をベースに選ぶけど……。

環境を選ばずどこでも使えるのはメリットです。独自の形式に変換するので、デバッガがまともに使えないことが最大の欠点でしょうか。トラブルが起きた時の原因究明は絶望的です。JavaScript のパースも行うので、JavaScript の新しい機能は使えないでしょう。残念ながら、更新も止まっているようです。すごいライブラリには違いないのですが、あくまで研究用だと思っておくのがよさそうです。

私の特技はコールバックを削ぎ落とすことです…必要に迫られればいつでも披露します…私のgeneratorsを体験したい方がいれば…どうぞ一番先に近づいて来てください

さて、ここからは JavaScript に根本的に機能を追加して解決をはかる手段です。これらは同期的な処理を複数同時に行う、つまりマルチスレッディングを可能にしようというものですが、マルチスレッドもいくつかの種類にわけられます。

  1. メモリ空間を共有し、プリエンプティブに実行される古典的マルチスレッド
  2. プリエンプティブに実行されるがメモリ空間を完全に分離する『プロセス』
  3. メモリ空間を共有し、各スレッドが独自の判断で制御を譲る協調的マルチスレッド

1 は効率よく実行できるので昔からよく使われてきましたが、デッドロックなどマルチスレッド特有の問題が多く発生しやすく非常に扱いが難しいので近年では直接使われることが少なくなっているように思います。Java や C# などにあるスレッドはこれで、排他制御をしそこねると非常にわかりにくいバグになります。

2 は安全に複数のスレッドを実行できるのでもうすぐ JavaScript にも Web Workers という形で導入されることが決まっています。Erlang などにも『プロセス』として言語に組み込まれていることで知られていますし、OS レベルで提供されるプロセス間通信もこれに近いものです。ただし、プロセス間でデータを直接共有できないので、プロセス同士のメッセージングが煩雑になったり効率が悪化しやすいです。

3 は generators として JavaScript にも導入される予定で、各スレッド(?)でデータを共有でき比較的安全で軽量である反面、各スレッドが独自の判断で制御を交代するのでスレッドの流れについてよく把握しておかないとうまくコードを書くことができません。

また、いずれの方法を取るにしろそれぞれのスレッドがまったく独立して動くわけではないので、常に同期処理と非同期処理をうまく混在させていかなくてはなりません。マルチスレッドを導入して同期処理が増えていったしても、非同期処理がなくなるわけではないのです。以下のいくつかの項ではかつて非同期に処理してきたものを同期で処理する方法を示しますが、その同期処理どうしをどのように並列して非同期に協調させるかということについてはほとんど述べられていません。そのような残された非同期処理については、先ほど触れた jQuery.deferred や async.js のようなフレームワークを利用して解決していくことになるでしょう。

まずは次期の JavaScript で導入される generators という機能をみていきます。generator があれば、 yield というキーワードを使って次のように書けるようになるみたいです(get は generator で使えるように適当に定義されているものとします)。

var generator = (function*(){
    var hoge = yield get("hoge.txt", generator);
    var piyo = yield get("piyo.txt", generator);
    console.log(hoge + piyo);
})();
generator.next();

このコードではあくまで直列にファイルを操作しています。ここでいう『直列』というのは、このコードでは hoge.txt を読み込んで、 それが完了したら piyo.txt の読み込みを開始しているということです(ちなみにこういうのを『直列』と呼んでいるのはたぶん俺だけです)。でも、この場合別に hoge.txt と piyo.txt の読み込みは並列に行なって構いません。piyo.txt の読み込みをするときにいちいち hoge.txt の読み込みを待っていたら遅くなってしまいます。並列に処理したい場合は次のような感じになるでしょう。くわしくはもっと下の方の完全なコードを参照してください。

var generator = (function*(){
    var x = get("hoge.txt", generator);
    var y = get("piyo.txt", generator);
    yield;
    yield;
    console.log(x() + y());
})();
generator.next();

並列で処理した結果を受け取るためにちょっと工夫しています。get は関数を返すようになっていて、この関数は yield から復帰したあとで呼び出すと、並列処理の結果を返すようになっています。

直列、並列いずれについても generator という変数が不思議な使われかたをしていて直感的にわかりづらいところがあったり、get にいちいち generator を渡さなければならないという難点はありますが、非同期処理としてのわかりやすさとしては比較的いい線をいっているように見えます。

他に気をつけるべき点としては、 yield の回数をうっかり間違えないようにすることあたりでしょうか。うっかりひとつ多く yield を書くと処理がそこでストップしてしまいますし、ひとつでも足りないとすべての非同期処理が完了しないまま先に進んでしまい、上のコードで言うと y()undefined になってしまいます。非同期処理ひとつにたいして yield ひとつ、という対応関係が保証できないので、常に非同期処理が何個走っているか意識しておく必要があります。

かなり読みやすく直列な処理と並列な処理の書き分けもしやすいものの、おまじないともいえるような注意点がいろいろあるのがネックですし、そもそもこのコードが動く環境がほとんどないのが困ります。

我々はgeneratorsによってコールバックの侵攻を阻止するのみならず、generators の正体にたどり着くすべを獲得した!

さて、さきほどの generators の使い方についての表面的なコードを見て、なんとなく yieldというキーワードを使えばいいことぐらいはわかったと思います。他の人が書いてくれたライブラリをありがたくダウンロードしてサンプルコードをコピペ、ちょっといじって実行してみてなんかエラーになったら詳しそうな先輩や同僚に見てもらって……っていう感じならそのくらいの理解でもいいのですが、自分でgeneratorsを使って同期的な仕組みを実装したくなる時もあるでしょう。それに、先ほどのコード、yield get(...) というような部分が何度かあるのが気になりませんでしたか?共通する部分があるなら、次のように関数としてまとめたくなりますよね。

function hyperGet(path, generator){
    return yield get(path, generator);  // 常にyieldもくっついているすごいgetだ!
}

var generator = (function*(){
    var hoge = hyperGet("hoge.txt", generator);
    var piyo = hyperGet("piyo.txt", generator);
    console.log(hoge + piyo);
})();
generator.next();

でもこのコードはうまく動きません。なぜなのでしょうか。これを理解するには、yield というキーワードの意味をちゃんと理解する必要があるのです。せっかくですから、generators の動作についても詳しく説明しておきます。generators は今後の非同期処理の主流になるはず。この知識はきっと無駄にならないと思いますが、面倒くさいひとは別に読まずに飛ばしてしまっていいと思います。他の手段について知りたい者は解散したまえ。……では今、ここを読んでいるものを新たな調査兵団として迎え入れます。よく恐怖に耐えてくれた。君たちは勇敢な戦士です。

Firefox Nightly で動く generators の完全なテストコードは次のようなものです。本当はXHRで実際にファイルを読むサンプルにしたかったんですが、Firefox のセキュリティ上の制限でローカルファイルが読めないので、代わりに setTimeout で非同期な処理をしているつもりにしています。<any> に無理矢理感が漂っていますが、これは TypeScript が generator に対応すれば必要なくなるはずです。yield は本当はキーワードですが、現時点の TypeScript でコンパイルできるように関数に見せかけてごまかしています。あと、さっきのサンプルコードとは違って function*(){ ... } っていう感じに functionキーワードの後ろにアスタリスクが付いていませんが、Firefox における generators の実装は現在提案されているものとは異なるということです。危なかった……もしこのアスタリスクが必要だったら、それが邪魔して TypeScript で書けなくなるところでした。このアスタリスクは正直要らない子だと俺も思います。 ポイントは yieldnextsend です。

declare function yield<T>(f: ()=>T): T;

interface Generator {
    send(value: any): void;
    next(): void;
}

declare var StopIteration: new()=>void;

function get(path: string, generator: Generator): ()=>string {
    var value: string;
    setTimeout(()=>{ 
        try{
            value = "[content: " + path + "]";            
            generator.send(value);
        }catch(e){
            if (! (e instanceof StopIteration)) throw e;
        }
    }, 500);
    return ()=>value;
}

var generator: Generator = <any> (()=>{
    // 直列バージョン
    var x: string = yield(get("hoge.txt", generator));
    var y: string = yield(get("piyo.txt", generator));
    console.log(x + y);

    // 並列バージョン
    var w: ()=>string = get("hoge.txt", generator);
    var z: ()=>string = get("piyo.txt", generator);
    yield;
    yield;
    console.log(w() + z());
})();
generator.next();

(1) まずは var generator: Generator = <any> (()=>{ ... })(); という無名関数の部分です。無名関数 ()=>{ ... } を直後に () で呼び出す即時関数パターンになっていますが、そこは別にどうでもいいです。最初のポイントは、 この関数呼び出しで、この関数自身が呼び出されるわけじゃなくて、なぜか Generator オブジェクトが作成されて返ってくる ことです。これはこの無名関数の本体に yield が含まれているからです。なんだかよくわかりませんがそういうものです。

(2) その Generator オブジェクトが変数 generator に代入されます。 ここで generator.next を呼び出していますが、このときさっきの無名関数が呼び出されます 。意味がわかりませんが、そういうものです。

(3) この無名関数の一行目は var x: string = yield(get("hoge.txt", generator)); となっています。ここで generatos で導入された新しいキーワード yieldが出てきますが、ひとまずそのまま素直に yield の隣の式を実行します。get("hoge.txt", generator) という式なので、get 関数の定義に飛びます。generator を引数で渡していることにも注目です。

(4) get 関数の中身は setTimeout の呼び出しだけです。これが非同期処理の開始にあたります。非同期処理を開始しただけで今はとくに何もしません。500 ミリ秒後にタイマーをセットして終了です。この関数の返り値は並列処理するときに非同期処理の結果を受け取るためのトリックなので気にしないでください。get 関数を脱出します。

(5) さて、無名関数に戻ってくると、get 関数の返り値がまるで yield という関数に渡されているように見えます。でもこの yield が曲者です。 ここを実行しようとすると、なぜかさっきの (2) での generator.next の呼び出しが終了し、そこに戻ります。 実はまたココにあとで戻ってきます。return で抜けだしたらそこで終了なのに対して、 yield で逃げ出したらいつかまたそこに戻ってくるのです。

(6) なぜか (2) の generator.next の呼び出しが終了しました。さて、ここでスクリプトの最後に到達したので、一旦実行は終了です。

(7) ブラウザが一休みしているところで、さっき仕掛けた setTimeout 時限爆弾がついに起動します!これはつまり非同期処理の終了です。setTimeout で渡した方の無名関数に飛びます。

(8) try/catch とその次の代入式はひとまず無視して、 次にあるのは generator.send(value) です。これが実行されると、先ほど戻ってくると宣言した yield の位置に戻ります。yield で抜けだしたら、次の nextsend で戻ってくるというのがポイントです。しかも、戻ってくるときに send に渡した引数を持ち帰って yield に戻ってきます。 このサンプルコードでは、ファイルの中身を模して "[content: hoge.txt]" という文字列を返すようにしています。

(9) yield に戻ってきたら、yield(...) という式の値が先ほど send に渡した値になります。 そのため、この値が hoge に代入されるのです。get がファイルを取ってくる非同期処理だったら、x に hoge.txt の中身が代入されることになります。

(10) ここまでの (3) ~ (9) が var x: string = yield(get("hoge.txt", generator)); という行が引き起こす結果です。この次もほぼ同じような var y: string = yield(get("piyo.txt", generator)); という式ですから、(3) ~ (9) のような処理がもう一回行われます。その結果、y にも piyo.txt の中身が読み込まれます。

(11) 最後に console.log(x + y); でふたつのファイルの中身が結合されて出力されます。

(12) この無名関数の終端まで到達すると、さきほどの send の呼び出しに戻ります。戻ると言っても、sendStopIteration という例外が起こったという形で戻ってきます。そのため、try/catch でこの例外を補足します。これは異常な状態というよりは、generators では generator の実行の通常の終了を示す例外なので、そのままもみ消してしまって構いません。それ以外の種類の例外はもみ消しては困るので、instanceof で判別して投げ直します。

generators の動作がつかめてきたでしょうか。ポイントをまとめると、次のようになります。

  • yield が含まれた関数を呼び出すと、Generator オブジェクトが作成される
  • その Generator オブジェクトの next を呼び出すと、さっきの関数が実行が開始される
  • yield に到達すると、直前に呼んだ nextsend が終了する
  • nextsend を呼び出すと、最後に到達した yield に戻ってくる
  • 関数の終端まで到達すると StopIteration が発生する

こうやって通常の実行のコンテキストと generator のコンテキストを行き来して、マルチスレッドのような動作を実現しているのです。こんな奇妙な動作は従来の JavaScript ではどうやっても不可能です。これが generators の魔術なのです。

generators の用途は非同期処理だけではありません。このテキストでは触れませんが、調べてみるともっと面白い使い方がほかにもたくさん見つかると思います。

彼の持つcoとgeneratorsが組み合わされば、この街の奪還も不可能ではありません!! 人類の栄光を願い、これから死に行くせめてもの間に、coの戦術的価値を説きます!!

generator を利用したもっと格好いいライブラリには co というものもあります。Node の開発版でしか動かないようで自分はまだ試していないのですが、どうやら次のように書けるようになるみたいです。

co(function *(){
  var a = yield get('http://google.com');
  var b = yield get('http://yahoo.com');
  var c = yield get('http://cloudup.com');
  console.log(a.status);
  console.log(b.status);
  console.log(c.status);
})

co(function *(){
  var a = get('http://google.com');
  var b = get('http://yahoo.com');
  var c = get('http://cloudup.com');
  var res = yield [a, b, c];
  console.log(res);
})

なんと!これまであった問題点がだいたい解決しています。co を知ってしまった今では、jQuery.deferredはまるでゴミクズ少々力不足のようにも思えます。自分は get にも Generator を渡す必要があると思っていたのですが、うまいことやると get("hoge.txt") みたいなコードだけで実現できるようです。yield [a, b, c] というのも謎で、ここだけちょっと静的型付けとの折り合いがつかなそうですが……?どうなっているのかはまだよくわかりませんが、とにかく凄そうです。将来の非同期処理の大本命と言っていいでしょう。

fibersです!調理場に丁度頃合のものがあったので!つい!

Node 環境に限っては、fibers を使うという選択肢もないことはありません。node-fibers では V8 にネイティブな fibers を追加します。Node限定、しかもどこのNode環境でも使えるとは限りませんが、根本的な解決策ではあります。fiber ならコンソールに出力し、1秒停止し、その後またコンソールに出力するという一連の処理を次のようにとても自然に書くことができるようになります。

Fiber(function() {
    console.log('wait... ' + new Date);
    sleep(1000);
    console.log('ok... ' + new Date);
}).run();

構文としては申し分なく、generators と並んでこれまで見てきた中ではもっとも扱いやすいもののひとつといえるでしょう。でも現実は非情で、もちろんブラウザではまったく使えません……。それに generatos が正式に導入されたら fibers はお払い箱になってしまう可能性が高いと思います。

あのぅ、みなさん……。上官の食糧庫から、Web Workers盗ってきました……。後でみなさんで分けましょう。スライスしてパンに挟んで…むふふ…。

Web Workers はブラウザのような環境でマルチスレッドを実現する API です。これは fiber のような協調的マルチスレッドではなくて本当のマルチスレッドなので、 yield で時々処理を譲るということも必要ありません。まだ Working Draft の段階の仕様ですが最近の PC のブラウザなら大抵実装されていますし、Node 環境でも Web Worker の API を実装したものもあるようです。

File API には非同期 API のほかに同期API があって、Worker スレッド内でFileReaderSync を使えばブラウザのスレッドをブロックすることなく同期的にファイル操作をすることができます。Web Workers はマルチスレッドといっても、Web Workers の空間とブラウザの空間は完全に分離されていて、Worker とブラウザのスレッドの間はやはり非同期の API でやりとりすることになります。Worker スレッド側では windowconsole オブジェクトすらないので、Worker スレッドで処理した結果を出力するには postMessage でブラウザ側に結果を送り返し、ブラウザ側で console.log を呼び出します。Worker スレッド内で完結する処理であればとてもきれいに書けますが、頻繁にブラウザと Worker の間でやりとりがある場合は結局のところ非同期処理が多くなり、コールバックも増えてきます。ブラウザ側の処理と Worker 側の処理をどれだけうまく分離できるかが鍵になるでしょう。

Worker 内では XMLHttpRequest も使えます。ファイルをふたつ読み取り、結合して出力するサンプルは次のようになります。

app.js
var worker = new Worker("worker.js");
worker.onmessage = function(event) {
    console.log(event.data);
};
worker.js
function get(path){
    var xhr = new XMLHttpRequest();
    xhr.open("GET", path, false);
    xhr.send(null);
    return xhr.responseText;
}

var hoge = get("hoge.txt");
var piyo = get("piyo.txt");
postMessage(hoge + piyo);

worker.js 側の処理は同期で書けてとてもきれいです。Web Workers は並列処理のためのフレームワークを提供するものではありませんから、例えばふたつのファイルを並行して読み込んで両方の完了を待つ場合はなにか別のライブラリを使うことになるでしょう。また、Web Workers が登場したのは比較的最近で、時間のかかる処理はそれまで非同期処理が基本でしたから、同期と非同期両方が用意してある API はそれほど多くありません。同期 API が用意されていない場合は、Web Workers では解決できませんから、また別の手段を考慮する必要があります。

また、どうしても気になるのはやはり静的型付け(いやまあ、そんなこと気にしてるのは俺だけですけど)。Worker 内ではもちろんきれいに型付けできますが、メッセージングに関しては postMessage というたった一本のパイプであらゆるデータをやりとりするわけで、もちろんここには静的型付けなんて望むべくもありません。onmessage で飛んでくるデータはいったい何なのかはさっぱりわからないので、やはりメッセージングの回数をどこまで減らせるかがポイントでしょう。

あと現時点の実際上の問題として、ブラウザでは Worker スレッドのデバッグができないというものがあります。代わりに fakeworker.js で WebWorkers をシミュレートしてデバッグする方法はあります。

調査兵団に入って……とにかくコールバックをぶっ殺したいです……!

いろいろな非同期処理の制御の方法を見てきましたが、どうもしっくりくるものが見つからないので、静的に型付け可能でしかも同期的に処理してるっぽく書けるライブラリを作ってみました。このライブラリを使うと、非同期処理を次のように書けます。

var hoge = masync.get("hoge.txt");       // get でファイルを ajax で持ってくる
var piyo = masync.get("piyo.txt");
var main = masync.log(masync.concat(hoge, piyo));      // concat で結合、log で出力
masync.run(main);                                     // run で実際に実行される

こんな感じで、非同期に取得しているはずのデータに対して自然に関数に適用できたりします。このとき、hoge.txt とpiyo.txtの読み込みは並列に走っています。もし直列に読み込みたい場合は、次のようにします。

var hoge = masync.get("hoge.txt"); 
var piyo = masync.get("piyo.txt");
masync.run(
    hoge,
    piyo,
    masync.log(masync.concat(hoge, piyo))
);

masync.run は引数に与えた処理を直列に順番に実行していく関数です。hoge は式のなかに2回出てきますが、この時本当に2回非同期処理を行うのか、それとも先に出現した方だけ実行し2回目の評価では結果だけ再利用するかをオプションで選択できます。式の中では変数の宣言を行えないので、変数の宣言は少し離れた別の行になってしまっているのがちょっと不満です。変数の宣言と初期化の位置が離れてしまう問題は多分解決しました。 でも『AをBより先に実行したい』という場合は『Bを実行するためにAの結果が必要』という場合が多いので、直列な操作を直接記述することはさほど多くはないでしょう。このライブラリで自然に書いていけば、直列に処理しなければならない部分は直列に、そうでない部分は並列に自動的に処理してくれます。

少々不思議なのは、masync.get を呼び出しても、その非同期な処理の結果が実際に使われることがないのなら、その非同期処理は行われないということです。たとえば、

var hoge = masync.get("hoge.txt");
var piyo = masync.get("piyo.txt");
var main = masync.log(hoge);
masync.run(main);

とすると masync.log で実際に出力しているのは hoge.txt の中身だけなので、piyo.txt への get は実際には行われません。『不要な操作は省略』というのも自動的にやってくれるのです。

同期的なデータと非同期的なデータを同じように扱えるので、たとえばhoge.txtにほかのファイルのパスが書いてあってhoge.txtのつぎに piyo.txt じゃなくてそれを読みたいという場合は、次のようにします。

var hoge = masync.get("hoge.txt");
var nyan = masync.get(hoge);
var main = masync.log(masync.concat(hoge, nyan));
masync.run(main);

これだとさっき並列に読み込んだ時のパターンに似ていますが、もしこれが並列だとすると hoge を読む前に nyan を読んでしまって失敗するということがときどき起こるはずです。でも内部でその辺りをうまく処理してくれて、nyan を読むのに hoge が必要だということから、ちゃんとhogeを先に読んでからnyanを読んでくれます。

先ほどの並列処理では並列に行っているすべての処理が完了してから次の処理に進んでいきますが、複数の処理を並列に行い、そのうちひとつでも完了すればすぐに次の処理に進む、という処理を実現する関数 sooner もあります。以下のコードで、piyo.txt より hoge.txt のほうがずっと大きいファイルなら、 piyo.txt の読み込みのほうが早く終わるので piyo.txt の内容が出力されます。それに対して、piyo.txt のほうがずっと大きければ hoge.txt の内容が出力されます。簡単に実装できたから実装してみただけで、正直この機能は何の役に立つのかよくわかりません。もし使い道が思いついたら教えてください。

var hoge = masync.get("hoge.txt");
var piyo = masync.get("piyo.txt");
var main = masync.log(masync.sooner(hoge, piyo));
masync.run(main);

とはいえ、直列、並列(全部完了まで待つ)、並列(ひとつ終わるまで待つ)といった柔軟なコントロールフローが自在に記述できるのが便利だと思います。たとえば、あるファイルを読み込んでそれをすぐに出力する、という処理を行う関数 getAndLog を次のように簡単に定義できます。

var getAndLog = path => masync.bind(masync.get(path), masync.log);

abcd をそれぞれ a.txt、b.txt、c.txt、d.txt を非同期に読み込んで出力する処理だとしましょう。abcdgetAndLog を使って次のように定義できます。

var a = getAndLog("a.txt");
var b = getAndLog("b.txt");
var c = getAndLog("c.txt");
var d = getAndLog("d.txt");

そして、a と並行して、bc を直列にしたものを行い、そのあとで d を行う、というかなり複雑なコントロールをしたいという場合でも、次のようにとても自然に書けます。masync.seq および masync.parallel はそれぞれ直列処理、並列処理を明示的に行うときに使う関数です。

masync.run(
    masync.parallel(a, masync.seq(b, c)),
    d
);

シーケンス図で描けば、例えばこんな処理になります。

  =====================================> 時間
a --------->
b ---------------->
c                  --->
d                      ------------>

ファイルの get だけでなく、一時停止のような処理もまるで同期処理のように自然に書けます。次のコードでは、実行するとまず "hoge" と出力し、1秒待機してから、そのあと "piyo" と出力します。

masync.run(
    masync.log("hoge"),    // "hoge" と出力
    masync.wait(1000),     // 1秒待機
    masync.log("piyo")     // "piyo" と出力
);

jQuery.deferred と相互にデータを交換する機能もつけときました。

masync.run(
    masync.log(
        masync.strcat(
            //  fromPromise で Promise を masync のデータ型に変換
            masync.fromPromise(jQuery.get("a.txt")),   
            masync.get("d.txt")
        )
    )
);

// masync.log のような一部の関数は Promise を直接受け取って処理できる
masync.run(masync.log(jQuery.get("a.txt")));

// toPromise で masync のデータ型を Promise に変換
masync.toPromise(masync.get("b.txt")).then((data)=>console.log(data));

ついでに Node の fs.readFile にもアクセスできるようにしときました。同期的に処理してるっぽくみえますが、同期 API のほうの fs.readFileSync ではなく、あくまで非同期版の fs.readFile をラップしています。ちなみに、引数の最後にコールバックを渡すという例の Node の非同期処理の規約に従った関数を masync の非同期処理に変換する関数も定義してありますので、それを使えば比較的簡単にほかの Node の API もラップできるはずです。

var a = masync.fs.readFile("a.txt");
var b = masync.fs.readFile("b.txt");
masync.run(masync.log(masync.strcat(a, b)));

エラー処理も簡単です。一番簡単には、非同期処理の失敗時に別の値を返すようにする recover 関数を使うことができます。以下のコードで、findFile は指定したパスのファイルを get しますが、ファイルが取得できない場合は "Error: No such file: notexistsfile.txt" のようなメッセージを代わりに返します。ついでにいうと、こんなふうに非同期処理を関数としてまとめることもまったく問題ありません。

function findFile(path: string){
    return masync.recover("Error: No such file: " + path,
        masync.get(path)
    );
}

masync.run(
    masync.log(findFile("a.txt")),                // a.txt のファイルの中身が出力される
    masync.log(findFile("noexistfile.txt"))   // "Error: No such file: notexistsfile.txt" と出力される
);

ある程度の簡単な処理ならコールバックを使わずに書けるのですが、もっと複雑な処理を行いたければ非同期で取得したデータに直接アクセスする必要も出てくるでしょう。そのための方法は幾つかあるのですが、比較的に直感的に理解しやすい方法として eject という関数を用意してあります。この関数の最初の引数に get などの返り値、第2引数に直接のデータを受け取る関数を渡すと、その関数に直接のデータが渡されます。このライブラリでも、生のデータにアクセスしようとするとついにコールバックが露呈します。

var hoge = masync.get("hoge.txt"); // hoge は string ではない
masync.run(
    masync.eject(hoge, function(data){ console.log(data); }) // data は hoge.txt の中身。typeof data == "string"
);

このライブラリの特徴としては、以下のようなことが挙げられます。

  • 簡単な処理であればコールバックを完全に排除できる
  • 同期処理っぽい見た目で書ける
  • TypeScript でガッチガチに静的型付けされていてる
  • どんな環境でも動く
  • 非同期なデータの操作を簡単に定義できる

特に、静的型付けされていることは大きなメリットで、ちょっとでも変な使い方をするとすぐコンパイルエラーになるので安心です(JavaScript からでも使えます)。こんなやたら長い記事をここまで読んできた方ならうっすら気付いていると思いますが、自分の静的型付けに関するこだわりは伊達や酔狂ではありません。このライブラリでも静的型付けについては徹底されていて、いわゆるダウンキャストのようなものが必要になる場面はいっさいなくなるように設計しています。あの子達の羨望の眼差しも、俺の潔癖すぎる性格を知れば幻滅するだろうね!

また、先程から何度も使っている masync.logconsole.log の非同期操作バージョンなのですが、masync.logmasync.lift という関数を使って masync.lift(console.log.bind(console)) と表すことができます。つまり、 lift は同期操作バージョンの関数を非同期操作バージョンの関数に変えてくれる関数なのです。this の扱いの関係で bind の呼び出しが必要になっていますが、this に依存しない関数、例えば Math.abs なら masync.lift(Math.abs) だけで非同期バージョンの abs になるのです。これは jQuery.deferred でいえば独自の Deferred を定義することに相当しますが、Deferred を定義するよりはるかに簡単です。

それに対し、欠点としては次のようなものが挙げられます。

  • masync.run で書ける直列な非同期処理の制限が一度に 26 個まで
  • 同期的に処理しているように見えても、そこにデバッガのブレークポイントが仕掛けられるわけではない
  • 変数の宣言と、実際に代入するソースコード上の位置が離れてしまう場合がある多分解決しました

masync.runの引数が26個までという謎制限はもうすこしライブラリに手を入れればいくらでも緩和できるのですが、とりあえず26個あれば十分かなと思うのでこうなっています。masync.seq という関数を入れ子にすることでも回避できます。

デバッガのブレークポイントを仕掛けにくいのもつらいですが、どうしてもブレークポイントを仕掛けたければ、さきほど示したような eject のコールバック関数内にならブレークポイントを仕掛けることができます。

まるで化け物を見るような...俺がそうだというのか…!?

これらのコードにはコールバックは無いように見えますが、このライブラリが面倒なコールバックをほぼすべて覆い隠してくれるのです。実はライブラリ内部ではコールバックの嵐で大変なことになっています。見たらたぶん吐きます。というか、上記のコードはコールバックに見えないだけで、このライブラリは『コールバックがない』どころかむしろ これらのコードはすべてコールバックでできているといっても過言ではない レベルだったりします。 『すべてのコールバックを駆逐してやる!』と決意して兵士になったら、実は自分がコールバックだった とか、なんかどこかで聞いたことがあるような話ですね。なにそれこわい。ここでいわゆるタイトル回収です。

なんじゃそりゃ、いったいどーなってんだ?と思う人もいるとは思いますが、ソースコードは Haskell の Functor, Applicative, Monad あたりの概念がふんだんに使われていて、このあたりの知識がないひとにはちょっとばかり理解するのがたいへんです。例えば、先ほど挙げた lift は Haskell に存在する lift に対応する関数なのです。ほかにも、このライブラリで最初に定義されている関数は fmap という関数なのですが、よく訓練された Haskeller ならこの名前を見ただけで何の関数なのか一発で理解しますし、実装は見なくてもだいたい想像がつきます。ちなみに定義は

function fmap<T,S>(f: (t: T)=>S, x: Async<T>): Async<S> {
        return ap(pure(f), x);
}

となっていますが、これを見た Haskeller は『ああ、やっぱりね』みたいに言い出します。このライブラリの実装の詳細について理解したければ、Haskell を学ぶのが最も早道だと思います。このライブラリの関数の実装はそれぞれ長くて 10数行なので一見簡単そうですが、とても抽象的な操作ばかりなので、Haskell を知らない人は処理の流れを追えば追うほどわけがわからなくなるので注意してください。

( Haskellを知っている人向けに説明すると、このライブラリは 非同期処理モナドライブラリ なのです。また、いわゆる継続渡し の応用で、成功と失敗の2系統の継続を管理するためのフレームワークを提供するものでもあります。 )

このへんの小難しい話にはみんな興味なさそうだし、もう疲れたのでこれ以上このライブラリの内部までの解説はしませんが、JavaScriptって本当にクソ本当に奥が深いですね。

この謎のライブラリのソースコードはこちら: kontan/masync 直角三角形とかも見れます。readme に API リファレンスやほんのちょっとだけライブラリ内部の解説も書いておきましたので、知りたいヒトはどうぞ。あと、readme はネタ無しでとても真面目に書いていますので、ネタ混じりの文章にうんざりしたひとにもお勧めです。

まとめ

  • generators がどのブラウザでも動くようになったら、co を使え。それまでは気合でなんとか持ちこたえろ
  • Haskell ってすごい
  • 俺のmasyncは並列と直列が両方そなわり最強に見える

あの…おなか痛いんで…負傷者に…してもらって…いいですか!?

このテキストはあくまで『非同期処理を使わざるをえないときに、どのようにコールバックのネストの多重化を防ぐか』を検討するもので、『ふたつのファイルを読み込んでつなげて出力』というのは問題を把握しやすくするための例題に過ぎませんでした。しかし途中から『手段に関わらずこの例題をどう処理するか』の話とごっちゃになってます。 同期処理が使えるなら同期で処理すればいいです。本来の論旨は『同期処理が使えないとき』であって、fibers や同期版 XHR、FileReaderSync の話はよく考えたら微妙に別件でした。つまり、Web Workers を例に挙げると、ファイルの読み込みについてはWeb Wokers で同期的に可能なのでその部分は本テキストの話題の対象ではなく、Web Workers での Worker スレッドとブラウザのスレッド間のメッセージングについては非同期処理を使わざるをえないがこれをどうするか、というのが本来このテキストで検討したかった対象です。でも関連性はある話題ですし有用ではありそうなので、これらの微妙に主旨からズレた部分の話題に関してもそのまま残しておきます。このテキストの主旨に関して混乱したかたがいたらごめんなさい。

さいごに

InfoQ: そのライブラリを改善できるような、例えば、もっと簡潔にできるなど、JavaScript言語に対する新たな機能や変更というのはありますか?

Isaac氏 (slide-flow-control): いいえ。私のフローコントロールがベストです。だれも改善なんてできません。これがベストなのは私自身に直接かかわっているからです。だから、外部の影響というのは私にとってはマイナスで、ベストではなくなるのです。もし私と同じ経験をしたいなら、フローコントロールライブラリを書くことをおすすめします。自分で書けばすぐにそれがベストだとわかるでしょう。もし他のライブラリの方がよさそうに見えたなら、大急ぎでエディターに戻って、恥を隠して、すぐに彼らのアイデアを自分自身の少し違ったやり方で再発明しましょう。そうすれば、今やそれがベストなのだと実感できるでしょう。

――仮想パネル: JavaScriptで非同期プログラミングを乗り切る方法

参考文献