JavaScriptのプロパティとプロトタイプ
2021/02/14更新
目次
プロパティの種類
データプロパティとアクセサプロパティ
オブジェクトが持つプロパティのうち、単に値の出し入れができるごく普通のプロパティのことをデータプロパティという。それに対し、常に内部的なアクセサ関数を通して値がセットされたり取り出されたりするプロパティをアクセサプロパティという。
アクセサプロパティに値を代入すると、あらかじめ用意されたセッター関数を通して処理されたものがセットされる。また、値を取り出そうとすると、あらかじめ用意されたゲッター関数を通して処理されたものが得られる。
アクセサプロパティを実装するには、オブジェクトリテラルでgetとsetを用いてゲッター関数とセッター関数を実装する。
const obj = {
// 代入されたら別のデータプロパティ_valueに値を保持
set hoge(value) {
this._value = value;
},
// 取り出されたら_valueを2倍にしたものを返す
get hoge() {
return this._value * 2;
}
};
// 代入すると2倍になるプロパティ
obj.hoge = 100;
console.log(obj.hoge); // 200
ゲッター関数とセッター関数は、どちらか片方だけを実装することもできるが、その場合は「代入できるけど取り出せない」または「取り出せるけど代入できない」プロパティとなる。他のプロパティの値に応じて自動的に計算値を返すプロパティなどを作りたい場合などに使える。
const obj = {
get norm() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
}
// xとyを代入すると原点からの距離を返すプロパティ
obj.x = 3;
obj.y = 4;
console.log(obj.norm); // 5
// セッター関数が無いので直接代入しても何も起こらない(strictモードだとエラー)
obj.norm = 10;
console.log(obj.norm); // 5
プロパティの属性
プロパティは単に名前と値を持つだけでなく、以下のように列挙可能性などの属性を持つ。
属性 | 意味 |
|---|---|
configurable |
|
enumerable |
|
writable |
|
通常のリテラル表記などで作るデータプロパティは、これらの属性は全てtrueである。属性を変更したプロパティを作りたい場合は、Object.defineProperty()を使う。
const obj = {};
// データプロパティの定義
Object.defineProperty(obj, 'hoge', {
value: 100, // このプロパティにセットする値
configurable: false,
enumerable: false,
writable: true
});
// アクセサプロパティの定義
Object.defineProperty(obj, 'fuga', {
get: function() { return this.hoge * 2; },
set: function(value) { this.hoge = value; },
configurable: false,
enumerable: false
});
console.log(obj.hoge); // 100
console.log(obj.fuga); // 200
Object.defineProperty()を使ってプロパティを定義する場合、configurable、enumerable、writableはいずれもデフォルトでfalseとなる。指定されたプロパティがすでに存在する場合は、そのプロパティの変更を試みる。
複数のプロパティを一度に定義するために、同様の関数Object.defineProperties()がある。
Symbolをキーとしたプロパティ
オブジェクトのキーであるプロパティの名前は従来文字列である必要があったが、ES2015で新しく登場したSymbolの値はオブジェクトのキーとして使うことができる。ただし、文字列を使った従来のプロパティと挙動が異なる部分があるため、注意が必要である。
Symbolをキーにしたプロパティは、一見列挙不可能なプロパティとほぼ同じ挙動に見えるが、Object.getOwnPropertyNames(obj)では通常の列挙不可能なプロパティが列挙されるのに対し、Symbolをキーにしたプロパティでは列挙の対象とならない。Symbolをキーにしたプロパティを列挙するにはObject.getOwnPropertySymbols(obj)を使う必要がある。
プロトタイプの基本
プロトタイプチェーンとは
JavaScriptでは、どんなオブジェクトもプロトタイプを持っている。プロトタイプの実体は別のオブジェクトへの参照である。各オブジェクトのプロトタイプは、Object.getPrototypeOf()で取得できる。__proto__という特別なプロパティを参照する方法もあるが、こちらは現在では非推奨である。
const obj = { hoge: 1234 };
// objのプロトタイプとなっているオブジェクトを取得
const protoObj = Object.getPrototypeOf(obj);
// 以下の方法は上記と同じだが、現在では非推奨
//const protoObj2 = obj.__proto__;
//console.log(protoObj === protoObj2); // true
あるオブジェクトのあるプロパティにアクセスしたとき、JavaScriptの内部で何が行なわれているかというと、まずそのオブジェクト自身にそのプロパティが定義されていないか探索が行なわれる。もし定義されていなかった場合、そのオブジェクトのプロトタイプのオブジェクトに同名のプロパティが定義されていないか探索される。それでも見つからなかった場合、さらにそのオブジェクトのプロトタイプを…といった具合に、プロトタイプがどんどん辿られていく。これをそのオブジェクトのプロトタイプチェーンと呼ぶ。
このようにプロトタイプを辿っていくと、通常は最終的にObject.prototypeに辿り着く。Object.prototypeには、toString()メソッドなどが定義されており、どんなオブジェクトでもtoString()が実行できる理由がここにある。
Object.prototypeのプロトタイプはnullであるため、これ以上のプロトタイプチェーンは存在しない。つまり、ここまで辿ってもプロパティが見つからなかったとき、初めてundefinedが返される。
また、このような仕組みから分かるように、自分自身を含むプロトタイプチェーンの中に同名のプロパティが複数定義されていた場合は、プロトタイプチェーンの浅い(自分自身に近い)方のプロパティが選択されることになる(いわゆるオーバーライド)。
プロトタイプのプロパティの判別
オブジェクトobjがプロパティpropを持っているかどうかは、通常、in演算子で確認できる。ただし、in演算子はプロトタイプチェーンを全て辿って確認するため、自分自身のプロパティかプロトタイプチェーンのプロパティかは区別できない。
そこで、プロトタイプチェーンではなく、自分自身に本当に備わっているプロパティだけを確認できるのがhasOwnProperty()である。こちらは、プロトタイプチェーンを辿らないとアクセスできないプロパティの場合はfalseを返す。
const obj = { hoge: 1234 };
// そのオブジェクト自身に備わっているプロパティ
console.log('hoge' in obj); // true
console.log(obj.hasOwnProperty('hoge')); // true
// プロトタイプチェーンに備わっているプロパティ
console.log('toString' in obj); // true
console.log(obj.hasOwnProperty('toString')); // false
プロパティを列挙する際は、プロトタイプチェーンのプロパティも含めるのかどうかに注意する必要がある。昔から使われているfor-in文では、プロトタイプチェーンを含めた列挙可能プロパティが列挙される。Object.keys(obj)を使うと、自分自身の列挙可能プロパティのみが列挙される。さらに、Object.getOwnPropertyNames(obj)というものもあり、こちらは列挙不可能プロパティも含めた自分自身のプロパティが列挙される。
方法 | 自分自身のプロパティ | プロトタイプチェーンのプロパティ | ||
|---|---|---|---|---|
列挙可能 | 列挙不可能 | 列挙可能 | 列挙不可能 | |
|
|
|
|
|
|
|
|
|
|
| 列挙される | 列挙されない | 列挙される | 列挙されない |
| 列挙される | 列挙されない | 列挙されない | 列挙されない |
| 列挙される | 列挙される | 列挙されない | 列挙されない |
プロトタイプと継承
関数オブジェクトには、必ずprototypeという名前のプロパティがある。ここにセットされているオブジェクトは、その関数をクラスとしてnewで生成したインスタンスがプロトタイプとして参照するものとなる(関数オブジェクト自身のプロトタイプではないことに注意)。つまり、ここにプロパティ(メソッドなど)を定義すれば、newで生成する全てのインスタンスが、プロトタイプチェーンの一部として参照できることになる。
// MyFuncクラス
const MyFunc = function() { ~ };
MyFunc.prototype.myMethod = function() { ~ }; // prototypeにメソッドが定義されている
// MyFuncクラスのインスタンス
const myObj = new myFunc();
// myObjのプロトタイプは、MyFunc.prototypeである。
console.log(Object.getPrototypeOf(myObj) === MyFunc.prototype); // true
// myObjは、prototypeに定義したメソッドを使用できる。
console.log(myObj.myMethod());
// MyFunc.prototypeは、MyFunc自身のプロトタイプではない。
console.log(Object.getPrototypeOf(MyFunc) === MyFunc.prototype); // false
この関係は、逆も成り立つ。つまり、あるオブジェクトmyObjのプロトタイプが、関数MyFuncのprototypeプロパティのオブジェクトと一致するとき、「myObjはMyFuncクラスのインスタンスである」と言える。
さらに、MyFunc.prototypeのプロトタイプチェーンのいずれかが、別の関数MyParentFuncのprototypeプロパティのオブジェクトと一致するとき、「MyFuncはMyParentFuncのサブクラスである」と見なされる。
instanceofはこのことを確認できる演算子である。a instanceof Bは、aのプロトタイプチェーンのどこかにB.prototypeと一致するものがあるかどうかを判定する。B.isPrototypeOf(a)も同様の機能を持つ関数で、Bがaのプロトタイプチェーンに含まれているかどうかを判定する。
// MyFuncクラス
const MyFunc = function() { ~ };
// MyParentFuncクラス
const MyParentFunc = function() { ~ };
Object.setPrototypeOf(MyFunc.prototype, MyParentFunc.prototype); // ※
// MyFuncクラスのインスタンス
const myObj = new MyFunc();
// myObjのプロトタイプは、MyFunc.prototype
if (Object.getPrototypeOf(myObj) === MyFunc.prototype) {
// …なので、myObjはMyFuncクラスのインスタンス
// 以下は全てtrue
console.log(myObj instanceof MyFunc);
console.log(MyFunc.prototype.isPrototypeOf(myObj));
}
// MyFunc.prototypeのプロトタイプは、MyParentFunc.prototype
if (Object.getPrototypeOf(MyFunc.prototype) === MyParentFunc.prototype) {
// …なので、MyFuncクラスはMyParentFuncクラスのサブクラス
// 以下は全てtrue
console.log(myObj instanceof MyParentFunc);
console.log(MyFunc.prototype instanceof MyParentFunc);
console.log(MyParentFunc.prototype.isPrototypeOf(myObj));
console.log(MyParentFunc.prototype.isPrototypeOf(MyFunc.prototype));
}
上記のようにMyFuncをMyParentFuncのサブクラスにしたい場合に、MyFunc.prototypeのプロトタイプを設定する際は注意が必要である(上記の※の部分)。従来より、以下のようなコードがさまざまなところで使われているのを見かけるが、それぞれ意味が異なるので、正しく理解する必要がある。
// 1. 親クラスのインスタンスを代入 MyFunc.prototype = new MyParentFunc(); // 2. 親クラスのprototypeを丸ごと代入 MyFunc.prototype = MyParentFunc.prototype; // 3. Object.create()を使用 MyFunc.prototype = Object.create(MyParentFunc.prototype); // 4. Object.setPrototypeOf()を使用 Object.setPrototypeOf(MyFunc.prototype, MyParentFunc.prototype); // 5. 親クラスのprototypeを__proto__へ代入 MyFunc.prototype.__proto__ = MyParentFunc.prototype; const myObj = new MyFunc();
結論を先に言うと、現時点で、少なくとも言語仕様上最も適切なのは、4.のsetPrototypeOf()を使う方法である。まず、それぞれの方法について、プロトタイプチェーンがどうなるかを見てみると、以下のようになる。
prototypeの継承方法 |
|
| |
|---|---|---|---|
1 | インスタンスを代入 |
|
|
2 | prototypeを丸ごと代入 |
| |
3 | Object.create()を使用 | 空オブジェクト |
|
4 | setPrototypeOf()を使用 |
|
|
5 | __proto__へ代入 |
|
|
これに基づいて、挙動の違いに注目すると、以下のようになる。
|
|
| 互換性 | |
|---|---|---|---|---|
1 |
| コンストラクタのプロパティが呼ばれる |
| 問題なし |
2 |
|
|
| 問題なし |
3 |
|
|
|
|
4 |
|
|
|
|
5 |
|
|
|
|
1、2、3ではいずれもMyFunc.prototypeに直接別のオブジェクトを代入しているため、constructorが適切に設定されているMyFunc.prototypeの初期オブジェクトが上書きされてしまう。そのため、myObj.constructorが、本来MyFuncとなるべきところ、プロトタイプチェーンを辿ってMyParentFuncとなってしまう。ただ、3に限って言えば、自力でconstructorを設定し直して結果的に4や5と同じにすることもできるが、本来のconstructorは列挙不可能なプロパティであるため、Object.defineProperty()を使って適切に設定しないと、for-in文で列挙されたりしてしまうので注意が必要である。
1の方法では、MyParentFuncのインスタンスがプロトタイプとなるため、MyParentFunc.prototypeのメソッドだけでなく、MyParentFuncのコンストラクタで作られたメソッドなどにもアクセスできてしまう。
2の方法では、プロトタイプチェーンが1個短くなり、myObjのプロトタイプが直接MyParentFunc.prototypeとなる。サブクラスではなく、エイリアスのようなものを作りたい場合などは有効であるが、さもないとMyFunc.prototypeに施した変更がそのままMyParentFunc.prototypeにも反映されてしまい、危険である。
5の方法は、結果としては4と同じであるが、__proto__の使用は現在では非推奨とされている。
以上より、現時点での言語仕様上は、4のObject.setPrototypeOf()を使う方法が最も適切なプロトタイプの設定方法と言える。ただ、MDNのsetPrototypeOf()の解説ページを読むと、この方法はパフォーマンスの低下を引き起こす可能性があると書かれており、その場合は、3のObject.create()を使って自前でconstructorを設定する方が良いとされている。
クラス構文による継承
ES2015ではクラス構文が登場し、前述のようなややこしいプロトタイプのことを意識しなくてもextendsキーワード一つで継承が簡単にできるようになった。
// 親クラス
class MyParentFunc {
constructor() {
// コンストラクタ
}
myParentMethod() {
// インスタンスメソッド
}
}
// extendsで継承
class MyFunc extends MyParentFunc {
constructor() {
// コンストラクタ
super();
}
myMethod() {
// インスタンスメソッド
}
}
// MyFuncのインスタンス
const myObj = new MyFunc();
// 従来通り以下は全てtrue
console.log(Object.getPrototypeOf(myObj) === MyFunc.prototype);
console.log(Object.getPrototypeOf(MyFunc.prototype) === MyParentFunc.prototype);
console.log(myObj instanceof MyFunc);
console.log(myObj instanceof MyParentFunc);
newを使わずにインスタンスを作る
例えば、jQueryの$関数は、newを使わずに$('#id')と通常の関数呼び出しをするだけで、$のインスタンスが得られる。プロトタイプの仕組みを理解すれば、このようなことも簡単にできる。
// 内部で使う実際のクラス
const _dummy = function(arg) {
// コンストラクタ
};
_dummy.prototype.myMethod = function() {
// インスタンスメソッド
};
// 外部へ公開するクラス
const MyFunc = function(arg) {
// _dummyクラスのインスタンスを返す。
return new _dummy(arg);
};
// MyFuncを_dummyのエイリアスにする。
MyFunc.prototype = _dummy.prototype;
// new無しの「MyFunc()」で、MyFuncのインスタンスが作れる。
const myObj = MyFunc(arg);
console.log(myObj instanceof MyFunc); // true
これはjQueryで昔から使われている方法であるが、結局内部ではnewを使うため、内部でもう一つダミーのクラスが必要になる。ちなみに、MyFuncの中にnew MyFunc()を書くことは当然できない。無限ループとなってしまうので、注意が必要である。
Object.create()は、コンストラクタを呼ばずにインスタンスを作成できるため、これを使うと、1つのクラスだけで実現できる。
const MyFunc = function(arg) {
// Object.create()でMyFuncのインスタンスを作成
const obj = Object.create(MyFunc.prototype);
// インスタンスプロパティの定義など
obj.myProp = 1234;
return obj;
};
MyFunc.prototype.myMethod = function() {
// インスタンスメソッド
};
// new無しの「MyFunc()」で、MyFuncのインスタンスが作れる。
const myObj = MyFunc(arg);
console.log(myObj instanceof MyFunc); // true