継承は、クラスベースのオブジェクト指向における基本的な概念のひとつであると信じられています。JavaScriptにもES2015以降はclass
構文があり、extends
を用いてクラスの継承を記述することができます。また、それより以前もprototype
を通じてオブジェクト指向的なプログラムが書かれてきました。
この記事では、JavaScriptにおける「継承」がどのようなものであり、どのように定義されるのかを解説します。タイトルにある通り、今回はECMAScript仕様書に対する解説を中心とします。
仕様書はJavaScriptというプログラミング言語がどのようなものかを定義する文書であり、あなたが書いたJavaScriptプログラムは仕様書に書かれた通りの動きをすることになります1。したがって、たとえあなたが自分自身で書いたものだったとしても、JavaScriptプログラムの意味を完全な確信を持って理解したいならば、仕様書を読んで理解することがその唯一の手段なのです。
今回は継承というトピックを取り上げつつ、仕様書がJavaScriptという言語をどのように定義するのかの一端を理解するための、仕様書入門のような記事を目指しました。仕様書というだけで臆することなく、この記事を頼りに果敢に仕様書リーディングに挑戦してみましょう。
ウォーミングアップ:prototype
と継承
仕様書を読み始める前に、JavaScriptにおいて継承という機構がそもそもどのように実現されているのかを解説しておきます。もう知っているという方は次の節に進んでも構いません。
インスタンスの作成とinstanceof
JavaScriptにはinstanceof
という演算子があります。これは、あるオブジェクトがあるクラスのインスタンスであるかどうかを判定する演算子です。
class MyClass {}
const normalObj = {};
const myClassObj = new MyClass();
console.log(normalObj instanceof MyClass); // false
console.log(myClassObj instanceof MyClass); // true
逆に、あるクラスのインスタンスを作る方法は、このプログラムにもあるようにnew
を使うことです。
なお、上のプログラムではclass
構文を用いてクラスを宣言しましたが、これはES2015以降で使用できる方法です。それ以前はただの関数をクラスとして扱っていました。この関数はコンストラクタとして扱われ、new
されるとその関数が呼ばれます。
function MyClass() {}
const myClassObj = new MyClass();
console.log(myClassObj instanceof MyClass); // true
prototype
によるインスタンスの特徴付け
クラス定義にはメソッドを含めることができます。クラス定義に書かれたメソッドは、当然ながらインスタンスから利用可能です。しかしながらhasOwnPropertyメソッドで調べると、インスタンスにそんな名前のプロパティは無いという結果になります。
class MyClass {
method() {
console.log("hi");
}
}
const obj = new MyClass();
obj.method(); // "hi" と表示される
console.log(obj.hasOwnProperty("method")); // false と表示される
これはやや難しいところですが、我々にオブジェクトのプロパティ・メソッドとして見えるものは2種類あります。ひとつはオブジェクト自身のプロパティ、もうひとつはオブジェクトのプロトタイプ由来のプロパティです。{ foo: 123 }
とかobj.bar = 456
といった方法で宣言されるのはオブジェクト自身のプロパティである一方、クラスにて宣言されたメソッドはプロトタイプ由来のプロパティとなります。そして、hasOwnProperty
は前者に対してのみtrue
を返すのです。
つまるところ、JavaScriptのオブジェクトは「連想配列 + プロトタイプ」として説明できます2。そして、JavaScriptのオブジェクト指向的側面を支えるのがプロトタイプなのです。昔を知っている方は「prototype
なんてES2015が出てお役御免になったでしょ?」とお思いかもしれませんが、class
構文の裏を支えるのもやはりプロトタイプの機構です。
オブジェクトのプロトタイプは、何か別のオブジェクトです(無い場合もあります)。プロトタイプの機構は非常に単純なもので、あるオブジェクトが自身が持たないプロパティにアクセスされたとき、次にプロトタイプを探しに行くのです。
あるオブジェクトのプロトタイプを取得する手段がObject.getPrototypeOfです。MyClass
のインスタンスの場合、そのプロトタイプはMyClass.prototype
になります。
class MyClass {
method() {
console.log("hi");
}
}
const obj = new MyClass();
console.log(Object.getPrototypeOf(obj) === MyClass.prototype); // true
そして、obj.method
として呼び出せるものは実はMyClass.prototype.method
なのです。method
はプロトタイプ由来のプロパティだったことになります。
console.log(obj.method === MyClass.prototype.method); // true
プロトタイプ関連のメソッド
先ほどObject.getPrototypeOf
を紹介しましたが、他にも関連メソッドがあります。ひとつはisPrototypeOfです。これはオブジェクトが持つプロパティ(Object.prototype
に存在)であり、自分自身が与えられたオブジェクトのプロトタイプであるかどうかを判定します。例で見ると分かりやすいでしょう。
console.log(MyClass.prototype.isPrototypeOf(obj)); // true
つまるところ、これはobj instanceof MyClass
と同じ意味となります。
もうひとつはObject.setPrototypeOfです。これは、既に存在するオブジェクトのプロトタイプをあとから書き換えることができるというたいへん強力な(そして遅い)メソッドです。
例えば、ただのオブジェクトである{}
を、Object.setPrototypeOf
を使ってあとからMyClass
のインスタンスにできます。
class MyClass {
method() {
console.log("hi");
}
}
const obj = {};
console.log(obj instanceof MyClass); // false
console.log(obj.method); // undefined
Object.setPrototypeOf(obj, MyClass.prototype);
console.log(obj instanceof MyClass); // true
obj.method(); // "hi" と表示される
Object.setPrototypeOf
を使う機会は滅多に無いでしょう。MDNのページにも、こんなもの使うんじゃないぞという注意がでかでかと書いてあります。それよりも使いそうなのがObject.createです。これは新しいオブジェクトを作るときにプロトタイプを指定できるメソッドです。MyClass
のインスタンスを作りたければこうです。
const obj2 = Object.create(MyClass.prototype);
console.log(obj2 instanceof MyClass); // true
obj2.method(); // "hi" と表示される
Object.create(MyClass.prototype)
はnew MyClass
と同様にMyClass
のインスタンスを作ることができます。違いは、後者はMyClass
のコンストラクタが呼ばれる一方で前者は呼ばれないことです。逆に言えば、new
構文は「適当なプロトタイプを持つオブジェクトを作る」「コンストラクタを呼ぶ」という2段階の工程をまとめてやってくれる親切な構文だということです。
__proto__
読者の中には__proto__
についてご存知の方も多いでしょう。オブジェクトをコピーする系の関数の脆弱性の原因によくなっているあれです。これはオブジェクトのプロトタイプが入っているという直球なプロパティです。
class MyClass {
method() {
console.log("hi");
}
}
const obj = new MyClass();
console.log(obj.__proto__ === MyClass.prototype); // true
obj.method(); // "hi"
obj.__proto__ = null;
console.log(obj instanceof MyClass); // false
console.log(obj.method); // undefined
これはObject.getPrototypeOf
やObject.setPrototypeOf
と同等の機能を有しています。見て分かる通り__proto__
のほうが名前が怪しいので、基本的には避けましょう3。
継承
次に、継承がJavaScriptでどう扱われているかを見ましょう。まず、instanceof
やisPrototypeOf
の挙動です。
class SuperClass {
method() {
console.log("hey");
}
}
class SubClass extends SuperClass {}
const obj = new SubClass();
obj.method(); // "hey" と表示される
console.log(obj instanceof SubClass, obj instanceof SuperClass); // true true
console.log(
SubClass.prototype.isPrototypeOf(obj),
SuperClass.prototype.isPrototypeOf(obj)
); // true true
このように、obj
はSubClass
のインスタンスですが、SubClass
はSuperClass
を継承しているためobj
は間接的にSuperClass
のインスタンスとなります。instanceof
やisPrototypeOf
はそれを認識して上記のような場合にもtrue
を返します。
ところで、SubClass
がSuperClass
を継承しているということはどのように表現されるのでしょうか。答えは、「SubClass.prototype
がSuperClass
のインスタンスである」です。確かめてみましょう。
class SuperClass {}
class SubClass extends SuperClass {}
console.log(SubClass.prototype instanceof SuperClass); // true
console.log(Object.getPrototypeOf(SubClass.prototype) === SuperClass.prototype); // true
SubClass
のインスタンスであるobj
がSuperClass
のメソッドを持っているのは次のように説明できます。すなわち、obj
自身method
という名前のプロパティを持っていないためプロトタイプ(SubClass.prototype
)に移譲されます。SubClass.prototype
はmethod
という名前のプロパティを持っていないため、次はSubClass.prototype
のプロトタイプであるSuperClass.prototype
に移譲されます。ここでmethod
が発見されます。
このような移譲の連鎖によって継承という機構が実現されています。これが、プログラマのレベルから見たJavaScriptの継承です。
最上位のベースクラスとしてのObject
実は、普通のオブジェクトはObject
のインスタンスであるということが知られています。さっそく試してみましょう。
const obj = {};
console.log(obj instanceof Object); // true
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true
すでに何気なく登場していたhasOwnProperty
やisPrototypeOf
も全てのオブジェクト4が持つプロパティでしたが、その実態はObject.prototype
にあります。
console.log(obj.hasOwnProperty === Object.prototype.hasOwnProperty);
その意味で、全てのオブジェクトはObject
のインスタンスであると言えます。全てのクラスは暗黙のうちにObject
を継承しています。
ただし、上で「全てのオブジェクト」の述べましたが、ひとつ例外があります。実はプロトタイプが無いオブジェクトを作ることができるのです。JavaScriptプログラム上では、そのようなオブジェクトはプロトタイプがnull
のオブジェクトとして現れます。例えば、プロトタイプが無いオブジェクトを新規に作るにはObject.create(null)
とすればよいことが分かります。実際にやってみると、そのようなオブジェクトは確かにObject
のインスタンスではないことが分かります。
const obj = Object.create(null);
console.log(obj instanceof Object); // false
console.log(Object.getPrototypeOf(obj)); // null
実はObject.prototype
もまた、プロトタイプが無いオブジェクトです。Object.prototype
はその意味でプロトタイプチェーンの終端であるといえるのです。
プロトタイプの機構を仕様書で追う
さて、ウォーミングアップは以上です。ここからは、前節で触れたような内容がJavaScript仕様書でどのように定義されているのか見ていきましょう。
最初は、そもそも「オブジェクトは連想配列+プロトタイプである」という概念が仕様書にどう書かれているのか見ましょう。この記事では手とり足取り解説しますのでご安心ください。ただ、実際に自分で仕様書を開いたほうがついて来やすいかもしれません。仕様書へのリンクも改めて用意しておきます。
オブジェクトの定義
仕様書を散策する手がかりは目次と全文検索が基本ですが、今回は目次を眺めていくといいですね。オブジェクトに関することが書かれていそうな箇所はいろいろありますが、最初に目に付くのは6 ECMAScript Data Types and Valuesです。この章には、そもそも「JavaScriptにおける値とは何か」に関する定義が書かれています。6.1 ECMAScript Language Types では、JavaScriptの値の種類が1つずつ定義されています。
今回はオブジェクトに興味があるので、6.1.7 The Object Typeを見ることになります。いくつかの文を引用します。以降、訳はすべて筆者によるものです。
An Object is logically a collection of properties. Each property is either a data property, or an accessor property.
(訳)オブジェクトは論理的にはプロパティの集まりです。プロパティは、データプロパティかアクセサプロパティのどちらかです。
logically(論理的には)という言葉の意味がつかめない人もいるかもしれません。これは、仕様書では処理系における実際のメモリ配置とかそういった部分には踏み入らないことを意味しています。仕様書に書かれていることを処理系(ブラウザなど)がどう実装するかは自由であり、どんな手段であろうと仕様書に書かれている通りに動けば構わないのです。仕様書ではオブジェクトについて「プロパティの集まりである」という要件が満たされていればよく、それが実際どう実装されているのかは興味がありません。
この要件は仕様書がJavaScriptという言語を定義するための最低限の定義とも言えるものであり、あくまで実際の実装に踏み入らずに定義をベースとして言語を定めるという姿勢の現れです。このことが論理的という言葉で表現されています。
プロパティについてはさらに記載があります。
Properties are identified using key values. A property key value is either an ECMAScript String value or a Symbol value.
(訳)プロパティはキー値によって識別されます。プロパティのキー値は文字列かSymbolのどちらかです。
アクセサプロパティ(ゲッタとセッタで定義されるプロパティのことです)は置いておくとしても、「オブジェクトは連想配列である」ということがここまでで書かれていることになります。キーによって識別されるデータ(プロパティ)の集まりというのはまさに連想配列のことだからです。
次に6.1.7.2 Object Internal Methods and Internal Slotsに目を向けます。この節ではインターナルスロット(内部スロット)という概念が定義されています。インターナルスロットは、言うなれば仕様書内からしか見えないプロパティです。各オブジェクトはそれぞれインターナルスロットを持っており、仕様書のアルゴリズムはインターナルスロットを見たり書いたりすることができます。実際のプログラムからはインターナルスロットは不可視です。
また、インターナルメソッドというものもあり、これはインターナルスロットの関数版です。この節ではインターナルメソッドの定義が中心です。なお、ここでessential internal methods(必須インターナルメソッド)についても定義されています。これは全てのオブジェクトが持つインターナルメソッドのことです。
お察しの通り、オブジェクトのもうひとつの特徴である「プロトタイプを持つ」という点はこのインターナルスロットによって表現されます。しかし、ここまで見てきた箇所を探してもプロトタイプに関する記載はありません。そもそもここで具体的に定義されているのは必須インターナルメソッドだけです。
ということで再び目次を眺めると、9.1 Ordinary Object Internal Methods and Internal Slotsが関係ありそうです。以下に関連部分を引用します。なお、文中に出てくるordinaryオブジェクトというのは普通のオブジェクトのことで、我々が普段扱うオブジェクトは大抵がordinaryオブジェクトです。
All ordinary objects have an internal slot called [[Prototype]]. The value of this internal slot is either null or an object and is used for implementing inheritance.
(訳)全てのordinaryオブジェクトは[[Prototype]]というインターナルスロットを持ちます。このインターナルスロットの値はnullまたはオブジェクトであり、継承を実装するのに用いられています。
このように、インターナルスロットは[[ ]]
で囲われた名前を持ちます。この文には[[Prototype]]について「継承を実装するために用いられる」としかありませんので、これまで説明したような機構が具体的にどう実装されているのかを理解するにはさらに仕様書を読み進める必要があります。
Object.create
の定義を読む
手始めに、Object.create
がどう定義されているのか、仕様書を読み解いてみましょう。このメソッドは、指定されたプロトタイプでオブジェクトを作るだけという簡単な挙動なので定義も比較的読みやすいものとなっています。
仕様書の目次からそれっぽいところを探すと、Object.create
の定義は19.1.2.2 Object.create(O, Properties)に見つかります。定義はやはり短いですね。
The create function creates a new object with a specified prototype. When the create function is called, the following steps are taken:
- If Type(O) is neither Object nor Null, throw a TypeError exception.
- Let obj be ObjectCreate(O).
- If Properties is not undefined, then
a. Return ? ObjectDefineProperties(obj, Properties). - Return obj.
(訳)create関数は与えられたプロトタイプを持つ新しいオブジェクトを作成します。create関数が呼ばれたとき、次の手順が実行されます。
- もし Type(O) が Object でも Null でもなければ、TypeError例外を発生させる。
- objをObjectCreate(O)とする。
- もしPropertiesがundefined以外なら、
a. ? ObjectDefineProperties(obj, Properties)を返す。 - objを返す。
このように、組み込み関数の定義は案外直感的です。関数の動作がこのように自然言語で書かれています。また、Type, ObjectCreate, ObjectDefinePropertiesという別の関数の呼び出しが含まれています。これらは abstract operation(抽象操作?)と呼ばれ、仕様書内で定義された関数です。abstract operationはランタイムに利用できる何らかの組み込み関数に対応するものではなく、仕様書内でのみ参照・利用されるものです。
また、ステップ3-aに?という記法があります。これは5.2.3.4 ReturnIfAbrupt Shorthandsで定義されているものであり、簡単に言えば「抽象操作でエラーが発生したらそのエラーを伝播させる」という挙動を表す省略記法です。JavaScriptプログラムでは発生したエラーは自動的に伝播しますが、仕様書のアルゴリズムのレベルでは明示的に伝播させなければいけません。この操作は頻出なので、?という短い記法が用意されているのです。また、?のほかに!という種類があり、これはその操作が失敗しないことを表すものです5。
さて、Object.create
の定義を読むと分かる通り、処理の本体はObjectCreateという抽象操作にあるようです。次はこちらを読みましょう。
多くの定義はこのように、通常の言葉で書かれた説明と、番号付きリストで書かれたアルゴリズム部分から成ります。リスト部分だけ見れば動作はちゃんと定義されていますが、読み手の分かりやすさのために最初の説明が書かれています。
The abstract operation ObjectCreate with argument proto (an object or null) is used to specify the runtime creation of new ordinary objects. The optional argument internalSlotsList is a List of the names of additional internal slots that must be defined as part of the object. If the list is not provided, a new empty List is used. This abstract operation performs the following steps:
- If internalSlotsList is not present, set internalSlotsList to a new empty List.
- Let obj be a newly created object with an internal slot for each name in internalSlotsList.
- Set obj's essential internal methods to the default ordinary object definitions specified in 9.1.
- Set obj.[[Prototype]] to proto.
- Set obj.[[Extensible]] to true.
- Return obj.
(訳)抽象操作ObjectCreateは引数proto(オブジェクトまたはnullである)を取り、新しいordinaryオブジェクトをランタイムで作るために使用されます。オプショナル引数internalSlotsListは、作成されるオブジェクトに対して定義されなければならない追加のインターナルスロットのリストです。このリストが渡されなかった場合は、空リストが用いられます。この抽象操作は次の操作を実行します。
- internalSlotsListが存在しない場合は、新しい空リストとする。
- internalSlotsListで示されたそれぞれのインターナルスロット名を備えた新しいオブジェクトを作り、それをobjとする。
- objの必須インターナルメソッドを9.1で定義されたデフォルトの内容で作成する。
- obj.[[Prototype]]をprotoとする。
- obj.[[Extensible]]をtrueにする。
- objを返す。
読むと分かる通り、「新しいオブジェクトを作成し、必要なインターナルスロットを用意する」という内容です。ObjectCreateは普通のオブジェクトを作る操作なので、インターナルメソッドの動作はデフォルトの内容です。そして、問題の[[Prototype]]インターナルスロットもちゃんとここでセットされています。
このように、インターナルスロットはちゃんとオブジェクトが作成されるたびに明示的に用意されています。たいへん健気ですね。そもそも「結局オブジェクトを作るってどういうことなの?」と思った方もいるかもしれませんが、それについては仕様書の守備範囲ではありません。そこに踏み込まなくても言語は定義できるからです。
Object.getPrototypeOf
の定義も見てみる
では、次にObject.getPrototypeOf
の定義はどうなっているか見てみましょう。とはいっても、ここまで読んだ皆さんはその定義がどうなっているか容易に想像できるでしょう。与えられたオブジェクトの[[Prototype]]インターナルスロットの値を返せばいいのです。
19.1.2.12 Object.getPrototypeOf(O)
When the getPrototypeOf function is called with argument O, the following steps are taken:
- Let obj be ? ToObject(O).
- Return ? obj.[[GetPrototypeOf]]().
(訳)getPrototypeOf関数が引数Oで呼ばれたとき、次の手順が実行されます。
- objをToObject(O)とする。
- obj.[[GetPrototypeOf]]()を返す。
実際の定義は非常に短いですが、どうも一筋縄ではいかないようです。最初のToObjectは与えられた値がオブジェクト以外だったらオブジェクトに変換する抽象操作です(nullとundefinedはエラーになります)。
問題は2で、実際にプロトタイプを返す処理が[[GetPrototypeOf]]インターナルメソッドに移譲されています。その理由はProxyの存在です。Proxyは「プロトタイプを取得する」という操作に対してカスタムされた挙動を定義することができます。このことを、仕様書では「[[GetPrototypeOf]]インターナルメソッドの挙動が異なる」という形で定義しているのです。この記事では触れませんが、Proxyの定義を読むとそのようなことが書いてあります。
では、今回は普通のオブジェクトを相手しているので、普通のオブジェクトの[[GetPrototypeOf]]の挙動を追いましょう。Object.createの定義を読んだときに見たように、普通のオブジェクトに対する必須インターナルメソッドの定義は9.1に書かれています。[[GetPrototypeOf]]の定義はこうです。
9.1.1 [[GetPrototypeOf]]()
When the [[GetPrototypeOf]] internal method of O is called, the following steps are taken:
- Return ! OrdinaryGetPrototypeOf(O).
9.1.1.1 OrdinaryGetPrototypeOf(O)
When the abstract operation OrdinaryGetPrototypeOf is called with Object O, the following steps are taken:
- Return O.[[Prototype]].
(訳)Oの[[GetPrototypeOf]]内部メソッドが呼ばれた場合、次の操作が実行されます。
- ! OrdinaryGetPrototypeOf(O)を返す。
9.1.1.1 OrdinaryGetPrototypeOf(O)
抽象操作OrdinaryGetPrototypeOfがオブジェクトOで呼ばれた場合、次の操作が実行されます。
- O.[[Prototype]]を返す。
ということで、やっと[[Prototype]]が登場しました。ここに書いてある通り、普通のオブジェクトは[[GetPrototypeOf]]()が呼ばれたらそのオブジェクトの[[Prototype]]内部スロットの値が返されます。これでObject.getPrototypeOf
の説明がつきましたね。
プロパティアクセスの定義を見る
次は、プロトタイプによる継承機構の本体とも言えるプロパティアクセス(obj.foo
)の定義を見てみます。ここには、無かったらプロトタイプを辿るという挙動が定義されているはずです。今回は構文定義がスタート地点となります。構文の定義はいくつかの章にまとまっており、obj.foo
は式なので12 ECMAScript Language: Expressionsの中にあります。具体的には12.3.2.1 Runtime Semantics: Evaluationです。
MemberExpression . Identifier の定義
以下は MemberExpression . Identifier という構文の実行の定義です。
MemberExpression: MemberExpression.IdentifierName
- Let baseReference be the result of evaluating MemberExpression.
- Let baseValue be ? GetValue(baseReference).
- Let bv be ? RequireObjectCoercible(baseValue).
- Let propertyNameString be StringValue of IdentifierName.
- If the code matched by this MemberExpression is strict mode code, let strict be true, else let strict be false.
- Return a value of type Reference whose base value component is bv, whose referenced name component is propertyNameString, and whose strict reference flag is strict.
(訳)
- MemberExpressionを実行し、その結果をbaseReferenceとする。
- baseValueを? GetValue(baseReference)とする。
- bvを? RequireObjectCoercible(baseValue)とする。
- propertyNameStringをIdentifierNameのStringValueとする。
- もしこのMemberExpressionがstrictモードのコードならstrictをtrueとし、それ以外ならstrictをfalseとする。
- base value componentがbvであり、refereced name componentがpropertyNameStringであり、strict reference flagがstrictであるようなReference型の値を返す。
これを読むと、ちょっと様子がおかしいですね。最後の行を見ると「Referenceを返す」というよく分からないことが書いてあります。Referenceとは仕様書内でのみ用いられる値で、プロパティ(または変数)へのアクセスそのものを表す特殊な値です。6.2.4 The Reference Specification Typeには以下のように記載されています。
A Reference is a resolved name or property binding. A Reference consists of three components, the base value component, the referenced name component, and the Boolean-valued strict reference flag. The base value component is either undefined, an Object, a Boolean, a String, a Symbol, a Number, or an Environment Record. A base value component of undefined indicates that the Reference could not be resolved to a binding. The referenced name component is a String or Symbol value.
(訳)Referenceは変数またはプロパティに対する解決済のバインディングです。Referenceはbase value component, referenced name component, そして真偽値を値に持つstrict reference flagという3つの構成要素から成ります。base value componentはundefinedか、真偽値、文字列、シンボル、数値またはEnvironment Recordです。base value componentがundefinedの場合、そのReferenceがバインディングに解決できなかったことを表します。referenced name componentは文字列またはシンボルです。
バインディングというのは、変数やプロパティの中身ではなく、変数・プロパティそれ自身を指す言葉です。要するに、obj.foo
と書いた段階ではまだ実際にobj
からfoo
というプロパティが取り出されているわけではないのです。代わりに、「obj
のfoo
にアクセスする」という情報がそのまま入ったReferenceが返されるのです。
Referenceがどのように活躍するかについては筆者の過去記事でも扱っています。詳細はそちらに譲りますが、参照の機構は代入などを仕様化する際に役立っています。代入の際は「どのオブジェクトのどのプロパティに代入するか」という情報を取り回す必要があり、それがまさにReferenceです。
さて、上の定義で行われているのはReferenceを作ることだけであり、実際に参照を解決してobj.foo
の値を得るのは別の箇所で行われます。具体的にはこれを行うのはGetValueです。このGetValueは非常に出番の多い抽象操作です(上の定義にも出てきていますね)。値が欲しいのに参照が渡されるかもしれない画面では、参照を値に解決するためにGetValueが用いられます。
GetValueの定義を読む
GetValue(V)の定義を以下に引用します。訳すほど複雑なことは書いていないので日本語訳は省略します。
- ReturnIfAbrupt(V).
- If Type(V) is not Reference, return V.
- Let base be GetBase(V).
- If IsUnresolvableReference(V) is true, throw a ReferenceError exception.
- If IsPropertyReference(V) is true, then
a. If HasPrimitiveBase(V) is true, then
i. Assert: In this case, base will never be undefined or null.
ii. Set base to ! ToObject(base).
b. Return ? base.[[Get]](GetReferencedName(V), GetThisValue(V)). - Else base must be an Environment Record,
a. Return ? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V)) (see 8.1.1).
1と2は、渡された値Vが参照以外だった場合の処理です。参照以外は何もせずにそのまま返されます。
3はGetBaseを呼び出していますが、これはVのbase value componentを取得するだけです。もしobj.foo
を表すReferenceなら、base value componentはobj
になります。
4はIsUnresolvableReference(V)ならばReferenceErrorを発生させます。これは、Vが「未定義の変数」への参照だった場合のことです。プログラム中で未定義の変数にアクセスした場合にエラーが発生するのはよく知られていますが、そのエラーはこのGetValueから発生しているのです。
5は、参照がプロパティへの参照か、それともただの変数への参照かによって分岐しています。今回はプロパティへの参照に興味があるのでifの中を見ましょう。結局のところ、よく見ると処理はbase.[[Get]]に移譲されていることが分かります。何やら先が長いですね。
[[Get]]の定義を読む
[[Get]]は見て分かるとおり内部メソッドであり、プロパティアクセスの処理を定義するものです。今回はやはり普通のオブジェクトにおける挙動を見ます。9.1.8 [[Get]](P, Receiver)です。なお、これはOrdinaryGet(O, P, Receiver)に移譲されています。
When the abstract operation OrdinaryGet is called with Object O, property key P, and ECMAScript language value Receiver, the following steps are taken:
- Assert: IsPropertyKey(P) is true.
- Let desc be ? O.[[GetOwnProperty]](P).
- If desc is undefined, then
a. Let parent be ? O.[[GetPrototypeOf]]().
b. If parent is null, return undefined.
c. Return ? parent.[[Get]](P, Receiver). - If IsDataDescriptor(desc) is true, return desc.[[Value]].
- Assert: IsAccessorDescriptor(desc) is true.
- Let getter be desc.[[Get]].
- If getter is undefined, return undefined.
- Return ? Call(getter, Receiver).
1はAssertと書いてありますが、これはその地点で必ず満たされている条件を宣言するものです。仕様書は気をつけて書かれているため、Assertに反する状況に陥ることはありません(仕様書にバグが無ければ)。Assertは読み手が理解しやすいように提供されているものです。
2で[[GetOwnProperty]]という別の内部メソッドが使われています。深追いするのはやめておきますが、これはそのオブジェクトの与えられた名前のプロパティのプロパティデスクリプタを返す内部メソッドです。もしオブジェクトがそのプロパティを持たなければundefinedが返されます。
3の分岐は[[GetOwnProperty]]がundefinedを返した場合、つまりオブジェクトがそのプロパティを持っていなかった場合の処理です。最初に説明した通り、この場合にプロトタイプを辿るという動作が発生することになります。3の中身を見ると、まず[[GetPrototypeOf]]でOのプロトタイプを取得しています(少し前に確認した通り、普通のオブジェクトに対する[[GetPrototypeOf]]の挙動はそのオブジェクトの[[Prototype]]内部スロットの値を返すだけです)。ここではプロトタイプはparentという名前がついた変数に保存されています。
parentがnullのとき、すなわちプロトタイプが無かった場合はundefinedを返すとあります。これが、「オブジェクトの存在しないプロパティにアクセスしたらundefiedが返る」という挙動を定義している箇所です。プロトタイプチェーンの末端(Object.prototype)まで見ても見つからなかった場合に最終的にここに行き着きます。
プロトタイプがあった場合はプロトタイプに処理が移譲されます。それを表すのが parent.[[Get]](P, Receiver) という部分です。
4はオブジェクト自身がデータプロパティを持っていた場合にその値を返すという処理で、5以降はアクセサプロパティがあった場合の処理です。
ともかく、これでプロトタイプチェーンの機構を仕様書で確認することができました。「オブジェクト自身のプロパティをチェックし、無ければプロトタイプを見に行く」という処理が、普通のオブジェクトの[[Get]]内部メソッドの定義にほぼそのままの形で記述されていましたね。これは再帰になっているため、プロトタイプチェーンが長く連なっている場合にも正しく処理されます。
組み込みオブジェクトの継承構造
おまけ的な話題として、組み込みのオブジェクトの間の継承構造がどのように定義されているのかを見てみます。
普通のオブジェクトのプロトタイプ
我々がオブジェクトを作るとき、最も一般的なのは{}
のようなオブジェクトリテラルを使って作る方法です。この方法で作られるのは普通のオブジェクトであり、しかも自動的にObject.prototype
をプロトタイプに持っています。このことは仕様書でどう定義されているのでしょうか。
今回は{}
の挙動を調べたいので、オブジェクトリテラルを定義している部分を探しましょう。オブジェクトリテラルが式の一種であることを理解していれば探すのは難しくなく、目次のありそうな部分を眺めれば12.2.6 Object Initializerを見つけるのは容易いでしょう。文や式の場合は、Evaluationと書かれている部分を探しましょう。そこに実行時の挙動が定義されています。今回の場合は12.2.6.7 Runtime Semantics: Evaluationです。
ObjectLiteral:{}
- Return ObjectCreate(%ObjectPrototype%).
はい、非常に単純ですね。ObjectCreateは既に出てきた抽象操作で、与えられたオブジェクトをプロトタイプとして、新しい普通のオブジェクトを作るものです。今回プロトタイプとして指定されているのは%ObjectPrototype%だそうです。
ここで何やら新しい記法が出てきました。このように% %で囲まれた名前はintrinsic objectと呼ばれ、要するに仕様書内で通用するグローバル変数のようなものです(書き換えられることはないので定数と呼ぶべきかもしれませんが)。仕様書のあちこちで使われるオブジェクトはこのようにintrinsic object(内部オブジェクト?)として定義され、仕様書内で簡単に参照できるようになっています。Object.prototype
に相当するオブジェクトは仕様書内でよく使われるので、%ObjectPrototype%として簡単に参照できるようになっています。
%ObjectPrototype%がどんなオブジェクトかということは、19.1.3 Properties of the Object Prototype Objectで定義されています。
The Object prototype object:
- is the intrinsic object %ObjectPrototype%.
- is an immutable prototype exotic object.
- has a [[Prototype]] internal slot whose value is null.
(訳) Object prototypeオブジェクトは、
- 内部オブジェクト%ObjectPrototype%です。
- immutable prototypeエキゾチックオブジェクトです。
- [[Prototype]]内部スロットを持ち、その値はnullです。
この節では、「Object prptotypeオブジェクトというオブジェクトが存在する」ということを定義しています。つまり、ある種のオブジェクト作成の定義になっています。実際にこのオブジェクトを作るのは、もちろんJavaScript処理系が実行環境の準備中に勝手にやってくれます。オブジェクトを作成する以上、必要な内部スロットはちゃんと明示的に作ってあげなければいけません。そのため、[[Prototype]]内部スロットの存在及びその中身がここに明記されています。
Object prototypeオブジェクトはimmutable prototypeエキゾチックオブジェクトであるとされていますが、これの定義は9.4.7 Immutable Prototype Exotic Objectsにあります。
An immutable prototype exotic object is an exotic object that has a [[Prototype]] internal slot that will not change once it is initialized.
Immutable prototype exotic objects have the same internal slots as ordinary objects. They are exotic only in the following internal methods. All other internal methods of immutable prototype exotic objects that are not explicitly defined below are instead defined as in ordinary objects.
(訳)immutable prototypeエキゾチックオブジェクトは、作られた後に[[Prototype]]内部スロットが変更されないようなエキゾチックオブジェクトです。
Immutable prototypeエキゾチックオブジェクトは普通のオブジェクトと同じ内部スロットを持ちます。Immutable prototypeエキゾチックオブジェクトは次の内部メソッド(訳注:[[SetPrototypeOf]])のみが普通のオブジェクトと異なります。以下で明示的に定義されていない他の内部メソッドは、普通のオブジェクトと同様に定義されます。
目ざとい方は先ほどの%ObjectPrototype%の定義について「必須インターナルスロットの定義がないじゃないか」とお思いになったかもしれませんが、上記に「以下で明示的に定義されていない他の内部メソッドは、普通のオブジェクトと同様に定義されます」という文があるためクリアしていると考えられます。
エキゾチックオブジェクトというのは、内部メソッドの動作が普通のオブジェクトと事なるオブジェクトのことであり、いくつか種類があります。そのうちのひとつであるimmutable prototypeエキゾチックオブジェクトは、[[SetPrototypeOf]]という内部メソッドの動作が普通のオブジェクトと違います。
[[SetPrototypeOf]]はObject.setPrototypeOf
経由で呼び出される内部メソッドであり、要するにオブジェクトのプロトタイプをあとから変更するためのものです。immutable prototypeエキゾチックオブジェクトはこれを許可せず、[[SetPrototypeOf]]の挙動を何もしないように変更することで、プロトタイプの書き換えを防いでいます。
Object prototypeオブジェクトがimmutable prototypeエキゾチックオブジェクトとして定義されていることにより、Object.prototyppe
のプロトタイプはnull
に固定されています。実際、以下のようにこれを書き換えようとするとエラーが発生します。
const p = Object.create(null);
Object.setPrototypeOf(Object.prototype, p);
// エラーメッセージ(Google Chromeの場合):
// Uncaught TypeError: Immutable prototype object '#<Object>' cannot have their prototype set
話がそれましたが、このように仕様書内で存在が定義されているオブジェクトというのは数多くあります。一部はintrinsic objectとして名前が付けられており、6.1.7.4 Well-Known Intrinsic Objectsにそれが列挙してあります。現在のところこれは111種類あります。
組み込みオブジェクトの継承関係:配列の場合
最後に、組み込みオブジェクトの継承関係を見ておきましょう。例えば、ArrayはObjectを継承しているため、Objectのメソッドは配列に対して使用することができます。
const arr = [1, 10, 100];
console.log(arr.hasOwnProperty("1")); // true
これは仕様書でどのように定義されているでしょうか。とはいっても、話は難しくありません。すでにご存知の通り、「ArrayがObjectを継承している」というのは「Array.prototype
がObjectのインスタンスである」ということ、言い換えれば「Array.prototype
のプロトタイプがObject.prototype
である」ということです。これを確かめればいいわけです。
ここまで読んだ皆さんなら、仕様書の「Array.prototype
というオブジェクト」を定義している部分を読めばいいというのはすぐに分かるでしょう。早速目次から探しましょう。
すると、22.1.3 Properties of the Array Prototype Objectが見つかります。Properties ofとあるタイトルは若干ミスリーディングですが、Array Prototype Objectそのものもここで定義されています。
The Array prototype object:
- is the intrinsic object %ArrayPrototype%.
- is an Array exotic object and has the internal methods specified for such objects.
- has a "length" property whose initial value is 0 and whose attributes are { [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: false }.
- has a [[Prototype]] internal slot whose value is the intrinsic object %ObjectPrototype%.
NOTE
The Array prototype object is specified to be an Array exotic object to ensure compatibility with ECMAScript code that was created prior to the ECMAScript 2015 specification
(訳)Array prototypeオブジェクトは、
- 内部オブジェクト%ArrayPrototype%です。
- Arrayエキゾチックオブジェクトであり、対応する内部メソッドを持ちます。
- "length" プロパティを持ちます。これは0で初期化されており、[[Writable]]属性がtrue、[[Enumerable]]属性がfalse、[[Configurable]]属性がfalseです。
- [[Prototype]]内部スロットを持ち、その値は%ObjectPrototype%です。
NOTE
Array prototypeオブジェクトはArrayエキゾチックオブジェクトであると定められていますが、これはECMAScript 2015 以前に作られたコードとの互換性を保証するためです。
後方互換性の関係でいろいろと書いてありますが、4つ目の項目にArray.prototype
の[[Prototype]]スロットの中身は%ObjectPrototype%であると明記されています。
まとめ
この記事では、JavaScriptにおける継承の機構を仕様書のレベルで解説しました。[[Prototype]]内部スロットの存在や実際のプロパティアクセスの挙動を理解を通じて、JavaScriptにおけるプロトタイプベースのオブジェクト指向がどのように実現されているかを仕様書に見出しました。
仕様書の読み方については比較的丁寧に解説しましたが、アルゴリズム部分も自然言語で書かれているため読むのに必要な知識は多くありません。恐らく最も大変なのは、目的の記述を見つけることでしょう。この記事では基本的に目次から探すものとして解説しましたが、素早く目的の記述を見つけるためには仕様書のどこに何が書かれているのか把握することが重要です。仕様書の目次にひと通り目を通しておくのは効果的でしょう。
冒頭でも述べた通り、仕様書を読んで理解することがJavaScriptプログラムの意味を正確に理解するための唯一の方法です。常日頃からclass
の構文にお世話になっている方も多いと思いますが、そのインスタンスのメソッドを呼び出すということがどういうことなのか、この記事を全部読んだ方は半分くらいは理解できたかと思います(もう半分はそのときのthis
の扱いです。今回はプロトタイプの扱いに絞ったのでthis
の扱いがどうなるのかは省略しました)。
仕様書を読むというのはハードルが高く感じられるかもしれませんが、ちゃんと仕様書が存在するという点でJavaScriptは幸せな部類です。仕様書さえ読めばプログラムの意味に確信が持てるというのは実はたいへんありがたいことなのです。仕様書が無い言語では、コンパイラの動作がどうなっているかにまで立ち入らなければプログラムの意味の真なる理解が達成できないかもしれないのですから。その点で、仕様書というのはプログラムの意味に関する優れた抽象化レイヤーとして働いているのです。
残念なことに、世の中の誰も彼もが自分の書いたプログラムの意味を理解しているわけではありません。それどころか、この記事の長さからも分かるように、プログラムの意味を把握するというのは決してハードルが低い行為ではありません。実際のところ、意味が分からずに書いたプログラムであってもプログラムは動いてしまいます。それが良いことなのか悪いことなのかという答えを筆者は持っていませんから、この機会に考えてみてはいかがでしょうか。