Наслідування класу – це коли один клас розширює інший.
Таким чином, ми можемо створити нову функціональність на основі тої, що існує.
Ключове слово “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
.
Внутрішньо, ключове слово extends
працює за допомогою старої-доброї механіки прототипу. Він встановлює в Rabbit.prototype.[[Prototype]]
значення Animal.prototype
. Тому, якщо метод не знайдено в Rabbit.prototype
, JavaScript бере його з Animal.prototype
.
Наприклад, для пошуку методу rabbit.run
, рушій перевіряє (знизу вгору на рисунку):
- Об’єкт
rabbit
(не має методуrun
). - Прототип
Rabbit
, тобтоRabbit.prototype
(маєhide
, але не маєrun
). - Прототип
Animal
, тобтоAnimal.prototype
(завдякиextends
), який, нарешті, має метод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("Привіт")
.
Це може бути корисним для просунутих шаблонів програмування, коли ми використовуємо функції для створення класів залежно від багатьох умов і можемо успадкуватися від них.
Перевизначення методу
Тепер рухаймося вперед і перевизначимо метод. Типово, всі методи, які не вказані в class Rabbit
, беруться безпосередньо “як є” від класу Animal
.
Але якщо ми вкажемо наш власний метод в Rabbit
, наприклад, stop()
, то він буде використовуватися замість методу з класу Animal
:
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(); // а потім ховається
}
}
let rabbit = new Rabbit("Білий Кролик");
rabbit.run(5); // Білий Кролик біжить зі швидкістю 5.
rabbit.stop(); // Білий Кролик стоїть. Білий Кролик ховається!
Тепер Кролик
має метод 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"
. Це особлива внутрішня позначка.
Ця позначка впливає на поведінку функції-конструктора з new
.
- Коли звичайна функція виконується з ключовим словом
new
, воно створює порожній об’єкт і присвоює його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 = 'тварина';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'кролик';
}
new Animal(); // тварина
new Rabbit(); // тварина
Тут клас Rabbit
наслідує клас Animal
і перевизначає поле name
власним значенням.
Немає власного конструктора в Rabbit
, тому викликається конструктор Animal
.
Цікаво, що в обох випадках: new Animal()
і new Rabbit()
, alert
в рядку (*)
показує animal
.
Інакше кажучи, батьківський конструктор завжди використовує власне значення поля, а не перевизначене.
Що в цьому дивного?
Якщо це ще не зрозуміло, будь ласка, порівняйте з методами.
Ось той самий код, але замість поля this.name
ми викликаємо метод this.showName()
.
class Animal {
showName() { // замість this.name = 'тварина'
alert('тварина');
}
constructor() {
this.showName(); // замість alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('кролик');
}
}
new Animal(); // тварина
new Rabbit(); // кролик
Будь ласка, зверніть увагу: тепер вивід відрізняється.
І це те, що ми, дійсно, очікуємо. Коли батьківський конструктор викликається в похідному класі, він використовує перевизначений метод.
…Але для полів класу це не так. Як сказано, батьківський конструктор завжди використовує батьківське поле.
Чому існує різниця?
Причина полягає у порядку ініціалізації поля. Поле класу ініціалізується:
- До конструктора для базового класу (котрий нічого не наслідує),
- Відразу після
super()
для похідного класу.
У нашому випадку Rabbit
– це похідний клас. У ньому немає конструктора. Як сказано раніше, це те ж саме, якби там був порожній конструктор лише з super(...args)
.
Отже, new Rabbit()
викликає super()
, таким чином, виконуючи батьківський конструктор, і (за правилом для похідних класів) лише після того ініціалізує свої поля класу. На момент виконання батьківського конструктора, ще немає полів класу Rabbit
, тому використовуються класу Animal
.
Ця тонка різниця між полями та методами є специфічною для JavaScript
На щастя, ця поведінка виявляє себе лише якщо перевизначене поле використовується у батьківському конструкторі. Тоді важко зрозуміти, що відбувається, тому ми пояснюємо це тут.
Якщо це стає проблемою, її можна вирішити за допомогою методів або геттерів/сеттерів, а не полів.
Super: властивості, [[HomeObject]]
Якщо ви читаєте підручник вперше – цей розділ можете пропустити.
В ньому йде мова про внутрішній механізм наслідування та super
.
Подивімося трохи глибше під капот super
. Ми побачимо деякі цікаві речі.
Перш за все, з усього, що ми дізналися дотепер, super
взагалі не може працювати!
Так, дійсно, поставмо собі питання, як він повинен технічно працювати? Коли метод об’єкта запускається, він отримує поточний об’єкт як this
. Якщо ми викликаємо super.method()
, рушій повинен отримати method
від прототипу поточного об’єкта. Але як?
Завдання може здатися простим, але це не так. Рушій знає поточний об’єкт this
, тому він міг би отримати батьківський method
як this.__proto__.method
. На жаль, таке “нативне” рішення не буде працювати.
Продемонструймо проблему. Без класів, використовуючи прості об’єкти заради наочністі.
Ви можете пропустити цю частину та перейти нижче до розділу [[HomeObject]]
якщо ви не хочете знати деталі. Це не завдасть шкоди вашому загальному розумінню. Або читайте, якщо ви зацікавлені в розумінні поглиблених речей.
У прикладі нижче, rabbit.__proto__ = animal
. Тепер спробуймо: у rabbit.eat()
ми викличемо animal.eat()
, використовуючи this.__proto__
:
let animal = {
name: "Тварина",
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(`Я тварина`);
}
};
// кролик наслідується від тварини
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("Я рослина");
}
};
// дерево наслідується від рослини
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // Я тварина (?!?)
Виклик до tree.sayHi()
показує “я тварина”. Безумовно, це неправильно.
Причина проста:
- У рядку
(*)
, методtree.sayHi
був скопійований зrabbit
. Можливо, ми просто хотіли уникнути дублювання коду? - Його
[[homeobject]]
– цеrablit
, оскільки метод було створено в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
, тому вони прозоро вписуються в навколишній контекст.