ヤフー株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。LINEヤフー Tech Blog

テクノロジー

JavaScriptとコールバック地獄

Yahoo!デベロッパーネットワークの中野(@Hiraku)です。JavaScriptでサンプルコードを書く機会があったので、どんなインターフェースで提供するのが便利なのか考えてみました。よく問題になるコールバックのネスト問題について、一般的な話をまとめてみます。

お題

突然ですが、次のような処理を行う必要があるとします。

  1. 「0」を出力する
  2. 1秒待つ
  3. 「1」を出力する
  4. 1秒待つ
  5. 「2」を出力する

これをプログラムで書くとどうなるでしょうか?

シェルスクリプトの場合(同期)

たとえばシェルスクリプトで素直に書くと、次のようになります。

#!/bin/sh
echo 0
sleep 1
echo 1
sleep 1
echo 2

お題をそのまま翻訳したようなプログラムになりました。変数もループも条件分岐も出てこない単純な見た目ですね。

JavaScriptの場合(非同期)

これがJavaScriptの場合、そのまま素直に書いていくと、次のようになります。出力にはconsole.log()を使うものとします。

console.log(0);
setTimeout(function(){
  console.log(1);
  setTimeout(function(){
    console.log(2);
  }, 1000);
}, 1000);

ずいぶん見た目が変わりました。もしこれに加えて「3,4,5以降も同じように出力」しようとすれば、さらに関数が入れ子になっていきます。

この2つの例を比べると大抵の人は「シェルスクリプトの方が読みやすい」と思うのではないでしょうか。このように何の工夫もせずに書くと、関数の入れ子が積み重なっていき、コードがどんどん読みにくくなっていきます。これを俗に「 コールバック地獄(Callback Hell) 」と呼びます。

なぜコールバック地獄になるのか

JavaScriptにはシェルスクリプトで言うところのsleepに相当する関数がありません。無限ループを使って無理やり再現することはできますが、もしそうしてしまうとsleep中はブラウザーがフリーズし、操作できなくなってしまいます。常にsetTimeout()のような関数を使い、非同期に処理を行っていくことが強く推奨されます。

非同期(もしくはノンブロッキング)な関数は、

  1. 「処理が終わったら実行する関数」をあらかじめ渡しておく
  2. 処理が終わったらその関数を呼び出す

というインターフェースを持ちます。(もう一つイベント系の処理がありますがここでは扱いません)

asyncFunction(someParam, function(result){ /*終わったら呼ばれる*/ });

非同期の関数を使う場合、処理前と処理後で関数を分割しなければなりません。これはどんなに同期的に(先ほどのシェルスクリプトの例のように)書きたくても、以下のような関数の羅列ができてしまうことを意味します。

[
  function(){ /* sleep()の前に実行する処理 */ }
 ,sleep //非同期な関数
 ,function(){ /* sleep()の後に実行する処理 */ }
];
//これを何とかまとめて同期っぽくするしかない

この特徴により、非同期処理を綺麗に書くのは難しいのです。

非同期は伝播する

また、非同期処理をまとめて同期的な見た目の関数を作ることはできません。一度でも非同期の関数を使ってしまうと、その処理全体もまた非同期な処理になってしまいます。

JavaScriptを扱う上ではコールバック地獄は避けて通れず、JavaScriptを扱うエンジニアは日夜読みにくく乱雑化するコードと戦っています。この中で、数多くの解決策が編み出されてきました。

解決策1 Generator

まずJavaScriptの次期バージョンで取り込まれるであろうGeneratorという機能を使った解決策を考えます。JavaScriptの将来の姿を見れば、きっとどのような解決策が理想的かわかるはずです。

http://wiki.ecmascript.org/doku.php?id=harmony:generators

Generatorとは、端的に言うと関数の一時停止、再開ができる機能です。通常の関数は常に最初から実行され、returnが発生するまで一気に処理されますが、Generatorではその処理の途中で一度関数を抜けたり、また一時停止中の位置から処理を再開したりできます。一時停止、再開する際はパラメータの受け渡しも可能です。

Firefoxが先行実装している例で説明します。yieldと書かれた部分が一時停止・再開のポイントになります。yieldまで到達すると処理は中断され、.next()もしくは.send()が呼び出されると、その時点から次のyieldに到達するまで処理されます。なお、Firefox版の実装と、ES.nextに記載されている仕様は若干の差異(function* で宣言しなければならないなど)がありますのでご注意ください。

function generator() {
  console.log(0);
  yield;
  console.log(1);
  yield;
  console.log(2);
}

//呼び出し方法
var g = generator();
g.next(); //0が出力
g.next(); //1が出力
g.next(); //2が出力、StopIteration例外が発生

この機能を使うと、コールバック地獄を同期的なコードに修正できます。

先ほど述べた通り、通常の非同期関数では

  1. 「処理が終わったら実行する関数」をあらかじめ渡しておく
  2. 処理が終わったらその関数を呼び出す

というインターフェースを持ちますが、これを

  1. 「処理が終わったら再開するGenerator」をあらかじめ渡しておく
  2. 非同期関数を実行すると同時にGeneratorの処理を一時中断する
  3. 処理が終わったらそのGeneratorを再開する

という形に書き換えればよいのです。

//冒頭のお題を書き換えた
var thread = (function(){
  console.log(0);
  setTimeout(function(){ thread.next() }, 1000);
  yield;
  console.log(1);
  setTimeout(function(){ try{thread.next()}catch(e){} }, 1000);
  yield;
  console.log(2);
})();
thread.next();

入れ子状態になっていた関数が平準化されました。

ただ、これだとまだ読みにくいですね。setTimeoutの部分を関数化してみましょう。また、yieldは引数を取ることができるため、yieldと関数の呼び出しは一行にまとめられます。

function sleep(ms, generator) {
  setTimeout(function(){
    try {
      generator.next();
    } catch (e) {
      if (! (e instanceof StopIteration)) throw e;
    }
  }, ms);
}

var thread = (function(){
  console.log(0);
  yield sleep(1000, thread);
  console.log(1);
  yield sleep(1000, thread);
  console.log(2);
})();
thread.next();

sleepという関数を定義した部分を除けば、かなり冒頭のシェルスクリプトの例に近づけることができたのではないでしょうか。

なお、これを順番ではなく、並列実行に書き換えるのも簡単です。先に非同期処理を並べておき、直後でyieldを必要な回数だけ並べておくだけです。

var thread = (function(){
  console.log(0);
  sleep(1000, thread); //1秒待て
  sleep(2000, thread); //2秒待て
  //同時に実行
  yield;
  yield;
  //2つとも処理が終わったら(=2秒待ったら)先に進む
  console.log(1);
  console.log(2);
})();
thread.next();

ただし、並列実行した処理のそれぞれの戻り値を受け取りたい場合は、もう少し工夫する必要があります。

以上のGeneratorの使い方は一例であり、他にもさまざまな書き方が考えられます。さらに再利用性の高い書き方を考えてみると面白いと思います。

Generatorは「簡易的にイテレータを生成する機能」と説明されることがありますが、実は「本来複数の関数を作らないとできないかったことを、ひとつの関数にまとめてしまえる」という性質から、非常に応用範囲の広い機能です。もし多くの環境で使えるようになれば、JavaScriptのコードはかなり様変わりするかもしれませんね。

解決策2 jQuery.Deferred

Generatorによる解は分かりやすく理想的ですが、残念ながら今すぐ使える環境は非常に限られています。現状では、関数の羅列をうまく組み合わせるような補助ライブラリを使うことで解決するしかありません。

コールバック地獄に対処するライブラリは数多くありますが、ここではメジャーだと思われるjQuery.Deferredの例を取り上げてみます。現状のJavaScriptでの対処を行うため、Generatorほどの劇的な効果はありませんが、コールバック地獄状態のコードを読みやすく改善できます。バージョンはjQuery1.9での動作を想定して解説します。

jQuery.Deferredでは、それぞれの非同期処理をDeferredというオブジェクトでラッピングします。Deferredは「処理中か、処理が正常終了したか、エラーが起きたか」という状態を持っており、状態が変わったタイミングでそのイベントに対応する処理を呼び出します。

Deferredオブジェクトは、入れ子にしたり、直列に連結したりして複雑な構造を持つことができ、どこか一部のDeferredオブジェクトの状態が変化すると、その状態が伝播していくことで処理が進みます。

まず、非同期関数を「Deferred化」します。関数の中でDeferredオブジェクトを作り、非同期処理が終わればDeferredオブジェクトの.resolve()を呼び出すようにセットしておき、最後に.promise()したものを返します。setTimeout()をDeferredに対応させると、以下のようになります。

function sleep(ms) {
  var d = new $.Deferred;
  setTimeout(function(){
    d.resolve();
  }, ms);
  return d.promise();
}

.promise()という謎のメソッドが書かれていますが、これはDeferredオブジェクトから.resolve().reject()といった「状態を変更するメソッド」を取り除いたサブセットを返します。Deferredオブジェクトを直接返してしまうと、受け取った側で状態を変更できてしまい、非同期処理そのものが本当に終わったのかどうか保証が取れなくなります。これを防ぐため、.promise()によるクリーニング作業が必要になるわけです。

このsleep()を使うと、冒頭のお題は以下のように書き換えられます。

console.log(0);
sleep(1000).then(function(){
  console.log(1);
  sleep(1000).then(function(){
    console.log(2);
  });
});

...まだ入れ子が解消していないですね。少し工夫すると、.then()の位置を変えられます。

console.log(0);
sleep(1000)
.then(function(){
  console.log(1);
  return sleep(1000);
}).then(function(){
  console.log(2);
});

このように、.then()に渡した関数がDeferredを返す場合、.then()を連結する形に書き換えることができるのです。これで関数の入れ子を解消することができました。通常の用途ならここまでの改善でコードを書いていくことになります。

しかし、sleep(1000)がまだ入れ子の内側に残ってしまっていますね。冒頭のシェルスクリプトの例と一対一になっていないように見えます。

少し無理やりですが、sleep()を関数で囲めば、.then()に渡すことができるようになります。

console.log(0);
sleep(1000)
.then(function(){  console.log(1); })
.then(function(){ return sleep(1000); })
.then(function(){  console.log(2); });

さらに、非同期処理に入る前のコードもまとめてしまいたいなら$.when()で囲って、

$.when(function(){ console.log(0); }())
.then(function(){ return sleep(1000); })
.then(function(){  console.log(1); })
.then(function(){ return sleep(1000); })
.then(function(){  console.log(2); });

ここまで修正すればどうでしょうか。functionは消せないので冗長ではありますが、構造的には冒頭のシェルスクリプトの例と対応する形になったかと思います。

ライブラリを使うべきかどうか

jQuery.Deferredは優れた解決策ですが、一度使ってしまうと処理の根底からjQueryに依存してしまうという問題があります。非同期の処理を全てDeferred化しておかないと使えない(使いにくい)ためです。

...いや、元からjQueryを使って開発している場合や、jQueryプラグインを作っている場合は何の問題もありません。しかし、YUI Libraryなど他の系統のライブラリを使って開発している場合にも、Deferredに引きずられてjQueryが必要になってしまいます。

依存のない、非同期処理専用のライブラリを使うことも可能ですが、すると今度はjQueryとインターフェースが合わず、jQueryと組み合わせる際の使い勝手が悪くなってしまいます。

結局、昔ながらの「処理が終わったら引数から渡してもらった関数を実行する」というコールバックスタイルで作っておいた方が、汎用性は高くなります。どのライブラリでも、コールバックスタイルの非同期処理であれば、少しの修正でライブラリの流儀に合わせられるようになっているはずです。

「あるライブラリ用に特化したプログラム」を書きたいのであれば、その作法に従うべきでしょう。「どんなライブラリと併用しても汎用的に使えるプログラム」を書きたいのであれば、外部に公開するインターフェースとしてはまずコールバックスタイルにしておき、必要に応じてライブラリに合わせたラッパーを作るという形になるかと思います。

これは非同期処理に限った話ではありませんが、ライブラリを使うなら意識しておくべき点だと思います。

まとめ

  • コールバックスタイルの非同期処理は素直に書くと非常に読みにくくなる
  • ライブラリによる改善手段はいくつかあるが、ライブラリへの依存とのトレードオフがある
  • Generatorによる解決策がおそらく理想

非同期処理でよく問題になる点としては、他にもエラー処理があります。こちらはまた別の機会に書いてみたいと思います。

こちらの記事のご感想を聞かせください。

  • 学びがある
  • わかりやすい
  • 新しい視点

ご感想ありがとうございました

このページの先頭へ