ООП в JavaScript. Часть 1

Введение

Как вы, должно быть, знаете, в 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();

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

Вариант 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 ChromeMozilla FirefoxOperaIE6IE7IE8IE9IE10
Добавление методов в конструкторе
Копирование прототипа через for..in
Временный конструктор
Object.create
(4+)

(12+)
__proto__

Производительность

 Память (Мб)Скорость определения (оп/с)Скорость создания (оп/c)
Добавление методов в конструкторе74.9112 152 481 (34375,7%)31 716 (0,2%)
Копирование прототипа через for..in5.617 946 (22,5%)13 880 306 (96,7%)
Временный конструктор5.0320 484 (57,9%)14 361 306 (100%)
Object.create4.9835 352 (100%)14 103 497 (98,2%)
__proto__4.9822 474 (63,6%)13 893 309 (96,7%)

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

Второй способ уступает по скорости определения, но всё ещё вполне годен для использования, если не принимать в расчёт тот факт, что это не совсем наследование. На самом деле, он активно используется, когда вопрос заходит об аналоге mixin или traits.

Остальные способы примерно равны по производительности и потреблению памяти.

Использование памяти измерялось в Google Chrome. В нем есть инструмент Profiles, который позволяет создавать снимки памяти и изучать, на что она была потрачена. Вы можете и сами провести исследование и сравнить с моим. Вот для этого вам несколько ссылок: генератор страниц для проверки потребления памяти, тестирование скорости определения классов на jsperf и тестирование скорости создания объектов на jsperf.

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

Часть 2


  1. На самом деле, при создании массива будет еще создано поле length, в котором содержится длина.

  2. Поддерживается начиная с IE9.

  3. Это не верно, если функция-конструктор возвращает объект. В этом случае результатом будет возвращённый объект, а не полученный из этого конструктора. Т. е. в этом случае нет большой разницы между вызовом функции с new и вызовом без new.

  4. Кстати, именно так реализуются статические методы и свойства.