2011/03/10

JavaScriptのオブジェクト指向は、逆の順番で学んだほうが理解しやすいと思うので…

 事の発端というか、きっかけは、id:perlcodesampleさんとid:gfxさんの下のポストを見て、

 newとかprototypeを使うのが推奨されてないとか、直接代入するほうが楽とかじゃなくて、挙動が違うんだよなぁ、と思ったこと。
 挙動が違うんだから、もちろん使いどころも違うんですよね。

 でも実際、JavaScriptのオブジェクト指向は混乱しやすいと思います。
 自分もご多分にもれず、さんざん混乱させられたクチですしね。
 わかってしまえば、どってことなくて、とってもシンプルなんですけどね。

 せっかくなので、今だからこそ言える、自分だったらこうやって教えて欲しかったなぁ、っていう説明をしてみようかと思います。
 題して、JavaScriptのオブジェクト指向は、逆から入門しろ!

逆からってどこから? → Object.createから

 思うに、JavaScriptのオブジェクト指向は、newから入るよりも、Object.createから入ったほうが理解しやすいと思うんですよね。
 Object.createっていうのは、新しいJavaScriptの仕様(ECMAScript 5th Edition)で標準に取り入れられたメソッドで、引数で指定したオブジェクトをプロトタイプとする新しいオブジェクトを作ることができます。
 こんなふうにです:

var mam = {
  given_name : "サザエ",
  family_name: "フグタ",
  who_am_i   : function(){ alert(this.family_name + this.given_name);  }
};

var kid = Object.create(mam);
kid.given_name = "タラオ";

mam.who_am_i();  // フグタサザエ
kid.who_am_i();  // フグタタラオ

 kidがmamのメソッドを継承していることがわかりますね。

 そもそもJavaScriptのオブジェクト指向ってプロトタイプベースの継承なわけで、newとかコンストラクタっていうのはなくてもいいんです。
 このObject.createメソッドからはじめて、従来からあるnewとコンストラクタ、それからprototypeプロパティの挙動をみていこうと思います。
 つまり、歴史的に逆順というわけです。

 ちなみに、このObject.createメソッドは、モダンなブラウザなら既に実装されているので、実際に実行して試すことができます。(今回自分は、Chrome 9.0で動作確認をしました。)

そもそもプロトタイプってなんなん?

 上のコードを見て、「Object.createの中では新しいオブジェクトを生成してmamのプロパティをコピってるんだな、こんなふうに↓」

Object.create = function ( o ) {
    var p = {};
    for ( var i in o ) {
        p[i] = o[i];
    }
    return p;
};

 と思ったアナタ!イイ線いってます!
 でも、事はそれほど単純ではありません。次のコードを続けて実行してみましょう:

mam.call = function(){ alert("カツオ!"); };

mam.call();  // カツオ!
kid.call();  // カツオ!

 callはmamにしか追加していないのに、kidのほうでも使えるようになってしまいましたよ。
 実はmamとkidは同じオブジェクトを指している?いやいや、上のでwho_am_iを呼んだ時には、ちゃんと別々になってましたよね。

 種明かしをすると、kidは自身にセットされたgiven_nameプロパティ以外はなんにも持っていない、ほとんど空っぽのオブジェクトです。
 ただし、自分が持っていないプロパティについてはmamに問い合わせて、mamのものを自分のものとして使おうとするようになっています。
 実はkidはユーザの気づかないところでmamへの参照を隠し持っていて、自分が知らないことはmamのマネをするわけなんですね。
 このような特別な関係があるとき、mamはkidのプロトタイプだと言います。

 また、kidがmamに問い合わせたプロパティをmamも持っていなかったとしましょう。
 するとmamは、やはり自分が持っている隠し参照を使って、自分自身のプロトタイプへと問い合わせを伝搬させます。

 このようにプロパティの探索をたどっていくリンクの連なりを、プロトタイプチェインと呼んだりします。
 最終的に、この鎖の終端は通常はObject.prototypeになっており、終端にたどりついてもまだ該当するプロパティが見つからなかった場合に、プロパティが未定義ということになります。

  Object.createは、オブジェクト間でこのような特別な関係を築いてくれるわけですね。

オーバーライドは子供で値を設定するだけ

 ところで、上のcallメソッドでは、kidもmamのマネをしてカツオを呼び捨てにしてしまっていました。
 ここはひとつ、もう少し子供らしく呼ぶようにしつけをしてあげましょう:

kid.call = function(){ alert("カツオおにいちゃん"); };
kid.call();  // カツオおにいちゃん

 プロパティ探索の挙動がわかってしまえば、挙動を変えたい場合は単に値を代入してしまえば良いことがわかりますね。
 もちろん、この段階でもmamのcallの値は元のままです。

mam.call();  // カツオ!

 逆に、kid独自の挙動を元に戻したいと思ったら、プロパティを削除してやればOKです:

delete kid.call;
kid.call();  // カツオ!

 kidからプロパティを消しても、mamからも消えるわけではないので、ふたたびマネをするようになるわけですね。

継承とMixi-in

 ただ同じ振る舞いをさせたいだけであれば、単純に片方のオブジェクトからもう片方のオブジェクトへとすべてのプロパティをコピーしてしまえば良い話です。
 このように既存の実装コードを、まったく関係のないオブジェクト間で再利用するというのは、継承ではなくMix-in(Rubyのincludeとか)の考え方です。
 利用ケースによっては、Mix-inと継承はまったく同じような結果をもたらします。

 しかしプロトタイプ継承を使うと、親への機能拡張(メソッドの追加)が、すぐさま子供からも使えるようになるという点が大きな違いです。
 実行時に、プロトタイプチェインに連なっているすべてのオブジェクトについて、一度の変更で機能を追加・変更できます。
 また、どれだけたくさんの子オブジェクトが作られていても、たとえ子オブジェクトにアクセスすることができなかったとしても、変更の影響を及ぼすことができます。
 Mixi-inとは違って、親子オブジェクト間で特別な絆ができている継承でなくては、こう柔軟にはいきませんよね。

 しかし、継承がかさんでプロトタイプチェインがあまりに長くなってしまうと、ずっと親の代で定義されているプロパティを探索するのに多くの時間がかかってしまうことにもなります。(このあたりは処理系の最適化にも大きく依存しますが)
 だからといって、なんでもかんでもそれぞれのオブジェクトが自分で持っていては、メモリ効率も悪くなるでしょうし、継承を使った柔軟な挙動変更もできなくなってしまいます。

 結局、継承とMixi-inは適材適所。
 その場で相応しいほうを使い分けることができてこそ、真のJavaScripterというものですよね!

プロトタイプ関係を作れるのはnewだけ(だった)

 ここからは、上で使ったObject.createの機能を、従来のJavaScriptだけで実装してみましょう。

 いきなりコードで書くと、こういうことになります:

function dummy(){}
Object.create = function ( o ) {
    dummy.prototype = o;
    return new dummy();
}

※実際のObject.createは、変更不可能なプロパティを定義できたりと、3rd Editionの範囲では決して実現できなかった機能も含む高機能なものです!ここではあくまで、プロトタイプの継承関係の構築のみに焦点を当てています。

 空の関数であるdummyをnewして返す前に、dummyのprototypeプロパティにプロトタイプとなるオブジェクトを設定しているのがミソです。
 JavaScriptでは、「new F(ARGS)」という式は、次のように処理されます:

  1. 新しい空のオブジェクトを作る
  2. 新しいオブジェクトのプロトタイプとしてF.prototypeを設定する
  3. F(ARGS)を呼び出す。この時、呼び出され側のthis値として新しく作ったオブジェクトを使う
  4. 3の結果がオブジェクトだったら、これを結果として返す。
  5. そうでなければ、1で作ったオブジェクトを返す

 擬似コード的に書くと、こんなかんじ:

function new ( F, ARGS ) {
    var o = {};
    o.__proto__ = F.prototype;
    var r = F.apply(o, ARGS);
    return r != null && r instanceof Object ? r : o;
}

 あれ、ここで使ってる__proto__って、どこかで見たことありませんか?
 そうです。継承するコードでたまに出てくるやつですね。FirefoxとかChromeとか、特定の処理系だけで使えます。
 実はこれが、上で言っていた「ユーザが気づかないところで持っている隠し参照」のことです。
 処理系によっては、これがユーザから直接触れるようになっているということですね。

 直接いじれちゃえば、もっと細かい制御もやり放題なので、これは便利!なのですが、もちろん、ブラウザ間の互換性が損なわれるので、あまりオススメはしません。
 非標準なAPIは、ちゃんと使える場所と使いどころとをわきまえて使いましょうね。JavaScripterとの約束だよ!

newいらない子(ェ

 上でnewを実装するために__proto__プロパティを使いましたが、Object.createを標準で手に入れた我々は今や、__proto__に頼らずともオブジェクトの親子関係を作れる力を手にしたのでした。
 つまり、newに相当する機能をJavaScript標準の機能だけで実装することができるようになったのです!
 こんなふうに:

function New ( F, ARGS ) {
    var o = Object.create(F.prototype);
    var r = F.apply(o, ARGS);
    return r != null && r instanceof Object ? r : o;
}

function Cat ( name ) {
    this.name = name;
}
Cat.prototype.caterwaul = function(){
    alert(this.name + "「ニャーニャー」");
};

var cat = New(Cat, ["タマ"]);
cat.caterwaul();  // タマ「ニャーニャー」

 上ではnewを使ってObject.createを実装する例を示しましたが、今回はその逆です。
 つまり、Object.createが最初からあれば、newっていらなかったってことですね(>_<)

蛇足: なんでわざわざ関数のプロパティを経由するようになってるの?

 なんででしょうね?
 ここからは勝手に推測してみますが、たぶん、関数だけでなんとかしてJavaのコンストラクタの見た目をマネしたかったんじゃないでしょうか?

 普通は、一連の機能をもったオブジェクトをただひとつ一点物で作る(シングルトンパターンみたいな)ケースよりも、同じ機能をもったオブジェクトをたくさん量産するケースのほうが多いと思います。
 すると当然、オブジェクトの初期化を行う処理をサブルーチン化しておいて、その中でオブジェクトを生成して返すことを考えますよね。
 こんなふうに:

function bear ( name ) {
    var k = Object.create(mam);
    k.name = name;
    return k;
}

var kid = bear("タラオ");

 つまるところこれって、コンストラクタですよね?

 また一方で、JavaScriptはその出自から、Javaに見た目を似せなければならないという宿命を持って生まれてきました。(このあたりは、id:badatmathさんのプレゼン資料がとってもわかりやすいです)
 で、Javaってコンストラクタを呼び出すときにはnewをつけますよね?
 そこで、newをつけて関数を呼び出したときに特別な処理を持たせ、関数にコンストラクタとクラスの2つの役割を押し付けてしまおうと考えたのではないかと思います。

 そうなると、Javaのコンストラクタっぽく見せるためには、関数がnewをつけて呼び出されたときには、新しいオブジェクトが適切に(プロトタイプから継承されて)作られ、thisにバインドされていなければなりません。
 そこで、そのコンストラクタ(関数)がオブジェクトを生成する際に暗黙的に使うプロトタイプを、コンストラクタ自身に持たせておこう。でも、どの関数がコンストラクタとして呼び出されるかはわからないから、すべての関数にプロパティで持たせておこう。
 っていうことになったんじゃないかと。

 えっ、JavaScriptは最初からプロトタイプベースのオブジェクト指向を採用するつもりだったのかって?
 それはわかりません。Sun Microsystemsがプロトタイプベースの始祖であるSelfの研究者を抱えていたってこと以外、私は知りません。
 開発者のEich氏は、最初からSchemeのような関数型言語を作るつもりだったようですけどね。

書いたあとに一言

 うん!
 長々と書いたわりには、全然わかりやすくなってない気がするね(´・ω・`)

0 件のコメント:

コメントを投稿