Наследование классов – это способ расширения одного класса другим классом.
Таким образом, мы можем добавить новый функционал к уже существующему.
Ключевое слово «extends»
Допустим, у нас есть класс Animal
:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} бежит со скоростью ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} стоит неподвижно.`);
}
}
let animal = new Animal("Мой питомец");
Вот как мы можем представить объект animal
и класс Animal
графически:
…И мы хотели бы создать ещё один class Rabbit
.
Поскольку кролики – это животные, класс Rabbit
должен быть основан на Animal
, и иметь доступ к методам животных, так чтобы кролики могли делать то, что могут делать «общие» животные.
Синтаксис для расширения другого класса следующий: class Child extends Parent
.
Давайте создадим class Rabbit
, который наследуется от Animal
:
class Rabbit extends Animal {
hide() {
alert(`${this.name} прячется!`);
}
}
let rabbit = new Rabbit("Белый кролик");
rabbit.run(5); // Белый кролик бежит со скоростью 5.
rabbit.hide(); // Белый кролик прячется!
Объект класса Rabbit
имеет доступ как к методам Rabbit
, таким как rabbit.hide()
, так и к методам Animal
, таким как rabbit.run()
.
Внутри ключевое слово 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("Привет") {}
new User().sayHi(); // Привет
Здесь class User
наследует от результата вызова f("Привет")
.
Это может быть полезно для продвинутых приёмов проектирования, где мы можем использовать функции для генерации классов в зависимости от многих условий и затем наследовать их.
Переопределение методов
Теперь давайте продвинемся дальше и переопределим метод. По умолчанию все методы, не указанные в классе Rabbit
, берутся непосредственно «как есть» из класса Animal
.
Но если мы укажем в Rabbit
собственный метод, например stop()
, то он будет использован вместо него:
class Rabbit extends Animal {
stop() {
// ...теперь это будет использоваться для rabbit.stop()
// вместо stop() из класса Animal
}
}
Впрочем, обычно мы не хотим полностью заменить родительский метод, а скорее хотим сделать новый на его основе, изменяя или расширяя его функциональность. Мы делаем что-то в нашем методе и вызываем родительский метод до/после или в процессе.
У классов есть ключевое слово "super"
для таких случаев.
super.method(...)
вызывает родительский метод.super(...)
для вызова родительского конструктора (работает только внутри нашего конструктора).
Пусть наш кролик автоматически прячется при остановке:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} бежит со скоростью ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} стоит.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} прячется!`);
}
stop() {
super.stop(); // вызываем родительский метод stop
this.hide(); // и затем hide
}
}
let rabbit = new Rabbit("Белый кролик");
rabbit.run(5); // Белый кролик бежит со скоростью 5.
rabbit.stop(); // Белый кролик стоит. Белый кролик прячется!
Теперь у класса Rabbit
есть метод stop
, который вызывает родительский super.stop()
в процессе выполнения.
super
Как упоминалось в главе Повторяем стрелочные функции, стрелочные функции не имеют super
.
При обращении к super
стрелочной функции он берётся из внешней функции:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // вызывает родительский stop после 1 секунды
}
}
В примере super
в стрелочной функции тот же самый, что и в stop()
, поэтому метод отрабатывает как и ожидается. Если бы мы указали здесь «обычную» функцию, была бы ошибка:
// Unexpected super
setTimeout(function() { super.stop() }, 1000);
Переопределение конструктора
С конструкторами немного сложнее.
До сих пор у Rabbit
не было своего конструктора.
Согласно спецификации, если класс расширяет другой класс и не имеет конструктора, то автоматически создаётся такой «пустой» конструктор:
class Rabbit extends Animal {
// генерируется для классов-потомков, у которых нет своего конструктора
constructor(...args) {
super(...args);
}
}
Как мы видим, он просто вызывает конструктор родительского класса. Так будет происходить, пока мы не создадим собственный конструктор.
Давайте добавим конструктор для Rabbit
. Он будет устанавливать earLength
в дополнение к name
:
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;
}
// ...
}
// Не работает!
let rabbit = new Rabbit("Белый кролик", 10); // Error: this is not defined.
Упс! При создании кролика – ошибка! Что не так?
Если коротко, то:
- Конструкторы в наследуемых классах должны обязательно вызывать
super(...)
, и (!) делать это перед использованиемthis
.
…Но почему? Что происходит? Это требование кажется довольно странным.
Конечно, всему есть своё объяснение. Давайте углубимся в детали, чтобы вы действительно поняли, что происходит.
В JavaScript существует различие между «функцией-конструктором наследующего класса» и всеми остальными. В наследующем классе соответствующая функция-конструктор помечена специальным внутренним свойством [[ConstructorKind]]:"derived"
.
Разница в следующем:
- Когда выполняется обычный конструктор, он создаёт пустой объект и присваивает его
this
. - Когда запускается конструктор унаследованного класса, он этого не делает. Вместо этого он ждёт, что это сделает конструктор родительского класса.
Поэтому, если мы создаём собственный конструктор, мы должны вызвать super
, в противном случае объект для this
не будет создан, и мы получим ошибку.
Чтобы конструктор Rabbit
работал, он должен вызвать super()
до того, как использовать this
, чтобы не было ошибки:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// теперь работает
let rabbit = new Rabbit("Белый кролик", 10);
alert(rabbit.name); // Белый кролик
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() { // вместо this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // вместо 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.
К счастью, такое поведение проявляется только в том случае, когда переопределенное поле используется в родительском конструкторе. Тогда может быть трудно понять, что происходит, поэтому мы объясняем это здесь.
Если это становится проблемой, её можно решить, используя методы или геттеры/сеттеры вместо полей.
Устройство super, [[HomeObject]]
Если вы читаете учебник первый раз – эту секцию можно пропустить.
Она рассказывает о внутреннем устройстве наследования и вызовe super
.
Давайте заглянем «под капот» super
. Здесь есть некоторые интересные моменты.
Вообще, исходя из наших знаний до этого момента, super
вообще не может работать!
Ну правда, давайте спросим себя – как он должен работать, чисто технически? Когда метод объекта выполняется, он получает текущий объект как this
. Если мы вызываем super.method()
, то движку необходимо получить method
из прототипа текущего объекта. И как ему это сделать?
Задача может показаться простой, но это не так. Движок знает текущий this
и мог бы попытаться получить родительский метод как this.__proto__.method
. Однако, увы, такой «наивный» путь не работает.
Продемонстрируем проблему. Без классов, используя простые объекты для наглядности.
Вы можете пропустить эту часть и перейти ниже к подсекции [[HomeObject]]
, если не хотите знать детали. Вреда не будет. Или читайте далее, если хотите разобраться.
В примере ниже rabbit.__proto__ = animal
. Попробуем в rabbit.eat()
вызвать animal.eat()
, используя this.__proto__
:
let animal = {
name: "Animal",
eat() {
alert(`${this.name} ест.`);
}
};
let rabbit = {
__proto__: animal,
name: "Кролик",
eat() {
// вот как предположительно может работать super.eat()
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // Кролик ест.
В строке (*)
мы берём eat
из прототипа (animal
) и вызываем его в контексте текущего объекта. Обратите внимание, что .call(this)
здесь неспроста: простой вызов this.__proto__.eat()
будет выполнять родительский eat
в контексте прототипа, а не текущего объекта.
Приведённый выше код работает так, как задумано: выполняется нужный alert
.
Теперь давайте добавим ещё один объект в цепочку наследования и увидим, как все сломается:
let animal = {
name: "Животное",
eat() {
alert(`${this.name} ест.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...делаем что-то специфичное для кролика и вызываем родительский (animal) метод
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ...делаем что-то, связанное с длинными ушами, и вызываем родительский (rabbit) метод
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
.// внутри longEar.eat() у нас this = longEar this.__proto__.eat.call(this) // (**) // становится longEar.__proto__.eat.call(this) // то же что и rabbit.eat.call(this);
-
В строке
(*)
вrabbit.eat
мы хотим передать вызов выше по цепочке, ноthis=longEar
, поэтомуthis.__proto__.eat
снова равенrabbit.eat
!// внутри rabbit.eat() у нас также this = longEar this.__proto__.eat.call(this) // (*) // становится longEar.__proto__.eat.call(this) // или (снова) rabbit.eat.call(this);
-
…
rabbit.eat
вызывает себя в бесконечном цикле, потому что не может подняться дальше по цепочке.
Проблема не может быть решена с помощью одного только this
.
[[HomeObject]]
Для решения этой проблемы в JavaScript было добавлено специальное внутреннее свойство для функций: [[HomeObject]]
.
Когда функция объявлена как метод внутри класса или объекта, её свойство [[HomeObject]]
становится равно этому объекту.
Затем super
использует его, чтобы получить прототип родителя и его методы.
Давайте посмотрим, как это работает – опять же, используя простые объекты:
let animal = {
name: "Животное",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} ест.`);
}
};
let rabbit = {
__proto__: animal,
name: "Кролик",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "Длинноух",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// работает верно
longEar.eat(); // Длинноух ест.
Это работает как задумано благодаря [[HomeObject]]
. Метод, такой как longEar.eat
, знает свой [[HomeObject]]
и получает метод родителя из его прототипа. Вообще без использования this
.
Методы не «свободны»
До этого мы неоднократно видели, что функции в JavaScript «свободны», не привязаны к объектам. Их можно копировать между объектами и вызывать с любым this
.
Но само существование [[HomeObject]]
нарушает этот принцип, так как методы запоминают свои объекты. [[HomeObject]]
нельзя изменить, эта связь – навсегда.
Единственное место в языке, где используется [[HomeObject]]
– это super
. Поэтому если метод не использует super
, то мы все ещё можем считать его свободным и копировать между объектами. А вот если super
в коде есть, то возможны побочные эффекты.
Вот пример неверного результата super
после копирования:
let animal = {
sayHi() {
alert("Я животное");
}
};
// rabbit наследует от animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("Я растение");
}
};
// tree наследует от plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // Я животное (?!?)
Вызов tree.sayHi()
показывает «Я животное». Определённо неверно.
Причина проста:
- В строке
(*)
, методtree.sayHi
скопирован изrabbit
. Возможно, мы хотели избежать дублирования кода? - Его
[[HomeObject]]
– этоrabbit
, ведь он был создан вrabbit
. Свойство[[HomeObject]]
никогда не меняется. - В коде
tree.sayHi()
есть вызовsuper.sayHi()
. Он идёт вверх отrabbit
и берёт метод изanimal
.
Вот диаграмма происходящего:
Методы, а не свойства-функции
Свойство [[HomeObject]]
определено для методов как классов, так и обычных объектов. Но для объектов методы должны быть объявлены именно как method()
, а не "method: function()"
.
Для нас различий нет, но они есть для JavaScript.
В приведённом ниже примере используется синтаксис не метода, свойства-функции. Поэтому у него нет [[HomeObject]]
, и наследование не работает:
let animal = {
eat: function() { // намеренно пишем так, а не eat() { ...
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // Ошибка вызова super (потому что нет [[HomeObject]])
Итого
- Чтобы унаследовать от класса:
class Child extends Parent
:- При этом
Child.prototype.__proto__
будет равенParent.prototype
, так что методы будут унаследованы.
- При этом
- При переопределении конструктора:
- Обязателен вызов конструктора родителя
super()
в конструктореChild
до обращения кthis
.
- Обязателен вызов конструктора родителя
- При переопределении другого метода:
- Мы можем вызвать
super.method()
в методеChild
для обращения к методу родителяParent
.
- Мы можем вызвать
- Внутренние детали:
- Методы запоминают свой объект во внутреннем свойстве
[[HomeObject]]
. Благодаря этому работаетsuper
, он в его прототипе ищет родительские методы. - Поэтому копировать метод, использующий
super
, между разными объектами небезопасно.
- Методы запоминают свой объект во внутреннем свойстве
Также:
- У стрелочных функций нет своего
this
иsuper
, поэтому они «прозрачно» встраиваются во внешний контекст.