Наследование классов — это способ расширения одного класса другим классом.
Таким образом, мы можем создавать новые функции поверх существующих.
Допустим, у нас есть класс Animal
:
класс Животное { конструктор(имя) { this.speed = 0; это.имя = имя; } бежать (скорость) { this.speed = скорость; alert(`${this.name} работает со скоростью ${this.speed}.`); } останавливаться() { this.speed = 0; alert(`${this.name} стоит на месте.`); } } let Animal = new Animal("Мое животное");
Вот как мы можем графически представить объект animal
и класс Animal
:
…И мы хотели бы создать еще один class Rabbit
.
Поскольку кролики — животные, класс Rabbit
должен быть основан на Animal
и иметь доступ к методам животных, чтобы кролики могли делать то, что могут делать «обычные» животные.
Синтаксис расширения другого класса: class Child extends Parent
.
Давайте создадим class Rabbit
, который наследуется от Animal
:
класс Rabbit расширяет Animal { скрывать() { alert(`${this.name} скрывается!`); } } let Rabbit = new Rabbit("Белый Кролик"); кролик.run(5); // Белый Кролик бежит со скоростью 5. кролик.скрыть(); // Белый Кролик прячется!
Объект класса 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
.
Например, вызов функции, которая генерирует родительский класс:
функция f(фраза) { возвращаемый класс { SayHi() { оповещение (фраза); } }; } класс User расширяет f("Hello") {} новый Пользователь().sayHi(); // Привет
Здесь class User
наследуется от результата f("Hello")
.
Это может быть полезно для расширенных шаблонов программирования, когда мы используем функции для создания классов в зависимости от многих условий и можем наследовать от них.
Теперь давайте двинемся дальше и переопределим метод. По умолчанию все методы, не указанные в class Rabbit
берутся напрямую «как есть» из class Animal
.
Но если мы укажем в Rabbit
наш собственный метод, например, stop()
то вместо него будет использоваться:
класс Rabbit расширяет Animal { останавливаться() { // ...теперь это будет использоваться для Rabbit.stop() // вместо stop() из класса Animal } }
Однако обычно мы не хотим полностью заменять родительский метод, а скорее надстраиваем его поверх него, чтобы настроить или расширить его функциональность. Мы что-то делаем в нашем методе, но вызываем родительский метод до/после этого или в процессе.
Классы предоставляют для этого ключевое слово "super"
.
super.method(...)
для вызова родительского метода.
super(...)
для вызова родительского конструктора (только внутри нашего конструктора).
Например, пусть наш кролик автоматически скрывается при остановке:
класс Животное { конструктор(имя) { this.speed = 0; это.имя = имя; } бежать (скорость) { this.speed = скорость; alert(`${this.name} работает со скоростью ${this.speed}.`); } останавливаться() { this.speed = 0; alert(`${this.name} стоит на месте.`); } } класс Rabbit расширяет Animal { скрывать() { alert(`${this.name} скрывается!`); } останавливаться() { супер.стоп(); // вызываем родительскую остановку это.скрыть(); // и затем скрываем } } let Rabbit = new Rabbit("Белый Кролик"); кролик.run(5); // Белый Кролик бежит со скоростью 5. кролик.стоп(); // Белый Кролик стоит на месте. Белый Кролик прячется!
Теперь Rabbit
есть метод stop
, который в процессе вызывает родительский метод super.stop()
.
Стрелочные функции не имеют super
Как упоминалось в главе «Возврат к стрелочным функциям», стрелочные функции не имеют super
.
Если есть доступ, он берется из внешней функции. Например:
класс Rabbit расширяет Animal { останавливаться() { setTimeout(() => super.stop(), 1000); // вызываем родительскую остановку через 1 секунду } }
super
в функции стрелки такой же, как и в stop()
, поэтому он работает по назначению. Если бы мы указали здесь «обычную» функцию, возникла бы ошибка:
// Неожиданный супер setTimeout(function() { super.stop() }, 1000);
С конструкторами все немного сложнее.
До сих пор у Rabbit
не было своего constructor
.
Согласно спецификации, если класс расширяет другой класс и не имеет constructor
, то генерируется следующий «пустой» constructor
:
класс Rabbit расширяет Animal { // генерируется для расширения классов без собственных конструкторов конструктор(...args) { супер(...аргументы); } }
Как мы видим, он в основном вызывает родительский constructor
передавая ему все аргументы. Это произойдет, если мы не напишем собственный конструктор.
Теперь давайте добавим в Rabbit
собственный конструктор. В дополнение к name
будет указана earLength
:
класс Животное { конструктор(имя) { this.speed = 0; это.имя = имя; } // ... } класс Rabbit расширяет Animal { конструктор(имя, EarLength) { this.speed = 0; это.имя = имя; this.earLength = EarLength; } // ... } // Не работает! let Rabbit = new Rabbit("Белый Кролик", 10); // Ошибка: это не определено.
Упс! У нас ошибка. Теперь мы не можем создавать кроликов. Что пошло не так?
Краткий ответ:
Конструкторы в наследующих классах должны вызвать super(...)
и (!) сделать это перед использованием this
.
…Но почему? Что здесь происходит? Действительно, требование кажется странным.
Конечно, есть объяснение. Давайте углубимся в детали, чтобы вы действительно поняли, что происходит.
В JavaScript существует различие между функцией-конструктором наследующего класса (так называемый «производный конструктор») и другими функциями. Производный конструктор имеет специальное внутреннее свойство [[ConstructorKind]]:"derived"
. Это особый внутренний ярлык.
Эта метка влияет на его поведение с помощью new
.
Когда обычная функция выполняется с помощью new
, она создает пустой объект и присваивает его this
.
Но когда запускается производный конструктор, он этого не делает. Он ожидает, что родительский конструктор выполнит эту работу.
Таким образом, производный конструктор должен вызвать super
, чтобы выполнить свой родительский (базовый) конструктор, иначе объект для this
не будет создан. И получим ошибку.
Чтобы конструктор Rabbit
работал, ему необходимо вызвать super()
перед использованием this
, как здесь:
класс Животное { конструктор(имя) { this.speed = 0; это.имя = имя; } // ... } класс Rabbit расширяет Animal { конструктор(имя, EarLength) { супер(имя); this.earLength = EarLength; } // ... } // теперь все в порядке let Rabbit = new Rabbit("Белый Кролик", 10); оповещение(кролик.имя); // Белый Кролик оповещение(кролик.earLength); // 10
Расширенное примечание
В этом примечании предполагается, что у вас есть определенный опыт работы с классами, возможно, на других языках программирования.
Он обеспечивает лучшее понимание языка, а также объясняет поведение, которое может быть источником ошибок (но не очень часто).
Если вам сложно понять, просто продолжайте читать, а затем вернитесь к нему через некоторое время.
Мы можем переопределять не только методы, но и поля классов.
Однако при доступе к переопределенному полю в родительском конструкторе возникает сложное поведение, которое сильно отличается от большинства других языков программирования.
Рассмотрим этот пример:
класс Животное { имя = 'животное'; конструктор() { оповещение(это.имя); // (*) } } класс Rabbit расширяет Animal { имя = 'кролик'; } новое животное(); // животное новый Кролик(); // животное
Здесь класс Rabbit
расширяет Animal
и заменяет поле name
своим собственным значением.
В Rabbit
нет собственного конструктора, поэтому называется конструктор Animal
.
Что интересно, в обоих случаях: new Animal()
и new Rabbit()
alert
в строке (*)
показывает animal
.
Другими словами, родительский конструктор всегда использует собственное значение поля, а не переопределенное.
Что в этом странного?
Если еще не ясно, пожалуйста, сравните с методами.
Вот тот же код, но вместо поля this.name
мы вызываем метод this.showName()
:
класс Животное { showName() { // вместо this.name = 'животное' оповещение('животное'); } конструктор() { это.показатьИмя(); // вместо alert(this.name); } } класс Rabbit расширяет Animal { шоуИмя() { оповещение('кролик'); } } новое животное(); // животное новый Кролик(); // кролик
Обратите внимание: теперь вывод другой.
И это то, чего мы, естественно, ожидаем. Когда родительский конструктор вызывается в производном классе, он использует переопределенный метод.
…Но для полей классов это не так. Как уже было сказано, родительский конструктор всегда использует родительское поле.
Почему существует разница?
Ну, причина в порядке инициализации полей. Поле класса инициализируется:
Перед конструктором базового класса (который ничего не расширяет),
Сразу после super()
для производного класса.
В нашем случае Rabbit
— это производный класс. В нем нет constructor()
. Как было сказано ранее, это то же самое, как если бы существовал пустой конструктор только с super(...args)
.
Итак, new Rabbit()
вызывает super()
, тем самым выполняя родительский конструктор, и (согласно правилу для производных классов) только после этого инициализируются поля его класса. На момент выполнения родительского конструктора полей класса Rabbit
еще нет, поэтому используются поля Animal
.
Эта тонкая разница между полями и методами специфична для JavaScript.
К счастью, такое поведение проявляется только в том случае, если в родительском конструкторе используется переопределенное поле. Тогда может быть трудно понять, что происходит, поэтому мы объясняем это здесь.
Если это становится проблемой, ее можно исправить, используя методы или методы получения/установки вместо полей.
Дополнительная информация
Если вы читаете руководство впервые – этот раздел можно пропустить.
Речь идет о внутренних механизмах наследования и super
.
Давайте заглянем немного глубже под капот super
. По пути мы увидим кое-что интересное.
Во-первых, судя по всему, что мы узнали до сих пор, super
вообще не может работать!
Да, действительно, давайте спросим себя, как это должно работать технически? Когда запускается метод объекта, он получает текущий объект в this
виде. Если мы вызовем super.method()
, движку необходимо получить method
из прототипа текущего объекта. Но как?
Задача может показаться простой, но это не так. Движок знает текущий объект this
, поэтому он может получить родительский method
как this.__proto__.method
. К сожалению, такое «наивное» решение не сработает.
Давайте продемонстрируем проблему. Без классов, с использованием простых объектов для простоты.
Вы можете пропустить эту часть и перейти к подразделу [[HomeObject]]
если не хотите знать подробности. Это не повредит. Или читайте дальше, если вы заинтересованы в более глубоком понимании вещей.
В приведенном ниже примере rabbit.__proto__ = animal
. Теперь попробуем: в rabbit.eat()
мы вызовем animal.eat()
, используя this.__proto__
:
пусть животное = { название: «Животное», есть() { alert(`${this.name} eats.`); } }; пусть кролик = { __proto__: животное, название: «Кролик», есть() { // вот как предположительно может работать super.eat() this.__proto__.eat.call(это); // (*) } }; кролик.есть(); // Кролик ест.
В строке (*)
мы берём eat
из прототипа ( animal
) и вызываем его в контексте текущего объекта. Обратите внимание, что .call(this)
здесь важен, потому что простой this.__proto__.eat()
будет выполнять родительский eat
в контексте прототипа, а не текущего объекта.
И в приведенном выше коде это действительно работает так, как задумано: у нас есть правильный alert
.
Теперь добавим в цепочку еще один объект. Посмотрим, как все обернется:
пусть животное = { название: «Животное», есть() { alert(`${this.name} eats.`); } }; пусть кролик = { __proto__: животное, есть() { // ...подпрыгиваем в стиле кролика и вызываем родительский (животный) метод this.__proto__.eat.call(это); // (*) } }; пусть longEar = { __proto__: кролик, есть() { // ...сделаем что-нибудь с длинными ушами и вызовем родительский (кроличий) метод this.__proto__.eat.call(это); // (**) } }; longEar.eat(); // Ошибка: превышен максимальный размер стека вызовов
Код больше не работает! Мы видим ошибку при попытке вызвать longEar.eat()
.
Это может быть не так очевидно, но если мы отследим вызов longEar.eat()
, то поймем, почему. В обеих строках (*)
и (**)
this
является текущий объект ( longEar
). Это очень важно: все методы объекта получают текущий объект как this
, а не прототип или что-то в этом роде.
Итак, в обеих строках (*)
и (**)
значение this.__proto__
одинаково: rabbit
. Они оба вызывают rabbit.eat
, не переходя вверх по цепочке в бесконечном цикле.
Вот картина того, что происходит:
Внутри longEar.eat()
строка (**)
вызывает rabbit.eat
, передавая ему this=longEar
.
// внутри longEar.eat() у нас есть это = longEar this.__proto__.eat.call(this) // (**) // становится longEar.__proto__.eat.call(это) // то есть кролик.есть.вызов(это);
Затем в строке (*)
rabbit.eat
мы хотели бы передать вызов еще выше в цепочке, но this=longEar
, поэтому this.__proto__.eat
снова будет rabbit.eat
!
// внутри Rabbit.eat() у нас также есть это = longEar this.__proto__.eat.call(this) // (*) // становится longEar.__proto__.eat.call(это) // или (опять) кролик.есть.вызов(это);
…Итак, rabbit.eat
вызывает себя в бесконечном цикле, потому что он не может подняться дальше.
Проблему невозможно решить, используя только this
.
[[HomeObject]]
Чтобы обеспечить решение, JavaScript добавляет еще одно специальное внутреннее свойство для функций: [[HomeObject]]
.
Когда функция указана как метод класса или объекта, ее свойство [[HomeObject]]
становится этим объектом.
Затем super
использует его для разрешения родительского прототипа и его методов.
Давайте посмотрим, как это работает, сначала с простыми объектами:
пусть животное = { название: «Животное», eat() { // Animal.eat.[[HomeObject]] == животное alert(`${this.name} eats.`); } }; пусть кролик = { __proto__: животное, название: «Кролик», eat() { // Rabbit.eat.[[HomeObject]] == кролик супер.есть(); } }; пусть longEar = { __proto__: кролик, название: «Длинное ухо», eat() { // longEar.eat.[[HomeObject]] == longEar супер.есть(); } }; // работает правильно longEar.eat(); // Длинноухое ест.
Он работает так, как задумано, благодаря механике [[HomeObject]]
. Такой метод, как longEar.eat
, знает свой [[HomeObject]]
и берет родительский метод из своего прототипа. Без всякого использования this
.
Как мы уже знали, функции в JavaScript обычно «свободны» и не привязаны к объектам. Таким образом, их можно копировать между объектами и вызывать с помощью другого this
.
Само существование [[HomeObject]]
нарушает этот принцип, поскольку методы запоминают свои объекты. [[HomeObject]]
нельзя изменить, поэтому эта связь вечна.
Единственное место в языке, где используется [[HomeObject]]
— это super
. Итак, если метод не использует super
, мы все равно можем считать его свободным и копировать между объектами. Но с super
что-то может пойти не так.
Вот демонстрация неправильного super
после копирования:
пусть животное = { сказатьПривет() { alert(`Я животное`); } }; // кролик наследует от животного пусть кролик = { __proto__: животное, сказатьПривет() { супер.sayHi(); } }; пусть растение = { сказатьПривет() { alert("Я растение"); } }; // дерево наследует от растения пусть дерево = { __proto__: растение, SayHi: Rabbit.sayHi // (*) }; дерево.sayHi(); // Я животное (?!?)
Вызов tree.sayHi()
показывает: «Я животное». Определенно неправильно.
Причина проста:
В строке (*)
метод tree.sayHi
был скопирован из rabbit
. Может быть, мы просто хотели избежать дублирования кода?
Его [[HomeObject]]
— rabbit
, поскольку он был создан в rabbit
. Невозможно изменить [[HomeObject]]
.
В коде tree.sayHi()
есть super.sayHi()
внутри. Он исходит от rabbit
и берет метод от animal
.
Вот схема того, что происходит:
[[HomeObject]]
определяется для методов как в классах, так и в простых объектах. Но для объектов методы должны быть указаны именно как method()
, а не как "method: function()"
.
Для нас разница может быть несущественной, но для JavaScript она важна.
В приведенном ниже примере для сравнения используется синтаксис, не являющийся методом. Свойство [[HomeObject]]
не установлено и наследование не работает:
пусть животное = { eat: function() { // намеренно написал так вместо eat() {... // ... } }; пусть кролик = { __proto__: животное, есть: функция() { супер.есть(); } }; кролик.есть(); // Ошибка при вызове super (потому что нет [[HomeObject]])
Чтобы расширить класс: class Child extends Parent
:
Это означает, что Child.prototype.__proto__
будет Parent.prototype
, поэтому методы наследуются.
При переопределении конструктора:
Мы должны вызвать родительский конструктор как super()
в Child
конструкторе перед использованием this
.
При переопределении другого метода:
Мы можем использовать super.method()
в Child
методе для вызова Parent
метода.
Внутренности:
Методы запоминают свой класс/объект во внутреннем свойстве [[HomeObject]]
. Вот как super
разрешает родительские методы.
Поэтому копировать метод с помощью super
из одного объекта в другой небезопасно.
Также:
Стрелочные функции не имеют собственных this
или super
, поэтому они прозрачно вписываются в окружающий контекст.
важность: 5
Вот код, в котором Rabbit
расширяет Animal
.
К сожалению, объекты Rabbit
создать невозможно. В чем дело? Исправьте это.
класс Животное { конструктор(имя) { это.имя = имя; } } класс Rabbit расширяет Animal { конструктор(имя) { это.имя = имя; this.created = Date.now(); } } let Rabbit = new Rabbit("Белый Кролик"); // Ошибка: это не определено оповещение(кролик.имя);
Это потому, что дочерний конструктор должен вызывать super()
.
Вот исправленный код:
класс Животное { конструктор(имя) { это.имя = имя; } } класс Rabbit расширяет Animal { конструктор(имя) { супер(имя); this.created = Date.now(); } } let Rabbit = new Rabbit("Белый Кролик"); // ок, сейчас оповещение(кролик.имя); // Белый Кролик
важность: 5
У нас есть класс Clock
. На данный момент он печатает время каждую секунду.
класс Часы { конструктор({шаблон }) { this.template = шаблон; } оказывать() { пусть дата = новая дата(); пусть часы = date.getHours(); если (часы < 10) часы = '0' + часы; пусть mins = date.getMinutes(); если (минуты < 10) минуты = '0' + минуты; пусть секунды = date.getSeconds(); если (секунды < 10) секунды = '0' + секунды; пусть вывод = this.template .replace('ч', часы) .replace('m', мин) .replace('s', секунды); console.log(выход); } останавливаться() { ClearInterval(this.timer); } начинать() { это.рендер(); this.timer = setInterval(() => this.render(), 1000); } }
Создайте новый класс ExtendedClock
, который наследуется от Clock
и добавляет precision
параметра — количество ms
между «тиками». По умолчанию должно быть 1000
(1 секунда).
Ваш код должен находиться в файле extended-clock.js
Не изменяйте исходный файл clock.js
. Расширьте его.
Откройте песочницу для задачи.
класс ExtendedClock расширяет Clock { конструктор (опции) { супер(варианты); пусть {точность = 1000} = варианты; this.precision = точность; } начинать() { это.рендер(); this.timer = setInterval(() => this.render(), this.precision); } };
Откройте решение в песочнице.