16 июня 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, таким как rabbit.run().

Внутри ключевое слово extends работает по старой доброй механике прототипов. Оно устанавливает Rabbit.prototype.[[Prototype]] в Animal.prototype. Таким образом, если метода не оказалось в Rabbit.prototype, JavaScript берет его из Animal.prototype.

Например, чтобы найти метод rabbit.run, движок проверяет (снизу вверх на картинке):

  1. Объект rabbit (не имеет run).
  2. Его прототип, то есть Rabbit.prototype (имеет hide, но не имеет run).
  3. Его прототип, то есть (вследствие 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 вызывается в бесконечном цикле не поднимаясь по цепочке вызовов.

Картина того, что происходит:

  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("Я животное");
  }
};

// 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]])

Итого

  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("Белый кролик"); // 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); // White Rabbit
важность: 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);
  }
};

Открыть решение в песочнице.

Карта учебника