NHN Cloud Meetup 編集部
JavaScriptのプロトタイプチェーンの紹介
2019.06.17
1,084
JavaScriptはオブジェクト指向言語であり、関数を第一級オブジェクトとして扱う関数型プログラミングも可能なマルチパラダイムプログラミング言語です。JavaScriptを少しでも扱ったことがある方なら既知の内容ですが、JavaScriptにはクラスという概念がなく、オブジェクトの作成や継承が他の言語と違って、特にOOPは主にプロトタイプとメカニズムを用いて行われます。ここではプロトタイプで継承を実装する重要なメカニズムであるプロトタイプチェーンについて紹介したいと思います。
オブジェクト
JavaScriptでオブジェクトを作成する方法は2つあります。オブジェクトリテラル、あるいはコンストラクタを使用します。
var objectMadeByLiteral = {}; var objectMadeByConstructor = new Object();
リテラルはObject型のオブジェクトを作成する一種のショートカットで、2行目のコンストラクタを利用したコードもObjectコンストラクタです。そのため上記の例では、リテラルまたはコンストラクタともオブジェクトの内容やプロトタイプの構造面で同じオブジェクトを作り出します。両方のオブジェクトでObject型のメソッドであるhasOwnProperty, toString, valueOfなどを使用できます。Object型はすべてのオブジェクトの最上位タイプです。他のオブジェクト指向言語の観点からみると、上記のコードは、Object型のインスタンスを作成したに過ぎず、継承されたと表現するのは難しいでしょう。しかし、JavaScriptでは少し異なる概念で考えなければなりません。先ほど作成したオブジェクトがObject型のインスタンスであることも事実ですが、プロトタイプを用いた継承をサポートするJavaScriptでは、Objectコンストラクタのプロトタイプを継承したオブジェクトと表現するのが正確でしょう。継承という表現もOOPの観点から使用される単語で表現しただけで、実際にはリンクリスト形態の参照を用いたオブジェクト同士の連結に近く、クラスメカニズムのように静的ではなく非常に動的です。このような動的なリンクが良いという意味ではありません。継承構造の変更が言語やエンジンのレベルで何の制約もないため、コンベンションの規則やアンチパターンについての理解がなく、正しく使用しなかった場合は不具合の温床となるでしょう。
プロトタイプ
プロトタイプを使うと、オブジェクトとオブジェクトを連結して一方に継承する形を作ることができます。JavaScriptでオブジェクトとオブジェクトを連結して継承するということは、オブジェクトとオブジェクトを連結し、メンバ関数やメンバ変数を共有するという意味です。このような点を利用して、JavaScriptでは継承と同様の効果が得られます。
var a = { attr1: 1 } var b = { attr2: 2 }
aとbという名前のオブジェクトがあります。aはattr1というメンバ変数が、bにはattr2というメンバ変数があります。この状態ではbオブジェクトからaのattr1属性にアクセスする方法がありません。bオブジェクトでも、aのattr1
を所有しているように使用したい場合、__proto__
という特殊な属性を使用します。
var a = { attr1: 'a1' } var b = { attr2: 'a2' } b.__proto__ = a; b.attr1 // 'a1'
JavaScriptエンジンは、オブジェクトをキーと値を持つハッシュマップのように扱います。値にはデータと関数を入れることができ、エンジン内部に必要なデータを任意に作成して入れたりもできます。もちろん、それをJavaScriptで表示したり、表示させなかったりもできます。プロトタイプチェーンの中核は、エンジンが使用する__proto__という属性です。__proto__属性は、ECMAScriptのスペック[[Prototype]]がJavaScriptに公開されたもので、以前のスペックがlegacyのように残っています。モダンブラウザ開発ツールでもデバッグの便宜上、表示していますが、開発コードから直接アクセスすることは避けるべきでしょう。__proto__が参照するオブジェクトを確認する状況(例えばフレームワーク開発)では、__proto__属性ではなく、Object.getPrototypeOf()
を使用してみましょう。bの__proto__属性がaオブジェクトを参照しています。これは、aのメンバ変数やメソッドに一部制限がありますが、あたかもbが所有しているかのように使用できるという意味です。この過程で、継承と同様の効果が得られます。別のクラスを用いた継承の場合、クラスの継承情報を利用して、継承構造を持った新しいオブジェクトを取り出す一方、プロトタイプによる継承構造は存在するオブジェクトと存在するオブジェクトの動的な連結を解除します。そのため、すでにオブジェクトが作成された状態であっても、継承された内容が変更されたり、追加されたりして継承構造を変えることもできます。もちろん大半はアンチパターンです。
var a = { attr1: 'a1' } var b = { attr2: 'a2' } b.__proto__ = a; b.attr1 // 'a1' a.attr1 = 'a000'; // 継承したオブジェクトの内容を変更 b.attr1 // 'a000' a.attr3 = 'a3' // 継承したオブジェクトの内容を追加 b.attr3 // 'a3' delete a.attr1 // 継承したオブジェクトの内容を削除 b.attr1 // undefined
一般的なクラスの概念では想像もできないことですね。この方法の良し悪しは別として、JavaScriptではこのようにしかできません。実際にこのような性質を利用してクラスのメカニズムを模倣するのは比較的簡単ですが、クラス基盤の言語がプロトタイプのメカニズムを模倣するのは非常に難しいでしょう。オブジェクトとオブジェクトの連結からの一方向の共有関係をプロトタイプチェーンといいます。このような連結を直接コードから__proto__にアクセスせずに作成する方法はいくつかあります。まず、プロトタイプを使用して識別子を検索する過程を見てみましょう。
プロトタイプの識別子lookup
JavaScriptで識別子をlookupする方法には2種類のメカニズムがあります。プロトタイプlookupとスコープlookupですが、ここではプロトタイプチェーンを用いて識別子を検索するプロトタイプlookupについて考えます。プロトタイプlookupと呼ばれる過程はクラスの継承と比較される特徴の1つです。簡単に言えば、クラスの継承はオブジェクトを作成した時点で、当該オブジェクトが継承構造によってどのようなメンバを保有するか決定するのに対し、プロトタイプチェーンによる継承の場合は、実行した時点でオブジェクトが当該メンバを保有します。つまりJavaScriptエンジンの観点ではメソッドを実行するときに動的に対応するメソッドを探して実行します。そのため、すでに作成されたオブジェクトの継承内容が変更されることもあります。オブジェクトが作成されるときではなく、実行するときの内容が重要だからです。このようにプロトタイプチェーンを通じてオブジェクトのメソッドや属性を検索する過程をプロトタイプlookupといいます。
var a = { attr1: 'a' }; var b = { __proto__: a, attr2: 'b' }; var c = { __proto__: b, attr3: 'c' }; c.attr1 // 'a'
上記のコードでは、オブジェクトを3つ作成し、各オブジェクトの__proto__属性を利用して、c -> b-> aに連結しました。c.attr1にcオブジェクトにはないattr1という属性にアクセスすると、JavaScriptエンジンは次のようなタスクを実行します。(もちろんエンジンの最適化によってステップは縮小されます)
- cオブジェクト内部にattr1
属性を探す – >ない
- cオブジェクトに__proto__属性が存在するか確認する – >ある
- cオブジェクトの__proto__属性が参照するオブジェクトに移動する – > bオブジェクトに移動
- bオブジェクト内部にattr1
属性を探す – >ない
- bオブジェクトに__proto__属性が存在するか確認する – >ある
- bオブジェクトの__proto__
属性が参照するオブジェクトに移動する – > aオブジェクトに移動
- aオブジェクト内部にattr1
属性を探す – >ある
- 見つかった属性の値を返す
単純に表現すると、__proto__の連結に従ってリンクリストを参照するように移動し、目的のキー値を求めます。ここでどのオブジェクトにも存在しない属性であるattr0が見つかると、ステップ7から別の過程を辿ることになります。
(ステップ7から)
8. aオブジェクト内部にattr0属性を探す – >ない
9. aオブジェクトに__proto__属性が存在するか確認する – >ある
10. aオブジェクトの__proto__属性が参照するオブジェクトに移動する – > Object.prototypeに移動
11. Object.prototypeでattr0属性を探す – >ない
12. Object.prototypeで__proto__属性を探す – >ない
13. undefinedを返す
プロトタイプチェーンの終わりは、常にObject.prototypeです。そのためObject.prototypeは__proto__
属性がありません。attr0
というプロパティはプロトタイプの最終段階であるObject.prototypeには存在しません。また、Object.prototype
には__proto__
属性が存在しないのでナビゲーションを終了してundefinedを返します。ChromeとNode.jsのJavaScriptエンジンであるV8は、この過程を最適化して検索コストを削減しパフォーマンスを向上させました。単一リンクリストの形態で一方向の連結であるため、継承という概念を大方適用できます。cからaの属性はアクセスできますが、aからcの属性はアクセスできません。
var a = { attr1: 'a' }; var b = { __proto__: a, attr2: 'b' }; var c = { __proto__: b, attr3: 'c' }; a.attr3 // undefined
このような点を用いてメソッドのオーバーライドを実装できます。
var a = { method1: function() { return 'a1' } }; var b = { __proto__: a, method1: function() { return 'b1' } }; var c = { __proto__: b, method3: function() { return 'c3' } }; a.method1() // 'a1' c.method1() // 'b1'
cオブジェクトでmethod1()メソッドを実行すると、プロトタイプのlookupを通じてcからbに移動し、bには既にそのメソッドがあるため、aまで上昇せず、bのmethod1()が実行されます。JavaScriptではこのような状況を、aのmethod1()メソッドをbがオーバーライドしたといいます。このようなプロトタイプを用いてオブジェクトを作成する方法を見てみましょう。
コンストラクタ
コンストラクタを利用してオブジェクトを作成すると、作成されたオブジェクトは、コンストラクタのプロトタイプオブジェクトとプロトタイプチェーンに接続されます。ここではコンストラクタの基礎的な内容は扱わず、プロトタイプチェーンについて説明します。
//constructor function Parent(name) { this.name = name; } Parent.prototype.getName = function() { return this.name; }; var p = new Parent('myName');
非常に簡単なコンストラクタの例です。Parentというコンストラクタが作り出すオブジェクトは、pはnameという属性を保有し、getNameというプロトタイプメソッドを使用できます。pオブジェクトがParentのプロトタイプメソッドにアクセスできる理由は、pオブジェクトの__proto__属性がParent.prototypeを参照しているからです。この過程は、コンストラクタをnewキーワードと一緒に使用するとき、エンジン内部で連結します。実際のエンジンは以下のように動作します。
var p = new Parent('myName'); // エンジン内部で行うこと p = {}; // 新しいオブジェクトを作成して Parent.call(p, 'myName'); // callを利用してParent関数のthisをpに代わり実行して p.__proto__ = Parent.prototype; // プロトタイプを連結する p.getName(); // 'myName'
上記のコードは、コンストラクタが作り出すオブジェクトがどのようにprototypeと連結するかを表しています。上の過程からプロトタイプlookupのとき、p -> Parent.prototypeの検索過程を作成できます。現在のコードでは、Object型を除き、意図的に実装された継承であるという概念はまだありません。Parentタイプを継承するChildタイプというタイプを作成する場合、どうすればよいでしょうか?プロトタイプチェーンの連結構造は、Childのインスタンス- > Child.prototype – > Parent.prorotypeのような連結構造を作成する必要があります。Childのインスタンス- > Child.prototypeへの連結は、上記コードのParentの場合と変わりません。重要なのは、どのようにChild.prototypeとParent.prototypeを連結するかです。この過程はプロトタイプを理解する上で最も曖昧な部分ですが、これを理解すれば数段階の継承構造も簡単に作ることができます。
Object.create();
子プロトタイプと親プロトタイプの連結は、最終的にオブジェクトとオブジェクトを連結することを意味します。方法としては2つあります。1つ目は旧式で、2つ目は標準的なAPIを利用した新しい方式です。標準APIがあるのに、なぜ旧式が残っているのか不思議に思うかもしれませんが、標準APIに対するブラウザのサポートが十分ではないことがその理由です(IE9以上で対応)。標準APIはObject.create()です。Object.create()
はオブジェクトを引数として受け取り、そのオブジェクトとプロトタイプチェーンに連結している新しいオブジェクトを返却します。先で説明した__proto__
の例に戻って説明しましょう。
var a = { attr1: 'a' }; var b = { __proto__: a, attr2: 'b' };
上記のコードは、__proto__を通じてプロトタイプが連結している過程を説明するために作成したもので、実際には使用してはいけないコードです。Object.create()
を使って__proto__
プロパティに直接アクセスせずに、プロトタイプチェーンを連結することができます。
var a = { attr1: 'a' }; var b = Object.create(a); b.attr2 = 'b';
Object.create()から__proto__
がaを参照する新しい空のオブジェクトを返します。bを参照して、b.attr1のような手法でaオブジェクトのメンバにもアクセスできます。上記のコードは、Object.create()
がなくても実装できます。Object.create()
のようなAPIが生じるのも、このようなヒントやトリックに着目したものと考えられます。
var a = { attr1: 'a' }; function Ghost() {} Ghost.prototype = a; var b = new Ghost(); b.attr2 = 'b';
Object.create()は上記のコードと変わりがありません。Ghostという代理(あるいは一時的)コンストラクタを作成してprototypeがaを参照させた後、オブジェクトを作成すると、__proto__
がaを参照しているGhostのインスタンスが作成されます。bがGhostのインスタンスという他にもObject.create()
が作り出したオブジェクトと大差ないですね。Ghostのインスタンスと呼ばれるように、b.constructorの参照をオブジェクトに変えて隠すことができますが、このような冗長プロセスよりはObject.create()
を使用する方が正しい選択でしょう。おそらく今までの内容だけでは、子プロトタイプと親プロトタイプが連結するだけで、思考がつながらないですね。ここでプロトタイプ同士を連結してみましょう。
function Parent(name) { this.name = name; } Parent.prototype.getName = function() { return this.name; }; function Child(name) { Parent.call(this, name); this.age = 0; } Child.prototype = Object.create(Parent.prototype); // (1) Child.prototype.constructor = Child; Child.prototype.getAge = function() { return this.age; }; var c = new Child(); // (2)
(1)でObject.create()を使ってChildのprototypeオブジェクトを交換しました。(1)で作成された新しいオブジェクト、つまりChild.prototypeは__proto__
属性がParent.prototype
を指すようになり、(2)でChildのインスタンスcの__proto__がChild.prototypeを参照することになります。このようにして、c -> Child.prototype -> Parent.prototypeに連結したプロトタイプが作られ、プロトタイプlookupのとき、私たちが意図した検索経路で識別子を探すようになります。さらにChildコンストラクタでParentコンストラクタを借りてオブジェクトを拡張する部分は、コンストラクタを借りて送信するという古い手法で、JavaScriptにおける継承では、親コンストラクタを実行する唯一の方法です。このようにして親タイプのコンストラクタの内容も継承します。
Object.create() が使用できない環境下では、先ほど扱った手法を用いる必要があります。
function Parent(name) { this.name = name; } Parent.prototype.getName = function() { return this.name; }; function Child(name) { Parent.call(this, name); this.age = 0; } // diff start function Ghost() {}; Ghost.prototype = Parent.prototype; Child.prototype = new Ghost(); // diff end Child.prototype.constructor = Child; Child.prototype.getAge = function() { return this.age; }; var c = new Child();
変更したコードの範囲はコメントに表現しました。コードだけをみると、Ghostと一時的なコンストラクタではなく、Parentコンストラクタを使用すればよいと考えられます。しかし、その方法はコンストラクタが生み出す属性のため使用できません。すなわちParentコンストラクタをGhostの代わりに使用した場合、Parent
コンストラクタを通じて作られたオブジェクトは、nameという属性を持つことになります。このオブジェクトをChildのプロトタイプとして使用すると、nameという属性をChildインスタンスが同じ内容で共有することになりますが、コンストラクタで作成された属性は共有するのではなく、インスタンス別に所有する必要があります。インスタンス別にname属性を作成するためにChildコンストラクタの中でParent
コンストラクタのコンストラクタを借りて書き込みを実行しました。Ghostのような一時的なコンストラクタを使用する理由は、純粋にプロトタイプチェーンだけが連結した空のオブジェクトを取得するためです。
ES6
Javascriptで継承が行われる概念は簡単ですが、それを実装するコードはかなり冗長化しています。Object.create()を使用しても冗長されるのは同じで、これを補完するためにECMAScript6でclassスペックが追加されました。クラスとはいえ新しい概念ではなく、継承の実装原理は、従来と同じ内容で冗長されたコードを簡潔にするショートカットが追加されたと考えるとよいでしょう。例のParent
とChildはES6のclass
を利用すると、以下のように使用できます。
class Parent { constructor(name) { this.name = name; } getName() { return this.name; } } class Child extends Parent { constructor(name) { super(name); // コンストラクタの代わりに....super関数を使用する this.age = 0; } getAge() { return this.age; } }
コードが簡潔になって理解しやすくなりましたね。コードが変更しても、これによって作られたオブジェクトのプロトタイプチェーンの連結構造は従来と同じで、同じ方式のプロトタイプlookupで識別子を検索します。
まとめ
この記事では、プロトタイプチェーンの概要とプロトタイプlookupを使った識別子の検索方法、プロトタイプチェーンの作成方法について紹介しました。プロトタイプチェーンの曖昧な部分を理解する上で少しでも役に立てれば幸いです。最後に扱ったクラスはES6スペックに対するブラウザサポートが低いですが、Babelを利用して変換すると、ES6をサポートしていないブラウザでも対応できます。