こんにちは。大形尚弘です。
さて、ついに OOP の話も ECMAScript の実装に移ります。乗りに乗った Dmitry 先生が、前節を越えるボリュームを持って、日本全国30人(はてなブックマークの平均による導出)の ECMAScript ファンの皆さんに襲いかかります。
正直に言って、私はもう10年以上 JScript 、 JavaScript 、 ActionScript に関わってきましたが、プロトタイプの話は、内部 [[Prototype]] プロパティが「どういうわけか」コンストラクタ関数の prototype プロパティと同じ呼び名を与えられていることを発端として、常に混乱を伴ってきました。一度理解したつもりでも、すぐに忘れてしまい、わけが分からなくなってしまうのです。
それはどういうことかというと、一度もしっかり理解していなかったからです。
この節を読めば、それが理解できます。おそらく、もう二度と忘れないと思います。
しかし、ここをご覧のみなさまにもその理解が訪れるかどうかは、ひとえに、この長大な節を読み通すことができるかということにかかっています。
どうかごゆっくり、落ち着いた、集中できる、邪魔の入らない環境で、じっくりご覧下さいますようお願い申し上げます。
第7章2節 OOP: ECMAScript での実装
目次
- はじめに
- ECMAScript の OOP 実装
- データ型
- プリミティブ値型
- オブジェクト型
- 動的な特質
- ビルトイン、ネイティブ、ホストオブジェクト
- Boolean 、 String 、 Number オブジェクト
- リテラル記法
- 正規表現リテラルと RegExp オブジェクト
- 連想配列?
- 型変換
- プロパティ属性
- 内部プロパティと内部メソッド
- コンストラクタ
- オブジェクト生成のアルゴリズム
- プロトタイプ
- constructor プロパティ
- 明示的な prototype と暗黙の [[Prototype]] プロパティ
- 非標準の __proto__ プロパティ
- オブジェクトはコンストラクタから独立している
- insanceof 演算子の特徴
- メソッドと共有プロパティのためのストレージとしてのプロトタイプ
- プロパティの読み書き
- [[Get]] メソッド
- [[Put]] メソッド
- プロパティアクセサ
- 継承
- プロトタイプチェーン
- 結論
- 参考文献
この節は、 ECMAScript におけるオブジェクト指向プログラミングに関する記事の第二部です。第一部ではオブジェクト指向の概論を、 ECMAScript に対応させながら検討しました。今節では、前節で定義した用語を積極的に使っていきますので、こちらをお読みになる前に、必要であれば第一部をお読みになることをお勧めします。第一部はこちら、『第7章1節 OOP: 概説』になります。
主要な概論を検討する長い道を通り抜け、私たちはやっと ECMAScript そのものへとたどり着きました。今や私たちは、 ECMAScript の OOP アプローチについて理解しています。再度正確な定義を与えましょう。
ECMAScript は、プロトタイプを元にした委譲による継承をサポートする、オブジェクト指向プログラミング言語です。
これからはまず、データ型の検討から分析を始めます。最初に、 ECMAScript がプリミティブ値とオブジェクトを区別しているという事実に気をつける必要があります。つまり、「 JavaScript 上の全ての要素はオブジェクトです」という、いくつかの記事で掲げられるフレーズは誤りである(完全ではない)ことがわかります。プリミティブ値とは、これから詳細を検討していく型というものの、データとしての状態に関するものです。
ECMAScript は動的で、ダックタイピングによる弱い型付けの、自動型変換のある言語ではありますが、それでもやはり、いくつかのデータ型を持ちます。つまり、ある瞬間において、オブジェクトは一つの具体的な型に属するわけです。
標準では9つの型が定義されており、うち6つは ECMAScript プログラムの中で直接参照することができます。
- Undefined
- Null
- Boolean
- String
- Number
- Object
この他の3つの型は、実装内部のレベルで参照できるもので( ECMAScript 上のオブジェクトでこのような型を持つものはありません)、いつくかの演算の振る舞いを説明するために仕様によって用いられたり、中間計算値の値を保存するなどといった用途に使われます。これらは以下の3つです。
- Reference
- List
- Completion
これらを簡単に見てみると、 Referene 型は delete や typeof といった演算子、 this 値などの説明のために用いられ、 base オブジェクトとプロパティ名によって構成されます。 List 型は実引数のリスト( new 式や関数呼び出しの際の)のふるまいを表現します。同じく Completion 型は break 、 continue 、 return 、 throw 文のふるまいを説明します。
訳注:これら3つの型は、 ECMA-263-5 では「仕様型( Specification Type )」という名前を与えられて区別されています。それぞれ Reference 仕様型、 List 仕様型、 Completion 仕様型となります。
ECMAScript プログラムによって使われる6つの型に戻りましょう。最初の5つ、 Undefined 、 Null 、 Boolean 、 String 、 Number は、プリミティブ値の型です。
プリミティブ値の例です。
var a = undefined;
var b = null;
var c = true;
var d = 'test';
var e = 10;
これらの値は、低いレベルの実装中において直接表現されます。これらはオブジェクトではありません。プロトタイプも、コンストラクタも持ちません。
typeof 演算子は、適切に理解しないとあまり直観的では無いかも知れません。顕著な例は null 値の扱いです。 typeof 演算子に null 値が与えられると、null 値の型は Null と定められている事実にもかかわらず、結果は "object" となります。
alert(typeof null); // "object"
この理由は、 typeof 演算子は単に、標準に定められた表に照らし合わせて取り出された値を返すものだからです。その表には単にこうあります。「 null 値には、文字列 "object" が返されるべきである」と。
仕様はこれをはっきりとさせていませんが、 Brendan Eich ( JavaScript 発明者)は、 undefined に対し null が、主にオブジェクトが現れる位置で使われ、つまり本質的にオブジェクトに密接に関わるものであると述べています(オブジェクトへの「空の」参照を意味し、将来的な使用のために留保しています)。しかし、いくつかの草案において、この「現象」が一般的なバグであるとする文書もあります。また、このバグは Brendan Eich も参加するバグトラッキングシステムにも登録されていました。とはいえ、結果として、 typeof null はそのまま残されることが決まりました。つまり、 ECMA-262-3 標準では null の型を Null と定めているにもかかわらず、 “object” が返されます。
次に、オブジェクト型は( Object コンストラクタと混同しないように!。ここでは、抽象型である Object について話しています)、 ECMAScript のオブジェクトを表現する唯一の型です。
オブジェクトとは、順序づけの無い、キーと値のペアのコレクションです。
オブジェクトのキーの部分はプロパティと呼ばれます。プロパティはプリミティブ値または他のオブジェクトの入れ物です。プロパティがその値として関数を保管する場合は、メソッドと呼ばれます。
例です。
var x = { // オブジェクト "x" は3つのプロパティ: a, b, c を持つ
a: 10, // プリミティブ値
b: {z: 100}, // プロパティ z を持つオブジェクト "b"
c: function () { // 関数(メソッド)
alert('method x.c');
}
};
alert(x.a); // 10
alert(x.b); // [object Object]
alert(x.b.z); // 100
x.c(); // 'method x.c'
第7章1節で触れたとおり、 ECMAScript におけるオブジェクトは完全に動的です。これは、プログラム実行中のどの時点においても、オブジェクトのプロパティの追加、変更、削除が可能だということを意味します。
例です。
var foo = {x: 10};
// 新しいプロパティを追加
foo.y = 20;
console.log(foo); // {x: 10, y: 20}
// プロパティの値を関数に変更
foo.x = function () {
console.log('foo.x');
};
foo.x(); // 'foo.x'
// プロパティを削除
delete foo.x;
console.log(foo); // {y: 20}
中には変更できないプロパティ( read-only プロパティ)や、削除できないプロパティ( non-configurable プロパティ)があります。これらのケースについては、プロパティ属性の項にて簡単に検討します。
ES5 では、新しいプロパティで拡張できず、プロパティの変更や削除もできない static オブジェクトを標準化しています。これらは frozen オブジェクトとも呼ばれ、 Object.freeze(o) メソッドを適用することによって得られます。
var foo = {x: 10};
// オブジェクトを freeze する
Object.freeze(foo);
console.log(Object.isFrozen(foo)); // true
// 変更できない
foo.x = 100;
// 拡張できない
foo.y = 200;
// 削除できない
delete foo.x;
console.log(foo); // {x: 10}
また、 Object.preventExtensions(o) メソッドを使って拡張を防ぎ、 Object.defineProperty(o) メソッドによって特定の属性を操作することができます。
var foo = {x : 10};
Object.defineProperty(foo, "y", {
value: 20,
writable: false, // read-only(読み取り専用)
configurable: false // non-configurable(再設定不可)
});
// 変更できない
foo.y = 200;
// 削除できない
delete foo.y; // false
// 拡張を防ぐ
Object.preventExtensions(foo);
console.log(Object.isExtensible(foo)); // false
// 新しいプロパティを追加できない
foo.z = 30;
console.log(foo); {x: 10, y: 20}
詳しくは、この章(未訳)を参照下さい。
仕様上では、ネイティブオブジェクトと、ビルトインオブジェクト、それにホストオブジェクトが区別されていることも、お伝えしておかないとなりません。
ビルトイン及びネイティブオブジェクトは、 ECMAScript 仕様によって定義され、その実装における差異はあまり重要ではありません。ネイティブオブジェクトは、 ECMAScrpt 実装によって与えられる全てのオブジェクトがそれに当たります(そのうちのいくつかはビルトインであり、その他はプログラム実行中に生成されるもの、例えばユーザの定義するオブジェクトです)。
ビルトインオブジェクトはネイティブオブジェクトの派生型(部分集合)であり、プログラムが開始されるより前に先だって ECMASCript にビルトインされるものです(例えば、 parseInt や Math オブジェクトなどです)。
ホストオブジェクトは、全てホスト環境、大抵はブラウザによって与えられ(訳注:最近では node.js 等も)、 window や alert といったものを含みます。
注意が必要なのは、ホストオブジェクトは時に ES 自身によって実装され、完全に仕様のセマンティック(意味的機序)に合致する場合があるということです。この観点から、主に理論上の側面ながら、それらは(非公式に)ネイティブ・ホストオブジェクトとも呼べるものです。しかしながら仕様では、「ネイティブ・ホスト」という考え方は定義されていません。
訳注:こうした分類はあくまで理論上のもので、例え同じオブジェクトでも、実装や実装のバージョンによって変わり得ます。 XMLHttpRequest オブジェクトは、初期バージョンの IE では当然ホストオブジェクトですが、 IE9 においては JavaScript エンジンによって実装されるネイティブ・ホストオブジェクトと考えることができます。
一部のプリミティブについては、仕様は特別なラッパーオブジェクトを定義しています。以下の3つです。
- Boolean オブジェクト
- String オブジェクト
- Number オブジェクト
これらのオブジェクトは、それぞれ対応するビルトインのコンストラクタによって生成され、内部プロパティの一つとしてプリミティブ値を持ちます。こうしたオブジェクト表現はプリミティブ値に変換することができ、またその逆も可能です。
それぞれのプリミティブ型に対応するオブジェクト値の例です。
var c = new Boolean(true);
var d = new String('test');
var e = new Number(10);
// プリミティブに変換
// 変換器: ToPrimitive
// "new" キーワード無しで、関数として適用する
c = Boolean(c);
d = String(d);
e = Number(e);
// オブジェクトに戻す
// 変換器: ToObject
c = Object(c);
d = Object(d);
e = Object(e);
加えて、特別なビルトインコンストラクタによって生成されるオブジェクトもあります。 Function (関数オブジェクトコンストラクタ)、 Array (配列コンストラクタ)、 RegExp (正規表現コンストラクタ)、 Math (算術モジュール)、 Date (日付のコンストラクタ)などです。これらのオブジェクトは全て Object 型の値であり、それらの区別は後に検討する内部プロパティによって行われます。
3つのオブジェクト値、 object (オブジェクト)、 array (配列)、 regular expression(正規表現)には、それぞれオブジェクト初期化子、配列初期化子、正規表現リテラルと呼ばれる短縮記法があります。
// new Array(1, 2, 3); または
// array = new Array();
// array[0] = 1;
// array[1] = 2;
// array[2] = 3;
// と同等
var array = [1, 2, 3];
// var object = new Object();
// object.a = 1;
// object.b = 2;
// object.c = 3;
// と同等
var object = {a: 1, b: 2, c: 3};
// new RegExp("^\\d+$", "g"); と同等
var re = /^\d+$/g;
Object 、 Array 、 RegExp といった名前結合に対し新しいオブジェクトを再代入する場合は注意が必要です。その代入の後にリテラル記法を使用した場合の機序は実装によって異なり得るからです。例えば、現行の Rhino 実装や、古い SpiderMonkey 1.7 では、対応するリテラル記法はそれぞれ新しく代入されたコンストラクタ名の new 文による値のオブジェクトを生成します。他の実装(現行の Spider/TraceMonkey )では、コンストラクタ名が新しいオブジェクトに再束縛されても、リテラル記法の機序は変更されません。
var getClass = Object.prototype.toString;
Object = Number;
var foo = new Object;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
var bar = {};
// Rhino 、 SpiderMonkey 1.7 では ― 0, "[object Number]"
// その他では:依然として "[object Object]", "[object Object]"
alert([bar, getClass.call(bar)]);
// Array という名前も同様
Array = Number;
foo = new Array;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
bar = [];
// Rhino 、 SpiderMonkey 1.7 では ― 0, "[object Number]"
// その他では:依然として "", "[object Object]"
alert([bar, getClass.call(bar)]);
// しかし RegExp に関しては、
// テストした全ての実装にて、
// リテラルの機序は変更されなかった
RegExp = Number;
foo = new RegExp;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
bar = /(?!)/g;
alert([bar, getClass.call(bar)]); // /(?!)/g, "[object RegExp]"
正規表現リテラルと RegExp オブジェクト
しかし、ここでご注意いただきたいのは、 ES3 において最後の二つ、正規表現が意味的に同じであるというケースにおいても、実は異なる点があるということです。ある一つの正規表現リテラルが、パース時に生成されるたった一つのインスタンスのみを生成しこれが再利用される一方、 RegExp コンストラクタは常に新しいオブジェクトを生成します。これは例えば正規表現オブジェクトの lastIndex プロパティを使用する際に問題となり得ます。正規表現テストが上手く動かない場合があるのです。
for (var k = 0; k < 4; k++) {
var re = /ecma/g;
alert(re.lastIndex); // 0, 4, 0, 4
alert(re.test("ecmascript")); // true, false, true, false
}
// に対し
for (var k = 0; k < 4; k++) {
var re = new RegExp("ecma", "g");
alert(re.lastIndex); // 0, 0, 0, 0
alert(re.test("ecmascript")); // true, true, true, true
}
ES5 ではこの問題は解決され、正規表現リテラルが常に新しいオブジェクトを生成します(訳注:訳者の手元の IE 環境では、バージョン 6 で既にこの問題は解決しているようです。ただし、ループ中で正規表現リテラルを使用する際は、念のためご自身でご確認ください)
しばしば、いくつかの記事や議論の中で、 JavaScript のオブジェクト(そして大抵は宣言的な形式、オブジェクト初期化子 {} で生成されたもの)は、ハッシュテーブル、または単にハッシュ( Ruby や Perl からの用語)、あるいは連想配列( PHP からの用語)、辞書( Python からの用語)などと呼ばれます。
この用語の使用法は、具体的な実装に対する習慣によるものです。実際の処これらは十分に同等と言えるものであり、「キーバリュー」ペアのストレージとして見れば、論理的に全く「連想配列」や「ハッシュテーブル」に対応しています。さらに言えば、ハッシュテーブルという抽象的なデータ型は実装のレベルでも利用できますし、また実際一般的に使われてもいます。
しかしながら、こうした用語は概念的な考え方を表現するものですが、一方 ECMAScript において実際には、これは技術的に正しいものであるとは言えません。前述したとおり、 ECMAScript はたった一つの オブジェクト型とその「派生型」を持ち、「キーバリュー」ペアのストレージという観点からは、これらの間に全く違いは無いのです。したがって、そこに個別の特別な用語(ハッシュやその他)はありません。なぜならどんなオブジェクトも、その内部プロパティに関わらず、これらのペアを保管できるからです。
var a = {x: 10};
a['y'] = 20;
a.z = 30;
var b = new Number(1);
b.x = 10;
b.y = 20;
b['z'] = 30;
var c = new Function('');
c.x = 10;
c.y = 20;
c['z'] = 30;
// などなど ― どんなオブジェクト「派生型」であれ
さらに、 ECMAScript のオブジェクトはその委譲の性質から空になり得ませんので、「ハッシュ」という用語も不適当になり得ます。
Object.prototype.x = 10;
var a = {}; // 「空の」「ハッシュ」を生成
alert(a["x"]); // 10 、しかし空では無かった
alert(a.toString); // 関数
a["y"] = 20; // 「ハッシュ」に新しいペアを追加
alert(a["y"]); // 20
Object.prototype.y = 20; // プロトタイプにプロパティを追加
delete a["y"]; // 削除
alert(a["y"]); // しかしキーバリューは依然として存在する ― 20
ES5 は、プロトタイプを持たないオブジェクトを生成する方法を標準化しました(未訳)。つまり、そのプロトタイプが null にセットされるのです。これは、 Object.create(null) メソッドを用いて行われます。この観点からは、こうしたオブジェクトは単なるハッシュテーブルであると言えます。
var aHashTable = Object.create(null);
console.log(aHashTable.toString); // undefined
そして、いくつかのプロパティは特別な getter/setter を持ち得ますので、さらに混乱の元になるかもしれません。
var a = new String("foo");
a['length'] = 10;
alert(a['length']); // 3
とは言え、「ハッシュ」がもし「プロトタイプ」を持つことができるとしても(例えば Ruby や Python では、クラスがハッシュオブジェクトから委譲できます)、 ECMAScript では、やはりこの用語は不適当になり得ます。なぜなら、プロパティアクセサの種類(ドットまたは括弧記法)の間に意味的な違いは無いからです。
さらに、 ECMAScript では、「プロパティ」の考え方が、「キー」「配列インデックス」「メソッド」「プロパティ」に意味的には区別されていません。これらは全てプロパティであり、プロトタイプチェーンの探索に関する読み書きのアルゴリズムの一般的な法則に従います。
以下の Ruby の例では意味論的な違い、そして結果として用語がそれぞれ異なり得ることを見て取ることができます。
a = {}
a.class # ハッシュ
a.length # 0
# 新しい「キーバリュー」ペア
a['length'] = 10;
# しかしドット記法の意味は依然として異なり、
# 「キー」ではなく、
# 「プロパティ・メソッド」へのアクセスを意味する
a.length # 1
# そして括弧記法は、
# ハッシュの「キー」へのアクセスを提供する
a['length'] # 10
# 動的に、 Hash クラスを新しい
# プロパティ・メソッドで拡張し、
# 委譲によって既に生成されたオブジェクトにも
# 利用可能になる
class Hash
def z
100
end
end
# 新しい「プロパティ」が利用可能
a.z # 100
# しかしこれは「キー」ではない
a['z'] # nil
ECMA-262-3 仕様は、「ハッシュ」(や同様のもの)の考え方を定義していません。しかし、(実装面・技術的ではない)論理的なデータ構造について言うのであれば、オブジェクトをそのように呼ぶことは可能です。
オブジェクトをプリミティブ値に変換するには、 valueOf メソッドが利用できます。既に触れたように、(ある型の)コンストラクタを関数として、 new 演算子無しで呼び出しても、オブジェクト型からプリミティブ値への変換を行えます。この変換には全くもって、暗黙的な valueOf メソッドの呼び出しが用いられています。
var a = new Number(1);
var primitiveA = Number(a); // 暗黙の "valueOf" 呼び出し
var alsoPrimitiveA = a.valueOf(); // 明示的な呼び出し
alert([
typeof a, // "object"
typeof primitiveA, // "number"
typeof alsoPrimitiveA // "number"
]);
このメソッドによって、オブジェクトは(そのままの形で)さまざまな演算に参加することができます。加算の例を見てみましょう。
var a = new Number(1);
var b = new Number(2);
alert(a + b); // 3
// あるいはさらに
var c = {
x: 10,
y: 20,
valueOf: function () {
return this.x + this.y;
}
};
var d = {
x: 30,
y: 40,
// オブジェクト "c" が持つ
// .valueOf の機能を拝借
valueOf: c.valueOf
};
alert(c + d); // 100
valueOf メソッドの値は、デフォルトでは(オーバーライドされていなければ)オブジェクトの型によってさまざまです。 this 値を返すものもあれば(例えば Object.prototype.valueOf() )、計算された値( Date.prototype.valueOf() のように)ある日の UNIX 時間を返すものもあります。
var a = {};
alert(a.valueOf() === a); // true, "valueOf" が this 値を返した
var d = new Date();
alert(d.valueOf()); // 時間
alert(d.valueOf() === d.getTime()); // true
オブジェクトのプリミティブな表象にはもう一つあります。文字列としての現れ方です。これは toString メソッドが担当します。このメソッドに関しても、いくつかの演算にて自動的に適用されます。
var a = {
valueOf: function () {
return 100;
},
toString: function () {
return '__test';
}
};
// この演算では、
// toString メソッドが
// 自動で呼び出される
alert(a); // "__test"
// ここでは .valueOf() メソッドが呼ばれる
alert(a + 10); // 110
// しかし同じ演算でも
// valueOf メソッドが無ければ、
// toString メソッドで代替される
delete a.valueOf;
alert(a + 10); // "_test10"
Object.prototype に定義された toString メソッドには特別な意味があります。後に検討する、内部プロパティ [[Class]] の値を返すのです。
ToPrimitive 変換と同じく、逆に値をオブジェクト型に変換する ToObject 変換もあります。
明示的に ToObject を呼び出す一つの方法は、ビルトインの Object コンストラクタを関数として(いくつかの型に関しては new 演算子付きも可能)呼び出すことです。
var n = Object(1); // [object Number]
var s = Object('test'); // [object String]
// いくつかの型に関しては
// new 演算子付きで Object を呼び出しても
// ToObject 変換が可能
var b = new Object(true); // [object Boolean]
// ただし引数無しで呼び出すと
// 単なるオブジェクトを生成する
var o = new Object(); // [object Object]
// もし Object 関数への引数が
// 既にオブジェクト値だった場合、
// 単にそれをそのまま戻す
var a = [];
alert(a === new Object(a)); // true
alert(a === Object(a)); // true
ビルトインコンストラクタを呼び出す際の new 演算子の有無に関しては一般的なルールは無く、コンストラクタ次第です。例えば Array や Function コンストラクタは、コンストラクタとして( new 付きで)呼び出した際も、単なる関数として( new 無しで)呼び出した際も、同じ結果を出力します。
var a = Array(1, 2, 3); // [object Array]
var b = new Array(1, 2, 3); // [object Array]
var c = [1, 2, 3]; // [object Array]
var d = Function(''); // [object Function]
var e = new Function(''); // [object Function]
演算子によっては、適用される際に明示的、あるいは暗黙的な型キャストが行われます。
var a = 1;
var b = 2;
// 暗黙的
var c = a + b; // 3 、 number
var d = a + b + '5' // "35" 、 string
// 明示的
var e = '10'; // "10" 、 string
var f = +e; // 10 、 number
var g = parseInt(e, 10); // 10 、 number
// 等々
全てのプロパティはいくつかの属性を持ちます(訳注:下記は ES3 でのものです)。
{ReadOnly} ― プロパティへの値の書き込もうとする試みは無視される。しかし、 ReadOnly プロパティはホスト環境の作用によって変化し得るため、 ReadOnly が「定数」を意味するわけではない。
{DontEnum} ― プロパティが for.. in ループに列挙されない。
{DontDelete} ― プロパティへの delete 演算子の作用は無視される。
{Internal} ― プロパティは内部的なものである。名前を持たず、実装レベルでのみ使用される。こうしたプロパティは、 ECMAScript のプログラムからはアクセスできない。
ES5 では、 {ReadOnly} 、 {DontEnum} 、 {DontDelete} はそれぞれ [[Writable]] 、 [[Enumerable]] 、 [[Configurable]] に名称変更されました(未訳)。また、これらの属性は Object.defineProperty などのメソッドを使って手動で修正することが可能です。
var foo = {};
Object.defineProperty(foo, "x", {
value: 10,
writable: true, // {ReadOnly} = false
enumerable: false, // {DontEnum} = true
configurable: true // {DontDelete} = false
});
console.log(foo.x); // 10
// 属性の集合は記述子と呼ばれます
var desc = Object.getOwnPropertyDescriptor(foo, "x");
console.log(desc.enumerable); // false
console.log(desc.writable); // true
// etc.
オブジェクトもまた、実装の一部であり ECMAScript プログラムからは直接にアクセスすることのできない(とはいえ今後見ていくように実装によってはアクセス可能な)、いくつかの内部プロパティを持ちます。これらのプロパティは、慣習として二重の角括弧 [[ ]] で囲われます。
ここではこれらのいくつかに触れていきますが(全てのオブジェクトに必須なもの)、その他のプロパティの詳細は仕様に見ることができます。
全てのオブジェクトは、以下の内部プロパティと内部メソッドを実装しなければなりません。
[[Prototype]] ― このオブジェクトのプロトタイプ(詳しくは後に検討します)。
[[Class]] ― オブジェクトの種類を文字列で表すもの(例えば Object 、 Array 、 Function など)。オブジェクトを区別するために使われる。
[[Get]] ― プロパティの値を取得するためのメソッド。
[[Put]] ― プロパティの値を設定するためのメソッド。
[[CanPut]] ― プロパティへの書き込みが可能かチェックする。
[[HasProperty]] ― オブジェクトが指定のプロパティを持つかチェックする。
[[Delete]] ― オブジェクトからプロパティを削除する。
[[DefaultValue]] ― オブジェクトに対応するプリミティブ値を返す(この値の取得には valueOf メソッドが呼ばれる。いくつかのオブジェクトに関しては、 TypeError 例外が投げられる)。
ECMAScript プログラムからの [[Class]] プロパティの取得は、 Object.prototype.toString() を経由して間接的に可能です。このメソッドは以下の文字列 "[object " + [[Class]] + "]" を返します。例えば、
var getClass = Object.prototype.toString;
getClass.call({}); // [object Object]
getClass.call([]); // [object Array]
getClass.call(new Number(1)); // [object Number]
// など
この機能はしばしばオブジェクトの種類を確認するために用いられますが、仕様によればホストオブジェクトの内部 [[Class]] プロパティはビルトインオブジェクトの [[Class]] プロパティを含め何でも良いと規定されていることに注意が必要です。つまり理論的には100%保証するチェックはできないということです。例えば、 document.childNodes.item(...) メソッドの IE における [[Class]] プロパティの値は "String" です(他の実装では、 "Function" が返されます)。
// IE では "String" 、 その他では "Function"
alert(getClass.call(document.childNodes.item));
訳注: ES5 仕様において、ホストオブジェクトの [[Class]] プロパティが、 "Arguments" 、 "Array" 、 "Boolean" 、 "Date" 、 "Error" 、 "Function" 、 "JSON" 、 "Math" 、 "Number" 、"Object" 、 "RegExp" 、 "String" 以外の値を取ると規定されました。
既に触れたように、 ECMAScript におけるオブジェクトは、いわゆるコンストラクタを経由して生成されます。
コンストラクタは、新しく生成されるオブジェクトを生成し、初期化する関数です。
生成(メモリアロケーション)に関しては、コンストラクタ関数の [[Constructor]] 内部メソッドが担当します。新しいオブジェクトのメモリを割り当てるために、この内部メソッドのふるまいが規定され、全てのコンストラクタ関数がこのメソッドを利用します。
そして初期化に関しては、新しく生成されたオブジェクトのコンテキストで関数を呼び出すことで行われます。ここではコンストラクタ関数の [[Call]] 内部メソッドが担当します。
注意としては、ユーザコードからアクセス可能なのはこのうち初期化フェーズのみだということです。しかし初期化フェーズにおいても、最初に生成された this オブジェクトを無視して、異なるオブジェクトを戻すことが可能です。
function A() {
// 新しく生成されたオブジェクトを更新
this.x = 10;
// しかし異なるオブジェクトを戻す
return [1, 2, 3];
}
var a = new A();
console.log(a.x, a); undefined, [1, 2, 3]
『第5章 関数』にて検討したように、関数オブジェクトの生成アルゴリズムを見ると、関数とはプロパティの中でもこの内部 [[Construct]] 及び [[Call]] プロパティを持ち、明示的な prototype プロパティ(将来この関数をコンストラクタとして生成されるオブジェクトのプロトタイプへの参照)を持つネイティブオブジェクトであることが分かります(下記の例での NativeObject とは、 ECMA-262-3 の「ネイティブオブジェクト」の考え方のために私が慣習的に名付けた擬似コード上の名称であって、ビルトインコンストラクタではありません)。
F = new NativeObject();
F.[[Class]] = "Function"
.... // その他のプロパティ
F.[[Call]] = <reference to function> // 関数自身
F.[[Construct]] = internalConstructor // 一般的な内部コンストラクタ
.... // その他のプロパティ
// F コンストラクタで生成されるオブジェクトのプロトタイプ
__objectPrototype = {};
__objectPrototype.constructor = F // {DontEnum}
F.prototype = __objectPrototype
したがって、 [[Class]] プロパティ( "Function" となる)に加えて [[Call]] が、オブジェクトを区別する点で重要になります。つまり、内部 [[Call]] プロパティを持つオブジェクトが、関数と呼ばれるのです。こうしたオブジェクトに対して、 typeof 演算子は "function" という値を返します。ただしこれは主にネイティブオブジェクトの話であって、呼び出し可能なホストオブジェクトの場合は、実装によっては typeof 演算子が( [[Class]] プロパティに負けず劣らず)他の値を返します。例えば、 IE において window.alert(...) は…、
// IE では ― "Object", "object" 、その他では "Function", "function"
alert(Object.prototype.toString.call(window.alert));
alert(typeof window.alert); // "Object"
内部 [[Construct]] メソッドはコンストラクタ関数に new 演算子を付与することでアクティベートされます。申し上げたように、このメソッドはメモリアロケーションとオブジェクトの生成を担当しています。もし実引数が無い場合は、コンストラクタ関数の呼び出し括弧は省略可能です。
function A(x) { // コンストラクタ A
this.x = x || 10;
}
// 実引数が無い場合、
// 呼び出し括弧は省略可能
var a = new A; // or new A();
alert(a.x); // 10
// 明示的な x 引数の
// 引き渡し
var b = new A(20);
alert(b.x); // 20
既にご存じの通り、コンストラクタ中の(初期化フェーズにおける) this 値は新しく生成されたオブジェクトにセットされます。
それでは次に、オブジェクト生成のアルゴリズムについて検討してみましょう。
内部 [[Construct]] メソッドのふるまいは以下のように記述できます。
F.[[Construct]](initialParameters):
O = new NativeObject();
// [[Class]] プロパティが "Object" にセットされる、つまり単純なオブジェクト
O.[[Class]] = "Object"
// この瞬間において F.protottype を参照している
// オブジェクトを取得
var __objectPrototype = F.prototype;
// もし __objectPrototype がオブジェクトならば
O.[[Prototype]] = __objectPrototype
// そうでは無い場合、
O.[[Prototype]] = Object.prototype;
// ここで O.[[Prototype]] とは生成されるオブジェクトのプロトタイプ
// F.[[Call]] を呼び出して
// 新しく生成されたオブジェクトを初期化
// this 値としては新しく生成されたオブジェクト ― O
// [[Call]] への実引数は F への initialParameters と同じ
R = F.[[Call]](initialParameters); this === O;
// R は [[Call]] の戻り値
// JS 的に見ればこのようになる
// R = F.apply(O, initialParameters);
// R がオブジェクトなら
return R
// そうでなければ
return O
二つの重要な特徴に注目して下さい。
最初に、生成されたオブジェクトのプロトタイプは、その瞬間のコンストラクタ関数の prototype プロパティから取られます(これの意味することは、ある一つのコンストラクタから生成された二つのオブジェクトのプロトタイプは異なる可能性がある、ということです。なぜならコンストラクタ関数の prototype プロパティは、いつでも変更し得るからです)。
次に、上で触れたように、オブジェクト初期化時に [[Call]] がオブジェクトを戻した場合、まさにそのオブジェクトが new 式全体の結果として用いられます。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10 - 委譲によって、プロトタイプから
// 関数の .prototype プロパティを
// 新しいオブジェクトにセットする
// .constructor プロパティを
// 明示的に定義している理由は後に説明する
A.prototype = {
constructor: A,
y: 100
};
var b = new A();
// オブジェクト "b" が新しいプロトタイプを持つ
alert(b.x); // undefined
alert(b.y); // 100 - 委譲によって、プロトタイプから
// しかし、オブジェクト "a" のプロトタイプは
// 依然として古いままである(理由は後述する)
alert(a.x); // 10 - 委譲によって、プロトタイプから
function B() {
this.x = 10;
return new Array();
}
// もし "B" コンストラクタが何も戻さなければ、
// (あるいは this を戻したとしたら)
// this オブジェクトが使われるが、
// この場合では配列である
var b = new B();
alert(b.x); // undefined
alert(Object.prototype.toString.call(b)); // [object Array]
それでは、プロトタイプについてより詳しく見ていきましょう。
全てのオブジェクトはプロトタイプを持ちます(システムオブジェクトには例外もあります)。プロトタイプとの通信は、内部的で、暗黙的で、直接にはアクセス不可の [[Prototype]] プロパティを経由して行われます。プロトタイプは、オブジェクトまたは null 値を取ります。
上の例には、二つの重要なポイントがありました。一つ目は関数の prototype プロパティの constructor プロパティに関係しています。
関数オブジェクト生成のアルゴリズムで見たとおり、 constructor プロパティは、関数の生成時に、関数の prototype プロパティ(訳注:この関数によって生成されたオブジェクトにとってのプロトタイプ)にセットされます。このプロパティの値は、関数自身そのものへの循環参照になっています。
function A() {}
var a = new A();
alert(a.constructor); // function A() {} 、委譲によって
alert(a.constructor === A); // true
しばしば、このケースについて誤解があります。 constructor プロパティを、誤って生成されたオブジェクト自身のプロパティと扱ってしまうのです。しかし、見てきたようにこのプロパティはプロトタイプに属し、継承を通じてオブジェクトにアクセスできるのです。
継承された constructor プロパティを使って、インスタンスは間接的にプロトタイプオブジェクトへの参照を得ることができるのです。
function A() {}
A.prototype.x = new Number(10);
var a = new A();
alert(a.constructor.prototype); // [object Object]
alert(a.x); // 10 、委譲を通じて
// the same as a.[[Prototype]].x
alert(a.constructor.prototype.x); // 10
alert(a.constructor.prototype.x === a.x); // true
しかしご注意いただきたいのは、関数の constructor 及び prototype プロパティ共に、オブジェクトが生成された後で再定義されることが可能だということです。この場合、オブジェクトは上記のメカニズムへの参照を失うことになります。
もしここで、関数の prototype プロパティを通じてオリジナルのプロトタイプに新しいプロパティを追加、または既存のプロパティを変更すると、インスタンスからはその新しく追加・変更されたプロパティを見ることになります。
しかし、もし関数の prototype プロパティを完全に変更してしまったら(新しいオブジェクトを代入することで)、オリジナルのコンストラクタ(もちろんオリジナルのプロトタイプも)への参照は失われてしまいます。もちろんこれは、新しく生成したオブジェクトが constructor プロパティを(自身に)持たないためです。
function A() {}
A.prototype = {
x: 10
};
var a = new A();
alert(a.x); // 10
alert(a.constructor === A); // false!
従って、この参照を戻すためには、手動で回復してあげなければなりません。
function A() {}
A.prototype = {
constructor: A,
x: 10
};
var a = new A();
alert(a.x); // 10
alert(a.constructor === A); // true
手動で回復された constructor プロパティは、失われたオリジナルに比較して、 {{DontEnum}} 属性を持たないことには注意が必要です。そしてこの結果として、 A.prototype を通じて for.. in ループ中に現れてしまいます。
ES5 では、 {{Enumerable}} 属性を通じて、プロパティの列挙可能状態を操作することができるように(未訳)なりました。
var foo = {x: 10};
Object.defineProperty(foo, "y", {
value: 20,
enumerable: false // {DontEnum} = true だったもの
});
console.log(foo.x, foo.y); // 10 、 20
for (var k in foo) {
console.log(k); // "x" のみ
}
var xDesc = Object.getOwnPropertyDescriptor(foo, "x");
var yDesc = Object.getOwnPropertyDescriptor(foo, "y");
console.log(
xDesc.enumerable, // true
yDesc.enumerable // false
);
時折、誤ってオブジェクトのプロトタイプを関数の prototype プロパティへの明示的な参照であると混同してしまうことがあります。これは完全な誤りとは言えませんが、正確ではありません。実際は、これらは共に、全く同じオブジェクト、つまりオブジェクトの [[Prototype]] プロパティへの参照なのです(訳注:どちらかが実体で、どちらかが参照、という関係では無く、どちらも参照でしかありません)。
a.[[Prototype]] ----> Prototype <---- A.prototype
さらにインスタンスの [[Prototype]] は、その値を(その参照を)、まさしくコンストラクタの prototype プロパティから、オブジェクト生成時に、得るのです。
しかしながら、コンストラクタの prototype プロパティを置き換えても、既に生成されたオブジェクト のプロトタイプには影響しません。変更されるのは、コンストラクタの prototype プロパティのみなのです!。つまり、今後新しく生成されるオブジェクトは、新しいプロトタイプを持ち、( prototype プロパティが変更される前に)既に生成されたオブジェクトは、古いプロトタイプへの参照を持ち続け、この参照は以後変更できないのです。
// A.prototype が変更される前
a.[[Prototype]] ----> Prototype <---- A.prototype
// 変更された後
A.prototype ----> New prototype // 新しいオブジェクトはこのプロトタイプを持つことになる
a.[[Prototype]] ----> Prototype // 古いプロトタイプへの参照を持ち続ける
例です。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
A.prototype = {
constructor: A,
x: 20
y: 30
};
// オブジェクト "a" は
// 暗黙の [[Prototype]] 参照を通じ
// 古いプロトタイプに委譲を行う
alert(a.x); // 10
alert(a.y) // undefined
var b = new A();
// しかし新しいオブジェクトは
// 生成時に新しいプロトタイプへの参照を取得する
alert(b.x); // 20
alert(b.y) // 30
訳注:分かりにくいポイントなので補足しますと、コンストラクタ関数の prototype プロパティそのものの参照先を変更した場合は、上記の通りです。対して、コンストラクタ関数の prototype プロパティであるオブジェクトのプロパティを一部変更した場合は、その変更が既に生成されたオブジェクトに継承を通じて影響します。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
// prototype 「の」プロパティを追加
A.prototype.y = 20;
// インスタンスにも継承を通じて反映
alert(a.y); // 20
// prototype 「そのもの」を変更
A.prototype = {
constructor: A,
x: 20,
y: 30
};
// インスタンスから参照されているプロトタイプは
// 古いまま
alert(a.x); // 10
alert(a.y); // 20
従って、 JavaScript に関数記事などでしばしば見受けられる、「プロトタイプそのものの動的な変更は全てのオブジェクトに影響し、それらのオブジェクトは皆新しいプロトタイプを持つことになる」という主張は、誤りです(訳注:補足したとおり「プロトタイプのプロパティへの変更」であれば、全てのオブジェクトに影響します)。新しいプロトタイプは、新しいオブジェクトにのみ影響します。
重要はルールはこうです。オブジェクトのプロトタイプは、オブジェクトの生成時にセットされ、その後は新しいオブジェクトに変更できません。コンストラクタからの明示的な prototype参照を使えば、それが(オブジェクトのプロトタイプとコンストラクタの prototype プロパティが共に)同じオブジェクトを指している限り、オブジェクトのプロトタイプに新しいプロパティを追加したり、既存のプロパティを変更することだけは可能です。
ところが、一部の実装、例えば SpiderMonkey は、(暗黙的であってオブジェクト生成後にはユーザコードからは変更できないはずの)オブジェクトのプロトタイプへの明示的な参照を持っています。非標準の __proto__ プロパティです。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
var __newPrototype = {
constructor: A,
x: 20,
y: 30
};
// 新しいオブジェクトへの参照
A.prototype = __newPrototype;
var b = new A();
alert(b.x); // 20
alert(b.y); // 30
// オブジェクト "a" は依然として
// 古いプロトタイプを持つ
alert(a.x); // 10
alert(a.y); // undefined
// プロトタイプを明示的に変更
a.__proto__ = __newPrototype;
// オブジェクト "a" も
// 新しいオブジェクトを参照するようになる
alert(a.x); // 20
alert(a.y); // 30
ES5 では、 Object.getPrototypeOf(O) メソッドが導入されました。これは直接オブジェクトの [[Prototype]] プロパティ、つまりインスタンスのオリジナルのプロトタイプを返すものです。しかし __proto__ と異なるのは、これは getter であり、プロトタイプをセットすることはできないということです。
var foo = {};
Object.getPrototypeOf(foo) == Object.prototype; // true
インスタンスのプロトタイプはコンストラクタ及びコンストラクタの prototype プロパティから独立していますから、その主な用途、オブジェクトの生成が終了した後は、コンストラクタは削除できます。コンストラクタが削除されてもプロトタイプオブジェクトは [[Prototype]] プロパティを通じて参照され、存在し続けます(訳注:コンストラクタの prototype プロパティもまた、プロトタイプオブジェクトへのある一つの参照でしかないということ。この「参照」がオブジェクト(インスタンス)生成時にオブジェクトに引き渡されるということを再度思い出して下さい。より根本的には、 JavaScript においてオブジェクトを格納する変数・プロパティはすべて参照である、ということも意識しておくと理解しやすいと思います)。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
// 明示的なコンストラクタへの参照である
// "A" を null にセットする
// (訳注:コンストラクタ関数へのある一つの参照を削除しただけ)
A = null;
// しかし依然として、
// .constructor プロパティが変更されていなければ
// 他のオブジェトからの間接的な参照を通じて
// オブジェクトを生成することができる
var b = new a.constructor();
alert(b.x); // 10
// 両方の暗黙的な参照を削除する
delete a.constructor.prototype.constructor;
delete b.constructor.prototype.constructor;
// これで "A" コンストラクタのオブジェクトを
// 生成することはできなくなったが、
// 生成された二つのオブジェクトには、
// それぞれのプロトタイプへの参照が存在し続ける
alert(a.x); // 10
alert(b.x); // 10
instanceof 演算子の働きには、コンストラクタの prototype プロパティを経由した、プロトタイプへの明示的な参照が関わっています。
この演算子は、コンストラクタに対してではなく、まさにプロトタイプチェーンに対して働きます。これを鑑みるに、いくつか誤解があるように思われます。例えば、このような検査があったとします。
if (foo instanceof Foo) {
...
}
これは、オブジェクト foo が、コンストラクタ Foo によって生成されたかどうかの検査では無いのです!。
instanceof 演算子が行っているのは、オブジェクトのプロトタイプ( foo.[[Prototype]] )を取り出し、プロトタイプチェーン( foo.[[Prototype]] 、 foo.[[Prototype]].[[Prototype]] 、 foo.[[Prototype]].[[Prototype]] …)中に Foo.prototype の存在を検査することだけです。
これをサンプルコードで見てみましょう。
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
alert(a instanceof A); // true
// もし A.prototype を
// null にセットしたら…
A.prototype = null;
// … "a" オブジェクトは
// 依然としてそのプロトタイプに
// a.[[Prototype]] を経由して
// アクセスできる
alert(a.x); // 10
// しかし、 instanceof 演算子は
// 働かなくなる
// なぜならその検査は
// コンストラクタのプロトタイププロパティに対して
// 行われるからである
alert(a instanceof A); // エラー、 A.prototype はオブジェクトではない
これに対し、あるコンストラクタからオブジェクトを生成しつつ、 instanceof が異なるコンストラクタとの検査に true を返すようにすることが可能です。オブジェクトの [[Prototype]] プロパティ及びコンストラクタの prototype プロパティを、同じオブジェクトに向けさえすればいいのです。
function B() {}
var b = new B();
alert(b instanceof B); // true
function C() {}
var __proto = {
constructor: C
};
C.prototype = __proto;
b.__proto__ = __proto;
alert(b instanceof C); // true
alert(b instanceof B); // false
ECMAScript におけるプロトタイプの最も有用な応用は、オブジェクトのメソッド、デフォルト状態、共有プロパティのストレージとしての役割です。
実際、オブジェクトはそれぞれ独自の状態を持つことができます。しかし、メソッドは大抵同じです。したがって、メモリ使用量の最適化のために、メソッドは通例プロトタイプ中に定義されます。これは、このコンストラクタから生成された全てのインスタンスが、常に同じメソッドを共有するということを意味します。
function A(x) {
this.x = x || 100;
}
A.prototype = (function () {
// コンテキストを初期化、
// 補助的なオブジェクトを使う
var _someSharedVar = 500;
function _someHelper() {
alert('internal helper: ' + _someSharedVar);
}
function method1() {
alert('method1: ' + this.x);
}
function method2() {
alert('method2: ' + this.x);
_someHelper();
}
// これがプロトタイプそのもの
return {
constructor: A,
method1: method1,
method2: method2
};
})();
var a = new A(10);
var b = new A(20);
a.method1(); // method1: 10
a.method2(); // method2: 10 、 internal helper: 500
b.method1(); // method1: 20
b.method2(); // method2: 20 、 internal helper: 500
// どちらのオブジェクトも
// 同じプロトタイプの
// 同じメソッドを使っている
alert(a.method1 === b.method1); // true
alert(a.method2 === b.method2); // true
前述したように、プロパティの読み書きはそれぞれ内部メソッド [[Get]] と [[Put]] によって行われます。これらのメソッドは、プロパティアクセサ、つまりドット記法及び括弧記法によってアクティベートされます。
// 書き込み
foo.bar = 10; // [[Put]] が呼ばれる
// 読み出し
console.log(foo.bar); // 10 、[[Get]] が呼ばれる
console.log(foo['bar']); // 上に同じ
[[Get]] メソッドはオブジェクトのプロトタイプチェーン中のプロパティも考慮します。したがって、プロトタイプのプロパティはオブジェクト自身のプロパティ同様にアクセスが可能です。
対して [[Put]] メソッドは、オブジェクト自身のプロパティを作成または更新するもので、プロトタイプ中の同名のプロパティを隠すように働きます。
これらのメソッドの働きを、擬似コードで見ていくことにしましょう。
[[Get]]
O.[[Get]](P):
// 自身のプロパティがあれば
// それを返す
if (O.hasOwnProperty(P)) {
return O.P;
}
// そうでなければ、プロトタイプを分析する
var __proto = O.[[Prototype]];
// もしプロトタイプが無ければ
// (例えばチェーンの最後の要素、
// Object.prototype.[[Prototype]]
// は null である)
// undefined を返す
if (__proto === null) {
return undefined;
}
// プロトタイプがあれば、
// プロトタイプに対し再帰的に [[Get]] メソッドを呼び出す
// つまりプロトタイプチェーン中の
// ある要素(プロトタイプ)に対し
// プロパティを見つけようとし、
// 見つからなければ
// [[Prototype]] が null に等しくなるまで
// そのプロトタイプのプロトタイプへと続く
return __proto.[[Get]](P)
補足としては、 [[Get]] メソッドはケースによっては undefined を返しますので、以下のように、変数の存在をチェックすることが可能です。
if (window.someObject) {
...
}
ここで、プロパティ someObject が window 、あるいはそのプロトタイプ、さらにそのプロトタイプのプロトタイプ…( null になるまで)に見つからなければ、アルゴリズムに従い、 undefined が返されます。
正確な存在の確認には、 in 演算子が使われることも覚えておいてください。こちらもプロトタイプチェーンを考慮します。
if ('someObject' in window) {
...
}
in 演算子は、例えば someObject が存在しながら値が false である場合でも、プロパティの存在を確認できます。もう一つ前の例では、 if を通過できません。
[[Put]]
O.[[Put]](P, V):
// このプロパティに
// 書き込めない場合は
// 終了する
if (!O.[[CanPut]](P)) {
return;
}
// もしオブジェクトが
// それ自身にプロパティを持っていなければ
// それを生成する
// 全ての属性は空に設定される( false になる)(訳注: ES3 )
if (!O.hasOwnProperty(P)) {
createNewProperty(O, P, attributes: {
ReadOnly: false,
DontEnum: false,
DontDelete: false,
Internal: false
});
}
// 値をセットする
// 属性は変更されない
O.P = V
return;
前述の通り、内部メソッド [[Get]] と [[Put]] はプロパティアクセサによってアクティベートされます。これは、 ECMAScript においてはドット記法または括弧記法を用いて利用可能です。ドット記法はプロパティ名が妥当な識別子であり予めその名前を知っている場合に用いられ、括弧記法ではプロパティの名前を動的に形成することができます。
var a = {testProperty: 10};
alert(a.testProperty); // 10 、ドット記法
alert(a['testProperty']); // 10 、括弧記法
var propertyName = 'Property';
alert(a['test' + propertyName]); // 10 、動的なプロパティによる括弧記法
ここに一つ重要な特徴があります。プロパティアクセサは、常に左辺のオブジェクトに関し ToObject 変換を行います。そして、この暗黙の変換のために、大雑把に言って、「 JavaScript における全ての要素はオブジェクト」と表現することが可能なのです(とはいえもちろん皆さんはご存じの通り、プリミティブ値というものがありますから、「全て」ではありません)。
プリミティブ値に対してプロパティアクセサを用いると、その値に対応する中間ラッパーオブジェクトを生成することになります。そして演算が終了した後、このラッパーは削除されます。
例です。
var a = 10; // プリミティブ値
// しかし、まるでオブジェクトであるように
// メソッドにアクセスできる
alert(a.toString()); // "10"
// さらに、 [[Put]] を呼び出して
// プリミティブ値 "a" に
// 新しいプロパティを作る(ことを試みる)
// ことさえできる
a.test = 100; // seems, it even works
// しかし [[Get]] は
// このプロパティの値を返さない
// アルゴリズムに従い undefined が返される
alert(a.test); // undefined
ではなぜ、この例では「プリミティブ」値が toString メソッドにアクセスしながら、新しく作られた test プロパティにはアクセスできないのでしょうか?
答えはシンプルです。
まず最初に、ご説明したとおり、プロパティアクセサが適用されると、それはプリミティブではなく、中間オブジェクトになります。この場合 new Number(a) が使われ、委譲によってプロトタイプチェーン中に toString メソッドが見つかります。
// a.toString() を評価するアルゴリズム
1. wrapper = new Number(a);
2. wrapper.toString(); // "10"
3. delete wrapper;
次に、 [[Put]] メソッドも test プロパティの評価時に、またそれ独自のラッパーオブジェクトを生成します。
// a.test = 100: を評価するアルゴリズム
1. wrapper = new Number(a);
2. wrapper.test = 100;
3. delete wrapper;
ステップ3でラッパーが削除され、同様に新しく生成された test プロパティもまた、オブジェクトそのものが削除されるのですから、もちろん削除されます。
そしてそれから再度 [[Get]] が呼び出されると、プロパティアクセサはもう一度新しいラッパーを生成します。もちろん、これには test プロパティのようなものは一切含まれません。
// a.test: を評価するアルゴリズム
1. wrapper = new Number(a);
2. wrapper.test; // undefined
これが、プリミティブ値のプロパティ・メソッドがプロパティの読み出し時にのみ有効となる仕組みです。従ってもし、プリミティブ値がしばしばプロパティにアクセスするのであれば、時間リソースを節約するために、予め直接オブジェクト表現に変換しておくと良いでしょう。それに対し、値が小さな計算にのみ参加し、プロパティへのアクセスを要求しないようであれば、プリミティブ値のまま取り扱うのが効率的です。
既にご存じの通り、 ECMAScript はプロトタイプに基づく委譲による継承を利用しています。
プロトタイプをつなげたものが、これも既に触れたプロトタイプチェーンを形成します。
実際には、委譲を実装する働きや、プロトタイプチェーンの分析は、記述した [[Get]] メソッドの働きに集約されます。
[[Get]] メソッドのシンプルなアルゴリズムを完全に理解していれば、 JavaScript における継承に関する疑問は自ずと消失するするでしょうし、その答えも明確になるでしょう。
しばしばフォーラム等において JavaScript の継承に関する話題が上ると、私は例として、完全にかつ正確に ECMAScript のオブジェクト構造を記述し、委譲ベースの継承について示してくれるたった一行の ECMAScript のコードを提示します。実に、コンストラクタやオブジェクトを一切生成できなくとも、言語そのものに継承が浸透しているのです。この一行のコードはとてもシンプルです。
alert(1..toString()); // "1"
今や私たちは、 [[Get]] メソッドのアルゴリズムとプロパティアクセサを理解していますから、このコードで何が起こるかを理解できるでしょう。
- まず、プリミティブ値
1 から、ラッパーオブジェクト new Number(1) が生成されます。
- そして、継承されたメソッド
toString がこのラッパーによって呼び出されます。
なぜ継承が利用されるのでしょうか?。それは ECMAScript のオブジェクトは自身のプロパティを持つことができますが、この場合、ラッパーオブジェクトが自身の toString メソッドを持たないためです。従ってプロトタイプから継承することになります。 Number.prototype からです。
シンタックスの微妙なケースについて注意して下さい。上記の例の2つのドットはエラーではありません。最初のドットは数字の小数点であり、二つ目のドットはご存じプロパティアクセサです。
1.toString(); // シンタックスエラー!
(1).toString(); // OK
1..toString(); // OK
1['toString'](); // OK
このプロトタイプチェーンというものを、ユーザ定義オブジェクトにおいてどのように生成するのか見てみましょう。とても簡単です。
function A() {
alert('A.[[Call]] activated');
this.x = 10;
}
A.prototype.y = 20;
var a = new A();
alert([a.x, a.y]); // 10 (自身の)、 20 (継承された)
function B() {}
// プロトタイプチェーン生成の
// 最も簡単な方法は、子のプロトタイプを
// 親コンストラクタによって新しく生成したオブジェクトに
// セットすることです。
B.prototype = new A();
// .constructor プロパティを修正します
// そうしなければ A を指してしまいます
B.prototype.constructor = B;
var b = new B();
alert([b.x, b.y]); // 10 、 20 、共に継承
// [[Get]] b.x:
// b.x (無) -->
// b.[[Prototype]].x (有) - 10
// [[Get]] b.y
// b.y (無) -->
// b.[[Prototype]].y (無) -->
// b.[[Prototype]].[[Prototype]].y (有) - 20
// ここでは、 b.[[Prototype]] === B.prototype であり
// b.[[Prototype]].[[Prototype]] === A.prototype である
このアプローチには二つの特徴があります。
一つ目は、 B.prototype が x プロパティを持つことです。一見すると、これは正しくないように見えるかもしれません。 x プロパティは A にて自身のものとして定義されていて、これを継承する B コンストラクタのオブジェクトが、自身のものとして持つことを期待してしまいます。
しかし、プロトタイプ継承の場合、これは特に不思議の無い状況です。子オブジェクトが、もし自身のものとしてプロパティを持たなければ、それをプロトタイプに委譲します。この背景にあるのは、 B コンストラクタによって生成されたオブジェクトが x プロパティを持つ必要が無いという考え方です。これに対してクラスベースモデルでは、全てのプロパティは子クラスにコピーされます。
ただしもし、どうしても(クラスベースのアプローチを模倣して) x プロパティを自身のものとして B コンストラクタのオブジェクトに持たせたい場合には、いくつかのテクニックがあります。その人は後ほどご紹介します。
二つ目は、これは特徴と言うよりむしろ欠点なのですが、コンストラクタの(初期化)コードが、子プロトタイプを生成するときにも実行されてしまうと言うことです。これは、上記サンプルコードを実行した際に、 "A.[[Call]] activated" が二度表示されることに見て取れます。 A コンストラクタによって生成されたオブジェクトが B.prototype として使われると共に、もちろん a オブジェクトを生成するときも使われるのです!
より問題となる例としては、親コンストラクタで投げられる例外があります。おそらく、コンストラクタによって実際のオブジェクトを生成する際には、こうしたチェックが必要だと思います。しかし明らかに、このコストラクタから生成する親オブジェクトを子のプロトタイプとして使おうとすると、同じケースは受け入れがたいものになります。
function A(param) {
if (!param) {
throw '引数が必要です';
}
this.param = param;
}
A.prototype.x = 10;
var a = new A(20);
alert([a.x, a.param]); // 10 、 20
function B() {}
B.prototype = new A(); // エラー
さらに、親コンストラクタ中で重たい計算を行う場合も、このアプローチの欠点であると捉えることができるでしょう。
こうした「特徴」や問題を解決するために、今日のプログラマは下記のようなプロトタイプを結びつける標準的なパターンを用います。このトリックの主な目的は、必要とされるプロトタイプ同士を結びつける、中間ラッパーコンストラクタを生成することにあります。
function A() {
alert('A.[[Call]] activated');
this.x = 10;
}
A.prototype.y = 20;
var a = new A();
alert([a.x, a.y]); // 10 (自身のもの)、 20 (継承で)
function B() {
// あるいは単に A.apply(this, arguments) とする
B.superproto.constructor.apply(this, arguments);
}
// 継承:空の中間コンストラクタを使って
// プロトタイプ間を結びつける
var F = function () {};
F.prototype = A.prototype; // 参照
B.prototype = new F();
B.superproto = A.prototype; // 親プロトタイプへの明示的な参照のための「シュガー」
// コンストラクタプロパティを修正する
// そうしなければ A となってしまう
B.prototype.constructor = B;
var b = new B();
alert([b.x, b.y]); // 10 (自身のもの)、 20 (継承で)
b インスタンスに自身のプロパティ x を生成している過程に注目して下さい。親コンストラクタを、新しく生成されたオブジェクトのコンテキストの中で、 B.superproto.constructor 参照を通じて呼び出しています。
さらに、子プロトタイプの生成のために、親コンストラクタの不要な呼び出しが発生する問題も解決しています。このアプローチでは、 "A.[[Call]] activated" のメッセージは本当に必要な時しか表示されません。
プロトタイプチェーンを作るために同じこと(中間コンストラクタの生成、 superproto シュガーの設定、オリジナルの constructor の回復)を都度実行することを避けるために、このテンプレートは便利なユーティリティ関数にまとめることができます。目的は、具体的なコンストラクタの名前に関わらず、プロトタイプを結びつけられるようにすることです。
function inherit(child, parent) {
var F = function () {};
F.prototype = parent.prototype
child.prototype = new F();
child.prototype.constructor = child;
child.superproto = parent.prototype;
return child;
}
これによって継承は次のようになります。
function A() {}
A.prototype.x = 10;
function B() {}
inherit(B, A); // プロトタイプを結びつける
var b = new B();
alert(b.x); // 10 、 A.prototype 中に見つかる
こうしたラッパーには、シンタックス的に多くのバリエーションがあります。しかし、それらは全て上記のような作用に集約されると思います。
例えば、このラッパーを最適化し、中間コンストラクタを外に出すことで(従って生成されるのはたった一つの関数になることで)再利用できます。
var inherit = (function(){
function F() {}
return function (child, parent) {
F.prototype = parent.prototype;
child.prototype = new F;
child.prototype.constructor = child;
child.superproto = parent.prototype;
return child;
};
})();
オブジェクトの本当のプロトタイプは [[Prototype]] プロパティですから、中間ラッパーである F.prototype は簡単に変更でき、再利用しても問題ありません。なぜなら new F を通じて生成される child.prototype は、その生成時に [[Prototype]] を parant.prototype のその瞬間の値から受け取るからです。
function A() {}
A.prototype.x = 10;
function B() {}
inherit(B, A);
B.prototype.y = 20;
B.prototype.foo = function () {
alert("B#foo");
};
var b = new B();
alert(b.x); // 10 、 A.prototype に見つかる
function C() {}
inherit(C, B);
// そして "superproto" シュガーを使えば、
// 同名の親メソッドを呼び出すこともできる
C.ptototype.foo = function () {
C.superproto.foo.call(this);
alert("C#foo");
};
var c = new C();
alert([c.x, c.y]); // 10 、 20
c.foo(); // B#foo, C#foo
ES5 は、このユーティリティ関数をより良いプロトタイプ連結のために標準化しました。それが Object.create メソッドです。
ES3 においても、このメソッドの簡易化されたバージョンを次のように実装できるでしょう。
Object.create ||
Object.create = function (parent, properties) {
function F() {}
F.prototype = parent;
var child = new F;
for (var k in properties) {
child[k] = properties[k].value;
}
return child;
}
使用例
var foo = {x: 10};
var bar = Object.create(foo, {y: {value: 20}});
console.log(bar.x, bar.y); // 10 、 20
詳細はこの章(未訳)を参照ください。
今存在する「 JS におけるクラス的継承」の模倣のバリエーションは、全てこの方式に基づいています。見てきたように、実際これは「クラスベースの継承の模倣」などではなく、単なる「プロトタイプ同士を結びつけることによる、便利なコード再利用」なのです。
この章は、かなり長く、そして詳細にわたるものとなりました。この資料がみなさまの役に立ち、 ECMAScript に関する疑念を解消してくれることを望みます。質問や補足などあれば、いつもの通りコメント欄で議論できます(訳注:このブログにはコメント欄がありません。いつものように、私の個人ブログにいずれ転載しますので、そちらで質問をいただければと思います)。
英語版翻訳: Dmitry A. Soshnikov 、 と Garret Smith による補足[英語版].
英語版公開日時: 2010-03-04
オリジナルロシア語版: Dmitry A. Soshnikov [ロシア語版]
オリジナルロシア語版公開日時: 2009-09-12
本シリーズはすべて英語版からの訳出です。