en.javascript.info/1-js/8-oop/5-functional-inheritance/article.md
Ilya Kantor 87bf53d076 update
2014-11-16 01:40:20 +03:00

16 KiB
Raw Blame History

Функциональное наследование

Наследование -- это создание новых "классов" на основе существующих.

В 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)
  }
*/!*

  ...
}

Общая схема переопределения метода (по строкам выделенного фрагмента кода):

  1. Мы скопировали доставшийся от родителя метод `enable` в переменную, например `parentEnable`.
  2. Заменили метод `this.enable()` на свою функцию...
  3. Которая по-прежнему реализует старый функционал через вызов `parentEnable`...
  4. И в дополнение к нему делает что-то своё, например запускает приготовление кофе.

Обратим внимание на строку (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 внутри.

Итого

Организация наследования, которая описана в этой главе, называется "функциональным паттерном наследования".

Её общая схема (кратко):

  1. Объявляется конструктор родителя `Machine`. В нём могут быть приватные (private), публичные (public) и защищённые (protected) свойства:
    function Machine(params) {
      // локальные переменные и функции доступны только внутри Machine
      var private; 
    
      // публичные доступны снаружи
      this.public = ...;
    
      // защищённые доступны внутри Machine и для потомков
      // мы договариваемся не трогать их снаружи
      this._protected = ...
    }
    
    var machine = new Machine(...)
    machine.public();
    
  2. Для наследования конструктор потомка вызывает родителя в своём контексте через `apply`. После чего может добавить свои переменные и методы:
    function CoffeeMachine(params) {
      // универсальный вызов с передачей любых аргументов
    *!*
      Machine.apply(this, arguments); 
    */!*
    
      this.coffeePublic = ...
    }
    
    var coffeeMachine = new CoffeeMachine(...);
    coffeeMachine.public();
    coffeeMachine.coffeePublic();
    
  3. В `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"; 
      };
    }
    

В следующих главах мы будем изучать прототипный подход, который обладаем рядом преимуществ, по сравнению с функциональным.

Но функциональный тоже бывает полезен.

В своей практике разработки я обычно наследую функционально в тех случаях, когда уже есть какой-то код, который на нём построен. К примеру, уже существуют классы, написанные сторонними разработчиками, которые можно доопределить или расширить только так.