Наследование – это создание новых «классов» на основе существующих.
В JavaScript его можно реализовать несколькими путями, один из которых – с использованием наложения конструкторов, мы рассмотрим в этой главе.
Зачем наследование?
Ранее мы обсуждали различные реализации кофеварки. Продолжим эту тему далее.
Хватит ли нам только кофеварки для удобной жизни? Вряд ли… Скорее всего, ещё понадобятся как минимум холодильник, микроволновка, а возможно и другие машины.
В реальной жизни у этих машин есть базовые правила пользования. Например, большая кнопка – включение, шнур с розеткой нужно воткнуть в питание и т.п.
Можно сказать, что «у всех машин есть общие свойства, а конкретные машины могут их дополнять».
Именно поэтому, увидев новую технику, мы уже можем что-то с ней сделать, даже не читая инструкцию.
Механизм наследования позволяет определить базовый класс Машина
, в нём описать то, что свойственно всем машинам, а затем на его основе построить другие, более конкретные: Кофеварка
, Холодильник
и т.п.
В веб-разработке нам могут понадобиться классы Меню
, Табы
, Диалог
и другие компоненты интерфейса. В них всех обычно есть что-то общее.
Можно выделить такую общую функциональность в класс Компонент
и наследовать их от него, чтобы не дублировать код.
Наследование от Machine
Базовый класс «машина» Machine
будет реализовывать общего вида методы «включить» enable()
и «выключить» disable()
:
function Machine() {
var enabled = false;
this.enable = function() {
enabled = true;
};
this.disable = function() {
enabled = false;
};
}
Унаследуем от него кофеварку. При этом она получит эти методы автоматически:
function CoffeeMachine(power) {
Machine.call(this); // отнаследовать
var waterAmount = 0;
this.setWaterAmount = function(amount) {
waterAmount = amount;
};
}
var coffeeMachine = new CoffeeMachine(10000);
coffeeMachine.enable();
coffeeMachine.setWaterAmount(100);
coffeeMachine.disable();
Наследование реализовано вызовом Machine.call(this)
в начале конструктора CoffeeMachine
.
Он вызывает функцию Machine
, передавая ей в качестве контекста this
текущий объект. Machine
, в процессе выполнения, записывает в this
различные полезные свойства и методы, в нашем случае this.enable
и this.disable
.
Далее конструктор CoffeeMachine
продолжает выполнение и может добавить свои свойства и методы.
В результате мы получаем объект coffeeMachine
, который включает в себя методы из Machine
и CoffeeMachine
.
Защищённые свойства
В коде выше есть одна проблема.
Наследник не имеет доступа к приватным свойствам родителя.
Иначе говоря, если кофеварка захочет обратиться к enabled
, то её ждёт разочарование:
function Machine() {
var enabled = false;
this.enable = function() {
enabled = true;
};
this.disable = function() {
enabled = false;
};
}
function CoffeeMachine(power) {
Machine.call(this);
this.enable();
// ошибка, переменная не определена!
alert( enabled );
}
var coffeeMachine = new CoffeeMachine(10000);
Это естественно, ведь enabled
– локальная переменная функции Machine
. Она находится в другой области видимости.
Чтобы наследник имел доступ к свойству, оно должно быть записано в this
.
При этом, чтобы обозначить, что свойство является внутренним, его имя начинают с подчёркивания _
.
function Machine() {
this._enabled = false; // вместо var enabled
this.enable = function() {
this._enabled = true;
};
this.disable = function() {
this._enabled = false;
};
}
function CoffeeMachine(power) {
Machine.call(this);
this.enable();
alert( this._enabled ); // true
}
var coffeeMachine = new CoffeeMachine(10000);
Подчёркивание в начале свойства – общепринятый знак, что свойство является внутренним, предназначенным лишь для доступа из самого объекта и его наследников. Такие свойства называют защищёнными.
Технически, залезть в него из внешнего кода, конечно, возможно, но приличный программист так делать не будет.
Перенос свойства в защищённые
У CoffeeMachine
есть приватное свойство power
. Сейчас мы его тоже сделаем защищённым и перенесём в Machine
, поскольку «мощность» свойственна всем машинам, а не только кофеварке.
function Machine(power) {
this._power = power; // (1)
this._enabled = false;
this.enable = function() {
this._enabled = true;
};
this.disable = function() {
this._enabled = false;
};
}
function CoffeeMachine(power) {
Machine.apply(this, arguments); // (2)
alert( this._enabled ); // false
alert( this._power ); // 10000
}
var coffeeMachine = new CoffeeMachine(10000);
Теперь все машины Machine
имеют мощность power
. Обратим внимание, что мы из параметра конструктора сразу скопировали её в объект в строке (1)
. Иначе она была бы недоступна из наследников.
В строке (2)
мы теперь вызываем не просто Machine.call(this)
, а расширенный вариант: Machine.apply(this, arguments)
, который вызывает Machine
в текущем контексте вместе с передачей текущих аргументов.
Можно было бы использовать и более простой вызов Machine.call(this, power)
, но использование apply
гарантирует передачу всех аргументов, вдруг их количество увеличится – не надо будет переписывать.
Переопределение методов
Итак, мы получили класс CoffeeMachine
, который наследует от Machine
.
Аналогичным образом мы можем унаследовать от Machine
холодильник Fridge
, микроволновку MicroOven
и другие классы, которые разделяют общую «машинную» функциональность, то есть имеют мощность и их можно включать/выключать.
Для этого достаточно вызвать Machine
в текущем контексте, а затем добавить свои методы.
// Fridge может добавить и свои аргументы,
// которые в Machine не будут использованы
function Fridge(power, temperature) {
Machine.apply(this, arguments);
// ...
}
Бывает так, что реализация конкретного метода машины в наследнике имеет свои особенности.
Можно, конечно, объявить в CoffeeMachine
свой enable
:
function CoffeeMachine(power, capacity) {
Machine.apply(this, arguments);
// переопределить this.enable
this.enable = function() {
/* enable для кофеварки */
};
}
…Однако, как правило, мы хотим не заменить, а расширить метод родителя, добавить к нему что-то. Например, сделать так, чтобы при включении кофеварка тут же запускалась.
Для этого метод родителя предварительно копируют в переменную, и затем вызывают внутри нового enable
– там, где считают нужным:
function CoffeeMachine(power) {
Machine.apply(this, arguments);
var parentEnable = this.enable; // (1)
this.enable = function() { // (2)
parentEnable.call(this); // (3)
this.run(); // (4)
}
...
}
Общая схема переопределения метода (по строкам выделенного фрагмента кода):
- Копируем доставшийся от родителя метод
this.enable
в переменную, напримерparentEnable
. - Заменяем
this.enable
на свою функцию… - …Которая по-прежнему реализует старую функциональность через вызов
parentEnable
. - …И в дополнение к нему делает что-то своё, например запускает приготовление кофе.
Обратим внимание на строку (3)
.
В ней родительский метод вызывается так: parentEnable.call(this)
. Если бы вызов был таким: parentEnable()
, то ему бы не передался текущий this
и возникла бы ошибка.
Технически, можно сделать возможность вызывать его и как parentEnable()
, но тогда надо гарантировать, что контекст будет правильным, например привязать его при помощи bind
или при объявлении, в родителе, вообще не использовать this
, а получать контекст через замыкание, вот так:
function Machine(power) {
this._enabled = false;
var self = this;
this.enable = function() {
// используем внешнюю переменную вместо this
self._enabled = true;
};
this.disable = function() {
self._enabled = false;
};
}
function CoffeeMachine(power) {
Machine.apply(this, arguments);
var waterAmount = 0;
this.setWaterAmount = function(amount) {
waterAmount = amount;
};
var parentEnable = this.enable;
this.enable = function() {
parentEnable(); // теперь можно вызывать как угодно, this не важен
this.run();
}
function onReady() {
alert( 'Кофе готово!' );
}
this.run = function() {
setTimeout(onReady, 1000);
};
}
var coffeeMachine = new CoffeeMachine(10000);
coffeeMachine.setWaterAmount(50);
coffeeMachine.enable();
В коде выше родительский метод parentEnable = this.enable
успешно продолжает работать даже при вызове без контекста. А всё потому, что использует self
внутри.
Итого
Организация наследования, которая описана в этой главе, называется «функциональным паттерном наследования».
Её общая схема (кратко):
-
Объявляется конструктор родителя
Machine
. В нём могут быть приватные (private), публичные (public) и защищённые (protected) свойства:function Machine(params) { // локальные переменные и функции доступны только внутри Machine var privateProperty; // публичные доступны снаружи this.publicProperty = ...; // защищённые доступны внутри Machine и для потомков // мы договариваемся не трогать их снаружи this._protectedProperty = ... } var machine = new Machine(...) machine.public();
-
Для наследования конструктор потомка вызывает родителя в своём контексте через
apply
. После чего может добавить свои переменные и методы:function CoffeeMachine(params) { // универсальный вызов с передачей любых аргументов Machine.apply(this, arguments); this.coffeePublicProperty = ... } var coffeeMachine = new CoffeeMachine(...); coffeeMachine.publicProperty(); coffeeMachine.coffeePublicProperty();
-
В
CoffeeMachine
свойства, полученные от родителя, можно перезаписать своими. Но обычно требуется не заменить, а расширить метод родителя. Для этого он предварительно копируется в переменную:function CoffeeMachine(params) { Machine.apply(this, arguments); var parentProtected = this._protectedProperty; this._protectedProperty = function(args) { parentProtected.apply(this, args); // (*) // ... }; }
Строку
(*)
можно упростить доparentProtected(args)
, если метод родителя не используетthis
, а, например, привязан кvar self = this
:function Machine(params) { var self = this; this._protected = function() { self.property = "value"; }; }
Надо сказать, что способ наследования, описанный в этой главе, используется нечасто.
В следующих главах мы будем изучать прототипный подход, который обладает рядом преимуществ.
Но знать и понимать его необходимо, поскольку во многих существующих библиотеках классы написаны в функциональном стиле, и расширять/наследовать от них можно только так.