Наслідування класу – це коли один клас розширює інший.
Таким чином, ми можемо створити нову функціональність на основі тої, що існує.
Ключове слово “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(); // Білий Кролик стоїть. Білий Кролик ховається!
Тепер 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"
. Це особлива внутрішня позначка.
Ця позначка впливає на поведінку функції-конструктора з 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
в рядку (*)
показує тварина
.
Інакше кажучи, батьківський конструктор завжди використовує власне значення поля, а не перевизначене.
Що в цьому дивного?
Якщо це ще не зрозуміло, будь ласка, порівняйте з методами.
Ось той самий код, але замість поля 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]]
– це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
, тому вони прозоро вписуються в навколишній контекст.
Коментарі
<code>
, для кількох рядків – обгорніть їх тегом<pre>
, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)