пятница, 30 марта 2012 г.

Ещё одна модель наследования на примере реализации Set

В 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;
};