Pull to refresh

Серия 2. Как выполнять методы предков в модификации прототипного наследования

Reading time15 min
Views2.6K
imageОформим начатое в habrahabr.ru/blogs/javascript/130495 в удобный для использования метод .inherit4 конструктора Constr, чтобы, фактически построить модель классов и наследования (она будет более мощной, чем классическая, но это побочный эффект). Если у Вас нет желания подключать Mootools с аналогичной моделью, будет достаточно этого метода на 2 КБ несжатого кода, чтобы нормально работать с прототипным наследованием и иметь пару дополнительных методов: доступ к методам предков .ancestor('имя_метода', номер_поколения_предка) и расширение хешей. Применение всех 3 методов позволяет исключить из лексикона слова prototype и constructor, продолжая работать с тем и другим, и делает код легко читаемым.

В 1-й части на 2 функциях мы построили реализацию идеи, которой можно пользоваться, но хотелось бы улучшить качество будущего кода. Список пожеланий для улучшения:

1) улучшить написание и читаемость кода при написании дерева наследования — в частности, записать наследование через метод объекта, выступающего как класс (сейчас это — функция);
2) использовать выполнение конструкторов, чтобы задействовать this.some_method =...; (сейчас они не выполняются, работают только прототипы, поэтому традиционное создание методов недоступно);
3) добавить параметр для расширения прототипа конструктора — мы регулярно, после каждого наследования, расширяем прототип потомка новыми методами; надо поставить операцию на регулярную основу; в прежней статье приведена функция extend();
4) встроить .ancestor в конструктор наследования (сейчас у нас это тоже функция).

Фактически, мы хотим получить объект Class в понятиях Mootools, но имеющий доступ к предкам и выполняющий регулярные действия по подгрузке прототипа потомка. И вообще, код наследования улучшается, это — одна из целей построения метода.

Про доступ к предкам в 1-й части прозвучала богатая по содержанию критика, спасибо всем, особенно, lalaki, AndrewSumin. Общее её направление — 1) доступ к предкам не нужен, разве что к непосредственному предку, согласно основным принципам ООП, иначе это говорит о некачественном проектировании модели и нужно менять модель. Также, 2) легко получить доступ через (имя_предка-конструктора).prototype.метод_предка.apply(this, параметры), но тоже замечания по ООП не следует игнорировать.

Ответы:
1. Совершенно верно, доступ к дальнему предку говорит о неправильности проектирования (принцип Лисков), за исключением случая, когда в прокрустово ложе наследования JS хотим вставить узел множественного наследования — где соединяются 2 и более классов (конструкторов). И о доступе к ближайшему родителю принцип говорит, что он допустим (как же иначе его использовать). Наша функция делает его в наиболее сокращённом виде, со 2-м параметром по умолчанию (1) — .ancestor('метод').

2. Да, доступ через 4 слова "класс.prototype.метод.apply" есть, только добавляются 3 лишних слова вместо одного: имя класса и 2 служебных, а достаточно 'ancestor' и относительный номер узла. С другой стороны, при перестраивании структуры наследования многословное выражение не меняется, а номер узла может измениться.

Таким образом, критика не заставляет свернуть с намеченного пути — цель введения метода .ancestor была обоснованной, надо довести изложение до конца и представить достаточно совершенный способ наследования, содержащий наш .ancestor. Тем не менее, критика важна — заставляет не забыть, что надо избегать излишних вызовов параметров для выполнения методов, и в идеале — обращение только к своему непосредственному предку (для этого в .ancestor второй параметр по умолчанию =1) или необращение к предкам вообще.

Оформление функции в метод для Function


Можно записать метод inherit() (наследование) через прототип базового класса. Придётся перегружать базовый класс Function, и этот метод будет встречаться в каждой функции. Это плохо — засоряется каждая функция, возникает источник конфликтов с другим ПО, но создаётся очень компактный формат записи наследования (это говорит о нехватке в языке конструктора для наследования). Поэтому, для иллюстрации и лучшего понимания строения, приведём пример наследования с этим компактным кодом.

Ссылка на пример (для просмотра работы и методов удобно пользоваться Firebug).

Фактически — это тест-кейс с проверкой возможностей, как и другие ссылки на примеры в статьях. Разбор и его построение будет ниже, но уже не на базе Function, поэтому к рассмотрению этого кода стоит обратиться, если далее будет что-то непонятно — или просто по эстетическим соображениям. Тем более, что этот код и пример делают абсолютно то же, что и последующий, более совершенный.

Решение без перегрузки базового объекта Function


Чтобы избежать появления методов в Function, создадим класс Constr (конструктор) — аналог конструктора Class в Mootools. Сразу возникает необходимость дополнительных движений — уже не можем писать (конструктор).inherit, потому что у функции его нет. Надо использовать или прототип конструктора (но это длинно), или объект Constr считать конструктором, но на самом деле конструктором будет функция в этом объекте, или каждый раз догружать функцию-конструктор определением inherit().

Последнее, хотя несколько затратно (копирование ссылки в каждый конструктор), но более соответствует понятию конструктора — он остаётся функцией. Поэтому остановимся на этом подходе: каждый раз будем догружать (дописывать) определение inherit().

Наследование в картинках


Здесь очевидно, что многофункциональный код труден для понимания, поэтому приведена схема того, как работает один шаг наследования (применение метода .inherit4() ). В основном, он делает новый конструктор операцией new и обеспечивает, чтобы в новом конструкторе были: 1) прототип (prototype), 2) функция inherit4(), а в прототипе чтобы были как минимум 4 свойства: ancestor (функция), extend (функция), constructor (функция — ссылка на себя), _anc (функция — ссылка на конструктор-родитель) и 3) к ним — все свойства конструктора и его прототипа, причём свойства прототипа — более приоритетные и не требуют действий. Первые 2 свойства прототипа (ancestor, extend) тоже копируются автоматически и не требуют действий — в коде их копирования нет.



Итог: запись функций работы с наследованием


/**
 * Родительский класс конструкторов для наследования с расширенными возможностями. spmbt, 2011
 * @param {Constructor} Inherited - класс-наследник (или extProto, если пустая функция)
 * @param {Object} extProto - прототип наследника или пусто
 * @param contextArg0 - 1-й аргумент предка или пусто
 */
var Constr = function(){};
Constr.inherit4 = function(Inherited, extProto, contextArg0){
  var f2 ={extend: function(obj, extObj){ //расширение свойств первого аргумента
      if(obj ==null)
        obj = this;
      if(arguments.length >2)
        for(var a =1, aL = arguments.length; a < aL; a++)
          arguments.callee(obj, arguments[a])
      else{
        if(arguments.length ==1){
          extObj = obj;
          obj = this;
        }
        for(var i in extObj)
          obj[i] = extObj[i];
      }
      return obj;
    },
    ancestor: function(name, level, constr){ //доступ к методу предка
      level = level || (level ==0 ? 0 : 1);
      var t =this;
      return level <= 1
        ? (level
          ? constr && constr.prototype[name] || t.constructor.prototype[name]
          : t[name])
        : arguments.callee.call(this, name, level -1
          ,  constr && (constr.prototype._anc != Function && constr.prototype._anc || constr.prototype.constructor)
            || t._anc );
  }};
  if(!this.prototype || !this.prototype.ancestor){
    if(!this.prototype)
      this.prototype ={};
    for(var i in f2) //подключение пары методов к прототипу старшего конструктора
      this.prototype[i] = f2[i];
  }
  if(this === Constr && Inherited != Constr){ //сделать новый конструктор
    if(Inherited ===null)
      Inherited = Constr;
    return arguments.callee.call(Inherited, Inherited, extProto, contextArg0);
  }else{
    if(Inherited || (Inherited && typeof Inherited !='function' && !extProto)){
        //применять вызов без параметров, чтобы добавить extend + ancestor к конструктору без наследования
      if(!extProto){ //вызов с 1 параметром-объектом для прототипа и с возвращением новой функции-наследника
        extProto = typeof Inherited !='function' ? Inherited :{};
        Inherited = typeof Inherited !='function' ? function(){} : Inherited;
      }
      Inherited.prototype = new this(contextArg0);
      Inherited.inherit4 = arguments.callee;
      f2.extend(Inherited.prototype, {_anc: this, constructor: Inherited}, extProto);
      return Inherited;
    }else{
      if(this === window)
        return Constr;
      else{
        this.prototype.constructor = this;
        return this; //если без параметров, то возвращается объект-предок
      }
    }
  }
};
//Тестирование:
A = Constr.inherit4(function(){this.prop ='A';}, {protoProp:'protoA'});
B = A.inherit4(function(){this.prop ='B';}, {protoProp:'protoB'});
C = B.inherit4(function(arg){this.prop ='C';this.propArg = arg ||'XX';}, {protoProp:'protoC'});
D = C.inherit4(function(arg){this.propArgD = arg ||'XX';}, {protoProp:'protoD'}, '3thArgInCInh');
	var c01 = new D('ArgInD');
	
//B.prototype._anc = B;
Alert(c01['protoProp'], c01.ancestor('protoProp', 0), c01.ancestor('prop', 0), c01.prop) //'protoD protoD D D'
Alert(c01.constructor.prototype.protoProp, c01.ancestor('protoProp'), c01.ancestor('prop', 1)) //'protoD protoD C'
Alert(c01.ancestor('protoProp', 2), c01.ancestor('prop', 2) ); //'protoC B'
Alert(c01.ancestor('protoProp', 3), c01.ancestor('prop', 3) ); //'protoB A'
Alert(c01.ancestor('protoProp', 4), c01.ancestor('prop', 4) ,'-- prop берёт самый нижний метод, а не даёт ошибку'); //'protoA C'
Alert(c01.ancestor('protoProp', 5), c01.ancestor('prop', 5) ,'-- protoProp берёт самый нижний метод'); //'protoA C'
Alert(c01.ancestor('protoProp', 6), c01.ancestor('prop', 6) ); //'protoA C'
Alert(c01.ancestor('protoProp2', 4), c01 instanceof A, c01 instanceof D, '-- отсутствующие методы - undedfined; instanceof - верны'); //'undefined D true true'
Alert(c01.propArg, '-- свойство из аргумента конструктора C; ', c01.propArgD, '-- из аргумента конструктора D');

(Работающий пример с рядом дополнительных тестов, описанных далее, смотреть с консолью (вывод — в console.log()).)

Правила пользования (вместо документации)


Создание корневого класса:
имя_класса = Constr.inherit4(function(){конструктор_класса;}, хеш_прототип_класса, 1_й_аргумент_конструктора);
//прототип может отсутствовать, аргумент может отсутствовать
//конструктор может отсутствовать, а на его месте - {} или полный прототип_класса

Наследование класса:
имя_наследника = класс_предок.inherit4(function(){конструктор_наследника;}, хеш_прототип_класса_наследника, 1_й_аргумент_наследника);

Создание экземпляра — как обычно, экземпляр = new класс();
Индикатор наследования — как обычно, экземпляр||класс instanceof класс_предок;
//при множественном наследовании instanceof не работает, если не выстроить дерево наследований в линию

Обращение к методу предка:
экземпляр.ancestor('метод', номер_поколения);
//номер по умолчанию =1

Возможное обращение к методу предка по имени класса (в коде не реализовано, ссылка #):
экземпляр.ancestor(класс, 'метод', [аргументы_в_массиве]);

Перегруженные операции:
-----------------------

Добавление одного хеша к самому себе:
экземпляр_класса_Constr.extend(xeш);

Добавление нескольких хешей к самому себе:
экземпляр.extend(null, xeш, xeш, ...);

Расширение любого хеша:
имя_объединённого хеша = экземпляр_класса_Constr.extend(xeш, xeш, ...); //более одного


Предполагаемая критика и ответы


Разбор тестирования будет чуть дальше, а здесь — ответы для тех, кто уже сам разобрался.

1) здесь выполняется конструктор класса вместо new F() в прежней функции; Среда будет нагружена чуть больше, если прикладной конструктор пустой и ничем не отличается от F(). Но появляется возможность конструировать свойства. Будет ли среда нагружена сильнее при появлении в конструкторе свойств? Нет, потому что иначе они объявлялись бы в прототипе, а подготовка их велась бы не в конструкторе, а где-то рядом, что грозит неструктурированностью кода. Поэтому лучше в конструкторе делать формирование тех свойств прототипа наследника, которые нам нужны и которые зависят от конструктора.

2) Не утяжеляется ли метод наследования парой дополнительных функций и рядом проверок? Зто прототип, поэтому объявление утяжеляется один раз в самом корневом конструкторе, но выполнение — нет, если он не используется. Если используется, то всё равно должен быть где-то определён.
Проверки, конечно, чуть замедляют работу, но получаем ряд удобств. Нужны ли они — решать разработчику. На мой взгляд, то, что облегчает разработку, то оправданно хотя бы на этапе разработки.

3) Почему для предка предусмотрен только один аргумент contextArg0? Потому что попытка записать конструирование объекта в виде Inherited.prototype = new (this.apply(this, contextArgs)); — не работает. Но одного аргумента будет достаточно, чтобы задать хеш со всеми параметрами в нём — практически то же самое и более удобное описание аргументов.

4) Зачем лишняя сущность if(this === window)...? Для перегруженного метода расширения хешей (с пустым 1-м аргументом или с 1 аргументом). При наследовании не используется.

Использование аргументов конструктора


Аргументы конструктора — весьма важный и полезный механизм конкретизации наследников или порождаемых объектов, который может определять свойства конструктора и, таким образом, они включаются в полноценный круг, становясь динамическими. Но в реализации не получилось передавать много аргументов в массиве, чтобы затем разбросать их через apply в функции new this() — в джаваскрипте это не реализовано. Но аргумент конструктора настолько важен, что введём хотя бы один — на месте третьего аргумента в inherti4. (Лучше бы на месте первого, но тогда усложняется запись при перегрузке метода.)

Демонстрация использования есть в примере. В классе C определили функцию, берущую первый аргумент — пишем первый аргумент для неё на месте третьего аргумента в C.inherit4(). В классе D определили аналогичную функцию — в порождении экземпляра аргумент используется на своём первом месте.

Конечно, неудобно, что аргумент скачет с места на место. Но как сделать перегрузку по-другому, при учёте, что аргумента для конструктора чаще всего нет? Можно использовать, что нынешний первый аргумент — всегда функция, но ведь и агрумент для конструктора может быть функцией. Поэтому пусть пока всё останется на местах, а при реальном использовании механизма, может быть, придумаем что-то получше. Главное, что этот механизм сейчас есть и работает.

К аргументу конструктора напрашивается функция автоматического разбрасывания хеша по свойствам this:

function(arg){ //конструктор в наследовании
	if( typeof arg =='object') //разбрасывание хеша по свойствам
		for(var i in arg)
			this[i] = arg[i];
}

Как сделать, чтобы такое разбрасывание было автоматическим? Каждый раз вставлять в функцию-конструктор даже одиночный вызов чего-нибудь — не очень интересно. Придумаем вместо простого конструирования наследника

Inherited.prototype = new this(contextArg0);

расширенное:

if(typeof contextArg0 =='object'){
	Inherited.prototype = contextArg0;
	f2.extend(Inherited.prototype, new this(contextArg0));
}else
	Inherited.prototype = new this(contextArg0);

Как можно убедиться, это работает, но вносит неочевидную особенность конструирования объекта и добавляет ещё одну проверку. Не будем вносить его в окончательный код, но следует попробовать применить, чтобы вывести удобный формат.

Если поменять местами порядок присваивания,

	Inherited.prototype = new this(contextArg0);
	f2.extend(Inherited.prototype, contextArg0);

получим нетипичную приоритетность присваивания свойств конструктора (приоритетнее, чем прототип), которые могут когда-то пригодиться (но это ещё более опасный эксперимент над привычками, поэтому тоже только упомянем о нём).

Разбор тестов и механизма действия


Можно сравнить вид тестов с примером из первой статьи. Выполняется почти то же самое (плюс выполнение конструкторов), но код уже не такой рыхлый, аргументы встали на свои места. Это — следствие обрастания кода функции новыми проверками и движения к некоторой цели.

В примере начали участвовать свойства конструкторов — те, которые возникают из "this.xxx". В тестовом примере мы аккуратно расположили буквы A, B, C, D каждую на своём уровне. В результатах видим смещение букв. Это вполне логично, потому что .ancestor извлекает прототипы (кроме c01.ancestor('prop', 0)), а прототип возникает у наследника: свойство предка пишется в прототип. Получается, что рядом видим: свойство — от предыдущего класса, прототип — от текущего. Только у экземпляра c01 в примере прототипа нет (не написали), поэтому он взят от предыдущего класса, поэтому первая строчка — «protoD D» — одинаковы по выводимым буквам, а в следующих наблюдается смещение.

Чтобы свойства от прототипов и от конструкторов вели себя совершенно одинаково, понадобилось отлавливать условие if(this === Constr) ..., чтобы обнаружить первого предка и «зациклить» наследование. Работает, как и в первой части повествования, «бомба доброты», которая не даёт ошибки при обращении к отсутствующему свойству. Тем не менее, добились того, что поведение при доступе к «слишком раннему» предку одинаково для всех свойств: возвращается первое существующее свойство родителя или undefined.

Тесты, работающие в примере по ссылке, показывают, как работает множественное наследование (неполноценно), как работают перегруженные методы в объектах, имеющих предка Constr. Об этом — чуть ниже.

Модель наследования и её вариации


Вот такая несколько причудливая модель наследования получилась, если задействованы и свойства конструкторов, и прототипы. Модель мощнее, чем классическое наследование, и если присмотреться, то в рамках неё (этого самого алгоритма на 30 строчек) можно реализовать аналог классического наследования более чем двумя способами — только через прототипы или только через свойства. Или наследование «с удвоенной частотой шагов» в таком порядке:
  1. Базовый класс — прототип конструктора А (свойства его хранятся там же, в прототипе);
  2. 1-й наследник — свойства конструктора А (хранятся в прототипе 2-го наследника);
  3. 2-й наследник — прототип конструктора В (свойства — здесь же, в прототипе 2-го наследника);
  4. 3-й наследник — свойства конструктора В (хранятся в прототипе 4-го наследника);
  5. ...
Разумеется, это не чистое классическое наследование, потому что можно менять прототипы любого конструктора, следовательно, мгновенно менять свойства у всех наследников. Зато, свойства предков не хранятся в каждом классе (если только нет оптимизации в движке скрипта), и если избегать «ошибок» смены прототипов после создания наследников, получится классическое наследование, даже в режиме с «удвоенной частотой шагов».

Множественное наследование


По совместимостям, надо ещё сказать, что множественное наследование не поддерживается операцией instanceof. Заставить её реагировать на дерево предков можно только способом выстраивания дерева в линию предков. В то же время, за счёт специального финта в inherit4() с подмешиванием свойств прототипа, если он уже существует, нормально делается слияние ветвей от предков. Код с подмешиванием не включён в пример, поэтому наследование не будет работать — прототип получит значение только от последнего предка.
Предок1 = Constr.inherit4(function(){this.prop ='prop1'; this.propA ='propA';}, {protoProp:'prot1'});
Предок2 = Constr.inherit4(function(){this.prop ='prop2';});
Наследник = Предок1.inherit4({protoProp:'prot11', protoPropA:'protA'});
Наследник = Предок2.inherit4(Наследник, {protoProp:'prot2'});

Правильный результат даёт выстраивание дерева слияний в цепочку "Предок1-Предок2-Наследник" наследований — случай, когда пригождается метод обращения к методам предков с поколением больше 1.

Где можно применить удвоенную скорость наследования?


Там, где структура наследования детерминирована и для каждого уровня мы знаем, что написать. Например, есть задача использования настроек программы по умолчанию (базовый класс), которые перекрываются «рекомендованными настройками», которые перекрываются «приоритетными рекомендованными», которые, наконец, перекрываются настройками пользователя. Всего — 4 уровня настроек, везде — отношения наследования. Очевидно, в JS их можно реализовать 2 конструкторами и наследованием описанного типа.

В этом примере даже процедуры классов будут одинаковыми. В случаях посложнее — вполне можно писать собственные процедуры для каждого «полушага» наследования.

Всё это не есть достоинство, а просто дополнительная возможность, которую можно не использовать, а писать по одному шагу наследования на операцию .inherit4(). И, конечно, нужно понимать модель наследования в JS для такого использования, одного знания классического наследования будет недостаточно.

Расширение свойств — вытащим описание extend в «паблик», и ряд других


Свойства, в которых нет необходимости, потому что решают побочные задачи, но код для них написан и используется. Это — метод extend для расширения хешей и связанные с ним возможности перегрузки аргументов — для использования в разных режимах, для разных целей. (Эти свойства уже реализованы в функции выше и в примере.) Зачем? Да хотя бы для более быстрого слияния хешей, чем в jQuery, где делается больше проверок типов.
1) В самом деле, зачем добру (в несколько строчек функции extend) пропадать, раз уж оно написано и готово внедриться везде, где определили Constr? (Мможет не понравиться тем, что вставляет extend во все наследованные объекты, но нет проблем отключить.)

2)… И, эх, гулять — так гулять, добавим в extend возможность расширения самого себя, если написан один аргумент или на месте первого аргумента стоит null.

3) Дополнительное удобство — можем писать (конструктор).inherit4(), без аргументов, чтобы включить в прототип обе функции — extend и ancestor, если не собираемся наследовать этот класс (конструктор). (объект).ancestor('имя_метода') тоже будет иметь смысл, если порождённому объекту (объект = new конструктор();) впоследствии приписали прямой затирающий метод — метод прототипа конструктора (не самого конструктора) будет иметь «человекопонятный» доступ (наряду с объект.constructor.prototype[name] ).

Пример (так будет понятнее, чем то же самое словами):
Обратим внимание, что аналогичный пример для Function.prototype.inherit3 тоже так умеет, но записан по-другому.

конструктор = Constr.inherit4(null, {a: 333}); //определили конструктор
объект = new конструктор(); //создали объект без классов-наследников
объект.a = 555; //затёрли свойство
Alert(объект.a, объект.ancestor('a'), "--имеем доступ к предку для экземпляра"); //но имеем доступ к предку: 333 и умеем расширять хеши любым объектом:
Alert(объект.extend({a:3}, {b:4}, {c:5}), "--расширение сторонних хешей с помощью любого объекта" ); //Object {a=3, b=4, c=5}

4) Учитывая, что возвращаем первый аргумент, возможно создание безымянного наследника, чтобы потом ему присвоить имя (правильнее — его присвоить имени :) ).

//Расширяем самого себя несколькими аргументами (первый аргумент - null)
obj = new (Constr.inherit4());
Alert( obj.extend(null, {a:1}, {b:2}) ); //Object { a=1, b=2, _anc=function(), ещё...

5) В примерах мы уже забыли о том, что в первой части статьи приходилось писать явным образом прототип объекта — теперь это делается 2-м параметром, это наглядно и удобно:

A = Constr.inherit4(function(){описание_корневого конструктора}, {хеш_прототипа});

В таком формате:
A = Function.inherit4(function(){this.prop ='A';}, {protoProp:'protoA'});
код выглядит гораздо лучше, чем при разрозненном определении свойств.
В итоге, видим, что код разросся «мясом», но каждый участок действует весьма эффективно.

Не следует забывать, что базовому классу Object метод .extend не приписали, поэтому такое — {x:2}.extend({a:1}); — работать не будет (издержки воздержания от расширения базового класса). Но

Alert( (new (Constr.inherit4(function(){this.x = 2;}) ) ).extend({a:1});

— будет (даст объект {a:1, x:2, и пару функций} ). (Кошмар — так, конечно, никто не будет делать, но идея продемонстрирована (extend самого себя) и будет работать в случае более растянутых кодов.) Главное, что мы ничего не теряем (только засоряется пространство имён), функция extend уже была, её только присвоили в прототип корневого объекта Constr, как и ancestor.

P.S. Если кто-то сообщит ссылку на описание хотя бы очень похожего подхода к наследованию или такого же, все мы будем очень благодарны — интересно узнать, куда приводит такой подход.

P.S.2 Надо ли избавляться от бомбы доброты и как?
Tags:
Hubs:
+14
Comments27

Articles