В JavaScript принято великое множество разнообразных моделей наследования - практически каждая библиотека (MooTools, DOJO, JQuery, ExtJS и т.д.) предлагает свою, преследующую специфические цели и многие из них при этом игнорируют стандартную операцию
instanceOf
. Понимая, что вступаю на сверхконкурентное поле, всё-таки рискну вставить свои 5 копеек, потому что именно того, до чего дошёл в последнем проекте сам, я ни у кого не видел (хотя не исключаю, что просто плохо искал - так что те, кто считают, что я изобрёл велосипед, прошу меня простить великодушно за потраченное на чтение статьи время).
Одну из этих форм, не игнорирующую instanceOf
, можно назвать классической для наследования в JavaScript, принятой в этом языке с момента его создания. Уже упоминавшийся мной Стоян Стефанов в своей книге "JavaScript Шаблоны" ("JavaScript Patterns") называет её "шаблоном по умолчанию" в разделе "Шаблонов повторного использования программного кода". Я долгое время использовал её в своих проектах. Не могу сказать, что она мне совсем уж во всём нравилась (об этом - ниже), по-этому я обдумывал, как её можно было бы улучшить, минимально изменив, но сделать это так, что бы она не потеряла ни своей краткости, ни своей понятности, ни красоты и что-то у меня в итоге, как мне кажется, получилось. :)
Классическую схему прототипного наследования можно продемонстрировать на следующем примере:
/** @constructor */
function A(x){
this.x = x;
}
A.prototype.getX = function(){ return this.x };
/** @constructor
* @extends A
* @param y
* @param [x=5] */
function B(y, x){
this.y = y;
if (typeof x !== 'undefined' && x !== this.x)
this.x = x;
}
B.prototype = new A(5);
B.prototype.getY = function(){ return this.y; };
//Проверим:
var b1 = new B(1),
b2 = new B(2, 1);
alert(b1.getX()); //5
alert(b1.getY()); //1
alert(b2.getX()); //1
alert(b2.getY()); //2
Здесь можно обратить внимание на то, что мы вынуждены вызывать конструктор предка лишь один раз и все его поля, установленные при этом вызове, автоматически становятся для нас как бы значениями по-умолчанию - мы проваливаемся по ссылке
__proto__
, считывая их, если происходит обращение к полученному объекту, а у него этого свойства нет. Т.е. в случае, если нам для них нужно иное значение, мы должны их заново устанавливать в конструкторе-потомке - помощи нам от конструктора-предка в этом нет никакой. Из этого вытекает, что в случае большого количества полей и при глубоком наследовании мы будем вынуждены каждый раз описывать
все поля. И даже более того, что бы полноценно воспользоваться этим бенефитом в виде экономии на полях по-умолчанию, мы вынуждены прибегать к громоздкой конструкции проверки на наличие у нас соответствующего аргумента и проверки на его неравенство значению по-умолчанию:
typeof x !== 'undefined' && x !== this.x
По сути это означает, что наследуются только методы прототипа конструктора и значения полей по-умолчанию.
На проблему того, что нам приходится устанавливать все поля при том, что это можно было бы сделать при помощи конструктора-предка так же обратил внимание Стоян Стефанов, и для её разрешения предложил шаблон "Заимствование и установка прототипа", который приводит в той же 6-й главе той же книги. В соответствии с ним мой пример можно было бы модифицировать так:
/** @constructor
* @extends A
* @param x
* @param y */
function B(x, y){
A.call(this, x);
this.y = y;
}
B.prototype = new A(5);
Таким образом, мы доверяем конструктору-предку сконфигурировать за нас объект, притворяясь, как будто он вызван в качестве конструктора, и даже если он в процессе этого задействует какие-то методы своего прототипа - они будут на месте, потому что прототип мы унаследовали. Хорошо, Стоян помог нам решить одну из проблем - мы добились повторного использования кода родительского конструктора и теперь не обязаны прописывать создание и инициализацию полей во всех потомках. Но осталась вторая проблема - в результате мы получаем объект, имеющий 3 набора свойств:
- набор свойств базового объекта, применённых к нашему основному объекту (в нашем случае - поле
this.x
), - набор свойств, присвоенных нашему объекту его основным конструктором (в нашем случае - поле
this.y
) - набор свойств, аналогичный первому, но присвоенный прототипу (в нашем примере - это поле
this.__proto__.x
или, что более правильно с точки зрения ECMAScript 5, Object.getPrototypeOf(this).x
)
В нужности первых двух наборов убедиться не сложно, но вот зачем нам третий?
Единственное, что обычно удаётся придумать - третий набор свойств может интересовать нас разве что как некоторый набор значений свойств по-умолчанию. Т.е. мы могли бы записать в эти поля наиболее типичные значения полей и не присваивать их в конструкторе-потомке, если они совпадают. Т.е. в базовом варианте "шаблона по умолчанию" по Стефану это можно было бы реализивать так, как я и показал в первом примере данной статьи, при помощи следующей конструкции:
typeof x !== 'undefined' && x !== this.x
А в варианте "заимствования и установки прототипа" - следующим, возможно ещё более неудобным образом:
/** @constructor
* @extends A
* @param y
* @param [x=5] */
function B(y, x){
A.call(this, typeof x === 'undefined' ? 5: x);
if (this.x === Object.getPrototypeOf(this).x)
delete this.x;
this.y = y;
}
B.prototype = new A(5);
И, спрашивается, ради чего такие сложности?
Нет, я не спорю, что иногда значения по умолчанию могут нам для чего-то пригодиться, но, ведь, они нужны нам далеко не всегда. А классическая модель и Стоян Стефанов предлагают нам использовать их по сути постоянно и в случае, если они нам не нужны - либо старательно выискивать и удалять их вручную, как было показано выше, либо не замечать их и просто забывать об их наличии, по факту захламляя ими объектную модель, как обычно все и делают в реальных проектах.
А теперь задумайтесь - много ли в Вашей практике программирования на JavaScript было случаев, когда Вам были нужны значения по-умолчанию? Я сам для себя могу таких случаев припомнить немного. Но и в тех достаточно редких случаях, когда нам действительно удобно воспользоваться этим бенефитом в виде поля по-умолчанию, наш объект этого поля не будет содержать, а за его операцией this.x
, фактически, будет стоять следующее действие интерпретатора JavaScript: он будет сначала искать это поле в объекте this
и только потом - в объекте this.__proto__
, и лишь там его, наконец, найдёт. Давайте теперь представим многоступенчатое наследование внушительного размера конструкторов с кучей полей в каждом, реализованное по такой вот модели - и совершенно точно получим нечто трудновообразимое...
Для того, что бы как-то оптимизировать такого рода структуру я несколько раз встречал у коллег костыльный код, подобный следующему:
for (var i in B.prototype)
if (!this.hasOwnProperty(i) && typeof B.prototype[i] !== 'function')
this[i] = B.prototype[i];
Не трудно увидеть, что смысл этой конструкции в том, что бы притянуть все установленные по умолчанию поля из всех прототипов объекта в сам этот объект, что бы их поиск по цепочке прототипов происходил быстрее - по мнению многих разработчиков, такой тюнинг вполне оправдан (иногда даже функции из прототипа подтягиваются так же). В итоге мы видим, как игнорируется и то призрачное преимущество полей по умолчанию, которое мы имели - даже тогда, когда они востребованы.
Кроме того, нельзя забывать о проблеме со ссылкой constructor
, которая в случае этой модели на нижнем уровне просто теряется - вызов b1.constructor
приведёт нас к функции A
, а не к B
.
По всему выходит, что все эти значения по-умолчанию в общем случае нужны нам обычно чуть меньше, чем собаке - пятая нога. В частном же случае, когда они действительно оказываются нужны, их, мне кажется, проще захарткодить. Но, используя такую модель, мы вынуждены везде втыкать эти значения, фактически отказываясь от их и так довольно призрачной использования при тюнинге. Так, может быть, "не стоит овчинка выделки"? :) Может, лучше будет, если мы будем сразу писать код, который будет оттюнен сразу?
Я предлагаю внести в эту схему изменение, направленные на её большую экономность и практичность: сэкономить на значении полей по-умолчанию.
Давайте подумаем, что нам для этого нужно? На мой взгляд, здесь важно понять, что в классической схеме ("шаблон по умолчанию" Стефана) мы вызывали конструктор предка с целью установки ссылки __proto__
и лишь побочным эффектом этого стала для нас установка в нагрузку ещё и значений полей в прототипе. Теперь давайте пойдём немного дальше и зададимся следующим, вытекающим отсюда вопросом - а существует ли иной, кроме создания экземпляра объекта, способ создать объект с ссылкой __proto__
, ведущей на нужный нам прототип? Оказывается - да, существует! В ECMAScript5 у нас есть прекрасный метод Object.create
, второй параметр которого необязательно указывать, а первый как раз-таки и содержит прототип вновь создаваемого объекта. Ссылку же 'çonstructor'
можно прописать и вручную тут же, не отходя от кассы, воспользовавшись тем, что любое равенство в JavbaScript возвращает то, что в нём присваивается. Т.е. получаем такой компактный код:
function B(){/*...*/}
(B.prototype = Object.create(A.prototype)).constructor = B;
Всё - разобрались с проблемой: и объект прототипа у нас чистенький и ссылается он ссылками
__proto__
и
constructor
куда надо - на прототип предка и на собственный конструктор соответственно, а если нам понадобится получить доступ к конструктору предка - это можно будет осуществить вызовом
Object.getPrototypeOf(Object.getPrototypeOf(b1)).constructor
, что, хоть и более громоздко, чем привычное и лаконичное
b1.__proto__.__proto__.constructor
, тем не менее является более правильным, чем прямое использование нестандартной ссылки
__proto__
, относительно которой даже
на сайте Mozilla Developer Network уже чёрным по синему написано: "Deprecated", то бишь со временем это, по-видимому, будет убрано... Да, я понимаю, у меня у самого в связи с этим до сих пор некоторая ломка, но "надо, Федя, надо..."(с) :)
Единственная проблема, которая здесь возникает - это проблема с поддержкой старых браузеров, из которых актуальными остаются на сегодняшний момент IE6-8, не поддерживающие ECMAScript5. Для них придётся писать заплатки. Вот они для использующихся методов Object.create
и Object.getPrototypeOf
:
Object.create = function(proto, descriptors){
if (typeof proto !== 'object' && typeof proto !== 'function')
throw TypeError(proto + ' is not an object or null');
var /** @private @type Object */ result,
/** @private @type Object */ objPrototype = Object.prototype;
Object.prototype = proto;
result = new Object();
Object.prototype = objPrototype;
if (typeof descriptors !== 'undefined')
Object.defineProperties(result, descriptors);
return result;
};
Object.defineProperties = function(object, descriptors){
for (var /** @private @type string */ i in descriptors)
Object.defineProperty(object, i, descriptors[i]);
return object;
};
Object.defineProperty = function(o, name, desc){
o[name] = ('value' in desc) ? desc.value: null;
return o;
};
Object.getPrototypeOf = function(o){ return o.__proto__; };
Увы, одним patch`ем отвертеться не удастся - для того, что бы последний метод работал, нужно в каждый конструктор вставлять ссылку "__proto__" выражением, которое я уже недавно демонстрировал в
статье "__proto__ во всех браузерах":
if (!this.hasOwnProperty('__proto__'))
this.__proto__ = B.prototype;
Почему Стоян Стефанов не обратил своё внимание на эту проблему - я не знаю. Может быть, счёл её не достаточно серьёзной, может - просто не придумал достаточно элегантного на его взгляд решения...
Вот что примерно получается, если сложить всё вместе (кроме patch`а для IE6-8):
/** @constructor */
function A(x){
this.x = x;
}
A.prototype.getX = function(){ return this.x };
/** @constructor
* @extends A
* @param x
* @param y */
function B(x, y){
A.call(this, x)
this.y = y;
}
((B.prototype = Object.create(A.prototype)
).constructor = B
).prototype.getY = function(){ return this.y; };
//Проверим:
var b = new B(1, 2);
alert(b.getX()); //1
alert(b.getY()); //2
Этот метод хотелось бы продемонстрировать на примере недавно обсуждавшейся в
статье "Коллекции в JavaScript - допиливаем setAddEventListener" JavaScript-аналоге коллекций типа множество (Set). Этот пример демонстрирует наследование от конструктора
Array
с переопределением метода
push
из несколько сомнительного расчёта на то, что множество будет наполняться исключительно посредством этого метода, хотя на самом деле, конечно же, может наполняться и прямой записью элементов по какому-либо индексу, но с этим уж ничего не поделаешь. А вот с функцией
push
кое-что поделать мы всё-таки можем:
/** Множество. При добавлении элемента массив проверяется на его наличие и добавление происходит лишь в
* случае отсутствия в нём этого элемента.
* @constructor
* @extends Array */
function ArraySet(baseType){
Array.apply(this, arguments);
}
/** Добавить элементы к множеству.
* @param {...Object} arguuments */
((ArraySet.prototype = Object.create(Array.prototype)
).constructor = ArraySet
).prototype.push = function(){
var /** @type Function */ push = Object.getPrototypeOf(Object.getPrototypeOf(this)).push,
/** @type number */ length = arguments.length;
for (var i = -1; ++i < length;)
if (this.indexOf(arguments[i]) === -1)
push.call(this, arguments[i]);
return this.length;
};
А вот и необходимая
заплатка для
Array.indexOf
в IE6-8:
Array.prototype.indexOf = function(value) {
var /** @type number */ length = this.length;
for (var /** @type number */ i = 0; i < length; i++)
if (this[i] === value)
return i;
return -1;
};