Pull to refresh

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

Reading time 8 min
Views 12K
При работе с наследованием иногда возникает желание иметь функцию доступа к методу предка (методу родительского класса) — в конструкторе (аналоге класса для Javascript) или в методе-потомке, потому что, бывает, что новый класс переопределяет его. Не просто какую-нибудь функцию (метод), а с совершенно понятной записью, чтобы название говорило само за себя, и имеющую доступ к указанному поколению предков (не «пра-пра-пра-», а «пра- 3 раза»).

Возьмём за основу метод прототипного наследования, который максимально эффективен тем, что производит минимум действий при описании цепочек наследуемых классов и при этом максимально поддерживает базовые операции и свойства наследования: instanceof, constructor. Для доступа к предку он создаёт свойство .superclass. (Утверждают, что этот взятый за основу подход к классам популяризовался Крокфордом, ссылок найти не удалось, но важно, что метод продуманный и экономичный. Автор неизвестен.)

Приведём для начала код этого метода со своими краткими комментариями.
function inherit2(Inherited, Parent){ //Наследование классов
	var F = function(){};
	F.prototype = Parent.prototype;
	(Inherited.prototype = new F()).constructor = Inherited; 
		//для корректного конструктора экземпляра
		//создают функцию с прототипом, равным прототипу родителя и с конструктором
	Inherited.superclass = Parent.prototype; //прототип предка
}

(Кто ранее интересовался темой наследования в Javascript, тот, конечно, его узнал.) Он был очень подробно рассмотрен в javascript.ru/tutorial/object/inheritance, и там же была освещена проблема доступа к методам предков: мы «поигрались» с ".constructor", замкнув ссылку на себя, чтобы будущие экземпляры класса имели корректное свойство .constructor. Но, естественно, потеряли корректность связей между наследуемыми классами (да её и не было, в языке это не заложено) и были вынуждены создать «бонусное» свойство .superclass для возможности доступа к родительскому классу.

Зачем нужны методы предков?


Наследование подразумевает «затирание» (экранирование) свойства, если имя его в потомке совпадает с именем в предках. Если описали функцию, а потом, например, расширили функциональность метода в классе-наследнике, то часть упрощённой (или просто предыдущей, или общей) функциональности приходится переписывать — повторять, если она нужна. Это уже непорядок. Ладно, если бы надо было полностью изменить метод, но бывает нужно повторить старый метод, а затем немного его дополнить. (Поэтому доступ к предкам важнее для методов, чем для свойств — свойства всегда полностью переписываются.) Тут-то пригождается «бонусное» свойство ".superclass", указывающее на прототип родителя.

Для унаследованных классов по схеме A => B => C => c01.MethodX

	function A(){this.xa =1;}
	function B(){this.xb =1;}
	function C(){this.xc =1;}
	A.prototype.MethodX ='protoA';
	inherit2(B, A);
	B.prototype.MethodX ='protoB';
	B.prototype.xx ='protoX';
	inherit2(C, B);
	C.prototype.MethodX = 'protoC';
	var c01 = new C();

получаем такие способы доступа к методу предков:

Alert = console.log;
Alert(c01.MethodX); //'protoC' //метод экземпляра
Alert(c01.constructor.prototype.MethodX); //'protoC' //тот же метод конструктора
Alert(c01.constructor.prototype.superclass.MethodX); //'protoВ' //от класса В
Alert(c01.constructor.prototype.superclass.constructor.superclass.MethodX); //'protoA'.

(Чтобы увидеть, как это работает, смотрим исполняемый пример для inherit2() (с Firebug смотреть не помешает).)
Такие вызовы неудобно использовать, но ими удобно запутывать коллег и последователей: вначале обращаемся через .constructor.prototype, а затем через .superclass, вместо того, чтобы сказать, что мы хотим; не можем указать номер глубины обращения к предку. Всего-то, требуется небольшая функция вида:

object.ancestor(name, level) - //указать имя метода/свойства и уровень углубления,

которую положили бы в самый глубокий класс и могли бы расширять функциональность новых методов «затираемыми» методами (да и никто не мешает обратиться к соседним методам).

Небольшая проблема останется в том, что контекст метода (this) будет «обогащённый», принадлежащий самому последнему классу, к которому базовые классы (A и B) ещё не должны знать, но это уж свойство реальности и далее — желание или нежелание автора полностью переписывать метод предка. Можем переписать по-старинке, а можем заранее позаботиться о понимании примитивными методами более сложного, неизвестного для них окружения.

Заменим также понятие «суперкласс» (понятно, откуда это идёт, из Джавы, но это путает, потому что обычно классами называют конструкторы, а не их прототипы) на «класс предка» _anc (ancestor). Это как раз то, чем должен быть конструктор относительно наследника, поэтому логика конечной функции упростится.

Что хотелось бы получить

c01['MethodX'] c01.ancestor('MethodX', 0)
c01.constructor.prototype['MethodX'] c01.ancestor('MethodX')
c01.constructor.prototype._anc.prototype['MethodX'] c01.ancestor('MethodX', 2)
c01.constructor.prototype._anc.prototype._anc.prototype['MethodX'] c01.ancestor('MethodX', 3)
...
При этом, c01.constructor мог бы быть c01._anc, если бы с каждым new мы это писали, т.е. создали бы специальную функцию вместо new (есть тенденция называть такую функцию create()), но так (пока что?) делать не будем, чтобы сохранить new как индикатор создания объекта.

Результат достигается такой функцией:

function ancestor(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 || constr.prototype.constructor) || t._anc //предок (если есть) или обычный конструктор
		);
}

Исходный код inherit3() тоже немного поменялся, чтобы пользоваться функцией ._anc().
function inherit3(Inherited, Parent){
	var F = function(){};
	F.prototype = Parent.prototype;
	(Inherited.prototype = new F())._anc = Parent;
	Inherited.prototype.constructor = Inherited;
}

Логика работы доступа к предкам.


Обращаемся не к текущему определению метода, а к тому, которое было на level уровней наследования раньше.

Если не попадаем точно на определение метода, по цепочке прототипов извлекается самый новый из родительских методов, взятых на level шагов раньше последнего порождения.

Если прототипов не нашлось — попадаем на «бомбу доброты» (медвежью услугу) функции, когда ошибки отсутствующего свойства, должной быть, не возникает, а имеем всегда самый первый определённый в цепочке метод. Если свойства вообще нет, получаем undefined на любом уровне.

Если не хотим иметь бомбу доброты, пишем пару фейковых свойств при ненахождении свойства на месте constr.prototype._anc — например, (constr.prototype._anc || constr.fail.fail) или просто ( constr.prototype._anc || fail ).

	function A(){this.prop =1;}
	function B(){this.prop =1;}
	function C(){this.prop =1;}
	A.prototype.protoProp ='protoA';
	inherit3(B, A);
	B.prototype.protoProp ='protoB';
	inherit3(C, B);
	C.prototype.protoProp ='protoC';
	var c01 = new C();
A.prototype.ancestor = ancestor;
Alert(c01['protoProp'], c01.ancestor('protoProp', 0)) //'protoC protoC'
Alert(c01.constructor.prototype.protoProp, c01.ancestor('protoProp')) //'protoC protoC'
Alert(c01.ancestor('protoProp', 2) ); //'protoB'
Alert(c01.ancestor('protoProp', 3) ); //'protoA'
Alert(c01.ancestor('protoProp', 4) ); //'protoA'
Alert(c01.ancestor('protoProp', 5) ); //'protoA'
Alert(c01.ancestor('protoProp', 6) ); //'protoA'
Alert(c01.ancestor('protoProp2', 4) ); //'undefined'

Посмотреть на работу скрипта, покрутить в Firebug — на демонстрационном примере (он — с бомбой доброты).
Прототип потомка требуется писать после наследования, а не где угодно — следствие работы с прототипами в inherit2(). Прототип общего предка может изменяться где угодно, что показано приписыванием метода ancestor() после всех наследований. В примерах везде использованы свойства, а не методы — для краткости, но нет никаких проблем с указанием или выполнением методов, в форматах
c01.ancestor('protoMethod', 3) //манипуляция методом
c01.ancestor('protoMethod', 3)(args) //выполнение метода

Что нужно улучшить?


Всё, задача статьи выполнена, можно идти спать? Нет, развитие никогда не останавливается, а взыскательный читатель не получил морального удовлетворения после получения 2 разрозненных функций и ряда условий использования. Смущает в этом методе смещение прототипа от предка к потомку. Ведь часто у объектов уже есть прототипы, их нужно не переписывать, а дополнять (extend), иначе на 3-ем этаже наследования получаем проблемы и необходимость переделки метода.

Почему же мы сразу не подготовили в пищу читателям законченный код, а заставляем ходить по полуфабрикатам? Потому что этот код нужен для думающих разработчиков, они должны знать, как способ доступа работает. В первой части построен скелет, без всяких красивостей и прочего мяса. Строение скелета лучше видно, когда на нём нет мяса. Продолжим о желаемых улучшениях.

В примерах видим, что делаем не переписывание прототипа (было бы неправильно), а его дополнение новыми свойствами. Напрашивается использование функции extend() для расширения прототипа после наследования. Значит, неплохо было бы иметь функцию расширения «под рукой». Она есть в jQuery и во многих других библиотеках, или для быстроты работы можем определить свою функцию, чтобы затем включить её в свой способ наследования. За основу можно взять такой формат, нарытый на просторах интернета:
extend = function(obj, extObj){ //расширение свойств первого аргумента
	if(arguments.length >2)
		for(var a =1, aL = arguments.length; a < aL; a++)
			arguments.callee(obj, arguments[a])
	else{
		for(var i in extObj)
			obj[i] = extObj[i];
	return obj;
};
Он хорош тем, что «между делом» позволяет использовать несколько аргументов для расширения первого. А пока даже эта функция позволит нам писать не "C.prototype.protoProp ='protoC';", а

extend(C.prototype, {protoProp: 'protoC'}); //уже лучше для внешнего вида.

Отметим, что .superclass нам уже не нужен; он выброшен из кода.

Кроме того, этот метод наследования настолько суров экономичен, что не выполняет конструкторы, поэтому свойства конструктора можем увидеть только там, где выполнено new — в объекте с01. Между классами выполнения конструкторов не происходит. Не всегда такая суровость нужна (ой, не всегда). Свойства конструкторов, которые генерируются по this.свойство, могли бы присутствовать и тоже участвовать в прототипном наследовании (быть свойством прототипа наследника).

О способе переделки этого подхода в более удобный — в следующей серии. Хотя и этот имеет все основания использоваться — всё зависит от количества требований к механизму наследований и желания запутать других разработчиков своим кодом, для этого предпочтительнее первый вариант.

Какие похожие методы для классов и коллекций (хешей) предпочитают разработчики?


Подобная задача, конечно, встречалась среди реализаций классов и наследования, что можно найти по словам «вызов методов родительского класса».
habrahabr.ru/blogs/javascript/40909

В нём создан базовый метод __parent, выполняющий те же задачи, что метод superclass. В нём тоже надо употреблять многократный вызов, если обращаться к предкам.
В библиотеке предусмотрены ещё несколько методов:
Mixin(A, B) — добавление из прототипа B в протототип A,
Clone() — клонирование объекта,
Extend() — расширение хешей; только для пары агрументов.

superclass не подключается к таким методам: [ «hasOwnProperty», «isPrototypeOf», «propertyIsEnumerable», «toLocaleString», «toString», «valueOf» ] — хорошая идея.

Другой пример доступа к предкам (от Резига)

В ejohn.org/blog/simple-javascript-inheritance (Simple JavaScript Inheritance By John Resig) видим наличие метода _super и extend вместе со специальным конструктором Class, который подчёркивает его классоподобность. Здесь extend — не расширение хеша, а специальная процедура пробегания по списку свойств, чтобы для «затирающих» поставить свойство _super — метод или свойство, которое создаётся, если у родителя обнаружился переписываемый метод. Очевидно, что это — более затратный механизм. Вместо одноразового создания доступа к методам предков и обращения к нему только при вызове создаём при каждом конструировании наследника «обходной» метод, пробегая для этого по всему списку методов предка. Может быть оправдано, если ресурсов много, обращаемся к предкам чаще, чем создаём новые классы. Вообще, обращение к предкам предполагается в описании функций — вполне рантаймный метод, который должен быть эффективен, так что подход прописывания прямых ссылок иногда оправдан. Как альтернатива, у нас тоже есть обращение через ссылки (объект.) без всяких затрат на пробегание по списку методов при .extend.

Содержание следующей серии


Герой, решивший в одиночку завалить четырёхголовую гидру реакции — почти Геракл, но мы-то знаем, что кадры отрисовывает задохлик-программист. Он с успехом рассматривает 4 обнаруженные проблемы (1 — код преобразован в метод, 2 — добавлено расширение прототипа наследника, 3 — возможно самостоятельное использование подмешанных методов, 4 — улучшен формат написания кодов при наследовании) и одним ударом — одним куском кода — решает их. Оказалось, что заодно решена и 5-я проблема-не проблема, но назовём её неперегруженностью (non-overloading) написанного кода — будущий коротенький метод сможет расширять любые другие хеши, не только прототипы наследников. В коде легко угадывается исходный, приведённый нами в этой статье, что поможет проследить разработчикам за развитием идей и выбрать свою подходящую реализацию. Зритель захочет узнать, как это получилось — покадровый просмотр с примерами поможет ему в этом. Код станет действительно удобнее. Следите за приключениями Геракла на страницах наших газет.
Tags:
Hubs:
+16
Comments 37
Comments Comments 37

Articles