JavaScriptはクロージャでガリガリ書いていく言語だという説もあるが、OOP原理主義としてはプロトタイプベースでもいいからOOPで書きたいのである。というか、クロージャは中途半端で気持ち悪い。
さて、事の発端は『JavaScript とクロージャ』という記事である。この記事によると、なにやらイベントにクロージャを渡すと幸せになれるという事らしい。ふむふむ、たしかにシンプルでいい感じである。
……が、しかしだ。オブジェクト指向なアレに対して、クロージャには決定的な弱点が有るはずなのだ。そう、メソッド(クロージャ)間での(メンバ)変数の共有である。
例えばだ。onmouseoverで黒くなったセルをonclickで白に戻したい場合、どうすればいいのだろうか。
ずばり、次のように書けばいい(某氏のアドバイスがとても参考になった)。
//
// canvas.js - a toy program to scribble using closures
//
// Copyright (C) 2005 Satoru Takabayashi <satoru@namazu.org>
// All rights reserved.
// This is free software with ABSOLUTELY NO WARRANTY.
//
// You can redistribute it and/or modify it under the terms of
// the GNU General Public License version 2.
//
function makeHandler(element) {
var depth = 2;
var closures = {
darken:
function() {
if (depth < 16) {
var c = (16 - depth).toString(16);
element.style.backgroundColor = "#" + c + c + c;
depth++;
}
},
whiten:
function() {
element.style.backgroundColor = "white";
depth = 2;
}
}
return closures;
}
function makeTile(size) {
var table = document.createElement("table");
var tbody = document.createElement("tbody");
for (var i = 0; i < size; ++i) {
var tr = document.createElement("tr");
for (var j = 0; j < size; ++j) {
var td = document.createElement("td");
var handle = makeHandler(td);
td.onmouseover = handle["darken"];
td.onclick = handle["whiten"];
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function makeCanvas() {
var canvas = document.getElementById("canvas");
if (canvas) {
var tile = makeTile(32);
canvas.appendChild(tile);
}
}
window.onload = makeCanvas;
こんな感じにmakeHandlerのスコープ内で共有される変数を2つのクロージャでそれぞれ操作すればいいらしい。
しかし、ここでOOP原理主義は考えるのである。「ていうか、状態(depth)をメンバ変数として保持するクラスを作って、メソッドを2つ用意して操作するのが普通じゃね?」と。
function CellState(element) {
this.depth = 2;
this.element = element;
}
CellState.prototype.darken = function() {
if (this.depth < 16) {
var c = (16 - this.depth).toString(16);
this.element.style.backgroundColor = "#" + c + c + c;
this.depth++;
}
};
CellState.prototype.whiten = function() {
this.depth = 2;
this.element.style.backgroundColor = "white";
};
普通に考えれば、こんな感じのクラスを作るのがOOP原理主義である。
さて、ここからが本題の、やってしまいがちなミスである。即ち、イベント(これはXMLHttpRequestのonreadystatechangeでも同様だ)に渡される関数オブジェクトをインスタンスのメソッドだと誤解してしまうミスである。
言葉で説明してもわかりにくいので、上で定義したクラスを用いるコードを書いてみよう。
function makeTile(size) {
var table = document.createElement("table");
var tbody = document.createElement("tbody");
for (var i = 0; i < size; ++i) {
var tr = document.createElement("tr");
for (var j = 0; j < size; ++j) {
var td = document.createElement("td");
var cs = new CellState(td);
td.onmouseover = cs.darken;
td.onclick = cs.whiten;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
何気なく書くと、このような感じになると思うが、このコードは期待通りに動作しない。なぜなら、cs.darkenおよびcs.whitenによって渡された「何か」は関数オブジェクトであり、関数中に含まれるthisが参照するのはcsでは無いからだ(ここではtdノードになる)。早い話、次のように書いているのとその実は変わらない。
td.onmouseover = function() {
if (this.depth < 16) {
var c = (16 - this.depth).toString(16);
this.element.style.backgroundColor = "#" + c + c + c;
this.depth++;
}
};
見ての通り、thisが何を指すのか、このコードだけでは判断できない。
では、インスタンスのメソッドとして関数オブジェクトを渡したい時はどうすればいいか、である。なんとここで救世主として現れるのがクロージャだ
function makeTile(size) {
var table = document.createElement("table");
var tbody = document.createElement("tbody");
for (var i = 0; i < size; ++i) {
var tr = document.createElement("tr");
for (var j = 0; j < size; ++j) {
var td = document.createElement("td");
var cs = new CellState(td);
td.onmouseover = function() { cs.darken(); };
td.onclick = function() { cs.whiten(); };
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
変数のスコープ的に、csはインスタンスであり、cs.darken()がインスタンスメソッドを実行するのはわかると思う。ポイントはこの部分をクロージャとして包み、そのクロージャを関数オブジェクトとしてイベントに渡すのである。このように1枚クロージャをかぶせることにより、OOP原理主義者も満足な結果が得られる……はずだったのだが、ここでそう行かないのがJavaScriptの凄いところである。
実際に実行してみると分かるが、このコードだと他のセル上にマウスが乗ったにもかかわらず、右下(1番最後)のセルだけがなぜか黒くなる。なぜか。
ここでクイズを出そう。次のコードを実行すると何が表示されるだろうか。
var i = 1; var f = function() { return i; }; i = 3; alert(f());
var i = 1; var f = function() { return i; }; var i = 3; alert(f());
1番は3で問題ないだろう。クロージャ内で参照しているiを後で書き換えている。では、2番はどうだろうか。JavaScript以外の言語に慣れている人ならば、答えは1になると予想するところではないだろうか。1番と違い、varが付くことによって、名前こそ同じだが(クロージャ内で参照している変数とは)全く違う変数が新たに製作されていると予想するだろう。だが、その予想は残念ながら間違ってる。結果は1番と同様、3になるのだ。JavaScriptにおいては、変数名が同じならば、2回目以降のvarというキーワードは有っても無くても同じなのである(らしい。仕様書を読んだわけではないので詳しいことは知らない)。
この変数名に関する動作はforループの中でも有効である。つまり、先述のループにおいて、クロージャ内の変数csはループが実行されるたびに、毎回書き換えられているのである(何せ変数名が変わらないので)。そのため、ループから抜ける直前、つまり1番右下のセルの状態を保持したcsが、全てのセルの、イベントに渡すクロージャにセットされてしまうのである。
この問題を解決するため、某氏が提案してくれた方法は次コードになる。
td.onmouseover = (function(cs_) { return function() { cs_.darken(); }; })(cs);
さらに1枚クロージャをかぶせることにより、変数名の切り離しを行っている。
このようなコードは直感的ではなく、最初に戻って素直にクロージャを使ったほうが良いのではないか、という疑惑をもたらす。最も、ループ外でインスタンスのメソッドをイベントに渡したい場合は先述したクロージャを1枚包む方法で解決できるので、それほど汚くはならない。これは特殊な例と考えても良いだろう。
というわけで、インスタンスのメソッドを何らかのイベントに渡したいときは、クロージャで包むといいよ、というお話でした。
前回の試行錯誤中に気がついたアイデア。クロージャでクラス定義すればthisとか煩わしくなくて良いのではないかという説。名づけて、クロージャベース・オブジェクト指向。
function Person(_name) {
var methods = {
__constructor: function() {name = _name; },
getName: function() { return name ; },
getType: function() { return "Person"; }
};
methods.__constructor();
return methods;
}
function Programmer(_name) {
// extends
var methods = Person(_name);
// override
methods["getType"] = function() { return "Programmer"; };
methods.__constructor();
return methods;
}
var psn = Person("Tom");
alert(psn.getName()); // Tom
alert(psn.getType()); // Person
var prg = Programmer("Jim");
alert(prg.getName()); // Jim
alert(prg.getType()); // Programmer
結構まともに動いちゃったりして。インスタンスごとにメソッド用のクロージャを保持するのでメモリ効率は悪いかもしれない。ちなみに、メンバ変数は全部protected強制、メソッドはpublicとprivateの使い分けができるっぽい。
実際使い物になるかどうかは謎。
最後にalert(psn.getName());を追加するとJimになるし、やっぱり駄目っぽい。