22 лютого 2024 р.

Наслідування класу

Наслідування класу – це коли один клас розширює інший.

Таким чином, ми можемо створити нову функціональність на основі тої, що існує.

Ключове слово “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, рушій перевіряє (знизу вгору на рисунку):

  1. Об’єкт rabbit (не має методу run).
  2. Прототип Rabbit, тобто Rabbit.prototype (має hide, але не має run).
  3. Прототип 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. При цьому не піднімаються ланцюжком наслідування та перебувають в нескінченній петлі.

Ось картина того, що відбувається:

  1. Всередині longEar.eat(), рядок (**) викликає rabbit.eat надаючи йому this=longEar.

    // всередині longEar.eat() у нас є this = longEar
    this.__proto__.eat.call(this) // (**)
    // стає
    longEar.__proto__.eat.call(this)
    // тобто те саме, що
    rabbit.eat.call(this);
  2. Тоді в рядку (*) в 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);
  3. …Отже, 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]])

Підсумки

  1. Щоб розширити клас треба використовувати синтакс: class Child extends Parent:
    • Це означає Child.prototype.__proto__ буде Parent.prototype, таким чином методи успадковуються.
  2. При перевизначенні конструктора:
    • Ми повинні викликати батьківський конструктор super() в Child конструкторі перед використанням this.
  3. При перевизначенні іншого методу:
    • Ми повинні використовувати super.method() в методі Child, щоб викликати Parent метод.
  4. Внутрішні деталі:
    • Методи запам’ятовують їх клас/об’єкт у внутрішній властивості [[HomeObject]]. Ось як super знаходить батьківські методи.
    • Так що це не безпечно копіювати метод з super від одного об’єкта до іншого.

Також:

  • Стрілочні функції не мають власного this або super, тому вони прозоро вписуються в навколишній контекст.

Завдання

важливість: 5

Ось код з Rabbit розширює Animal.

На жаль, неможливо створити об’єкти 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("Білий кролик"); // зараз добре
alert(rabbit.name); // Білий кролик
важливість: 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);
  }
}

Створіть новий клас ExtendedClock, який успадковує від Clock і додає precision – кількість мс між “цоканнями”. Типово, інтервал повинен бути 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);
  }
};

Відкрити рішення в пісочниці.

Навчальна карта