読者です 読者をやめる 読者になる 読者になる

くじら公園

今のところJavaScript関連のドキュメントの翻訳だけです

Promiseアンチパターン

Promise Anti-patternsを翻訳させて頂きました。著者のtaoofcodeから許可を頂いて翻訳、投稿しています。



Promiseは一度理解してしまえば簡単だが、いくつか頭を抱えさせるパターンがある。ここにあるのは私が経験したいくつかのアンチパターンだ。

ネストされたPromise

君は複数のPromiseをネストする:

loadSomething().then(function(something) {
    loadAnotherthing().then(function(another) {
                    DoSomethingOnThem(something, another);
    });
});

君がこれをする理由は、両方のPromiseの結果で何かをする必要があるからだ。then()は一つ前のPromiseの結果しかコールバックに渡せないのでここでチェインを用いることはできない。

だが、君がこうする本当の理由は君がall()メソッドを知らないからだ。

修正は:

q.all([loadSomething(), loadAnotherThing()])  
    .spread(function(something, another) {
        DoSomethingOnThem(something, another);
});

よりシンプルになった。q.allによって返されるPromiseは、2つのPromiseの結果の配列として解決されてthen()に渡される。spread()メソッドはこの配列をコールバックの仮引数に対して分配する。

壊れたチェイン

君は以下のようなコードを書く:

function anAsyncCall() {  
    var promise = doSomethingAsync();
    promise.then(function() {
        somethingComplicated();
    });

    return promise;
}

ここでの問題はsomethingComplicated()メソッドの中で発生したエラーを捕まえることができないことだ。Promiseはチェインされることを意図されて作られている、そのためそれぞれのthen()呼び出しは新しいPromiseを返却する。次のthen()はこの新しいPromiseに対しコールすべきだ。一般的に最後のコールはcatch()にする、そうすればチェインのどこで発生したエラーでもcatch()の中でハンドルできる。

上のコードでは君が最後のthen()の結果の代わりに最初のPromiseを返した時点でチェインが壊れている。

修正は:

function anAsyncCall() {  
    var promise = doSomethingAsync();
    return promise.then(function() {
        somethingComplicated()
    });   
}

常に最後のthen()の結果を返すべきだ。

コレクションのバカ騒ぎ

君の手元には要素の配列があり、要素個々に対し何かを非同期に実行したいとする。君は再帰を用いた以下のようなコードを書いている自分に気付く。

function workMyCollection(arr) {  
    var resultArr = [];
    function _recursive(idx) {
        if (idx >= resultArr.length) return resultArr;

        return doSomethingAsync(arr[idx]).then(function(res) {
            resultArr.push(res);
            return _recursive(idx + 1);
        });
    }

    return _recursive(0);
}

あぁ、なんて非直感的なコードか。問題はいくつチェインすることになるか分からないときのメソッドのチェインの仕方にある。map()reduce()を思い出そう。

修正は:

q.allはPromiseの配列を引数にとり、結果の配列に解決してくれる。q.allを使えば以下のように、簡単に非同期呼び出しをそれらの結果にマップできる:

function workMyCollection(arr) {  
    return q.all(arr.map(function(item) {
        return doSomethingAsync(item);
    }));    
}

再帰を使った(非)解決策と違って、このコードは全ての非同期呼び出しを並列に開始する。当然、時間的により効率的だ。

もしPromiseを連続に実行する必要があれば、reduceを使う。

function workMyCollection(arr) {  
    return arr.reduce(function(promise, item) {
        return promise.then(function(result) {
            return doSomethingAsyncWithResult(item, result);
        });        
    }, q());
}

最高に綺麗なコードではないけれど、間違いなくより綺麗なコードだ。

ゴーストPromise

非同期に何かをするメソッドと、同期に処理するメソッドがある。君は2つのコードを一貫して処理するために、その必要がないときでもPromiseを作成する。

var promise;  
if (asyncCallNeeded)  
    promise = doSomethingAsync();
else  
    promise = Q.resolve(42);

promise.then(function() {  
    doSomethingCool();
});

最悪のアンチパターンというわけではない、しかし絶対的に分かりやすい方法があるー「値かPromise」をQ()でくるめば良い。このメソッドは値かPromiseを引数にとって適宜処理する。

Q(asyncCallNeeded ? doSomethingAsync() : 42)  
    .then(
        function(value){
            doSomethingGood();
        })
    .catch( 
        function(err) {
            handleTheError();
        });

注釈:私は最初ここでQ.whenを使う方法を提示した。ありがたいことにKris Kowal*1がコメントでこれを訂正してくれた。Q.whenではなく、Q()を使うーこちらのほうがより明快だ。

過度に鋭利なエラーハンドラ

thenメソッドは引数を2つとる、fulfilledハンドラとrejectedハンドラである。君は以下のようなコードを書くかもしれない:

somethingAsync.then(  
    function() {
        return somethingElseAsync();
    },
    function(err) {
        handleMyError(err);
});

このコードの問題はfulfilledハンドラの中で発生したエラーがエラーハンドラに渡されないことだ。

修正は、別のthenの中でエラーハンドリングするようにすれば良い:

somethingAsync  
    .then(function() {
        return somethingElseAsync();
    })
    .then(null,
        function(err) {
            handleMyError(err);
        });

またはcatch()を用いて:

somethingAsync  
    .then(function() {
        return somethingElseAsync();
    })
    .catch(function(err) {
        handleMyError(err);
    });

こうすればチェインの中で発生したどんなエラーでもハンドリングされる保証がある。

忘れられたPromise

君はPromiseを返却するメソッドを呼び出す。でも君はこのPromiseのことを忘れて君自身のPromiseを作成する。

var deferred = Q.defer();  
doSomethingAsync().then(function(res) {  
    res = manipulateMeInSomeWay(res);
    deferred.resolve(res);
}, function(err) {
    deferred.reject(err);
});

return deferred.promise; 

このコードはPromiseの効果ーシンプルであることを全く利用できていない。たくさんの無意味なコードが記述されている。

修正は、単純にPromiseを返却するようにする。

return doSomethingAsync().then(function(res) {  
    return manipulateMeInSomeWay(res);
});

*1:Qの作者

JavaScript Promise ... イカした詳細

JavaScript Promises ... In Wicked Detailを翻訳させて頂きました、プロミスについて実装しながらその仕組みを学べるドキュメントです。著者のMatt Greer氏から許可を得て翻訳、公開しています。



私はここしばらく仕事でJavaScriptのプロミスを利用してきました。プロミスを使い始めたときは少し頭を悩ませたりもしましたが、今やかなり効率的にプロミスを利用しています。しかし、結局のところ、私はプロミスがどのように機能しているか理解できていませんでした。この文章はこのことに対する私の解です。この文章を最後まで読めば、プロミスについてあなたもよく理解できるでしょう。

この文章では、 ほぼ Promise/A+specに準拠したプロミスを目標にインクリメンタルにプロミスを実装しながら、プロミスが非同期プログラミングのニーズにいかにマッチしているか理解していきます。この文章はプロミスに対するある程度の理解を前提としています。もしまだプロミスに対する理解が足りない場合は、promisejs.orgをチェックすると良いでしょう。

目次

  1. なぜ
  2. 簡単なユースケース
  3. プロミスは状態を持っている
  4. プロミスのチェイン
  5. プロミスをリジェクトする
  6. プロミスの解決は非同期である必要がある
  7. まとめの前に…then/promise
  8. まとめ
  9. 参考文献

なぜ

なぜプロミスを詳細に理解する必要があるのでしょうか?ある物事がどのように機能しているのか正しく理解することは、これを利用する能力を向上させ、またトラブル時のデバッグをより容易にします。私は同僚と一緒にトリッキーなプロミスの振る舞いに悩まされた時にこの文章を書くことを思いつきました。もし今わかっていることをその時に知っていたなら、私はあの時悩まなかったと思います。

シンプルなユースケース

なるべくシンプルなところからプロミスの実装をはじめていきます。まず以下のコードを

doSomething(function(value) {
  console.log('Got a value:' + value);
});

以下のようにしてみます、

doSomething().then(function(value) {
  console.log('Got a value:' + value);
});

そのためには、doSomething()を以下から

function doSomething(callback) {
  var value = 42;
  callback(value);
}

以下のようにプロミスベースのコードに変更します。

function doSomething() {
  return {
    then: function(callback) {
      var value = 42;
      callback(value);
    }
  };
}

fiddle

これは単にコールバックパターンの些細なシュガーです。このままではとても無意味なシュガーです。しかし出発点としては十分だし、またプロミスの裏側にある核となる考えが見えてきました。

プロミスは結果の値の概念(the notion of an eventual value)をオブジェクトの中に取り込む

プロミスがこれほど興味深いのはこれこそが主な原因です。一度結果の値の概念をオブジェクトに取り込めれば、とても強力なことが実現できます。このことについては後でより詳細に説明します。

プロミス型を定義する

単純なオブジェクトリテラルでは今後の実装に耐えられません。拡張していけるように実際のPromise型を定義します。

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    callback(value);
  }

  fn(resolve);
}

そしてdoSomething()をこれを利用するように書き直します

function doSomething() {
  return new Promise(function(resolve) {
    var value = 42;
    resolve(value);
  });
}

ここで問題が一つあります。実行の順番を確認すると、resolve()then()の前に呼ばれているのが分かります、つまりresolve()が呼び出されたタイミングではcallbackがまだnullのままです。setTimeoutを用いた些細なハックでこの問題を隠蔽してみます。

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    setTimeout(function() {
      callback(value);
    }, 1);
  }

  fn(resolve);
}

fiddle

ハックを用いることで、我々の実装は何とか動くようになりました。

このコードは脆いし、悪い

ここまでの単純で貧弱なプロミスの実装は機能させるために非同期を用いなければなりません。この実装をもう一度失敗させるのは簡単です、then()を非同期にコールすると、コールバックが再びnullとなってしまいます。ここで、すぐに失敗してしまう実装を一度示したのは、ここまでの実装がthen()resolve()の重要性の理解をとても容易にするためです。これらはプロミスのキーとなるコンセプトです。

プロミスは状態を持っている

上記脆い実装は予期せずしてある事実を明らかにしています。プロミスは状態を持っています。実装を先に進める前に、プロミスがどんな状態を取り得るのか知っておく必要があります、そうすればこれら状態の間を正しく行き来することが可能になります。プロミスの取り得る状態を知ることで、我々の実装から脆さを取り除くことができます。

  • プロミスは値を待つためにpendingになり、また値と共にresolvedになることができる
  • 一度プロミスが値に解決されたら、プロミスはずっとその値を維持する、再度解決されることはない

(プロミスはまた拒否されることもあるが、このことについては後ほどエラーハンドリングの節で触れます)

プロミスの状態を明示的に追跡するよう実装を変更します、こうすることでハックを取り除くことができます

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if (deferred) {
      handle(deferred);
    }
  }

  function handle(onResolved) {
    if (state === 'pending') {
      deferred = onResolved;
      return;
    }

    onResolved(value);
  }

  this.then = funcition(onResolved) {
    handle(onResolved);
  };

  fn(resolve);
}

fiddle

実装は複雑になりました、しかし呼び出し元はthen()を好きな時に実行することができ、呼ばれた側はresolve()を好きなときに実行することができるようになりました。この変更で同期または非同期いずれにも対応できるようになっています。

これはstateフラグのおかげです。then()resolve()は共に新しいメソッドhandle()に処理を移譲しています、handle()は状況に応じて以下のいずれかを行います:

  • 呼ばれた側がresolve()を呼ぶ前に呼び出し元がthen()を呼んだ場合、この場合返却すべき値はまだありません。このときstatuspendingであり、呼び出し元のコールバックを後で使えるように保持しておきます。後でresolve()が呼ばれた時に、コールバックを実行し、このコールバック経由で値を渡すことができます。
  • 呼び出し元がthen()を呼ぶ前に呼ばれた側がresolve()を呼んだ場合: この場合は結果の値を保持しておきます。続いてthen()が呼ばれたときには既に値を返す準備ができています。

setTimeoutがなくなっていることに気づいたでしょうか?setTimeoutは後ほどまた登場しますが、一時に一事です。

プロミスではプロミスのメソッド呼び出しの順序は問題にはなりません。then()resolve()は目的にかなった時にいつでも自由に呼び出すことができます。これは結果の値の概念をオブジェクトに取り込んだことによる強力なメリットの一つです。

まだ実装すべき仕様は多く残っていますが、ここまでで既にとても強力なプロミスの実装ができています。then()を好きなだけ何回でも呼ぶことができ、毎回同じ値を得ることができます。

var promise = doSomething();

promise.then(function(value) {
  console.log('Got a value:', value);
});

promise.then(function(value) {
  console.log('Got the same value again:', value);
});

このプロミスの実装において、このことーthen()を何回でも呼ぶことができるーは完全には正しくはありません。もし反対のことが起きたら、つまりresolve()が呼ばれる前に呼び出し元がthen()を複数回呼んだ場合、then()呼び出しの最後の一回しか信用することはできません。プロミスの中に実行中のdeferredを一つではなく、リストで保持することでこの問題は解決できます。ここでは話をシンプルにするためにこの修正は行なっていません。

プロミスのチェイン

プロミスがオブジェクトの中に非同期の概念を取り込んでいるため、プロミスをチェインして、マップして、並列に又は連続に実行して…数々の便利なことを実現できます。プロミスを利用していると以下のようなコードをよく見かけます

getSomeData()
.then(filterTheData)
.then(processTheData)
.then(displayTheData)

getSomeData()はプロミスを返却しています、これはひき続くthen()呼び出しから読み取れます、しかし最初のthen()の結果もまたプロミスである必要があります、1度目のthen()の結果に対し再度then()が呼ばれているからです(2度も!)。then()でプロミスを返却するように修正できれば、物事はもっと面白くなります。

then()は常にプロミスを返す

以下はチェインをサポートするようアップデートしたプロミスの実装です

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if (deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if (state === 'pending') {
      deferred = handler;
      return;
    }

    if (!handler.onResolved) {
      handler.resolve(value);
      return;
    }

    var ret = handler.onResolved(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved) {
    return new Promise(function(resolve) {
      handle({
        onResolved: onResolved,
        resolve: resolve
      });
    });
  };

  fn(resolve);
}

fiddle

かなり実装が複雑になって来ました。インクリメンタルにゆっくりとこの実装を組み立ててきてよかったと思いませんか?この変更でのキーポイントはthen()が新しいプロミスを返却していることです。

then()は常に新しいプロミスオブジェクトを返すので、少なくとも常に一つのプロミスは作成され解決され無視されます。これは無駄なことに見えるかもしれません。コールバックによるアプローチはこの問題を孕んでいません。これはプロミスに対し繰り返し言及される問題の一つであり、いくつかのJavaScriptコミュニティがプロミスを避ける理由の一つです。

2つ目のプロミスはどんな値に解決されるのでしょうか? 2つ目のプロミスは最初のプロミスの返却値を受け取ります 。これは実装コードのhandle()の終わりで確認できます。handlerオブジェクトはonResolvedコールバックと一緒にresolve()への参照を持っています。一つ以上のresolve()のコピーが生成されて、各プロミスは自分のためのresolve()のコピーと、そのresolve()のコピーを実行するためのクロージャを持っています。これが最初のプロミスと2つ目のプロミスとの橋渡しとなっています。最初のプロミスは以下の行で終えることができます:

var ret = handler.onResolved(value);

例の中ではhandler.onResolvedは以下です

function(value) {
  console.log("Got a value:", value);
}

別な言い方をすれば、これが最初のthen()呼び出しの時にコールバックの引数として渡されます。この最初のハンドラの戻り値が2つ目のプロミスを解決するために用いられます。こうしてチェインが実現されています。

doSomething().then(function(result) {
  console.log('first result', result);
  return 88;
}).then(function(secondResult) {
  console.log('second result', secondResult);
});

// 出力は
//
// first result 42
// second result 88

doSomething().then(function(result) {
  console.log('first result', result);
  // 明示的に何も返さない
}).then(function(secondResult) {
  console.log('second result', secondResult);
});

// 今度の出力は
//
// first result 42
// second result undefined

then()は常に新しいプロミスを返却するので、このチェインは好きなだけ深くすることができます。

doSomething().then(function(result) {
  console.log('first result', result);
  return 88;
}).then(function(secondResult) {
  console.log('second result', secondResult);
  return 99;
}).then(function(thirdResult) {
  console.log('third result', thirdResult);
  return 200;
}).then(function(fourthResult) {
  // 続く…
});

ではこの例でもし全ての結果が最後に欲しくなったときはどうするのでしょうか?チェインでは手動で結果を持ち回す必要があります。

doSomething().then(function(result) {
  var results = [result];
  results.push(88);
  return results;
}).then(function(results) {
  results.push(99);
  return results;
}).then(function(results) {
  console.log(results.join(', ');
});

// 出力は
//
// 42, 88, 99

プロミスは常にひとつの値に解決される。もし一つ以上の値を渡したい場合、何らかの形で多値を作る必要がある(配列、オブジェクト文字列の連結、など)

より良い解決策としてはプロミスライブラリのall()メソッドか、プロミスの利便性を向上させるその他の数多くのユーティリティメソッドを用いることです、これらライブラリのAPIの探索はあなたに譲ります。

コールバックはオプション

then()の引数に渡すコールバックは必須ではありません。もし与えられなかった場合、プロミスはひとつ前のプロミスと同じ値に解決されます。

doSomething().then().then(function(result) {
  console.log('got a result', result);
});

// 出力は
//
// got a result 42

この部分の処理はhandle()の中で確認できます、コールバックが無い場合、handle()は単純にプロミスを解決して処理を終えています。この時のvalueは前のプロミスの値のままです。

if(!handler.onResolved) {
  handler.resolve(value);
  return;
}

チェインの中でプロミスを返却する

ここまでのチェインの実装は少し愚直です。渡されたresolvedな値を闇雲にひき続く処理に渡していっています。解決される値の一つがプロミスの場合どうなるでしょうか。例えば

doSomething().then(result) {
  // doSomethingElseはプロミスを返却する
  return doSomethingElse(result)
}.then(function(finalResult) {
  console.log("the final result is", finalResult);
});

現状では、上記コードは期待する通りに動きません、finalResultは実際には完全に解決された値にはならず、プロミスのままでしょう。期待される結果を得るためには以下の様に記述する必要があります

doSomething().then(result) {
  // doSomethingElseはプロミスを返却する
  return doSomethingElse(result)
}.then(function(anotherPromise) {
  anotherPromise.then(function(finalResult) {
    console.log("the final result is", finalResult);
  });
});

このような冗長なコードを書きたたがるプログラマがいるでしょうか?プロミスの実装でシームレスにこれをハンドルするよう修正してみます。修正は単純で、resolve()の中で解決された値がプロミスの場合を特別扱いするようにします

function resolve(newValue) {
  if (newValue && typeof newValue.then === 'function') {
    newValue.then(resolve);
    return;
  }
  state = 'resolved';
  value = newValue;

  if (deferred) {
    handle(deferred);
  }
}

fiddle

プロミスが返却される限り再帰的にresolve()を呼び出し続けます、一度プロミスではないものが返却されたら元の通り処理が続きます。

これは無限ループになる可能性を孕んでいます。Promises/A+ specでは実装に無限ループを検知することを推奨していますが、要求はしていません。


また、この実装は仕様に準拠していません。またこの文章中の実装全てこの点について仕様に完全に準拠しているわけではありません。さらなる理解のためにはプロミスの解決処理を読むことをおすすめします。

newValueがプロミスかどうか判定するための処理がとてもルーズなものであることに気づいたでしょうか?then()メソッドの存在だけを確認しています。このダックタイピングは意図的なものです。こうすることで異なるプロミスの実装が相互に対話することが可能になります。実際に複数のプロミスライブラリの混合は普通に起こり得ることです、あなたが利用するサードパーティ製のライブラリは、それぞれが異なるプロミスの実装を用いている可能性があるためです。

異なるプロミスの実装はそれらが仕様に準拠している限り相互に対話することができる

チェインを手に入れて、我々のプロミスの実装はかなり完成に近づきました。でもこれまでエラーハンドリングについて一切考慮できていません。

プロミスをリジェクトする

プロミスは処理中に都合が悪くなったら 理由 と共に 拒否 する必要があります。呼び出し元はこれが発生した時にどのようにしてこのことを知ることができるでしょうか?呼び出し元はthen()の第二引数にコールバックを渡すことでこれを知ることができます。

前に言及したとおり、プロミスは pending から resolved 又は rejected のいずれかの状態に遷移する、両方には遷移しない。言い換えれば、上記コールバックのいずれか一つのみが呼ばれる。

プロミスはreject()によって拒否を可能にします、reject()resolve()の邪悪な双子です(evil twin)。以下はdoSomething()にエラーハンドリングのサポートを加えたものです

function doSomething() {
  return new Promise(function(resolve, reject) {
    var result = somehowGetTheValue();
    if (result.error) {
      reject(result.error);
    } else {
      resolve(result.value);
    }
  });
}

プロミスの実装では拒否をハンドリングする必要があります。プロミスが拒否されたらすぐに、それ以降の全ての下流の(downstream)プロミスもまた拒否される必要があります。

以下は拒否をサポートした完全なプロミスの実装です

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    if (newValue && typeof newValue.then === 'function') {
      newValue.then(resolve, reject);
      return;
    }
    state = 'resolved';
    value = newValue;

    if (deferred) {
      handle(deferred);
    }
  }

  function reject(reason) {
    state = 'rejected';
    value = reason;

    if (deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if (state === 'pending') {
      deferred = handler;
      return;
    }

    var handlerCallback;

    if (state === 'resolved') {
      handlerCallback = handler.onResolved;
    } else {
      handlerCallback = handler.onRejected;
    }

    if (!handlerCallback) {
      if (state === 'resolved') {
        handler.resolve(value);
      } else {
        handler.reject(value);
      }

      return;
    }

    var ret = handlerCallback(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved, onRejected) {
    return new Promise(function(resolve, reject) {
      handle({
        onResolved: onResolved,
        onRejected: onRejected,
        resolve: resolve,
        reject: reject
      });
    });
  };

  fn(resolve, reject);
}

fiddle

reject()関数自信の追加に加えて、handle()内部で拒否をハンドルする必要があります。handle()の中で、stateの値に依存して拒否するかそれとも解決するか決定しています。引き続くプロミスのresolve()又はreject()呼び出しが自分自身のstate値を適宜に設定できるように、このstateの値は次のプロミスに渡されていきます。

プロミスを利用する際、エラーのコールバックを渡し忘れるのは容易なことです。もしそうした場合、プロミスの処理中に都合の悪いことが起こっている兆候を 一切 知ることができなくなります。せめて、チェインの最後のプロミスにはエラーコールバックを指定するべきです。飲み込まれたエラーについては以降の説で詳しく説明します。

予期せぬエラーが発生した場合も拒否されること

ここまでのプロミスの実装は既知のエラーに対してしか責任をとっていません。unandled exceptionは発生し得るし、その場合全てがクラッシュしてしまいます。プロミスの実装はこれらの例外をキャッチして適宜拒否することが必要不可欠です。

そのためにはresolve()をtry/catchブロックでくるむ必要があります

function resolve(newValue) {
  try {
    // ... 前の通り
  } catch(e) {
    reject(e);
  }
}

また呼び出し元から渡されたコールバックがunandled exceptionを発生させないことを保証する必要もあります。これらコールバックはhandle()の中で呼ばれています、最終的な実装は以下のようになります

function handle(handler) {
  // 前と同じ

  var ret;
  try {
    ret = handlerCallback(value);
  } catch (e) {
    handler.reject(e);
    return;
  }

  handler.resolve(e);
}

プロミスはエラーを飲み込む

プロミスを正しく理解していないとエラーがプロミスに飲み込まれてしまう、これはよく陥る罠である

以下の例を考えてみます

function getSomeJson() {
  return new Promise(function(resolve, reject) {
    var badJson = "<div>uh oh, this is not JSON at all!</div>";
    resolve(badJson);
  });
}

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}, function(error) {
  console.log('uh oh', error);
});

fiddle

何がおきたのでしょうか?then()の中のコールバックは有効なJSONを期待しています。そのため単純に引数にもらったjsonをパースしようとしていますが、ここで例外が発生します。でもエラーコールバックはちゃんと指定して有ります、そのためこのコードは問題ないのではないでしょうか?

いいえ、 このエラーコールバックは実行されないだろう fiddleでこの例を実行してみると、出力は何も得られないはず。エラーは置きないし、何も置きない。純粋に 寒気のする 静けさだけが残る(Pure chilling silence)

なぜでしょうか?unhandled exceptionthen()のコールバックの中で発生しているため、これはhandle()の中でcatchされます。そしてhandle()は今対応中のプロミスではなくthen()の返却したプロミスを拒否します、今対応中のプロミスは既に解決済みのため拒否されません。

then()のコールバックの中では、対応中のプロミスは既に解決されていることを常に覚えているべきだ。コールバックの結果はこのプロミスに対し何の影響も与えない

上記エラーを捕まえたい場合、さらに下流でエラーコールバックを渡す必要があります。

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}).then(null, function(error) {
  console.log("an error occured: ", error);
});

これで正しくエラーをログできるようになりました。

筆者の経験では、これがプロミスの最も大きな落とし穴であす。より良い解決策について次の節で説明します。

レスキューのためのdone()

ほとんどの(全部ではないが)プロミスライブラリはdone()メソッドを持っています。これは上記then()の落とし穴を回避する点を除いて、then()にとても良く似ています。

done()then()が呼べるタイミングではいつでも呼べます。異なる点はdone()はプロミスを返却しないこと、そしてdone()の中で発生したunhandled exceptionはプロミスの実装側ではcatchされないことです。別な言い方をすれば、done()は全体のプロミスチェインが完全に解決されたタイミングを表現しています。先ほどのgetSomeJson()の例はdone()を用いることでよりロバストなコードに修正できます。

getSomeJson().done(function(json) {
  // これが投げるとき、それは飲み込まれない
  var obj = JSON.parse(json);
  console.log(obj);
})

done()もまたthen()と同じくエラーコールバックを引数に取りますーdone(callback, errback)ーそして全てのプロミスが解決ーdoneされているので、発生したどんなエラーでも知ることができます。

done()は(少なくとも今は)Promises/A+ specの一部ではない、なのであなたの選択するライブラリはこれを持っていないかもしれない

プロミスの解決は非同期である必要がある

最初の方の実装でsetTimeoutを用いたハックを行いました。一度このハックを取り除いた後これまでsetTimeoutは登場してきませんでした。しかし実際には、Promises/A+ specではプロミスの解決は非同期に行うことを要求しています。単純にhandle()の実装をsetTimeoutコールにくるめばこの要求を満たすことができます。

function handle(handler) {
  if(state === 'pending') {
    deferred = handler;
    return;
  }
  setTimeout(function() {
    // ... as before
  }, 1);
}

これで要求に準拠することができました。実際には現実のプロミスライブラリはsetTimeoutを使わない傾向にあります。ライブラリがNodeJS向けの場合はprocess.nextTick、ブラウザ向けであれば新しいsetImmediatesetImmediate shim(setImmediateこれまでIEのみがを具備していました)、又はKris Kowalのasapのような非同期ライブラリを利用している可能性があります(Kris Kowalは有名なプロミスライブラリであるQの作者です)。

なぜ仕様はプロミスの解決に非同期を要求するのか

プロミスの解決を非同期にすることで、実行フローに一貫性と信頼性を保証することができるようになります。以下の混みいった例を考えてみます

var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();

ここでのコールフローはどうなっているでしょうか?関数の名前から、invokeSomething() -> invokeSomethingElse() -> wrapItAllUp()のフローが推測できます。しかし今の我々の実装では、コールフローはプロミスが同期に解決されるか非同期に解決されるかに依存して変わります。もしdoAnOperation()が非同期に動く場合、コールフローは推測通りです。でももし同期に動いた場合、実際のコールフローはinvokeSomething() -> wrapItAllUp() -> invokeSomethingElse()であり、期待したものにはならないでしょう。

上記のような状況に対応するために、プロミスはたとえ必要なくとも常に非同期に解決されます。プロミスを非同期にすることで、利用者の驚きを減らし、利用者が自分のコードを理解するときに非同期性について考えずにプロミスを利用することを可能にします。

まとめの前に … then/promise

多くのフル機能のプロミスライブラリが存在します。then organizationのpromiseライブラリはシンプルなアプローチをとっています。このライブラリは仕様に準拠すること、それ以上を具備しないことを目標としています。このライブラリの実装を見れば、見慣れたコードに見えると思います。then/promiseはこの文章中で実装したプロミスの基礎であり、我々は ほとんど 同じプロミス実装を組み立ててきました。Nathan ZadoksとForbes Lindsayに、彼らの素晴らしいライブラリとJavaScriptのプロミスへの働きかけについて感謝します。Forbes Lindsayはまた冒頭で言及したpromise.orgサイトの貢献者でもあります。

実際の実装とこの文章での実装では幾つかの相違点があります。これはPromises/A+ specの中にまだこの文章中で触れていない詳細がもっとあるためです。仕様を読んで見ることをお勧めします、この仕様は短く直感的です。

まとめ

ここまで読んでくれてありがとうございます。私達はプロミスの核となる部分をカバーしました、そしてこの核のみが仕様に記載されている内容です。多くの実装はもっとたくさんの機能を提供しています、all()spread()race()denodeify()、など他にも多くの機能があります。プロミスで実現可能な事を知るためにBluebirdのAPIドキュメントを見てみることをお勧めします。

一度、プロミスがどう機能しているのか、そしてプロミスを利用する際の注意点を理解したら、私はプロミスを本当に好きになりました。プロミスは私のプロジェクトにおけるコードをとてもクリーンでエレガントなものにしてくれました。まだ話すべきことはたくさんあります、この文章は始まりに過ぎません。

もしこの文章が面白かったら。私をTwitter上でフォローしてください、このようなガイドをまた書いた時にツイートします。

参考文献

プロミスに関し優れた文献が多く有ります

誤記を見つけましたか?指摘は歓迎です、emailissueを通じて教えてください。