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

Наследование на уровне объектов в JavaScript, как мы видели, реализуется через ссылку __proto__.

Теперь поговорим о наследовании на уровне классов, то есть когда объекты, создаваемые, к примеру, через new Admin, должны иметь все методы, которые есть у объектов, создаваемых через new User, и ещё какие-то свои.

Наследование Array от Object

Для реализации наследования в наших классах мы будем использовать тот же подход, который принят внутри JavaScript.

Взглянем на него ещё раз на примере Array, который наследует от Object:

  • Методы массивов Array хранятся в Array.prototype.
  • Array.prototype имеет прототипом Object.prototype.

Поэтому когда экземпляры класса Array хотят получить метод массива – они берут его из своего прототипа, например Array.prototype.slice.

Если же нужен метод объекта, например, hasOwnProperty, то его в Array.prototype нет, и он берётся из Object.prototype.

Отличный способ «потрогать это руками» – запустить в консоли команду console.dir([1,2,3]).

Вывод в Chrome будет примерно таким:

Здесь отчётливо видно, что сами данные и length находятся в массиве, дальше в __proto__ идут методы для массивов concat, то есть Array.prototype, а далее – Object.prototype.

console.dir для доступа к свойствам

Обратите внимание, я использовал именно console.dir, а не console.log, поскольку log зачастую выводит объект в виде строки, без доступа к свойствам.

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

Применим тот же подход для наших классов: объявим класс Rabbit, который будет наследовать от Animal.

Вначале создадим два этих класса по отдельности, они пока что будут совершенно независимы.

Animal:

function Animal(name) {
  this.name = name;
  this.speed = 0;
}

Animal.prototype.run = function(speed) {
  this.speed += speed;
  alert( this.name + ' бежит, скорость ' + this.speed );
};

Animal.prototype.stop = function() {
  this.speed = 0;
  alert( this.name + ' стоит' );
};

Rabbit:

function Rabbit(name) {
  this.name = name;
  this.speed = 0;
}

Rabbit.prototype.jump = function() {
  this.speed++;
  alert( this.name + ' прыгает' );
};

var rabbit = new Rabbit('Кроль');

Для того, чтобы наследование работало, объект rabbit = new Rabbit должен использовать свойства и методы из своего прототипа Rabbit.prototype, а если их там нет, то – свойства и метода родителя, которые хранятся в Animal.prototype.

Если ещё короче – порядок поиска свойств и методов должен быть таким: rabbit -> Rabbit.prototype -> Animal.prototype, по аналогии с тем, как это сделано для объектов и массивов.

Для этого можно поставить ссылку __proto__ с Rabbit.prototype на Animal.prototype.

Можно сделать это так:

Rabbit.prototype.__proto__ = Animal.prototype;

Однако, прямой доступ к __proto__ не поддерживается в IE10-, поэтому для поддержки этих браузеров мы используем функцию Object.create. Она либо встроена либо легко эмулируется во всех браузерах.

Класс Animal остаётся без изменений, а Rabbit.prototype мы будем создавать с нужным прототипом, используя Object.create:

function Rabbit(name) {
  this.name = name;
  this.speed = 0;
}

// задаём наследование
Rabbit.prototype = Object.create(Animal.prototype);

// и добавим свой метод (или методы...)
Rabbit.prototype.jump = function() { ... };

Теперь выглядеть иерархия будет так:

В prototype по умолчанию всегда находится свойство constructor, указывающее на функцию-конструктор. В частности, Rabbit.prototype.constructor == Rabbit. Если мы рассчитываем использовать это свойство, то при замене prototype через Object.create нужно его явно сохранить:

Rabbit.prototype = Object.create(Animal.prototype);
Rabbit.prototype.constructor = Rabbit;

Полный код наследования

Для наглядности – вот итоговый код с двумя классами Animal и Rabbit:

// 1. Конструктор Animal
function Animal(name) {
  this.name = name;
  this.speed = 0;
}

// 1.1. Методы -- в прототип

Animal.prototype.stop = function() {
  this.speed = 0;
  alert( this.name + ' стоит' );
}

Animal.prototype.run = function(speed) {
  this.speed += speed;
  alert( this.name + ' бежит, скорость ' + this.speed );
};

// 2. Конструктор Rabbit
function Rabbit(name) {
  this.name = name;
  this.speed = 0;
}

// 2.1. Наследование
Rabbit.prototype = Object.create(Animal.prototype);
Rabbit.prototype.constructor = Rabbit;

// 2.2. Методы Rabbit
Rabbit.prototype.jump = function() {
  this.speed++;
  alert( this.name + ' прыгает, скорость ' + this.speed );
}

Как видно, наследование задаётся всего одной строчкой, поставленной в правильном месте.

Обратим внимание: Rabbit.prototype = Object.create(Animal.prototype) присваивается сразу после объявления конструктора, иначе он перезатрёт уже записанные в прототип методы.

Неправильный вариант: Rabbit.prototype = new Animal

В некоторых устаревших руководствах предлагают вместо Object.create(Animal.prototype) записывать в прототип new Animal, вот так:

// вместо Rabbit.prototype = Object.create(Animal.prototype)
Rabbit.prototype = new Animal();

Частично, он рабочий, поскольку иерархия прототипов будет такая же, ведь new Animal – это объект с прототипом Animal.prototype, как и Object.create(Animal.prototype). Они в этом плане идентичны.

Но у этого подхода важный недостаток. Как правило мы не хотим создавать Animal, а хотим только унаследовать его методы!

Более того, на практике создание объекта может требовать обязательных аргументов, влиять на страницу в браузере, делать запросы к серверу и что-то ещё, чего мы хотели бы избежать. Поэтому рекомендуется использовать вариант с Object.create.

Вызов конструктора родителя

Посмотрим внимательно на конструкторы Animal и Rabbit из примеров выше:

function Animal(name) {
  this.name = name;
  this.speed = 0;
}

function Rabbit(name) {
  this.name = name;
  this.speed = 0;
}

Как видно, объект Rabbit не добавляет никакой особенной логики при создании, которой не было в Animal.

Чтобы упростить поддержку кода, имеет смысл не дублировать код конструктора Animal, а напрямую вызвать его:

function Rabbit(name) {
  Animal.apply(this, arguments);
}

Такой вызов запустит функцию Animal в контексте текущего объекта, со всеми аргументами, она выполнится и запишет в this всё, что нужно.

Здесь можно было бы использовать и Animal.call(this, name), но apply надёжнее, так как работает с любым количеством аргументов.

Переопределение метода

Итак, Rabbit наследует Animal. Теперь если какого-то метода нет в Rabbit.prototype – он будет взят из Animal.prototype.

В Rabbit может понадобиться задать какие-то методы, которые у родителя уже есть. Например, кролики бегают не так, как остальные животные, поэтому переопределим метод run():

Rabbit.prototype.run = function(speed) {
  this.speed++;
  this.jump();
};

Вызов rabbit.run() теперь будет брать run из своего прототипа:

Вызов метода родителя внутри своего

Более частая ситуация – когда мы хотим не просто заменить метод на свой, а взять метод родителя и расширить его. Скажем, кролик бежит так же, как и другие звери, но время от времени подпрыгивает.

Для вызова метода родителя можно обратиться к нему напрямую, взяв из прототипа:

 Rabbit.prototype.run = function() {
   // вызвать метод родителя, передав ему текущие аргументы
   Animal.prototype.run.apply(this, arguments);
   this.jump();
 }

Обратите внимание на вызов через apply и явное указание контекста.

Если вызвать просто Animal.prototype.run(), то в качестве this функция run получит Animal.prototype, а это неверно, нужен текущий объект.

Итого

  • Для наследования нужно, чтобы «склад методов потомка» (Child.prototype) наследовал от «склада метода родителей» (Parent.prototype).

    Это можно сделать при помощи Object.create:

    Код:

    Rabbit.prototype = Object.create(Animal.prototype);
  • Для того, чтобы наследник создавался так же, как и родитель, он вызывает конструктор родителя в своём контексте, используя apply(this, arguments), вот так:

    function Rabbit(...) {
      Animal.apply(this, arguments);
    }
  • При переопределении метода родителя в потомке, к исходному методу можно обратиться, взяв его напрямую из прототипа:

    Rabbit.prototype.run = function() {
      var result = Animal.prototype.run.apply(this, ...);
      // result -- результат вызова метода родителя
    }

Структура наследования полностью:

// --------- Класс-Родитель ------------
// Конструктор родителя пишет свойства конкретного объекта
function Animal(name) {
  this.name = name;
  this.speed = 0;
}

// Методы хранятся в прототипе
Animal.prototype.run = function() {
  alert(this.name + " бежит!")
}

// --------- Класс-потомок -----------
// Конструктор потомка
function Rabbit(name) {
  Animal.apply(this, arguments);
}

// Унаследовать
Rabbit.prototype = Object.create(Animal.prototype);

// Желательно и constructor сохранить
Rabbit.prototype.constructor = Rabbit;

// Методы потомка
Rabbit.prototype.run = function() {
  // Вызов метода родителя внутри своего
  Animal.prototype.run.apply(this);
  alert( this.name + " подпрыгивает!" );
};

// Готово, можно создавать объекты
var rabbit = new Rabbit('Кроль');
rabbit.run();

Такое наследование лучше функционального стиля, так как не дублирует методы в каждом объекте.

Кроме того, есть ещё неявное, но очень важное архитектурное отличие.

Зачастую вызов конструктора имеет какие-то побочные эффекты, например влияет на документ. Если конструктор родителя имеет какое-то поведение, которое нужно переопределить в потомке, то в функциональном стиле это невозможно.

Иначе говоря, в функциональном стиле в процессе создания Rabbit нужно обязательно вызывать Animal.apply(this, arguments), чтобы получить методы родителя – и если этот Animal.apply кроме добавления методов говорит: «Му-у-у!», то это проблема:

function Animal() {
  this.walk = function() {
    alert('walk')
  };
  alert( 'Му-у-у!' );
}

function Rabbit() {
  Animal.apply(this, arguments); // как избавиться от мычания, но получить walk?
}

…Которой нет в прототипном подходе, потому что в процессе создания new Rabbit мы вовсе не обязаны вызывать конструктор родителя. Ведь методы находятся в прототипе.

Поэтому прототипный подход стоит предпочитать функциональному как более быстрый и универсальный. А что касается красоты синтаксиса – она сильно лучше в новом стандарте ES6, которым можно пользоваться уже сейчас, если взять транслятор babeljs.

Задачи

важность: 5

Найдите ошибку в прототипном наследовании. К чему она приведёт?

function Animal(name) {
  this.name = name;
}

Animal.prototype.walk = function() {
  alert( "ходит " + this.name );
};

function Rabbit(name) {
  this.name = name;
}
Rabbit.prototype = Animal.prototype;

Rabbit.prototype.walk = function() {
  alert( "прыгает! и ходит: " + this.name );
};

Ошибка в строке:

Rabbit.prototype = Animal.prototype;

Эта ошибка приведёт к тому, что Rabbit.prototype и Animal.prototype – один и тот же объект. В результате методы Rabbit будут помещены в него и, при совпадении, перезапишут методы Animal.

Получится, что все животные прыгают, вот пример:

function Animal(name) {
  this.name = name;
}

Animal.prototype.walk = function() {
  alert("ходит " + this.name);
};

function Rabbit(name) {
  this.name = name;
}
Rabbit.prototype = Animal.prototype;

Rabbit.prototype.walk = function() {
  alert("прыгает! и ходит: " + this.name);
};

var animal = new Animal("Хрюшка");
animal.walk(); // прыгает! и ходит Хрюшка

Правильный вариант этой строки:

Rabbit.prototype = Object.create(Animal.prototype);

Если так написать, то в Rabbit.prototype будет отдельный объект, который прототипно наследует от Animal.prototype, но может содержать и свои свойства, специфичные для кроликов.

важность: 5

Найдите ошибку в прототипном наследовании. К чему она приведёт?

function Animal(name) {
  this.name = name;

  this.walk = function() {
    alert( "ходит " + this.name );
  };

}

function Rabbit(name) {
  Animal.apply(this, arguments);
}
Rabbit.prototype = Object.create(Animal.prototype);

Rabbit.prototype.walk = function() {
  alert( "прыгает " + this.name );
};

var rabbit = new Rabbit("Кроль");
rabbit.walk();

Ошибка – в том, что метод walk присваивается в конструкторе Animal самому объекту вместо прототипа.

Поэтому, если мы решим перезаписать этот метод своим, специфичным для кролика, то он не сработает:

// ...

// записывается в прототип
Rabbit.prototype.walk = function() {
  alert( "прыгает " + this.name );
};

Метод this.walk из Animal записывается в сам объект, и поэтому он всегда будет первым, игнорируя цепочку прототипов.

Правильно было бы определять walk как Animal.prototype.walk.

Тем более, что этот метод является общим для всех объектов, тратить память и время на запись его в каждый конструктор определённо ни к чему.

важность: 5

Есть реализация часиков, оформленная в виде одной функции-конструктора. У неё есть приватные свойства timer, template и метод render.

Задача: переписать часы на прототипах. Приватные свойства и методы сделать защищёнными.

P.S. Часики тикают в браузерной консоли (надо открыть её, чтобы увидеть).

Открыть песочницу для задачи.

function Clock(options) {
  this._template = options.template;
}

Clock.prototype._render = function() {
  var date = new Date();

  var hours = date.getHours();
  if (hours < 10) hours = '0' + hours;

  var min = date.getMinutes();
  if (min < 10) min = '0' + min;

  var sec = date.getSeconds();
  if (sec < 10) sec = '0' + sec;

  var output = this._template.replace('h', hours).replace('m', min).replace('s', sec);

  console.log(output);
};

Clock.prototype.stop = function() {
  clearInterval(this._timer);
};

Clock.prototype.start = function() {
  this._render();
  var self = this;
  this._timer = setInterval(function() {
    self._render();
  }, 1000);
};

Открыть решение в песочнице.

важность: 5

Есть реализация часиков на прототипах. Создайте класс, расширяющий её, добавляющий поддержку параметра precision, который будет задавать частоту тика в setInterval. Значение по умолчанию: 1000.

  • Для этого класс Clock надо унаследовать. Пишите ваш новый код в файле extended-clock.js.
  • Исходный класс Clock менять нельзя.
  • Пусть конструктор потомка вызывает конструктор родителя. Это позволит избежать проблем при расширении Clock новыми опциями.

P.S. Часики тикают в браузерной консоли (надо открыть её, чтобы увидеть).

Открыть песочницу для задачи.

Наследник:

function ExtendedClock(options) {
  Clock.apply(this, arguments);
  this._precision = +options.precision || 1000;
}

ExtendedClock.prototype = Object.create(Clock.prototype);

ExtendedClock.prototype.start = function() {
  this._render();
  var self = this;
  this._timer = setInterval(function() {
    self._render();
  }, this._precision);
};

Открыть решение в песочнице.

важность: 5

Есть класс Menu. У него может быть два состояния: открыто STATE_OPEN и закрыто STATE_CLOSED.

Создайте наследника AnimatingMenu, который добавляет третье состояние STATE_ANIMATING.

  • При вызове open() состояние меняется на STATE_ANIMATING, а через 1 секунду, по таймеру, открытие завершается вызовом open() родителя.
  • Вызов close() при необходимости отменяет таймер анимации (назначаемый в open) и передаёт вызов родительскому close.
  • Метод showState для нового состояния выводит "анимация", для остальных – полагается на родителя.

Открыть песочницу для задачи.

Обратите внимание: константы состояний перенесены в прототип, чтобы AnimatingMenu их тоже унаследовал.

Открыть решение в песочнице.

важность: 5

В коде ниже создаётся простейшая иерархия классов: Animal -> Rabbit.

Что содержит свойство rabbit.constructor? Распознает ли проверка в alert объект как Rabbit?

function Animal() {}

function Rabbit() {}
Rabbit.prototype = Object.create(Animal.prototype);

var rabbit = new Rabbit();

alert( rabbit.constructor == Rabbit ); // что выведет?

Нет, не распознает, выведет false.

Свойство constructor содержится в prototype функции по умолчанию, интерпретатор не поддерживает его корректность. Посмотрим, чему оно равно и откуда оно будет взято в данном случае.

Порядок поиска свойства rabbit.constructor, по цепочке прототипов:

  1. rabbit – это пустой объект, в нём нет.
  2. Rabbit.prototype – в него при помощи Object.create записан пустой объект, наследующий от Animal.prototype. Поэтому constructor'а в нём также нет.
  3. Animal.prototype – у функции Animal свойство prototype никто не менял. Поэтому оно содержит Animal.prototype.constructor == Animal.
function Animal() {}

function Rabbit() {}
Rabbit.prototype = Object.create(Animal.prototype);

var rabbit = new Rabbit();

alert( rabbit.constructor == Rabbit ); // false
alert( rabbit.constructor == Animal ); // true
Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.