16 KiB
Функциональное наследование
Наследование -- это создание новых "классов" на основе существующих.
В JavaScript его можно реализовать несколькими путями, один из которых -- с использованием наложения конструкторов, мы рассмотрим в этой главе. [cut]
Зачем наследование?
Ранее мы обсуждали различные реализации кофеварки. Продолжим эту тему далее.
Хватит ли нам только кофеварки для удобной жизни? Вряд ли... Скорее всего, ещё понадобятся как минимум холодильник, микроволновка, а возможно и другие машины.
В реальной жизни у этих машин есть базовые правила пользования. Например, большая кнопка -- включение, шнур с розеткой нужно воткнуть в питание и т.п.
Можно сказать, что "у всех машин есть общие свойства, а конкретные машины могут их дополнять".
Именно поэтому, увидев новую технику, мы уже можем что-то с ней сделать, даже не читая инструкцию.
Механизм наследования позволяет определить базовый класс Машина
, в нём описать то, что свойственно всем машинам, а затем на его основе построить другие, более конкретные: Кофеварка
, Холодильник
и т.п.
[smart header="В веб-разработке всё так же"]
В веб-разработке нам могут понадобиться классы Меню
, Табы
, Диалог
и другие компоненты интерфейса. В них всех обычно есть что-то общее.
Можно выделить такой общий функционал в класс Компонент
и наследовать их от него, чтобы не дублировать код.
[/smart]
Наследование от 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
, то её ждёт разочарование:
//+ run
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
.
При этом, чтобы обозначить, что свойство является внутренним, его имя начинают с подчёркивания _
.
//+ run
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
, поскольку "мощность" свойственна всем машинам, а не только кофеварке.
//+ run
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
, а получать контекст через замыкание, вот так:
//+ run
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"; }; }
Надо сказать, что способ наследования, описанный в этой главе, используется нечасто.
В следующих главах мы будем изучать прототипный подход, который обладаем рядом преимуществ.
Но знать и понимать его необходимо, поскольку во многих существующих библиотеках классы написаны в функциональном стиле, и расширять/наследовать от них можно только так.