ひだまりソケットは壊れない

ソフトウェア開発に関する話を書きます。 最近は主に Android アプリ、Windows アプリ (UWP アプリ)、Java 関係です。

まじめなことを書くつもりでやっています。 適当なことは 「一角獣は夜に啼く」 に書いています。

Promises/A+ 実装 Ten.Promise に関するメモ書き

JSDeferred とか WinJS.Promise とか jQuery の Deferred, Promise などを使うことがあるのだけれど、インターフェイスの違いが結構つらいので JSDeferred と WinJS.Promise の両方とほぼ互換なインターフェイスをもつ Promises/A+ 実装が欲しいなぁ、と思ったりしてた。 そんなわけで先週末に Ten.Promise ってのを TypeScript で書いたのだけど、そもそも Promises/A+ の仕様 とか WinJS.Promise の仕様とか JSDeferred の仕様がよくわかんない状態で書き始めたので自分でも仕様がよくわかんないものになってしまった。 一通り Promises/A+ のテスト とか JSDeferred のテストとか動かして仕様がわかってきたので、改めて実装する前に要件をまとめておこうと思う。

注意事項

ここに書かれていることは Ten.Promise の仕様と実装について考えていることをメモしているだけであって、すべての Promises/A+ 実装が同様の仕様や実装であるとは限らない。

目標

  • Promises/A+ テストの全通過
  • WinJS.Promise の API の全実装 (ドキュメントに書かれている範囲で仕様を満たす)
  • JSDeferredインスタンスメソッドの全実装とそれらに関するテストの通過

あくまで目標。 最低限達せられるべきことは Promises/A+ テストの全通過。

Promise オブジェクトの状態

  • unfulfilled
    • 外部から値が与えられるのを待っている状態 (空の状態); または
    • 内部に持っている別の Promise オブジェクトが fulfilled または error 状態になるのを待っている状態 (待機状態)
  • fulfilled: 正常な値を保持している状態
  • error: エラーの値を保持している状態

Promise オブジェクトのインターフェイスと内部に保持する値

インターフェイス

  • 外部から値を設定するためのメソッド
    • 外部から与えられる値としては、正常な値、エラーの値、Promise オブジェクトの 3 種類がある
    • 外部から値の設定が可能なのは空の状態の場合のみ (空の状態でない Promise オブジェクトは、外部から値の設定がなされようとしても何もしない)
  • then メソッド
    • unfulfilled 状態であれば、コールバック関数をキューに追加する
    • unfulfilled 状態でなければ、そのままコールバック関数に値を渡す (ただし、一度イベントループに制御を渡す必要がある; Promises/A+ の要件; これは WinJS.Promise との差異である)
    • 新しく Promise オブジェクトを生成して返す; この Promise オブジェクトがコールバック関数の返り値を待つ
  • cancel メソッド
    • 可能であれば、キャンセルする
    • キャンセルに関しての仕様をどうするべきか悩ましいが、WinJS.Promise と合わせることにする; 詳細は後述

内部に保持する値

  • 状態とそれに応じた値
    • 待機状態なのであれば、それに応じた値として内部に Promise オブジェクトを保持する
    • fulfilled 状態であれば正常な値を保持する
    • error 状態であればエラーの値を保持する
  • コールバックのキュー
    • コールバック関数と、コールバック関数の返り値を受け取る Promise オブジェクトの組を要素として持つキュー
    • 使用されるのは unfulfilled の 2 状態の場合のみなので、fulfilled 状態か error 状態に移ってキューに入っているコールバックの処理を終えたときには null にしてよい
  • 親 Promise オブジェクト
    • 使用されるのは空状態の場合のみなので、空状態から別の状態に移ったときには null にしてよい

Promise オブジェクトの親オブジェクト

上に書いたように、Promise オブジェクトの then メソッドでコールバック関数をキューに追加したとき、自動的に新しい Promise オブジェクトが生成される。 元の Promise オブジェクトがコールバック関数を呼び出して返り値を受け取ると、その値 (正常の値でもエラーの値でも Promise オブジェクトでも) を新しい Promise オブジェクトに設定する。

この新しいオブジェクトは、生成元のオブジェクトを親 Promise オブジェクトとして保持するものとする。 これは、cancel メソッドが呼ばれた場合に、親 Promise オブジェクトにも cancel を伝搬させるためである。

cancel メソッドの仕様と実装

  • fulfilled 状態と error 状態の場合は、cancel メソッドが呼び出されても何もしない
  • 空状態の場合は
    • 親 Promise オブジェクトが存在すれば親 Promise オブジェクトの cancel メソッドを呼び出す (cancel メソッドがある場合のみ)
    • その後、自身を error 状態に移す (保持するエラーの値は name: "Canceled" というプロパティをもつオブジェクト)
      • ただし、親 Promsie オブジェクトの cancel メソッドを呼び出したことにより、自身を error 状態に移す前にすでに fulfilled 状態や error 状態、待機状態に移ってしまっている可能性がある; その場合は何もしない
  • 待機状態の場合は
    • 待ち合わせている Promise オブジェクトの cancel メソッドを呼び出す (cancel メソッドがある場合のみ)
    • その後、自身を error 状態に移す (保持するエラーの値は name: "Canceled" というプロパティをもつオブジェクト)
      • ただし、待ち合わせている Promsie オブジェクトの cancel メソッドを呼び出したことにより、自身を error 状態に移す前にすでに fulfilled 状態や error 状態に移ってしまっている可能性がある; その場合は何もしない
  • 非同期の関数を扱う最初の Promise オブジェクトは独自の cancel メソッドを持つ
    • ユーザー定義の cancel メソッドがあればそれを呼び出し、その後、自身を error 状態に移す (保持するエラーの値は name: "Canceled" というプロパティをもつオブジェクト)
    • ただし、ユーザー定義の cancel メソッドを呼び出したことにより、自身を error 状態に移す前にすでに fulfilled 状態や error 状態、待機状態に移ってしまっている可能性がある; その場合は何もしない

テスト

ここで述べた仕様を満たすように実装すると、下記のテストに通るはずである。 WinJS.Promise は通る。 ちなみにこのテストは QUnit で書かれている。 大体キャンセル周りの仕様はこのテストに通れば問題ないんじゃないかな、って気がする。

(function () {
    var Promise = WinJS.Promise;
    var ok = QUnit.ok;
    var equal = QUnit.equal;
    var deepEqual = QUnit.deepEqual;
    QUnit.asyncTest("空状態の Promise オブジェクトに対する cancel メソッド呼び出し", 6, function () {
        var processOrder = [];
        var p = Promise.timeout(0);
        var p2 = p.then(function (val) {
            p2.then(null, function onError(err) {
                processOrder.push("p2.then 2");
                ok(err && err.name === "Canceled", "canceled");
            });
            p4.cancel();
            processOrder.push("p.then");
        });
        var p3 = p2.then(function (val) {
            ok(false, "ここにきてはいけない");
        }, function onError(err) {
            processOrder.push("p2.then");
            ok(err && err.name === "Canceled", "canceled");
            return Promise.timeout(0).then(function () {
                return 100;
            });
        });
        var p4 = p3.then(function (val) {
            processOrder.push("p3.then");
            equal(val, 100, "not canceled");
        }, function onError(err) {
            ok(false, "ここにきてはいけない");
        });
        var p5 = p4.then(null, function (err) {
            processOrder.push("p4.then");
            ok(err && err.name === "Canceled", "canceled");
            return 125;
        });
        p5.then(function (val) {
            equal(val, 125);
            processOrder.push("p5.then");
        }).
        then(function () { return Promise.timeout(0) }).
        done(function () {
            deepEqual(processOrder, ["p2.then", "p2.then 2", "p4.then", "p5.then", "p.then", "p3.then"]);
            QUnit.start();
        });
    });
    QUnit.asyncTest("待機状態の Promise オブジェクトに対する cancel メソッド呼び出し", 2, function () {
        var processOrder = [];
        var p = Promise.wrap(0).
        then(function () {
            return new Promise(function (c, e) {
                processOrder.push("Promise constructor");
            }, function onCancel() {
                processOrder.push("Promise oncancel");
            });
        });
        // この時点で p は待機状態の Promise オブジェクト
        p.cancel();
        p.then(function () {
        }, function onError(err) {
            processOrder.push("p.then");
            ok(err && err.name === "Canceled", "canceled");
        }).
        then(function () {
            deepEqual(processOrder, ["Promise constructor", "Promise oncancel", "p.then"]);
            QUnit.start();
        });
    });
    QUnit.asyncTest("待機状態の Promise オブジェクトに対する cancel メソッド呼び出し - cancel 処理で先に進む場合", 2, function () {
        var processOrder = [];
        var callback;
        var p = Promise.wrap(0).
        then(function () {
            return new Promise(function (c, e) {
                callback = c;
                processOrder.push("Promise constructor");
            }, function onCancel() {
                callback(120); // ユーザー定義のキャンセル処理の中で正常な値を返す場合
                processOrder.push("Promise oncancel");
            });
        });
        // この時点で p は待機状態の Promise オブジェクト
        p.cancel();
        p.then(function (val) {
            processOrder.push("p.then");
            equal(val, 120);
        }, function onError(err) {
            ok(false, "ここにきてはいけない");
        }).
        then(function () {
            deepEqual(processOrder, ["Promise constructor", "Promise oncancel", "p.then"]);
            QUnit.start();
        });
    });
    QUnit.asyncTest("待機状態の Promise オブジェクトに対する cancel メソッド呼び出し - cancel 処理で非同期で返される値を返す場合", 2, function () {
        var processOrder = [];
        var callback;
        var p = Promise.wrap(0).
        then(function () {
            return new Promise(function (c, e) {
                callback = c;
                processOrder.push("Promise constructor");
            }, function onCancel() {
                callback(Promise.timeout(0).then(function () { return 120 })); // ユーザー定義のキャンセル処理の中で正常な値を返す場合
                processOrder.push("Promise oncancel");
            });
        });
        // この時点で p は待機状態の Promise オブジェクト
        p.cancel();
        p.then(function (val) {
            ok(false, "ここにきてはいけない");
        }, function onError(err) {
            processOrder.push("p.then");
            ok(err && err.name === "Canceled", "canceled");
        }).
        then(function () {
            deepEqual(processOrder, ["Promise constructor", "Promise oncancel", "p.then"]);
            QUnit.start();
        });
    });
    QUnit.asyncTest("ユーザー定義のキャンセル処理", 1, function () {
        var callback = null;
        var p = new Promise(function (c, e) {
            callback = c;
            setTimeout(function () { c(120) }, 4);
        }, function onCancel() {
            callback(Promise.timeout(0).then(function () { return "キャンセルされたよ" }));
        });
        p.then(function (val) {
            equal(val, "キャンセルされたよ");
        }, function onError(err) {
            ok(false, "ここにきてはいけない");
        }).
        done(function () {
            QUnit.start();
        });
        p.cancel();
    });
}).call(this);