クラスの継承とは、あるクラスが別のクラスを拡張する方法です。
これにより、既存の機能の上に新しい機能を作成することができます。
“extends” キーワード
Animal
クラスがあるとしましょう。
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
let animal = new Animal("My animal");
animal
オブジェクトと Animal
クラスを図で表現すると次のようになります。
…そして、別の Rabbit
クラスを作成したいとします。
ウサギは動物なので、Rabbit
クラスは Animal
をベースにし、動物のメソッドにアクセスできる必要があり、ウサギは「一般的な」動物ができることを実行できる必要があります。
別のクラスを拡張するための構文は、class Child extends Parent
です。
Animal
から継承する Rabbit
クラスを作成してみましょう。
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!
Rabbit
クラスのオブジェクトは、rabbit.hide()
のような Rabbit
メソッドと、rabbit.run()
のような Animal
メソッドの両方にアクセスできます。
内部的には、extends
キーワードは昔ながらのプロトタイプメカニズムを使用します。Rabbit.prototype.[[Prototype]]
を Animal.prototype
に設定します。そのため、メソッドが Rabbit.prototype
に見つからない場合、JavaScript はそれを Animal.prototype
から取得します。
たとえば、rabbit.run
メソッドを見つけるために、エンジンは(図の下から上へ)次のことをチェックします。
rabbit
オブジェクト(run
を持たない)。- そのプロトタイプである
Rabbit.prototype
(hide
はあるが、run
はない)。 - そのプロトタイプである(
extends
により)Animal.prototype
。そこには最終的にrun
メソッドがある。
章 ネイティブプロトタイプ で思い出すことができるように、JavaScript 自体がビルトインオブジェクトにプロトタイプ継承を使用しています。たとえば、Date.prototype.[[Prototype]]
は Object.prototype
です。そのため、日付は汎用オブジェクトメソッドにアクセスできます。
extends
の後に任意の式が許可されますクラス構文では、クラスだけでなく、extends
の後に任意の式を指定できます。
たとえば、親クラスを生成する関数呼び出し。
function f(phrase) {
return class {
sayHi() { alert(phrase); }
};
}
class User extends f("Hello") {}
new User().sayHi(); // Hello
ここでは、User
クラスは f("Hello")
の結果から継承します。
これは、多くの条件に応じてクラスを生成する関数を使用し、そこから継承できる高度なプログラミングパターンで役立つ場合があります。
メソッドのオーバーライド
次に、メソッドをオーバーライドしてみましょう。デフォルトでは、Rabbit
クラスで指定されていないすべてのメソッドは、Animal
クラスから「そのまま」直接取得されます。
ただし、Rabbit
に stop()
などの独自のメソッドを指定すると、それが代わりに使用されます。
class Rabbit extends Animal {
stop() {
// ...now this will be used for rabbit.stop()
// instead of stop() from class Animal
}
}
ただし、通常、親メソッドを完全に置き換えたいのではなく、その機能をつまみ食いまたは拡張するために、その上に構築したいと考えています。メソッド内で何かを行い、その前/後、またはその過程で親メソッドを呼び出します。
クラスは、そのために "super"
キーワードを提供します。
- 親メソッドを呼び出すには
super.method(...)
。 - 親コンストラクタを呼び出すには
super(...)
(コンストラクタ内のみ)。
たとえば、ウサギが停止すると自動的に隠れるようにしましょう。
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
stop() {
super.stop(); // call parent stop
this.hide(); // and then hide
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stands still. White Rabbit hides!
これで、Rabbit
には、その過程で親の super.stop()
を呼び出す stop
メソッドがあります。
super
がありません章 アロー関数の再確認 で述べたように、アロー関数には super
がありません。
アクセスされると、外側の関数から取得されます。たとえば。
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // call parent stop after 1sec
}
}
アロー関数内の super
は stop()
のものと同じであるため、意図したとおりに動作します。ここで「通常の」関数を指定した場合、エラーが発生します。
// Unexpected super
setTimeout(function() { super.stop() }, 1000);
コンストラクタのオーバーライド
コンストラクタでは、少しややこしくなります。
これまで、Rabbit
には独自の constructor
がありませんでした。
仕様 によると、クラスが別のクラスを拡張し、constructor
を持たない場合、次の「空の」 constructor
が生成されます。
class Rabbit extends Animal {
// generated for extending classes without own constructors
constructor(...args) {
super(...args);
}
}
ご覧のとおり、基本的にすべて引数を渡して親の constructor
を呼び出します。これは、独自のコンストラクタを作成しない場合に発生します。
次に、Rabbit
にカスタムコンストラクタを追加してみましょう。name
に加えて earLength
を指定します。
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// Doesn't work!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.
おっと!エラーが発生しました。これでウサギを作成できなくなりました。何が悪かったのでしょうか?
短い答えは次のとおりです。
- 継承クラスのコンストラクタは
super(...)
を呼び出す必要があり、(!)this
を使用する前に呼び出す必要があります。
…しかし、なぜでしょうか?ここで何が起こっているのでしょうか?確かに、要件は奇妙に思えます。
もちろん、説明はあります。詳細を見ていきましょう。そうすれば、何が起こっているのかを本当に理解できるでしょう。
JavaScript では、継承クラス (いわゆる「派生コンストラクタ」) のコンストラクタ関数とその他の関数には違いがあります。派生コンストラクタには、特別な内部プロパティ [[ConstructorKind]]:"derived"
があります。これは特別な内部ラベルです。
そのラベルは、new
による動作に影響を与えます。
- 通常の関数が
new
で実行されると、空のオブジェクトが作成され、this
に割り当てられます。 - ただし、派生コンストラクタが実行される場合、これは行いません。親コンストラクタがこのジョブを行うことを期待します。
したがって、派生コンストラクタは親 (ベース) コンストラクタを実行するために super
を呼び出す必要があり、そうしないと、this
のオブジェクトは作成されません。そして、エラーが発生します。
Rabbit
コンストラクタが機能するためには、次に示すように、this
を使用する前に super()
を呼び出す必要があります。
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// now fine
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10
クラスフィールドのオーバーライド: 注意点
この注意点では、クラス、おそらく他のプログラミング言語での経験があることを前提としています。
これは、言語に対するより深い洞察を提供し、バグの原因となる可能性がある (ただし、それほど頻繁ではない) 動作についても説明します。
理解するのが難しい場合は、先に進んで読み進め、後で戻ってきてください。
メソッドだけでなく、クラスフィールドもオーバーライドできます。
ただし、親コンストラクタでオーバーライドされたフィールドにアクセスすると、他のほとんどのプログラミング言語とは大きく異なる、ややこしい動作があります。
次の例を考えてみましょう。
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
ここで、Rabbit
クラスは Animal
を拡張し、name
フィールドを独自の値でオーバーライドします。
Rabbit
には独自のコンストラクタがないため、Animal
コンストラクタが呼び出されます。
興味深いのは、new Animal()
と new Rabbit()
の両方の場合、行 (*)
の alert
が animal
を示すことです。
言い換えれば、親コンストラクタは常に、オーバーライドされた値ではなく、独自のフィールド値を使用します。
何が変なのでしょうか?
まだ明確でない場合は、メソッドと比較してください。
同じコードですが、this.name
フィールドの代わりに this.showName()
メソッドを呼び出します。
class Animal {
showName() { // instead of this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // instead of alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
注意してください: 出力が異なります。
そして、それが私たちが自然に期待することです。親コンストラクタが派生クラスで呼び出されると、オーバーライドされたメソッドが使用されます。
…しかし、クラスフィールドの場合、そうではありません。前述のように、親コンストラクタは常に親フィールドを使用します。
なぜ違いがあるのでしょうか?
その理由は、フィールドの初期化順序です。クラスフィールドは、以下のように初期化されます。
- (何も拡張しない)ベースクラスのコンストラクタの前
- 派生クラスの場合、
super()
の直後
私たちのケースでは、Rabbit
は派生クラスです。そこには constructor()
がありません。前に述べたように、これは super(...args)
のみを持つ空のコンストラクタがあるのと同じです。
したがって、new Rabbit()
は super()
を呼び出し、親コンストラクタを実行し、(派生クラスのルールに従って) その後にのみ、そのクラスフィールドが初期化されます。親コンストラクタの実行時には、Rabbit
クラスフィールドはまだ存在しないため、Animal
フィールドが使用されます。
フィールドとメソッドの間のこのわずかな違いは、JavaScript に特有のものです。
幸いなことに、この動作はオーバーライドされたフィールドが親コンストラクタで使用される場合にのみ現れます。その場合、何が起こっているのかを理解するのが難しくなる可能性があるため、ここで説明します。
問題が発生した場合は、フィールドの代わりにメソッドまたは getter/setter を使用することで解決できます。
スーパー: 内部構造、[[HomeObject]]
チュートリアルを初めて読んでいる場合は、このセクションはスキップできます。
これは、継承と super
の背後にある内部メカニズムに関するものです。
super
の内部構造を少し深く掘り下げてみましょう。途中で興味深いことがいくつかわかります。
まず言うと、これまで学んだことすべてからすると、super
がそもそも機能することは不可能です!
確かに、技術的にどのように機能する必要があるのか自問してみましょう。オブジェクトメソッドが実行されると、現在のオブジェクトが this
として取得されます。super.method()
を呼び出すと、エンジンは現在のオブジェクトのプロトタイプから method
を取得する必要があります。しかし、どのようにすればよいでしょうか?
タスクは単純に見えるかもしれませんが、そうではありません。エンジンは現在のオブジェクト this
を認識しているため、this.__proto__.method
として親 method
を取得できます。残念ながら、このような「素朴な」解決策は機能しません。
問題を実証しましょう。簡単にするために、クラスを使用せずに、プレーンオブジェクトを使用します。
詳細を知りたくない場合は、この部分をスキップして、下の [[HomeObject]]
サブセクションに移動してください。それは害にはなりません。または、物事を深く理解することに興味がある場合は、読み進めてください。
以下の例では、rabbit.__proto__ = animal
です。次に、試してみましょう: rabbit.eat()
で、this.__proto__
を使用して animal.eat()
を呼び出します。
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() {
// that's how super.eat() could presumably work
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // Rabbit eats.
行 (*)
で、プロトタイプ (animal
) から eat
を取得し、現在のオブジェクトのコンテキストで呼び出します。単純な this.__proto__.eat()
では、プロトタイプのコンテキストで親の eat
が実行され、現在のオブジェクトではないため、ここでは .call(this)
が重要であることに注意してください。
そして上記のコードでは、意図したとおりに動作します。正しいalert
が表示されます。
では、もう1つオブジェクトをチェーンに追加してみましょう。どうなるか見てみましょう。
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...bounce around rabbit-style and call parent (animal) method
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ...do something with long ears and call parent (rabbit) method
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
コードが動作しなくなりました! longEar.eat()
を呼び出そうとしてエラーが発生しているのがわかります。
あまり明白ではないかもしれませんが、longEar.eat()
の呼び出しをトレースすると、なぜそうなるかがわかります。(*)
と(**)
の両方の行で、this
の値は現在のオブジェクト(longEar
)です。これは重要です。すべてのオブジェクトメソッドは、プロトタイプなどではなく、現在のオブジェクトをthis
として取得します。
したがって、(*)
と(**)
の両方の行で、this.__proto__
の値はまったく同じで、rabbit
です。どちらも、無限ループでチェーンをたどることなく、rabbit.eat
を呼び出します。
これが何が起こっているかの図です。
-
longEar.eat()
内部で、(**)
の行はrabbit.eat
を呼び出し、this=longEar
を提供しています。// inside longEar.eat() we have this = longEar this.__proto__.eat.call(this) // (**) // becomes longEar.__proto__.eat.call(this) // that is rabbit.eat.call(this);
-
次に、
rabbit.eat
の(*)
の行で、呼び出しをチェーン内でさらに上に渡したいのですが、this=longEar
なので、this.__proto__.eat
は再びrabbit.eat
になります!// inside rabbit.eat() we also have this = longEar this.__proto__.eat.call(this) // (*) // becomes longEar.__proto__.eat.call(this) // or (again) rabbit.eat.call(this);
-
...したがって、
rabbit.eat
はそれ以上上昇できないため、無限ループでそれ自身を呼び出します。
この問題は、this
だけでは解決できません。
[[HomeObject]]
解決策を提供するために、JavaScriptは関数に特別な内部プロパティ[[HomeObject]]
をもう1つ追加します。
関数がクラスまたはオブジェクトメソッドとして指定されると、その[[HomeObject]]
プロパティはそのオブジェクトになります。
次に、super
はそれを使用して、親プロトタイプとそのメソッドを解決します。
それがどのように機能するかを、まずプレーンオブジェクトで見てみましょう。
let animal = {
name: "Animal",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "Long Ear",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// works correctly
longEar.eat(); // Long Ear eats.
[[HomeObject]]
のメカニズムにより、意図したとおりに動作します。longEar.eat
のようなメソッドは、その[[HomeObject]]
を知っており、そのプロトタイプから親メソッドを取得します。this
は一切使用しません。
メソッドは「自由」ではない
以前から知っているように、一般的にJavaScriptでは関数は「自由」であり、オブジェクトにバインドされていません。そのため、オブジェクト間でコピーして別のthis
で呼び出すことができます。
[[HomeObject]]
の存在そのものが、メソッドはそれらのオブジェクトを記憶するため、その原則に違反しています。[[HomeObject]]
は変更できないため、この結合は永遠です。
言語内で[[HomeObject]]
が使用される唯一の場所はsuper
です。したがって、メソッドがsuper
を使用しない場合、そのメソッドは自由と見なしてオブジェクト間でコピーできます。ただし、super
を使用すると問題が発生する可能性があります。
コピー後の誤ったsuper
の結果のデモを以下に示します。
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
// rabbit inherits from animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
// tree inherits from plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
tree.sayHi()
の呼び出しは、「私は動物です」と表示されます。明らかに間違っています。
理由は簡単です。
(*)
の行で、メソッドtree.sayHi
はrabbit
からコピーされました。もしかしたら、コードの重複を避けたかっただけかもしれません。- その
[[HomeObject]]
はrabbit
です。それはrabbit
で作成されたためです。[[HomeObject]]
を変更する方法はありません。 tree.sayHi()
のコード内にはsuper.sayHi()
があります。これはrabbit
から上に向かい、animal
からメソッドを取得します。
これが何が起こっているかの図です。
関数プロパティではなくメソッド
[[HomeObject]]
は、クラスとプレーンオブジェクトの両方のメソッドに対して定義されています。ただし、オブジェクトの場合、メソッドは"method: function()"
としてではなく、method()
として正確に指定する必要があります。
この違いは私たちにとって本質的ではないかもしれませんが、JavaScriptにとっては重要です。
以下の例では、比較のために非メソッド構文が使用されています。[[HomeObject]]
プロパティは設定されておらず、継承は機能しません。
let animal = {
eat: function() { // intentionally writing like this instead of eat() {...
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // Error calling super (because there's no [[HomeObject]])
まとめ
- クラスを拡張するには:
class Child extends Parent
- つまり、
Child.prototype.__proto__
はParent.prototype
になり、メソッドが継承されます。
- つまり、
- コンストラクターをオーバーライドする場合
this
を使用する前に、Child
コンストラクターで親コンストラクターをsuper()
として呼び出す必要があります。
- 別のメソッドをオーバーライドする場合
Child
メソッドでsuper.method()
を使用して、Parent
メソッドを呼び出すことができます。
- 内部
- メソッドは、内部の
[[HomeObject]]
プロパティでクラス/オブジェクトを記憶します。それがsuper
が親メソッドを解決する方法です。 - したがって、
super
を持つメソッドをあるオブジェクトから別のオブジェクトにコピーすることは安全ではありません。
- メソッドは、内部の
また
- アロー関数は独自の
this
またはsuper
を持たないため、周囲のコンテキストに透過的に適合します。
コメント
<code>
タグを使用し、複数行の場合は<pre>
タグで囲み、10行以上の場合はサンドボックス(plnkr、jsbin、codepen…)を使用してください。