指向性メモ::2005-07-24::クロージャとOOPとJavaScriptの謎仕様

ページ情報
制作日
2005-07-24T03:14:49+09:00
最終更新日
2005-10-28T11:35:21+09:00
ページ内目次

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原理主義である。

さて、ここからが本題の、やってしまいがちなミスである。即ち、イベント(これはXMLHttpRequestonreadystatechangeでも同様だ)に渡される関数オブジェクトをインスタンスのメソッドだと誤解してしまうミスである。

言葉で説明してもわかりにくいので、上で定義したクラスを用いるコードを書いてみよう。

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番最後)のセルだけがなぜか黒くなる。なぜか。

ここでクイズを出そう。次のコードを実行すると何が表示されるだろうか。

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枚包む方法で解決できるので、それほど汚くはならない。これは特殊な例と考えても良いだろう。

というわけで、インスタンスのメソッドを何らかのイベントに渡したいときは、クロージャで包むといいよ、というお話でした。

Comments

Name
satoru
Datetime
2005-07-24T13:39:00+09:00
Message

大変興味深く読ませていただきました。

スコープの問題、ややこしいですね。

var td = document.createElement("td");

var cs = new CellState(td);

td.onmouseover = function() { cs.darken(); };

td.onclick = function() { cs.whiten(); };

の部分を関数にして外に出すといいかなと思いました。

function makeCell() {

var td = document.createElement("td");

var cs = new CellState(td);

td.onmouseover = function() { cs.darken(); };

td.onclick = function() { cs.whiten(); };

return td;

}

...

for (var j = 0; j < size; ++j) {

var td = makeCell();

tr.appendChild(td);

}

Name
石川
Datetime
2005-07-24T15:32:32+09:00
Message

> 関数にして外に出すといいかなと思いました。

なるほど、こちらの方が分かりやすくて、良いアイデアですね。

Name
RUAH
Datetime
2005-08-12T21:31:07+09:00
Message

DOM的に考えれば、イベントハンドラで値を共有する場合は

属性使うんじゃないだろうか?

こんなんじゃ駄目かな。

function CellState(element) {

this.element = element;

this.element.setAttribute( "depth" , 2 );

}

CellState.prototype.darken = function() {

var depth = this.getAttribute("depth");

if (depth < 16) {

var c = (16 - depth).toString(16);

this.style.backgroundColor = "#" + c + c + c;

this.setAttribute( "depth" , ++depth );

}

};

OOPとは違うかもしれないが

Name
RUAH
Datetime
2005-08-12T21:35:11+09:00
Message

書き忘れた。

var depth = this.getAttribute("depth");

this.depthで参照できるので実は不要なんだけど。。。

Name
石川
Datetime
2005-08-25T18:35:02+09:00
Message

返事が送れました。

>RUSHさん

DOM的には、つまり一般的なXMLを操作する場合には問題ないかもしれませんが、HTMLに関してだけ言う場合には文法違反だと思われます

DTDに無い属性を追加するのは例えクライアントサイドのスクリプトだとしても(厳密には)許されません。

Trackbacks

Trackback Ping URI

http://yudai.arielworks.com/memo/2005/07/24/031449.trackback

末尾に「9 + 8」の計算結果を繋げて下さい。例えば計算結果が「17」の場合、「031449.trackback17」です。これは機械的なトラックバックスパムを防止するための措置です。

Title
活動日誌
Datetime
2005-10-15T12:32:06+09:00
Excerpt
先日、Timer クラスを new Timer(obj.func) のように書けないのかと悩みましたが...
From
関数オブジェクトはインスタンスメソッドではない
Title
クロージャとthisスコープ
Datetime
2005-10-28T11:35:21+09:00
Excerpt
AJAXって面白いよなーと思い、最近になってJavaScriptの勉強を始めたがとっても奥が深い。いろんなとこでつまづいてしまう...
From
くーすーって美味しいよね

Post a comment

Name (optional)
Email address or URI (optional)
Do the math below (required to filter comment spams)
9 + 8 + 1 =
Message (required)
Submit
連絡先、リンク、転載や複製などについては『サイト案内』をご覧ください。Powered by HIMMEL