Введение
Как вы, должно быть, знаете, в JavaScript используется особенная модель
Итак, обо всем по порядку.
Объекты
Объекты в JavaScript представляют собой, в первую очередь, ассоциативный массив. В нем строковым или числовым (а при большом желании и типа boolean) ключам соответствует одно любое значение. Значением массива может быть и функция, в таком случае она является методом этого объекта.
Для наглядности я буду обозначать объекты таким вот образом:
Кстати, многие знают, что у обыкновенных массивов и объектов практически никаких различий, кроме набора доступных методов и небольшого количества синтаксического сахара. Например, нет никакой разницы между obj.property
и obj['property']
и следующий код:
var obj = {};
var arr = [];
генерирует идентичные объекты1. Единственной разница — в указателе на прототип. Кстати, о прототипах.
Прототипы
Прототип примерно соответствует классу в стандартной
Прототип — это то место, где будет искаться поле или метод в том случае, если его нет в самом объекте. Например, когда мы пишем a.method()
, то сначала наличие метода method
будет проверено в объекте a
, и, если там его нет, то в прототипе объекта a
. Каждый объект имеет свой прототип.
«[[прототип]]» на рисунке выше — это внутреннее свойство объекта, его нельзя прочитать напрямую. Но есть стандартная функция Object.getPrototypeOf(obj)
2, которая позволяет
Но по своей сути прототип тоже является объектом, arr = []
, то у нас будет сформирована вот такая структура:
И даже у Object
есть прототип, но он равен null
, а потому никуда не ссылается.
Конструктор
Идем дальше. Как вы, должно быть, знаете из ООП, конструктор — это специальная функция, которая вызывается при создании объекта. В JavaScript тоже есть такая функция. Вернее даже так: в JavaScript любая функция может исполнять роль конструктора. Я понимаю, это сложно сразу воспринять, особенно учитывая тот факт, что функции зачастую возвращают какие-то данные с помощью конструкции return
. Так вот, если вы воспользуетесь подобной функцией как конструктором, возвращаемое значение будет успешно отброшено и конструктор вернёт сконструированный объект3.
Для того чтобы вызвать new
:
var f = new F();
Необходимо упомянуть ещё об одной детали перед тем, как мы перейдём к связи конструкторов и прототипов. Любая функция может вести себя как объект. Вы же помните, что в JavaScript всё (почти) может вести себя как объект? Т. е. ничто не мешает нам написать4:
F.someValue = 1;
F.someMethod = function () {};
У объекта функции есть несколько стандартных свойств/методов. Одно из них называется prototype
. Это свойство и является будущим прототипом нашего объекта, сконструированного из этой функции.
prototype
у его конструктора. Важно понимать, что у объектов нет свойства prototype
, это свойство есть только у функций. И {}
, а это то же самое, что и new Object()
.
Пример создания объекта
var F = function () {
this.a = 1;
this.b = 2;
}
F.prototype.a = 3;
F.prototype.c = 4;
var f = new F();
f.a = 5;
f.c = 6;
Разберём происходящее в этом примере более подробно.
К моменту создания объекта в строке 7 мы приходим с такой структурой:
Затем происходит вызов конструктора и создаётся новый пустой объект с прототипом, взятым из функции F
:
Вслед за этим наш свежесозданный объект заполняется свойствами в конструкторе F
.
И уже затем конструктор возвращает объект. Дальше он присваивается переменной f
и дозаполняется.
Свойство __proto__
Помните, я сказал, что значение прототипа нельзя получить напрямую? Я несколько слукавил. Большая часть браузеров (кроме Internet Explorer) предоставляют подобную возможность. Для этого у каждого объекта есть свойство __proto__
.
Более того, его можно не только читать, но и писать. И эта его особенность пригодится нам в упрощённой схеме наследования. Как именно, я расскажу чуть позже.
Некоторые считают свойство __proto__
устаревшим (deprecated), но это не так. Оно скорее нестандартное, но и это будет в относительно скором времени исправлено. В разрабатываемой спецификации ECMAScript 6 оно уже описано. Если вдруг кто не знает, JavaScript — это один из диалектов ECMAScript (наряду с ActionScript), а потому должен подчиняться общей спецификации.
Наследование
Теперь, когда основные концепции разобраны, пора переходить к наследованию. Начнём с неправильных вариантов, которые зачастую выдаются за верные.
Вариант №1
Простое копирование прототипов.
var ParentClass = function () {};
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {};
ChildClass.prototype = ParentClass.prototype;
ChildClass.prototype.method2 = function () {};
Вы, наверное, уже и сами видите, почему этот вариант неправильный. Но всё равно давайте разберём.
В строке 1 объявляем конструктор родителя:
Дальше добавляем в прототип ParentClass
метод method1
:
Строка 3. Объявляем дочерний конструктор:
Присваиваем дочернему прототипу родительский:
«Добавляем» ещё один метод к дочернему прототипу:
Как видите, получилось совсем не то, что ожидалось.
Вариант 2
Немного лучше первого хотя бы тем, что работает. Однажды меня пытались убедить, что именно так и должно выглядеть наследование.
var ParentClass = function () {}; // ParentClass contructor
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {}; // ChildClass constructor
ChildClass.prototype = ParentClass.prototype;
var child = new ChildClass();
child.method2 = function () {};
Различие только в двух последних строчках. Мы создаём экземпляр класса и добавляем в уже созданный объект ещё один метод.
Но ведь в таком случае нужно каждый раз писать включение дополнительных методов в объект! Согласен, можно вынести инициализацию в конструктор, ведь именно для этого он и предназначен.
var ChildClass = function () { // ChildClass constructor
this.method2 = function () {};
};
Большая (и не всегда очевидная) проблема с этим методом — количество используемой памяти. Чуть позже я покажу результаты тестирования, они играют не в пользу данного метода. Ещё одна проблема в том, что наследование от ChildClass
неосуществимо прямыми методами, придётся самостоятельно вызывать конструктор родителя, чтобы он добавил недостающие методы.
Вариант 3
В этом варианте будем простым копированием добавлять необходимые поля и методы в прототип:
var ParentClass = function () {}; // ParentClass contructor
ParentClass.prototype.method1 = function () {};
var ChildClass = function () {}; // ChildClass constructor
for (var prop in ParentClass.prototype) {
if (ParentClass.prototype.hasOwnProperty(prop)) {
ChildClass.prototype[prop] = ParentClass.prototype[prop];
}
}
ChildClass.prototype.method2 = function () {};
Тут стоит обратить внимание на условие ParentClass.prototype.hasOwnProperty(prop)
. Эта конструкция for..in перебирает не только то, что находится в самом объекте, но ещё и то, что находится в его прототипе (строка [[прототип]] на диаграммах выше). Поэтому получается, что будут скопированы и те методы, которые достались по наследству от класса Object
. А зачем нам их копировать, если они и так будут у дочернего класса? Метод hasOwnProperty
как раз и позволяет избежать подобного поведения. Это стандартный метод класса Object
и он возвращает true
, если свойство находится в самом объекте, а не в его прототипе.
В результате выполнения кода выше мы получим 2 класса, как и задумывали:
Однако в таком способе, помимо необходимости вручную копировать поля и методы, есть и ещё один недостаток: невозможно использовать оператор instanceof
. childObject instanceof ParentClass
вернёт true
, то здесь это не так. Кстати, то же самое касается и предыдущего варианта.
Вариант 4. Правильный
Что же, самое время разобраться с правильным вариантом. Как вообще должна выглядеть правильная структура объекта? На мой взгляд, так:
Так как же получить подобную красоту? Начнём с того, что у нас есть 2 конструктора: ChildClass
и ParentClass
.
Требуется, чтобы ChildClass.prototype
был равен пустому объекту, у которого в прототипе будет стоять ParentClass.prototype
.
Чтобы получить такой результат, достаточно одной строки:
ChildClass.prototype = new ParentClass();
Хотя в этом случае будет выполнен и конструктор ParentClass
, что не всегда желательно. Поэтому лучшим решением будет создать временный класс с таким же прототипом, как в ParentClass
, но пустым конструктором. И уже экземпляр этого временного конструктора присваивать дочернему прототипу:
var tempCtor = function () {};
tempCtor.prototype = ParentClass.prototype;
ChildClass.prototype = new tempCtor();
Вот в принципе и всё. То, что многие разработчики считают архисложным, записывается в
Вариант 5. Используем Object.create
Идем дальше. Вы же помните, что в JavaScript есть несколько способов сделать одно и то же? Тоже и с наследованием. Всё шаманство с созданием временного класса и последующим созданием объекта можно выразить с помощью вызова всего лишь одного метода — Object.create
.
Посмотрим описание этого метода. Он принимает 2 параметра: прототип объекта и описание свойств. Свойства выходят за рамки этой статьи, можете посмотреть их сами, а вот первый параметр — это именно то что нужно. Получается, что если вызвать Object.create
только с одним параметром (прототипом создаваемого объекта), то результат будет аналогичным предыдущему способу.
ChildClass.prototype = Object.create(ParentClass.prototype);
К сожалению, Object.create
объявлен только в спецификации ECMAScript 5, поэтому данный способ наследования работает только в относительно новых браузерах (IE9+, FF4+, O12+).
Вариант 6. Магия с prototype.__proto__
Ну и напоследок, есть совсем простой способ получить то, что требуется. Я рассказывал про наличие в некоторых браузерах свойства __proto__
, которое представляет собой ссылку на прототип и которое в остальных браузерах недоступно. Получается, что мы можем просто указать прототипу класса, от кого он унаследован, безо всяких дополнительных действий.
ChildClass.prototype.__proto__ = ParentClass.prototype;
Приведу ещё раз для наглядности схему с тем, что делает эта инструкция:
Вот так, легко и просто. Но посмотрим на список браузеров, где подобная конструкция будет работать правильно: Chrome, Firefox, Safari >= 5, Opera >= 10.50. Ни одна из версий Internet Explorer сюда не входит.
Упомяну так же, что данный способ очень часто используется в node.js,
Сравнение различных вариантов
Итак, у нас получилось 3 рабочих варианта, 2
Поддержка браузерами
Google Chrome | Mozilla Firefox | Opera | IE6 | IE7 | IE8 | IE9 | IE10 | |
---|---|---|---|---|---|---|---|---|
Добавление методов в конструкторе | ||||||||
Копирование прототипа через for..in | ||||||||
Временный конструктор | ||||||||
Object.create | (4+) | (12+) | ||||||
__proto__ |
Производительность
Память (Мб) | Скорость определения (оп/с) | Скорость создания (оп/c) | |
---|---|---|---|
Добавление методов в конструкторе | 74.91 | 12 152 481 (34375,7%) | 31 716 (0,2%) |
Копирование прототипа через for..in | 5.61 | 7 946 (22,5%) | 13 880 306 (96,7%) |
Временный конструктор | 5.03 | 20 484 (57,9%) | 14 361 306 (100%) |
Object.create | 4.98 | 35 352 (100%) | 14 103 497 (98,2%) |
__proto__ | 4.98 | 22 474 (63,6%) | 13 893 309 (96,7%) |
Как видно, первый способ можно смело исключить
Второй способ уступает по скорости определения, но всё ещё вполне годен для использования, если не принимать в расчёт тот факт, что это не совсем наследование. На самом деле, он активно используется, когда вопрос заходит об аналоге mixin или traits.
Остальные способы примерно равны по производительности и потреблению памяти.
Использование памяти измерялось в Google Chrome. В нем есть инструмент Profiles, который позволяет создавать снимки памяти и изучать, на что она была потрачена. Вы можете и сами провести исследование и сравнить с моим. Вот для этого вам несколько ссылок: генератор страниц для проверки потребления памяти, тестирование скорости определения классов на jsperf и тестирование скорости создания объектов на jsperf.
Лично я продолжу применять способ с использованием временного конструктора
На самом деле, при создании массива будет еще создано поле
length
, в котором содержится длина.Поддерживается начиная с IE9.
Это не верно, если
функция-конструктор возвращает объект. В этом случае результатом будет возвращённый объект, а не полученный из этого конструктора.Т. е. в этом случае нет большой разницы между вызовом функции сnew
и вызовом безnew
.Кстати, именно так реализуются статические методы и свойства.