Наследование в Javascript

В данной статье мы будем разбираться в наследовании в Javascript. Мы должны понять что такое prototype, __proto__, Object.create, constructor.

Классификация

Прежде чем говорить о деталях реализации наследования, хорошо бы понимать что это и зачем оно нужно. Главное назначение наследования - это избежание повторения кода (DRY - Do not Repeat Yourself). Оно помогает вынести повторяющееся поведение объектов в одно общее место (родителя) и затем в дочерних объектах использовать его. Делается это при помощи создания Классов - обобщенных сведений о группе объектов.

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

Кролик. Что мы знаем о нем? У него точно есть глаза, он точно умеет есть. При передвижении он прыгает.
Тигр. У него точно есть глаза, он умеет есть, но при передвижении он бегает.

Даже на этих двух примерах мы уже понимаем, что у Тигра и Кролика есть что-то общее. Соответственно, чтобы не повторяться, мы можем вынести их “общее” в класс под названием Животное.

Соответственно, наш пример можно оптимизировать с точки зрения концепции DRY:
Животное. Имеет глаза, умеет есть.
Кролик. Животное. Умеет прыгать.
Тигр. Животное. Умеет бегать.

Оптимизация на лицо. Таким образом, можно продолжить ряд, и создать еще несколько классов: Хищные, Травоядные. Их можно разделить на парнокопытные и т.д.

Свойство __proto__

Свойство __proto__ позволяет задать одному объекту в качестве прототипа другой объект. Вообще по своей сути свойство __proto__ - это просто ссылка на объект-прототип. Когда мы обращаемся к свойству какого-либо объекта, оно будет искаться внутри этого объекта. И если вдруг его там нет, то поиск его продолжится как раз в объекте-прототипе. Это можно использовать для организации наследования свойств.

Продолжим пример из первой части. И теперь перенесем полученный результат на javascript.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//объект, описывающий животных в целом
var Animal = {
hasEyes: true,
eats: true
};

var Rabbit = {
//указываем в качестве прототипа - объект Животное
__proto__: Animal,
jumps: true
};

var Tiger = {
__proto__: Animal,
//указываем в качестве прототипа - объект Животное
walks: true
};

console.log(Tiger.hasEyes);
console.log(Rabbit.eats);
console.log(Tiger.jumps);

Можно посмотреть этот пример в JSFiddle.

Если мы захотим добавить новое свойство ко всем животным, мы добавим его в объект Animal. И автоматически, благодаря прототипному наследованию, эти свойства станут доступны и Rabbit и Tiger. При этом мы видим, что ничто не мешает каждому из них иметь собственные свойства.

Можно выстраивать целый ряд цепочек прототипов, описывая наследование глубже и глубже.

Когда мы создаем объект, то его прототипом неявно является Object. И лишь у него свойство __proto__ будет равно null.

1
2
3
var a = {};
console.log(a.__proto__);
console.log(a.__proto__.__proto__);

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

Object.create(null)

Данный метод позволяет создавать объекты на основе других объектов. Грубо говоря, делает он тоже самое, что делали мы в примере выше. Разница лишь в том, что мы указывали __proto__ вручную. Для той же самой задачи мы могли бы использовать и Object.create.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Animal = {
hasEyes: true,
eats: true
};

var Rabbit = Object.create(Animal);
Rabbit.jumps = true;

var Tiger = Object.create(Animal);
Tiger.walks = true;

console.log(Tiger.hasEyes);
console.log(Rabbit.eats);
console.log(Tiger.jumps);

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

1
2
3
for (var prop in Rabbit){
console.log(prop);
}

В этом случае в консоль будут выведены свойства объекта Rabbit и свойства объекта Animal. Если мы хотим вывести только собственные свойства объекта, мы должны использовать метод .hasOwnProperty. Кстати, попробуйте догадаться откуда взялся этот метод у Rabbit, ведь мы его туда не добавляли.

1
2
3
4
5
for (var prop in Rabbit){
if(Rabbit.hasOwnProperty(prop)){
console.log(prop);
}
}

Созданные таким способом объекты всегда будут иметь в качестве последнего (самого верхнего) прототипа - Object. Да, именно там хранятся методы toString, hasOwnProperty и т.д.

Однако, Object.create позволяет создавать объекты и без прототипа вовсе. Например, это может быть полезно для различных хранилищ в виде коллекций. Для этого достаточно передать в качестве аргумента null.

1
2
var a = Object.create(null);
console.log(a.__proto__ === undefined); //true

Ну и помимо прочего Object.create может помогать задавать различные дескрипторы свойств. На них сейчас я останавливаться не буду.

Важно знать, что Object.create поддерживается не всеми браузерами (IE9+, FF4+, Safari 5+, Chrome 5+, Opera 11.60+).

Свойство prototype

До этого мы рассматривали примеры создания объектов с использованием {}. На реальных проектах, обычно, используют подход с созданием объекта через функцию-конструктор с использованием оператора new. Возникает вопрос: как же в этом случае указать объект-прототип?

Первое что приходит в голову:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Animal = {
hasEyes: true,
eats: true
};

//описываем функцию-конструктор
function Rabbit(){
//устаналиваем прототип
this.__proto__ = Animal;
}

//создаем кролика через new
var rabbit = new Rabbit();
console.log(rabbit.hasEyes); //true

Очевидное решение оказалось рабочим. Однако, мы говорили о том, что свойство __proto__ не поддерживается браузерами IE10 и ниже.

Когда мы описываем функцию-конструктор, то визуально она ничем не отличается от обычной функции. Все, что делает ее конструктором это оператор new с которым она вызывается при создании объекта. А что делает new?

Оператор new говорит интепретатору: “При создании нового объекта, установи ему в качестве __proto__ то, что у функции-конструктора находится в свойстве prototype“.

Окей, стало быть нам нужно сделать вот так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Animal = {
hasEyes: true,
eats: true
};

//описываем функцию-конструктор
function Rabbit(){
}
//меняем свойство у функции-конструктора
Rabbit.prototype = Animal;

//создаем кролика через new
var rabbit = new Rabbit();
console.log(rabbit.hasEyes); //true

Таким образом, когда мы поменяли свойство prototype и вызвали функции конструктор с оператором new, мы как бы сказали интерпретатору: “При создании нового объекта, установи ему в качестве __proto__ объект Animal.

Свойство constructor

Выше я упомянул, что при вызове функции с new будет создан объект, в __proto__ которого будет помещено то, что у функции-конструктора находится в свойстве prototype. Но я не сказал ЧТО находится в этом самом свойстве по умолчанию. Пришло время.

1
2
3
4
5
function Rabbit(){
Rabbit.prototype = {
constructor: Rabbit
};
}

Здесь я руками задал прототип, который по умолчанию генерируется автоматически.

Любая функция имеет свойство prototype, в котором находится объект. У этого объекта есть свойство constructor, которое указывает на саму функцию. Сам javascript никак не использует это свойство. Но оно может быть полезно в случаях, когда мы получили объект откуда-то из вне (например, из фабрики классов). И нам нужно создать точно такой же объект. Тогда-то нам и может пригодиться это свойство:

1
2
var rabbit = AnimalFactory.get('rabbit');
var anotherRabbit = new rabbit.constructor();

Еще одним важным моментом является то, что перезаписывание свойства prototype может привести к потере свойства constructor по невнимательности. Например:

1
2
3
4
5
function Rabbit(){
}
Rabbit.prototype = {
jumps: true
};

В примере выше мы заменили prototype по-умолчанию, на свой объект. И конечно же, забыл указать свойство constructor. Во избежание подобных недоразумений, принято не заменять, а дополнять свойство prototype:

1
2
3
function Rabbit(){
}
Rabbit.prototype.jumps = true;

Кроссбраузерное наследование

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

Эмуляция Object.create

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function inherits(Parent){
//создаем временную функцию
function F(){}
//указываем в качестве прототипа - родителя
F.prototype = Parent;
//создаем объект с указанным прототипом
return new F();
}

var Animal = {
hasEyes: true
};

Rabbit = inherits(Animal);
Rabbit.jumps = true;

console.log(Rabbit.jumps);
console.log(Rabbit.hasEyes);

Такой подход будет работать везде. Можно сказать, что это своего рода полифилл для Object.create, но за тем лишь исключением, что здесь не будет возможности задавать дескрипторы свойств.

Наследование на уровне “классов”

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

Ну что ж, вызов принят:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function inherits(Child, Parent) {
function F() {
this.constructor = Child;
}
F.prototype = Parent.prototype;
F.prototype._super = Parent.prototype.constructor;
Child.prototype = new F();
}


function Animal() {
this.hasEyes = true;
}

function Rabbit() {
//вызываем конструктор родителя
this._super();
//дальше определяем собственные свойства
this.jumps = true;
}

//наследуемся
inherits(Rabbit, Animal);

//создаем кролика
var rabbit = new Rabbit();

console.log(rabbit.jumps);
console.log(rabbit.hasEyes);

Этот пример в JSFiddle.

Как мы видим, в функции inherits мы снова создаем временную функцию. Правда на этот раз, в качестве свойства prototype мы выставляем свойство prototype родительского класса. Это происходит, потому что на данный момент объекта, который мы могли бы туда подставить у нас нет. Да и вообще как показывает пример дальше, объекта Animal у нас даже и не создается. Соответственно понимаем, что когда функци F() будет вызвана с оператором new, то в __proto__ будет помещен объект из свойства prototype класса Animal. А состоит он, как мы говорили выше, из свойства constructor, которое будет указывать на саму функцию Animal.

Дальше в качестве бонуса, мы добавляем в свойство prototype свойство _super, которое будет указывать на конструктор родителя. В дочернем классе мы должны обязательно его вызвать. Поскольку вызван он будет в контексте дочернего объекта, то и все свойства (this) будут записаны в дочерний объект.