Некоторое время назад у меня дошли руки до темы, которая давно уже меня
В сети есть много статей по данному вопросу, но мне не удалось найти обобщающего анализа, который бы удовлетворил меня своей полнотой и логикой. Почему хотелось найти именно обобщающий анализ? Дело в том, что особая, я бы сказал, уникальная сложность объектно ориентированного программирования в JS состоит в шокирующем (во всяком случае, меня) разнообразии его возможных реализаций. После довольно продолжительных неудачных поисков я решил попробовать разобраться в этом вопросе самостоятельно.
Хочу сразу сказать, что я не претендую на глубокое понимание ООП в JavaScript, и даже не претендую на глубокое понимание ООП вообще. Я буду рад, если моя попытка анализа окажется кому-нибудь полезной, но основная цель публикации, в некотором смысле, противоположная — мне бы хотелось самому воспользоваться замечаниями людей, которые лучше меня ориентируются в теме, чтобы прояснить ее для себя.
Общие рассуждения
Есть мнение, что JS — язык очень мощный и гибкий. С другой стороны, есть мнение, что JS — ну, скажем мягко, язык недоделанный. Что ж, когда дело доходит до ООП, JavaScript показывает все, на что способен — по обоим пунктам.
Из этого парадокса, в частности, следует, что у каждого джаваскриптера есть мотив и есть возможность изобрести свой собственный
Вначале я расчитывал что можно будет разделить все решения на две большие группы: основанные на классах и прототипные («бесклассовые»). Такое деление казалось естественным, учитывая то что сам JavaScript, как бы «завис» между классическим стилем ООП и прототипным. Однако, от такой классификации я решил отказаться, из-за того, что в подавляющем большинстве случаев, ООП в JavaScript это именно воспроизведение классического ООП средствами прототипного языка.
Вопросы
Вместо этого я решил использовать следующие три вопроса, которые, как кажется, помогают «сфокусировать взгляд» на наследовании в JS:
1) Как реализованы классы
2) Как экземпляры получают свойства своего класса
3) Как реализовано наследование классов
(Примечание: здесь и далее, под свойствами я понимаю как собственно свойства, так и методы объектов — вообще-то, в прототипном программировании есть подходящий термин «слот», но он редко применяется в данном контексте в JS)
Как организуем классы?
Есть два принципиально разных ответа на этот вопрос:
- Классы организованы с помощью функций конструкторов.
При этом, объекты-экземпляры создаются с помощью оператора new, а сами конструкторы, в характерном для JS стиле, совмещают сразу несколько ролей — одновременно и функции, и класса, и конструктора объектов. - Классы организованы с помощью функций фабрик объектов.
При этом экземпляры непосредственно возвращаются функциями-фабриками.
Простейший пример организации классов с помощью конструктора может выглядеть так:
// Пример 1 - конструкоторы (свойства хранятся в объекте)
function Class() {
this.value = 'some value'; // свойство
this.some_method = function() { // метод
console.log('some_method invoked');
}
}
var obj1 = new Class(); // экземпляр класса
var obj2 = new Class(); // еще один экземпляр
А вот аналогичный класс, организованный с помощью фабрики объектов:
// Пример 2 - фабрики (свойства хранятся в объекте)
function Class() {
var obj = {};
obj.value = 'some value'; // свойство
obj.some_method = function () { // метод
console.log('some_method invoked');
}
return obj;
}
var obj1 = Class(); // экземпляр (обратите внимание, в данном случае мы не используем new)
var obj2 = Class(); // еще один
(Можно заметить, что наличие специального ключевого слова new намекает, что именно функции-конструкторы являются мейнстримом языка)
Как храним свойства?
Переходим к второму вопросу: как экземпляры получают свойства своего класса.
Тут, прежде всего необходимо пояснить, что понимается под «получением свойств класса». Мы привыкли считать, что объекты автоматически «обладают» свойствами и методами, определенными в их классе. Факт этого «обладания» мы воспринимаем как нечто заданное. Но в JS это не так. Здесь мы можем выбрать каким образом наши объекты будут получать свойства от своих (псевдо) классов. У нас опять две возможности: либо объекты получают свойства своего класса в прототипе, либо содержат их непосредственно.
Что касается первого варианта, то мы только что его наблюдали — два предыдущих примера реализуют непосредственное хранение свойств в экземпляре для функций-конструкторов (пример 1) и для фабрик объектов (пример 2).
Как же выглядит получение свойств из прототипа? А вот как:
// Пример 3 - функции-конструкторы, свойства в прототипе
function Class() { }
Class.prototype.value = 'some value'; // свойство
Class.prototype.some_method = function() { // метод
console.log('some_method invoked');
}
var obj1 = new Class(); // экземпляр класса
var obj2 = new Class(); // еще один
Обратите внимание, что сама функция-конструктор в нашем примере совершенно пуста, а все свойства и методы задаются в прототипе. Конечно, в реальности конструктор, скорее всего, будет использоваться для чего-нибудь полезного (например для задания начальных параметров).
Как видете, с конструкторами прототипы сочетаются довольно естественно. А что с фабриками объектов? С ними ситуация сложнее, так как, согласно стандарту, прототип объекта задается с помощью его функции-конструктора. Иными словами, мы не можем работать с прототипом объекта, если не пользуемся конструкторами. Так что нам придется использовать трюк:
// Пример 4 - фабрики, свойства в прототипе
// Вспомогательная функция создает новый объект,
// прототип которого равен объекту, полученному ею в качестве параметра.
function derivate(o) {
function F() {}
F.prototype = o;
return new F();
}
function Class() { return derivate(Class.obj); }
// создаем объект, который будет прототипом для всех экземпляров данного класса
Class.obj = {};
Class.obj.value = 'some value'; // свойство
Class.obj.some_method = function () { // метод
console.log('some_method invoked');
}
var obj1 = Class(); // экземпляр
var obj2 = Class(); // еще один
Этот пример несколько сложнее предыдущих. Есть ли смысл в этой сложности? Ну, как минимум, пример интересен тем что функция derivate() расширяет именно прототипные возможности языка (ну, или компенсирует недостачу этих возможностей). С другой стороны интересно, что код, отвечающий за создание класса, получился очень похож на аналогичный код из предыдущего примера. Фактически, мы вместо встроенного Class.prototype создали собственное свойство Class.obj, при этом кое-что из того, чем JavaScript обеспечил нас в предыдущем примере, нам пришлось делать самим. Хочу заметить, что при всем своем сходстве, два примера вполне различны по сути.
Для чего вообще нужно помещать свойства класса в прототип экземпляра? Как минимум, таким образом мы можем сэкономить рессурсы, так как одноименные свойства всех экземпляров класса физически будут занимать одно и то же место в памяти.
Конструкторы. Как наследуем?
Наконец, мы подошли к самому интересному. Третий вопрос — наследование.
Фундаментально, в прототипном языке, наследование свойств может быть реализовано одним из двух способов: копированием или делегированием.
Сам JavaScript реализует наследование делегированием – коротко говоря, это значит, что отсутствующее свойство объекта ищется среди свойств его прототипа.
Наследование копированием соответствует концепции конкатенации прототипов. В этом случае свойства из родительского объекта просто копируются в объект дочерний. Конкатенация прототипов не является частью JavaScript, но не значит что ее нельзя реализовать
Решение с call/apply
Давайте посмотрим как можно реализовать наследование копированием. Теоретически, для этого можно было бы создать вспомогательную фунцию copyMethods:
// Вспомогательная функция, копирующая методы одного объекта в другой
function copyMethods(from, to) {
for(m in from) { // Цикл по всем свойствам прототипа источника
if (typeof from[m] != "function") continue; // Игнорировать все, что не является функциями
to[m] = from[m]; // Заимствовать метод
}
}
Однако можно сделать гораздо проще, если воспользоваться маленьким фокусом с методом apply.
// Пример 5 - конструкторы + свойства внутри экземпляров + наследование копированием
// ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ
// классы организуются с помощью функций конструкторов
function Class(val) {
// свойства объектов хранятся непосредственно в самих объектах
this.value = val;
this.getName = function() {
return "Class(id = " + this.getId() + ")";
}
this.getId = function() { return 1; }
}
function SubClass() {
// для реализации наследования копированием, можно было бы сделать так:
// copyMethods(new Class(arguments[0]), this);
// но есть способ лучше:
Class.apply(this, arguments); // вызываем Class() ОТ ИМЕНИ this со всеми переданными нам аргументами
var super_getName = this.getName; // так нужно запоминать свойства родителя, которые нам понадобятся
this.getName = function() {
return "SubClass(id = " + this.getId() + ") extends " + super_getName.call(this);
}
this.getId = function() { return 2; }
}
// ТЕСТИРУЕМ
o = new SubClass(5);
console.log(o.value); // 5
console.log(o.getName()); // "SubClass(id = 2) extends Class (id = 2)"
console.log(o instanceof Class); // false [не совместимо с instanceof]
Данный пример — первый отвечающий на все три предложенных вопроса, и фактически он представляет собой готовое решение, по-этому я сделал его слегка менее схематичным чем предыдущие. Он завершает начатое в примере 1.
С учетом законченности этого решения, имеет смысл оценить его плюсы и минусы. Итак,
Чем это хорошо:
– лаконичная и простая реализация
– нет необходимости повторять названия классов при каждом определении метода (принцип DRY)
– автоматическое наследование конструкторов
– не влияет на глобальный контекст программы (нет глобальных вспомогательных функций и т.п.)
– легко реализовать приватные свойства
Чем это плохо:
– не эффективно используется память (все одинаковые свойства всех объектов хранятся как копии)
– не удобно вызывать родительские методы
– не совместимо с оператором instanceof
– не совместимо с нативными классами (нельзя создать потомка встроенного класса вроде Date)
Почти стандартный подход
Разумеется, пример 1 (конструкторы + свойства класса внутри экземпляра), можно развить и вдругом направлении, в соответствии со вторым вариантом ответа ответа на вопрос о способе наследования.
// Пример 6 - конструкторы + свойства внутри экземпляров + наследование делегированием
// ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ
// классы организуются с помощью функций конструкторов
function Class(val) {
// свойства объектов хранятся непосредственно в самих объектах
this.value = val;
this.getName = function() {
return "Class(id = " + this.getId() + ")";
}
this.getId = function() { return 1; }
}
function SubClass() {
var super_getName = this.getName; // запоминаем свойство родителя
this.getName = function() {
return "SubClass(id = " + this.getId() + ") extends " + super_getName.call(this);
}
this.getId = function() { return 2; }
}
SubClass.prototype = new Class(); // обеспечиваем наследование через прототипы
// ТЕСТИРУЕМ
o = new SubClass(5);
console.log(o.value); // undefined [конструкторы не наследуются]
console.log(o.getName()); // "SubClass(id = 2) extends Class (id = 2)"
console.log(o instanceof Class); // true [совместимо с instanceof]
Обратите внимание, что дочерний класс не знает как ему задавать начальные параметры. Есть ли какие-то преимущества у такого подхода, по сравнению с предыдущим? Что ж, наследование через прототипы более экономно расходует память. С другой стороны, это решение явно половинчато — свойства класса-то мы по-прежнему создаем для каждого экземпляра независимо друг от друга.
Совсем стандартный подход
Наконец мы подошли к решению, которое вероятно было задумано как стандартное для JavaScript. Его «формула» с точки зрения наших трех вопросов выглядит так: конструкторы + свойства класса в прототипе + наследование делегированием.
// Пример 7 - конструкторы + свойства в прототипах + наследование делегированием
// ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ
// классы организуются с помощью функций конструкторов
function Class(val) { this.init(val); }
Class.prototype.init = function (val) { this.value = val; }
Class.prototype.value = 5; // значение по умолчанию
Class.prototype.getName = function() {
return "Class(id = " + this.getId() + ")";
}
Class.prototype.getId = function() { return 1; }
function SubClass(val) { this.init(val); }
SubClass.prototype = new Class(); // наследование делегированием
SubClass.prototype.class_getName = SubClass.prototype.getName; // запоминаем свойство родителя
SubClass.prototype.getName = function() {
return "SubClass(id = " + SubClass.prototype.getId() + ") extends " + SubClass.prototype.class_getName();
}
SubClass.prototype.getId = function() { return 2; }
function SubSubClass(val) { this.init(val); }
SubSubClass.prototype = new SubClass();
SubSubClass.prototype.subclass_getName = SubSubClass.prototype.getName; // запоминаем свойство родителя
SubClass.prototype.getName = function() {
return "SubSubClass(id = " + SubSubClass.prototype.getId() + ") extends " + SubSubClass.prototype.subclass_getName();
}
// ТЕСТИРУЕМ
o = new SubSubClass(5);
console.log(o.value); // 5
console.log(o.getName()); // "SubSubClass(id = 2) extends SubClass(id = 2) extends Class(id = 2))"
console.log(o instanceof Class); // true [совместимо с instanceof]
Я так хотел подчеркнуть неудобство этого примера, что описал в нем целых три класса. Кажется что весь пример состоит из повторения одних и тех же имен, одних и тех же многословных конструкций. Но этого мало, сам способ наследования прототипа содержит в себе принципиальную неэффективность: для того чтобы описать иерархию мы должны создавать и инициализировать (т.е. вызывать метод init) объекты-прототипы. Это плохо, хотя бы потому, что цинициализация может быть ресурсоемкой.
Как ни странно, несмотря на недостатки, этот подход пользуется большой популярностью. Почему? На самом деле тут нет никакой загадки: недостатки метода компенсируются с помощью всяческих надстроек, а вот его «нативность», встроенность в синтаксис языка, никакой надстройкой не обеспечишь.
Что же, у нас остался последний вариант из четверки решений с конструкторами: конструкторы + свойства в прототипах + наследование копированием. Я оставлю его в качестве самостоятельного упражнения для тех кто хочет на себе испытать что значит программировать против ветра ;) Скажу только, что теоретически такой подход возможен, и даже иногда
Таким образом, с конструкторами мы разделались, о фабриках объектов — в продолжении.
Update: к сожалению, по прошествии более чем года после написания первой части статьи, я вынужден признать, что на вторую часть у меня банально нет времени — и, судя по всему, не будет. Приношу извинения всем заинтересованным лицам :)