2022年5月12日

クラスの継承

クラスの継承とは、あるクラスが別のクラスを拡張する方法です。

これにより、既存の機能の上に新しい機能を作成することができます。

“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 メソッドを見つけるために、エンジンは(図の下から上へ)次のことをチェックします。

  1. rabbit オブジェクト(run を持たない)。
  2. そのプロトタイプである Rabbit.prototype (hide はあるが、run はない)。
  3. そのプロトタイプである(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 クラスから「そのまま」直接取得されます。

ただし、Rabbitstop() などの独自のメソッドを指定すると、それが代わりに使用されます。

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
  }
}

アロー関数内の superstop() のものと同じであるため、意図したとおりに動作します。ここで「通常の」関数を指定した場合、エラーが発生します。

// 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() の両方の場合、行 (*)alertanimal を示すことです。

言い換えれば、親コンストラクタは常に、オーバーライドされた値ではなく、独自のフィールド値を使用します。

何が変なのでしょうか?

まだ明確でない場合は、メソッドと比較してください。

同じコードですが、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を呼び出します。

これが何が起こっているかの図です。

  1. 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);
  2. 次に、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);
  3. ...したがって、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.sayHirabbitからコピーされました。もしかしたら、コードの重複を避けたかっただけかもしれません。
  • その[[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]])

まとめ

  1. クラスを拡張するには:class Child extends Parent
    • つまり、Child.prototype.__proto__Parent.prototypeになり、メソッドが継承されます。
  2. コンストラクターをオーバーライドする場合
    • thisを使用する前に、Childコンストラクターで親コンストラクターをsuper()として呼び出す必要があります。
  3. 別のメソッドをオーバーライドする場合
    • Childメソッドでsuper.method()を使用して、Parentメソッドを呼び出すことができます。
  4. 内部
    • メソッドは、内部の[[HomeObject]]プロパティでクラス/オブジェクトを記憶します。それがsuperが親メソッドを解決する方法です。
    • したがって、superを持つメソッドをあるオブジェクトから別のオブジェクトにコピーすることは安全ではありません。

また

  • アロー関数は独自のthisまたはsuperを持たないため、周囲のコンテキストに透過的に適合します。

タスク

重要度: 5

以下は、Animalを拡張するRabbitを使用したコードです。

残念ながら、Rabbitオブジェクトを作成できません。何が問題なのでしょうか?修正してください。

class Animal {

  constructor(name) {
    this.name = name;
  }

}

class Rabbit extends Animal {
  constructor(name) {
    this.name = name;
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
alert(rabbit.name);

これは、子コンストラクターがsuper()を呼び出す必要があるためです。

修正されたコードを以下に示します。

class Animal {

  constructor(name) {
    this.name = name;
  }

}

class Rabbit extends Animal {
  constructor(name) {
    super(name);
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // ok now
alert(rabbit.name); // White Rabbit
重要度: 5

Clockクラスがあります。今のところ、毎秒時刻を表示します。

class Clock {
  constructor({ template }) {
    this.template = template;
  }

  render() {
    let date = new Date();

    let hours = date.getHours();
    if (hours < 10) hours = '0' + hours;

    let mins = date.getMinutes();
    if (mins < 10) mins = '0' + mins;

    let secs = date.getSeconds();
    if (secs < 10) secs = '0' + secs;

    let output = this.template
      .replace('h', hours)
      .replace('m', mins)
      .replace('s', secs);

    console.log(output);
  }

  stop() {
    clearInterval(this.timer);
  }

  start() {
    this.render();
    this.timer = setInterval(() => this.render(), 1000);
  }
}

Clockから継承し、パラメーターprecision(「ティック」の間隔のms数)を追加する新しいクラスExtendedClockを作成してください。デフォルトでは1000(1秒)にする必要があります。

  • コードはextended-clock.jsファイルに記述する必要があります。
  • 元のclock.jsは変更しないでください。拡張してください。

タスクのサンドボックスを開いてください。

class ExtendedClock extends Clock {
  constructor(options) {
    super(options);
    let { precision = 1000 } = options;
    this.precision = precision;
  }

  start() {
    this.render();
    this.timer = setInterval(() => this.render(), this.precision);
  }
};

サンドボックスで解決策を開いてください。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善のための提案がある場合は、コメントの代わりにGitHub issueを送信するか、プルリクエストを行ってください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行以上の場合はサンドボックス(plnkrjsbincodepen…)を使用してください。