KDE BLOG

Webデザインやコーディングについて書いています

【JavaScript基礎】プロトタイプチェーンについて

<目次>

プロパティを参照して見つからない場合どのような処理が行われるのか

オブジェクト内に存在しないプロパティを参照すると、すぐにundefinedを返さず、常にプロトタイプチェーンをたどって探します。
そのプロパティの探索の流れは下記になります。

  1. まず対象のオブジェクトのプロパティを参照
  2. プロパティが存在しなければ、そのオブジェクトのコンストラクタ関数のプロトタイププロパティを参照
  3. それも見つからなければ最終的にはObject()オブジェクトのプロトタイププロパティまで参照を繰り返す
  4. それでもない場合にundefinedを返す

言葉で説明するよりも実際に下記のコードを上から順に辿っていただけると理解がスムーズかもしれません。

const myString = 'Hello!';

// myString自体にもコンストラクタ関数にもpropプロパティはないためundefinedとなる
console.log(myString.prop); // undefined

// 最終地点のObjectオブジェクトのprototypeプロパティにpropプロパティを定義
Object.prototype.prop = 10;

// myString.prop → String.prototype.prop → Object.prototype.propを参照
console.log(myString.prop); // 10

// myStringの直上のコンストラクタ関数であるStringオブジェクトのprototypeプロパティを定義
String.prototype.prop = 5;

// myString.prop → String.prototype.propを参照
console.log(myString.prop); // 5

このようにオブジェクトのプロパティは、そのオブジェクトのコンストラクタ関数のprototypeプロパティとリンクしています。(暗黙の参照)
このリンクの連鎖のことをプロトタイプチェーンといいます。

プロトタイプチェーンを使うメリット

JavaScriptにはJavaC++などのプログラミング言語にはある「クラス」がなく「プロトタイプ」という概念があります。
プロトタイプとは「あるオブジェクトの元となるオブジェクト」という意味です。
JavaScriptではこれを利用して新たなオブジェクトを作っていくので、プロトタイプベースのオブジェクト指向と呼ばれます。

※ES2015(ES6)でクラス構文が導入されましたが、これはプロトタイプベースのシンタックスシュガーに過ぎずクラス自体が新たに定義されたわけではありません。

ECMAScript 6 で導入された JavaScript クラスは、JavaScript にすでにあるプロトタイプベース継承の糖衣構文です。クラス構文は、新しいオブジェクト指向継承モデルを JavaScript に導入しているわけではありません。JavaScript クラスは、オブジェクトを作成して継承を扱うためのシンプルで明確な構文を用意します。 (クラス - JavaScript | MDNより)

  • 共通のプロパティ(メソッド)を継承させることができ、無駄なメモリを消費せずに済む
  • 効率的なコードが書ける
  • JavaScriptをより深く理解するために必要となる

きっとそれ以外にもいろいろなメリットがあると思うのですが、まだ使いこなせていない自分は現段階ではこれ以上分かりません。

prototypeプロパティ

それではプロトタイプチェーンを実現しているprototypeプロパティとはなんなのでしょうか。 さまざまな型に対して参照してみます。

const myString = 'hello';
const myNumber = 10;
const myBoolean = true;
const myObject = {};
const myFunction = function(){};
const myArray = [];

console.log(
    myString.prototype, // undefined
    myNumber.prototype, // undefined
    myBoolean.prototype, // undefined
    myObject.prototype, // undefined
    myFunction.prototype, // Object {constructor: function}
    myArray.prototype    // undefined
);

関数オブジェクトに対してのみ参照することができました。中身はオブジェクトです。
もう少し掘り下げてみます。

console.log(
    String.prototype, // String {length: 0, constructor: function…}
    Number.prototype, // Number {constructor: function, toExponential:…}
    Boolean.prototype, // Boolean {[[PrimitiveValue]]: false, constructor:…}
    Object.prototype, // Object {__defineGetter__: function, __defineSetter__:…}
    Function.prototype, // function () { [native code] }
    Array.prototype, // [constructor: function, toString: function,…]
    RegExp.prototype, // Object {constructor: function, exec: function…}
    Error.prototype // Object {name: "Error", message: ""…}
);

組み込み関数のprototypeプロパティを参照すると型は違えど値が入っているのが確認できました。

調べたところ、prototypeプロパティはすべてのFunction()オブジェクトのインスタンスに自動的に付与されます
つまり新たに作成した関数オブジェクトにはすべてprototypeプロパティが存在しています。 その中身は空のオブジェクト(厳密にはconstructorプロパティを持つオブジェクト)です。
この関数がnew演算子インスタンス化されると、そのインスタンスのプロパティにコンストラクタ関数のprototypeプロパティのオブジェクトが継承されます。

const Super = function() {};
Super.prototype.prop = function() {
    console.log('Hello!');
}

// インスタンス化
const sub = new Super();

sub.prop(); // Hello!
console.log(sub.constructor === Super) // true

繰り返しになりますが、prototypeプロパティはFunction()インスタンスに付与されます。
結局それは先ほどの組み込み関数のprototypeプロパティが定義されていることに繋がるのだと思います。
(組み込み関数自体も関数なのでFunction()インスタンスであるから)

protoプロパティ

コンストラクタ関数のprototypeプロパティとそのオブジェクトインスタンスをつなぐリンクの役目を担っているのが__proto__という秘密のプロパティです(言語仕様書ではPrototypeと表記されているようです)。
別の言い方をすると、オブジェクトインスタンスのプロトタイプを表すプロパティです。
prototypeプロパティとは別物です。

__proto__には下記のような特徴があります。

  • __proto__はすべてのオブジェクトインスタンスが持つプロパティ
  • オブジェクトに対象のプロパティがなければ、__proto__の指すオブジェクトをたどる
  • __proto__の値がnullになったら探索は打ち切られる。Object.prototype.__proto__はnullのため、最終地点となる
  • new演算子インスタンス化したときに、コンストラクタ関数のprototypeプロパティのオブジェクトが__proto__プロパティへ代入される

プロパティを探すとき、最初のオブジェクトにプロパティがなかったら、そのオブジェクトを起点にして、__proto__.__proto__.__proto__…というようにObject.prototype.__proto__までたどります。

const myArray = [];
console.log(
  // すべてtrue 
    myArray.__proto__ === Array.prototype,
    myArray.__proto__.__proto__ === Object.prototype,
  myArray.__proto__.__proto__.__proto__ === Object.prototype.__proto__
);

protoプロパティは実際のコーディングで使用するのは要注意

内部プロパティである__proto__は、ES2015で標準になりましたが、それまでは環境によって実行できないこともあったようです。
現在も下位互換性の観点からあまり推奨されないようなので、使用の際は注意が必要かもしれません。

JavaScript で obj のプロトタイプを参照するには Object.getPrototypeOf(obj) を使用します。 逆に obj のプロトタイプとして proto を設定するには Object.setPrototypeOf(obj, proto) を利用します。
Google流 JavaScript におけるクラス定義の実現方法より)

__proto__プロパティが使用できない時、オブジェクトが継承するプロトタイプオブジェクトへのリンクをトレースするための一般的な方法は、constructorプロパティを使用することです。
下記にて冗長的な書き方になりますがアクセスできるのを確認できます。

const myArray = [];
Array.prototype.foo = 'foo';

// .constructor.prototypeを使用してfooの値をトレース
console.log(myArray.constructor.prototype.foo) // foo
// プロトタイプチェーンも使用できる
console.log(myArray.foo) // foo

プロトタイプチェーンは最初に見つけたプロパティを返す

対象のオブジェクトのプロパティが参照できない場合、Object.prototypeまでさかのぼって探索すると述べました。
つまりそれまでに見つけることができればそこで探索はストップして値を返します。
これはスコープチェーンと同様です。

const myArray = [];
Object.prototype.foo = 'Object foo!';
Array.prototype.foo = 'Array foo!';
myArray.foo = 'myArray foo!'

console.log(myArray.foo); // myArray foo!

この時、myArray.fooはArray.prototype.fooとObject.prototype.fooの値を「隠蔽」しているといいます。

prototypeプロパティに新しいオブジェクトを定義するときの注意

関数生成時にデフォルトのprototypeプロパティに新しいオブジェクトを定義すると、自動で設定されていたconstructorプロパティが消去されます。

// 通常
const MyFunc1 = function MyFunc1() {};
const instance1 = new MyFunc1();
console.log(instance1.constructor === MyFunc1); // true 

// 関数生成時にprototypeプロパティに新しいオブジェクトを定義
const MyFunc2 = function MyFunc2() {};
MyFunc2.prototype = {};
const instance2 = new MyFunc2();
console.log(instance2.constructor === MyFunc2); // false

オブジェクトインスタンスはコンストラクタ関数のprototypeプロパティのオブジェクトを継承するので、今回新たに定義された新しいオブジェクトを継承していることになります。
そのためconstructorプロパティにはObjectコンストラクタ関数が参照されます。

constructorプロパティをつなげ直す方法

prototypeプロパティに空のオブジェクトを定義せずに、constructorプロパティがしかるべきコンストラクタ関数を参照するように明記します。

const Super = function Super() {};
Super.prototype = { constructor: Super }; // 空のオブジェクトではなくconstructorプロパティを持ったオブジェクトを定義

const superInstance = new Super();
console.log(superInstance.constructor === Super); // true

ただこの方法だとconstructorプロパティがfor-inで列挙されてしまうという問題がありますので要注意です。
他の方法に関しては、下記リンクが参考になります。

プロトタイプからプロパティを継承するインスタンスは常に最新の値を取得

インスタンスはプロトタイプを経由して常に最新の値を取得することから、prototypeプロパティは動的であるといえます。

const Foo = function() {};
Foo.prototype.x = 1;

const fooInstance = new Foo();
console.log(fooInstance.x); // 1

Foo.prototype.x = 2;
console.log(fooInstance.x); // 2

delete Foo.prototype.x;
console.log(fooInstance.x); // undefined

prototypeプロパティを新しいオブジェクトに差し替えた場合、過去のインスタンスは更新しない

インスタンスオブジェクトは、インスタンス化された時点でのprototypeプロパティを参照し続けます。
インスタンス化後にprototypeプロパティを新しいオブジェクトに差し替えた場合、その新しいオブジェクトを参照しません。

const Foo = function() {};
Foo.prototype.x = 1;

const fooInstance = new Foo();

// prototypeプロパティを新しいオブジェクトに差し替え
Foo.prototype = {
    x: 2,
    y: 3
}

console.log(
    fooInstance.x, // 1
    fooInstance.y  // undefined
);

// 新しいインスタンスを生成
const fooInstance2 = new Foo();
console.log(
    fooInstance2.x, // 2
    fooInstance2.y  // 3
);

console.log(
    Foo.prototype, // Object {x: 2, y: 3}
    console.log(fooInstance.__proto__) // Object {x: 1, constructor: function}
);

インスタンス化後に新しいオブジェクトにprototypeプロパティを差し替えた場合、差し替え前のprototypeオブジェクトにアクセスするためには、あらかじめ変数に逃がしておくか、上記の最下部のようにインスタンスオブジェクトの__proto__プロパティを参照することでアクセスができます。

しかしインスタンスによってプロトタイプチェーンが異なることになり複雑になるので、インスタンス化後にコンストラクタ関数のprototypeオブジェクトを差し替えるのは避けるべきとされています。

プロトタイプ継承チェーン生成

これまでは単純な継承を見てきましたが、もう少し深い継承の場合はどのように記述するのでしょうか。
あるオブジェクトからあるオブジェクトに強制的に継承させるには、継承するprototypeプロパティを持つオブジェクトのインスタンスを、継承させるオブジェクトのprototypeプロパティの値に設定します。

簡単なコードを見てみます。

const Person = function() {
    this.bar = 'bar!';
}
Person.prototype.foo = 'foo!';

const Chef = function() {
    this.goo = 'goo!';
}
Chef.prototype = new Person();
const taro = new Chef();

console.log(
    taro.foo, // foo!
    taro.bar, // bar!
    taro.goo  // goo!
);

上ではPerson()コンストラクタ関数とChef()コンストラクタ関数を定義します。
Chef()コンストラクタ関数から生成したインスタンスがPerson.prototypeオブジェクトを継承するようにしたいので、Chef.prototypeオブジェクトにPerson()コンストラクタのインスタンスを代入しています。

taroオブジェクトの各プロパティへのアクセスを順を追ってみると理解がしやすいかと思います。

▼taro.fooを参照する流れ

  1. taroオブジェクト(Chef()インスタンス)のプロパティを参照
  2. taroオブジェクトのコンストラクタChef()のprototypeを参照
  3. Chef.prototypeはPerson()インスタンスのため、Person.prototypeを参照
  4. Person.prototype.fooで発見

▼taro.barを参照する流れ

  1. 上記の1と同じ
  2. 上記の2と同じ
  3. Chef.prototypeはPerson()インスタンスのため、chef.prototype.barで発見

▼taro.gooを参照する流れ

  1. 上記の1と同じ
    (chef()インスタンス生成の際にgooはそのプロパティとして生成されるためtaro.gooで発見)

下記は若干無理やりですが、もっと深く継承させてみました。

const Animal = function() {};

// 関数以外で、継承されたものを含む列挙可能なプロパティと値を配列で出力
Animal.prototype.output = function() {
    const result = [];
    for (let key in this) {
        if (typeof this[key] !== 'function') {
            result.push(key + ':' + this[key]); 
        }
    }
    console.log(result);
};

const Dog = function() {};
Dog.prototype = new Animal(); // Animalを継承
Dog.prototype.category = '犬';

const Shibaken = function(name, age) {
    this.name = name;
    this.age = age;
};

const Chiwawa = function(name, age) {
    this.name = name;
    this.age = age;
};

Shibaken.prototype = new Dog(); // Dogを継承
Shibaken.prototype.bark = 'ワンワン!';
Shibaken.prototype.dogType = '柴犬';

Chiwawa.prototype = new Dog(); // Dogを継承
Chiwawa.prototype.bark = 'キャンキャン!';
Chiwawa.prototype.dogType = 'チワワ';

// 出力
new Shibaken('ハチ', 3).output();
  // ["name:ハチ", "age:3", "bark:ワンワン!", "dogType:柴犬", "category:犬"]
new Chiwawa('チョコ', 1).output();
  // ["name:チョコ", "age:1", "bark:キャンキャン!", "dogType:チワワ", "category:犬"]

Shibaken()コンストラクタ関数とChiwawa()コンストラクタ関数のインスタンスは、Dog.prototypeオブジェクトとAnimal.prototypeオブジェクトの値を継承しています。

この流れを図に表してみました。 f:id:jinseirestart:20170619010311j:plain

この図は、「や…やっと理解できた!JavaScriptのプロトタイプチェーン - maeharin log」の記事内の図が大変分かりやすかったので、同じルールで書いたつもりです。(が、いかんせん自分用に残したものなので誤っていたり分かりにくいかもしれません)

ただ、実際はここまで継承をネストさせると複雑になってしまうので、実際はもっとネストを浅くするなり、ライブラリを使用するのが良さそうです。

多段階のプロトタイプ階層はJavaScriptが実装する継承手法です。ユーザ定義クラス D のプロトタイプを別のユーザ定義クラス B にすると多段階の階層構造が作れます。しかしこれらの階層構造を正しく把握しておくのは、最初の状態と比べるとかなり難しいことです。このため、継承にはClosure Libraryのgoog.inherits()のようなライブラリ関数を使うのが最善です。
多段階のプロトタイプ階層 - Google JavaScript スタイルガイド - 日本語訳より)

参考