2007年 12月 02日

MochiKit Deferred と jQuery Deferred の違い

(動いてはいるけど、ちょっと挙動が思ったとおりでない気がする (巨大なループのあと、次のプロセスへ進むのが遅い気がする) のでコードを考えなおしのために現在の実装をメモ書きします) なんか jQuery Deferred って書くと jQuery Core に Deferred システムがあるみたいにみえるけどちがうよちがうよ。でも内部的には animate あたりで必要だから持ってるんだとおもう……

チェインの根本的構造

MochiKit Deferred では子と親がはっきりわかれていて、子をつくるときには親を pause し、子が実行しおわったら親の pause を解除するようになっています (だと思うけど、実はあんまり使ったことなくてわからない)。これは、MochiKit の Deferred が Array でチェインを持って処理をしていて、子 (もまた Array でチェインをもっている) がかえされたとき、こうするのが一番だからだと思います。

jQuery Deferred では子も親もはっきりわかれておらず、子 Deferred が出現したら、現在の継続を子 Deferred の継続にし、親はもう過去の存在となるような実装にしています (親には戻らない)。そういうアレで pause がないです。

jQuery Deferred は一個の Deferred は一個のコールバックしか持ちません。処理のプロセス一つをパッケージングし、次のプロセス (を Deferred でパッケージしたもの=継続) を持っています。Deferred.prototype.next(fun) は fun を this のプロセスとし、さらに新しく Deferred をつくり、新しく Deferred を作り、そのプロセスを fun とし、それを this の継続として設定する関数です。

// global function next
next(function () {
	console.log(["chain", 1]);
}).
// Deferred.prototype.next
next(function () {
	console.log(["chain", 2]);
});

// 定義ずみの関数
/*
function next (fun) {
        // 新しく呼ばれることが約束された Deferred をつくる
	var d = new Deferred();
	setTimeout(function () { d.call() }, 0);
        // fun をその Deferred のプロセスとして設定し返す。   
	d.callback.ok = fun;
	return d;
}
*/

うえのコードは

// あとで呼ばれることが約束された Deferred を作成
d1 = next(function () {
	console.log(["chain", 1]);
});

// 次のプロセスをパッケージする Deferred を作成
d2 = $.deferred(); // (Deferred をエクスポートしてないので new Deferred() とはできません。
// プロセスを設定
d2.callback.ok = function () {
	console.log(["chain", 2]);
};

// d1 の継続を d2 に設定
d1._next = d2;

と同じです。

子 Deferred の例をだしてみます。

next(function () {
	console.log(["child", 1]);
	return next(function () {
		console.log(["child", 2]);
	});
}).
next(function () {
	console.log(["child", 3]);
});

このように Deferred をコールバックで返すと、コールバックを実行した Deferred は返された Deferred の継続に自分の継続をセットし、自分ではなにもしません。

next(function () {
	console.log(["child", 1]);
	ret = next(function () {
		console.log(["child", 2]);
	});
	ret._next = this._next;
	this.cancel();
}).
next(function () {
	console.log(["child", 3]);
});

これと全く一緒です。

実装では以下のようになっています。

call  : function (val) { return this._fire("ok", val); },
fail  : function (err) { return this._fire("ng", err); },

_fire : function (okng, value) {
	// if (typeof log == 'function') log("_fire called");
	var self = this;
	var next = "ok";
	try {
		value = self.callback[okng].call(self, value);
	} catch (e) {
		next  = "ng";
		value = e;
	}
	if (value instanceof Deferred) {
		value._next = self._next;
	} else {
		setTimeout(function () {
			if (self._next) self._next._fire(next, value);
		}, 0);
	}
}

_fire が実際にコールバックしている関数です (この関数は call/fail から間接的に呼びます)。コールバックの返り値が Deferred のインスタンスの場合は、それの _next を設定しているだけです。それ以外の場合は setTimeout を通して継続をよびだしています (setTimeout をつかっているのは、永遠とコールバックチェインが続くとスタックオーバーフローになるからです)。

エラーの処理

jQuery Deferred では一つしかプロセスをもっていないと書きましたが、実際のところエラーを処理するためのコールバックも持っています。とりあえず例をだすと (テストケースから)

next(function () { throw "Error"; }).
error(function (e) {
	expect("Errorback called", "Error", e);
	return e; // エラーのリカバリー
}).
next(function (e) {
	// next だけど、リカバリーされたので実行される。
	expect("Callback called", "Error", e);
	// また投げてみる。
	throw "Error2";
}).
next(function (e) {
        // エラーがリカバリーされていないのでよばれない。
	ng("Must not be called!!");
}).
error(function (e) {
        // エラー専用のチェインをたどりここまでくる。
	expect("Errorback called", "Error2", e);
});

コールバックで発生したエラーはエラー専用のチェインをとおります。また、エラーバックでエラーを処理し、値をなげなおすことで、後続の処理を続けることができます。

このエラー専用のチェインですが、単に throw をくりかえすだけのチェインです。Deferred は以下のように初期化されれ、デフォルトのコールバックを持っています。

init : function () {
	this.callback = {
		ok: function (x) { return x },
		ng: function (x) { throw  x }
	};
	this._next    = null;
},

デフォルトでは ng は常に throw をするため、前途の _fire の catch に捉えられ、継続の ng を実行するように伝えられます。エラーのチェインへの分岐は throw するかしないかなので、エラーバックで普通に return すればエラーのリカバリーになります。



なんかつかめそうでつかめない。どっかおかしいような気がする。頭悪いのがむかつく

あーわかった。わかった。next で呼ばれる Deferred を一個余計につくっていたせいだった。

http://coderepos.org/share/changeset/2287

で修正した。ついでに説明も修正した。テスト書いといてよかった……役にたった (テストも Deferred 自身で書かれているから、あきらかにおかしいときはテストの数があっているかどうかをみる)