Наследование классов

прототипы≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Классы редко живут поодиночке: один описывает общее, другой — частный случай. extends связывает их так, что потомок бесплатно получает всё поведение родителя и дописывает поверх своё.

Разделы темы:·

Запись class Dog extends Animal говорит движку: если у Dog метода нет — ищи его у Animal. Технически прототип Dog.prototype начинает указывать на Animal.prototype, и поиск метода просто идёт на одну ступень выше.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

У Dog нет ни одной собственной строки, но объект уже умеет всё, что умел родитель. Это и есть главный смысл наследования: не переписывать общее, а уточнять частное.

extends тянет и статику: Dog.create() найдётся, если он объявлен у Animal.

Array, Map, Error — обычные классы, их тоже можно расширять. Методы вроде map при этом возвращают экземпляр вашего класса, а не голый массив.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Если у потомка есть свой constructor, он обязан вызвать super(...) — и сделать это раньше любого обращения к this. Причина честнее, чем «так принято»: конструктор потомка не создаёт объект сам. Объект создаёт конструктор родителя, а super() — это и есть момент его создания.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Переставьте строки местами — и получите ошибку. До super() переменной this просто не существует, и обращение к ней падает так же, как к переменной до её объявления.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Если конструктор потомку не нужен, его можно не писать вовсе — движок подставит невидимый constructor(...args) { super(...args); }, который передаст родителю все аргументы как есть.

Метод потомка с тем же именем полностью закрывает собой родительский. Но чаще нужно не «вместо», а «поверх»: взять результат родителя и что-то с ним сделать. Для этого и есть super.метод() — вызов родительской версии с текущим this.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Обратите внимание: super.format — это не «метод родительского объекта», отдельного объекта-родителя не существует. Это поиск метода на ступень выше по цепочке прототипов, но выполняется он на том же самом экземпляре.

Куда смотрит super, решается один раз — в момент объявления метода. Метод запоминает объект, в котором он определён, поэтому super продолжает работать, даже если вызвать метод с чужим this.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Побочный вывод: super работает только в методе, объявленном сокращённым синтаксисом, а не в обычной функции, присвоенной свойству. Стрелочные функции внутри метода super видят — они берут его из окружающего метода.

Поля класса (value = 1 прямо в теле) выглядят просто, но у них есть свой момент выполнения. У родителя они срабатывают в начале его конструктора, у потомка — сразу после возврата из super(). Значит, поле потомка перезаписывает одноимённое поле родителя.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отсюда популярная ловушка: конструктор родителя вызывает метод, который потомок переопределил, а тот опирается на поле потомка. Поля ещё не проинициализированы — получаем undefined.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
ШагЧто происходит
1Вызван конструктор потомка, this ещё не существует
2super(...) запускает конструктор родителя
3Поля родителя получают значения
4Выполняется тело конструктора родителя
5Поля потомка получают значения
6Выполняется остаток конструктора потомка

Практический вывод: не вызывайте из конструктора родителя методы, которые потомок может переопределить. Если без этого никак — вынесите такой вызов в отдельный метод init(), который потомок дёрнет сам, когда будет готов.

Наследование — самая жёсткая связь между классами: потомок зависит от каждой детали родителя, включая те, что ему не нужны. Проверка простая. Если про потомка можно сказать «это разновидность родителя» — наследование уместно. Если получается «внутри него есть родитель» — нужна композиция.

Композиция — это когда объект не наследует умение, а держит внутри другой объект, который умеет. Меняется только поле, а не форма всего дерева классов.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Вариант Car extends Engine тоже «заработал бы»: машина получила бы start(). Но заодно и всё остальное содержимое мотора, а поменять мотор на электрический стало бы невозможно без переписывания класса. Наследование стоит брать, когда общее поведение действительно одно на всех и меняться не будет; в остальных случаях сначала пробуйте композицию.

  • class B extends A связывает прототипы: чего нет у B, ищется у A — включая статические методы
  • Собственный конструктор потомка обязан вызвать super(...) до любого this: объект создаёт конструктор родителя. Без своего конструктора подставляется неявный constructor(...args) { super(...args); }
  • super.метод() вызывает родительскую версию на текущем экземпляре — удобно, когда нужно «поверх», а не «вместо»
  • Поля потомка инициализируются только после возврата из super(), поэтому конструктору родителя нельзя звать переопределяемые методы
  • «Это разновидность» — наследование, «внутри него есть» — композиция; вторая гибче и почти всегда безопаснее

1/3Что произойдёт, если в конструкторе потомка обратиться к this до вызова super()?

2/3Класс Child extends Base не объявляет собственный конструктор. Что случится при new Child(1, 2)?

3/3Что делает super.render() внутри метода класса-потомка?

Товар со скидкой

Есть класс Product. Напишите DiscountProduct, который принимает третьим аргументом процент скидки, хранит уже уменьшенную цену и дописывает к подписи пометку о скидке.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Цену пересчитываем прямо в аргументе super() — обращения к this там нет, поэтому так можно. Своё поле percent записываем уже после вызова.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Метод label не переписывает формат подписи заново, а берёт готовую строку у родителя через super.label() и дополняет её. Изменится формат в Product — потомок подхватит изменение сам.

Из наследования в композицию

Уведомления начали с наследования: EmailNotifier extends Notifier, рядом появился SmsNotifier. Теперь нужен вариант, который шлёт сразу в оба канала, — и дерево классов ломается. Переделайте на композицию так, чтобы набор каналов задавался при создании.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Канал — не «разновидность уведомителя», а его деталь. Значит, каждый канал становится отдельным объектом, а Notifier просто хранит их список.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Добавить третий канал теперь — одна строка в массиве, а не новый класс в дереве. И комбинация каналов больше не требует отдельного класса на каждое сочетание.