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;
};
function onReady() {
alert('Кофе готово!');
}
this.run = function() {
setTimeout(onReady, 1000);
};
}
var coffeeMachine = new CoffeeMachine(10000);
coffeeMachine.enable();
Наследование реализовано вызовом Machine.call(this)
в начале CoffeeMachine
.
Он вызывает функцию Machine
, передавая ей в качестве контекста this
текущий объект. Machine
, в процессе выполнения, записывает в this
различные полезные свойства и методы, в нашем случае this.enable
и this.disable
.
Далее 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;
*/!*
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);
Подчёркивание в начале свойства -- общепринятый знак, что свойство является внутренним, предназначенным лишь для доступа из самого объекта и его наследников. Такие свойства называют защищёнными.
Технически это, конечно, возможно, но приличный программист снаружи в такое свойство не полезет.
Вообще, это стандартная практика: конструктор сохраняет свои параметры в свойствах объекта. Иначе наследники не будут иметь к ним доступ.
Перенос свойства в защищённые
В коде выше есть свойство power
. Сейчас мы его тоже сделаем защищённым и перенесём в Machine
, поскольку "мощность" свойственна всем машинам, а не только кофеварке:
//+ run
function CoffeeMachine(power) {
*!*
Machine.apply(this, arguments); // (1)
*/!*
alert(this._enabled); // false
alert(this._power); // 10000
}
function Machine(power) {
*!*
this._power = power; // (2)
*/!*
this._enabled = false;
this.enable = function() {
this._enabled = true;
};
this.disable = function() {
this._enabled = false;
};
}
var coffeeMachine = new CoffeeMachine(10000);
В коде выше при вызове new CoffeeMachine(10000)
в строке (1)
кофеварка передаёт аргументы и контекст родителю вызовом Machine.apply(this, arguments)
.
Можно было бы использовать Machine.call(this, power)
, но использование apply
гарантирует передачу всех аргументов, мало ли, вдруг мы в будущем захотим их добавить.
Далее конструктор Machine
в строке (2)
сохраняет power
в свойстве объекта this._power
, благодаря этому кофеварка, когда наследование перейдёт обратно к CoffeeMachine
, сможет сразу обращаться к нему.
Переопределение методов
Итак, мы получили класс CoffeeMachine
, который наследует от Machine
.
Аналогичным образом мы можем унаследовать от Machine
холодильник Fridge
, микроволновку MicroOven
и другие классы, которые разделяют общий "машинный" функционал.
Для этого достаточно вызвать Machine
текущем контексте, а затем добавить свои методы.
// Fridge может добавить и свои аргументы,
// которые в Machine не будут использованы
function Fridge(power, temperature) {
Machine.call(this, arguments);
// ...
}
Кроме создания новых методов, можно заменить унаследованные на свои:
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)
}
*/!*
...
}
Общая схема переопределения метода (по строкам выделенного фрагмента кода):
- Мы скопировали доставшийся от родителя метод `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() {
this._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 private; // публичные доступны снаружи this.public = ...; // защищённые доступны внутри Machine и для потомков // мы договариваемся не трогать их снаружи this._protected = ... } var machine = new Machine(...) machine.public();
- Для наследования конструктор потомка вызывает родителя в своём контексте через `apply`. После чего может добавить свои переменные и методы:
function CoffeeMachine(params) { // универсальный вызов с передачей любых аргументов *!* Machine.apply(this, arguments); */!* this.coffeePublic = ... } var coffeeMachine = new CoffeeMachine(...); coffeeMachine.public(); coffeeMachine.coffeePublic();
- В `CoffeeMachine` свойства, полученные от родителя, можно перезаписать своими. Но обычно требуется не заменить, а расширить метод родителя. Для этого он предварительно копируется в переменную:
function CoffeeMachine(params) { Machine.apply(this, arguments); *!* var parentProtected = this._protected; this._protected = function(args) { parentProtected.call(this, args); // (*) // ... }; */!* }
Строку
(*)
можно упростить доparentProtected(args)
, если метод родителя не используетthis
, а, например, привязан кvar self = this
:function Machine(params) { var self = this; this._protected = function() { self.property = "value"; }; }
В следующих главах мы будем изучать прототипный подход, который обладаем рядом преимуществ, по сравнению с функциональным.
Но функциональный тоже бывает полезен.
В своей практике разработки я обычно наследую функционально в тех случаях, когда уже есть какой-то код, который на нём построен. К примеру, уже существуют классы, написанные сторонними разработчиками, которые можно доопределить или расширить только так.