Для полноты статьи и единого стиля, перевод начинается с вопросов наследования, несмотря на то, что они уже были упомянуты в конце первой части. Далее рассматриваются разнообразные задачи наследования так, как их рассмотрел автор. Надо сказать, что автор широко использует новые конструкции ES5 (объяснив это в конце), которые работают не во всех браузерах и заслоняют от понимания реализацию их на низком уровне языка, на котором они изначально применялись. Для настоящего понимания наследования следует обратиться к более глубокому разбору реализаций или к реализациям методов-обёрток из ES5: Object.create, Object.defineProperty, Function.bind, get и set literals, Object.getOwnPropertyNames, Object.defineProperty, Object.getOwnPropertyDescriptor, Object.getPrototypeOf. Часть их разбирается в статье (Object.create, get и set, Object.defineProperty, bind), но не всегда в порядке появления. Таким образом, статья стремится преподнести не реализацию наследования вообще, а ту реализацию, которую успели формализовать в рабочем черновике стандарта EcmaScript 5. Это лучше, чем ничего, но несколько меньше, чем полное понимание реализаций наследования.
Зато, данная часть статьи в нескольких (4) крупных примерах кода демонстрирует чистейшее прототипное наследование, которому не требуется привлекать понятие конструктора (хотя он там, в .create(), незримо присутствует), о котором много говорят и которое исключительно редко в чистом виде встречается.
1.1 Что есть объекты? (список свойств)
1.2 Создание свойств (Object.defineProperty)
1.3 Описатели свойств (Object.defineProperty)
1.4 Разбор синтаксиса (bracket notation: object['property'])
1.5 Доступ к свойствам (через скобочную нотацию)
1.6 Удаление свойств (оператор delete)
1.7 Геттеры и сеттеры (методы доступа и записи)
1.8 Списки свойств (getOwnPropertyNames, keys)
1.9 Литералы (базовые операторы) объекта
2. Методы
2.1 Динамический this
2.2 Как реализован this
2.2.1 Если вызывается как метод объекта
2.2.2 При обычном вызове функции (this === global)
2.2.3 При явном указании контекста (.apply, .call)
2.3 Привязывание методов к контексту (.bind)
4.1 Магия оператора new
4.2 Наследование с конструкторами
5. Соглашения и совместимость
5.1 Создание объектов
5.2 Определение свойств
5.3 Списки свойств
5.4 Методы связывания
5.5 Получение [[Prototype]]
5.6 Библиотеки обратной совместимости
6. Синтаксические обёртки
7. Что читать дальше
8. Благодарности
Примечания
3. Прототипное наследование
До сих пор мы рассматривали, как определяются методы в объектах и как их повторно используют в других объектах при явном указании контекста, но это — всё же не лучший путь использования и расширения объектов.
Далее в игру вступает наследование. Оно лучше разделяет понятия, когда объекты наделяются своими методами на основе методов других объектов.
Прототипное наследование идёт дальше и может избирательно расширять методы, описывать общее поведение и использовать другие занятные приёмы, которых мы коснёмся. Печалит лишь то, что модель наследования в JS немного ограничена, и для обхода трудностей эти приёмы будут временами избыточны выносить
3.1. Прототипы
Идея наследования в джаваскрипте крутится вокруг клонирования методов объекта и дополения его собственным поведением. Объект, который клонируется, называется прототипом (не путать со свойством prototype у функций).
Прототип — обычный объект, которому довелось расширять методы другого объекта — он выступает как родитель объекта. (Это — несколько смещённое понятие относительно общепринятого, когда родителем называют функцию-конструктор, содержащую этот прототип. По возможности, перевод старается не использовать понятие родителя применительно к прототипу — прим.перев.)
Однако, клонирование не означает, что вы будете иметь различные копии функций или данных. На самом деле, в JS реализовано наследование через делегирование: все свойства хранятся у родителя, а наследникам дают попользоваться.
Наш пример пока что хорошо укладывается в эту модель. Например, методы имени и приветствия могут быть описаны в отдельном объекте и показаны там, где надо. Что приводит нас к следующей модели:
Она описывается в JS таким кодом: (хардкорно новый синтаксис, для ES5. Напомним, что аргументы в defineProperty — это объект, его имя и присваиваемый специальный объект --прим.перев)
// () → String
function get_full_name(){ //возвращает полное имя объекта
return this.first_name + ' ' + this.last_name;
}
// (new_name:String) → undefined
function set_full_name(new_name){ //Вычисляем части имени из полного
var names = new_name.trim().split(/s+/);
this.first_name = names['0'] ||'';
this.last_name = names['1'] ||'';
}
//===============================================
var person = Object.create(null); //пустой объект ради правильного аргумента
//Впрочем, достаточно и {} --прим.перев.
Object.defineProperty(person, 'name'
,{get: get_full_name // используем предыдущие геттеры/сеттеры
,set: set_full_name
,configurable: true
,enumerable: true});
person.greet = function(person){
return this.name + ': Ну что, привет ' + person + '.';
};
// Присоединяем метод к новому объекту mikhail, добавляя person в [[Prototype]]
var mikhail = Object.create(person);
mikhail.first_name = 'Михаил';
mikhail.last_name = 'Белый';
mikhail.age = 19;
mikhail.gender = 'Male';
//===Тестируем сделанное===:
console.log(mikhail.name); // => 'Михаил Белый' - .name видно за счёт прототипа person
mikhail.name = 'Michael White'; // Присваивание в name должно запустить сеттер
//Теперь first_name и last_name показывают новые значения
console.log(mikhail.first_name); // => 'Michael' - действительно
console.log(mikhail.last_name); // => 'White'
// .greet тоже унаследовано из person.
console.log(mikhail.greet('тебе') ); // => 'Michael White: Ну что, привет тебе.'
// Убедимся, что видим собственные свойства у mikhail
console.log(Object.keys(mikhail) ); // => [ 'first_name', 'last_name', 'age', 'gender' ]
jsfiddle (1) для неверующих (IE9+)
3.2. Как работает [[Prototype]]
Как видно из примера, ни одно свойство из person не было упомянуто в mikhail, но все они прекрасно работают, потому что в JS передаются (делегируются) доступы к свойствам, т.е. свойства ищутся во всех родителях объекта.
Цепочка родителей определена в скрытых объектах каждого родителя, именуемых [[Prototype]]. Их нельзя изменить напрямую (кроме реализаций, где поддерживается .__proto__), поэтому единственный (специфицированный) способ — сеттеры при создании.
Когда свойство запрашивается из объекта, интерпретатор проверяет собственные свойства объекта. Если такое свойство отсутствует, проверяется родитель, и так — до конца цепочки родителей или до первого существующего свойства.
Если изменяем свойство прототипа, оно немедленно изменится для всех прототипов других объектов-наследников.
// (person:String) → String
person.greet = function(person){ // Приветствие человеку
return this.name + ': привет ' + person + '.'
};
mikhail.greet('тебе'); // => 'Michael White: привет тебе.'
3.3. Переопределение свойств
Таким образом, прототипы и наследование используется для разделения доступа к данным для разных объектов и выполняется очень быстро и эффективно по затратам памяти, поскольку используется один источник данных для всех наследников.
Что, если нужно добавить специализированные методы на основе данных, которые имелись в момент наследования? Мы видели раньше, что методы определяются на основе свойств, поэтому будем определять особое поведение тем же способом — просто присваивать новые свойства.
Для демонстрации предположим, что Person реализует общее приветствие, а наследники Person — определяют собственные. Кроме того, добавим ещё одного человека, чтобы увидеть разницу.
Заметьте, что mikhail и kristin имеют индивидуальные приветствия, выражаемые версиями метода greet.
// (person:String) → String
person.greet = function(person){ //общее формальное приветствие от персонажа
return this.name + ': Здравствуйте' + (person ?', '+ person :'') +'!';
};
var mikhail = Object.create(person);
mikhail.first_name = 'Михаил';
mikhail.last_name = 'Белый';
mikhail.age = 19;
mikhail.gender = 'Male';
//переопределим greet -- вспомним про индивидуальность Михаила:
//(person:String) → String
mikhail.greet = function(person){ //индивидуальное панибратское приветствие
return this.name + ': Здорово'+ (person ?', '+ person :', братан') +'!';
};
var kristin = Object.create(person); //новый персонаж
kristin.first_name = 'Кристина';
kristin.last_name = 'Белая';
kristin.age = 19;
kristin.gender = 'Female';
//(У неё другая манера приветствия)
// (person:String) → String
kristin.greet = function(person){ //индивидуальное эмоциональное приветствие
return this.name + ': Чмоки, ' + (person ||'парниша');
};
//===Проверим, как это всё работает===
console.log(mikhail.greet(kristin.first_name) ); //=> 'Михаил Белый: Здорово, Кристина!'
console.log(mikhail.greet() ); //=> 'Михаил Белый: Здорово, братан!'
console.log(kristin.greet(mikhail.first_name) ); //=> 'Кристина Белая: Чмоки, Михаил'
//пользуясь прототипом kristin, вернём Кристине стандартное поведение:
console.log('Удаление свойства: ', delete kristin.greet); //=> true
console.log(kristin.greet(mikhail.first_name) ); //=> 'Кристина Белая: Здравствуйте, Михаил'
3.4 Миксины (примеси)
Прототипы в Javascript позволяют использование общих методов, и хотя они — несомненно, сильный инструмент, они могли бы быть ещё мощнее. Они только обеспечивают наследование одного объекта другим в момент наследования.
Но такой подход не реализует другие интересные случаи, когда надо делать композицию методов, смешивание и комбинирование нескольких объектов в одном со всеми примуществами прототипного наследования.
К примеру, множественное наследование позволило бы использовать объекты — источники данных, дающие настройки методов или свойства по умолчанию.
К счастью, поскольку мы напрямую определяем методы объектов, мы можем решать эти проблемы примесями — некоторым дополнительным определением объектов во время их создания.
Что есть примеси? Можно сказать, они — «безродные», неунаследованные ниоткуда объекты. Они полностью определены в своих свойствах-методах и, чаще всего, сделаны для включения в другие объекты (хотя, их методы могли бы использоваться напрямую).
Развивая нашу небольшую модель персонажей, давайте добавим им некоторые способности. Пусть человек может быть пианистом или певцом — иметь методы pianist или singer в произвольных сочетаниях. Этот случай не укладывается в прототипную модель, поэтому пойдём на небольшой трюк. (На самом деле, можно заменить миксины переменной цепочкой наследований с прототипами, поэтому выбор миксина — это вопрос удобства и оптимальной реализации, а не следствие невыполнимости в модели прототипов. — прим.перев.)
Для работы миксинов, прежде всего, скомпонуем разные объекты в один. JS нативно не поддерживает этот необычный формат объекта, но он легко создаётся копированием всех собственных (не унаследованных) свойств.
var descriptor = Object.getOwnPropertyDescriptor //сокращения
,properties = Object.getOwnPropertyNames
,define_prop = Object.defineProperty;
// (target:Object, source:Object) → Object
function extend(target, source){ //копируем свойства source в target
properties(source).forEach(function(key){
define_prop(target, key, descriptor(source, key)) });
return target;
}
extend() здесь перебирает собственные свойства source и копирует их в target. Отметим, что target будет изменяться, для него эта функция — разрушительная, что обычно — не проблема. Важнее то, что она наименее затратна.
Теперь можем добавлять «способности» к нашим объектам.
var pianist = Object.create(null); //pianist - тот, кто может .play() на пианино
pianist.play = function(){
return this.name + ' начинает играть на пианино.';
};
var singer = Object.create(null); //singer - тот, кто может .sing()
singer.sing = function(){
return this.name + ' начинает петь.';
};
extend(mikhail, pianist); //добавляем возможности конечным объектам - примесь пианиста
console.log(mikhail.play() ); // => 'Михаил Белый начинает играть на пианино.'
// смотрим собственные, неунаследованные свойства у mikhail
console.log(Object.keys(mikhail) ); //=> ['first_name', 'last_name', 'age', 'gender', 'play']
extend(kristin, singer); //определим kristin как певца (певицу)
console.log(kristin.sing() ); //=> 'Кристина Белая начинает петь.'
// mikhail ещё не умеет петь:
try{
mikhail.sing(); //=> TypeError: Object #<Object> has no method 'sing'
}catch(er){console.error('Предусмотренная ошибка: ', er)}
// Но mikhail получит .sing, если расширить прототип у объекта-предка person:
extend(person, singer);
console.log(mikhail.sing() ); //=> 'Михаил Белый начинает петь.'
jsfiddle (3) для удобства контроля (IE9+)
3.5. Доступ к экранированным свойствам
Мы научились наследовать свойства и расширять их миксинами. Теперь есть небольшая проблема: что делать, если хотим получить доступ к перезаписанному (экранированному) свойству родительского объекта?
JS предоставляет функцию Object.getPrototypeOf которая возвращает [[Prototype]]. Поэтому доступ к свойствам прототипа достаточно прост:
Object.getPrototypeOf(mikhail).name; //не получаем результата, как и для person.name
// => 'undefined undefined'
person.first_name = 'Random'; //...но можем определить человека по .first_name и .last_name
person.last_name = 'Person'; //...пользуясь тем, что они вызываются в геттере
Object.getPrototypeOf(mikhail).name; //=> 'Random Person'
Можно было бы навно предположить, что достаточно обращения к прототипу контекста (this):
var proto = Object.getPrototypeOf;
// (name:String) → String
mikhail.greet = function(name){ //личное обращение к одной определённой персоне
return name == 'Кристина Белая'? this.name +': Приветик, Кристи'
: /*обращение ко всем остальным*/ proto(this).greet.call(this, name);
};
console.log(mikhail.greet(kristin.name) ); //=> 'Михаил Белый: Приветик, Кристи'
console.log(mikhail.greet('Маргарет') ); //=> 'Михаил Белый: Здравствуйте, Маргарет'
Выглядит хорошо, но есть загвоздка: если попытаться применить подход не к непосредственному предку, возникнет бесконечная рекурсия из-за того, что this видит всегда ближайший контекст функции и будет попадать на один и тот же родительский объект, как проиллюстрировано:
Простое решение — брать прототип из родительского объекта, а не из текущего. Последний пример становится таким:
var proto = Object.getPrototypeOf;
//(name:String) → String
//Явно указали прототип объекта mikhail - ошибки с искажением ссылки this не будет
mikhail.greet = function(name){ //Избирательное приветствие
return name =='Кристина Белая'
? this.name + ': Приветик, Кристи'
: proto(mikhail).greet.call(this, name); //обращение к остальным
};
mikhail.greet(kristin.name); //=> 'Михаил Белый: Приветик, Кристи'
mikhail.greet('Маргарет'); //=> 'Михаил Белый: Здравствуйте, Маргарет!'
Способ не лишён недостатков: объект жёстко задан в функции, и мы не можем так просто взять, и применить функцию к любому объекту, как было до сих пор. Функция будет зависима от предка объекта, а не от него самого.
Если делать динамический, универсальный достуступ к прототипу родителя, это потребовало бы передачи дополнительного параметра для каждого вызова функции, что не может быть решено, как сейчас, по-быстрому в уродливых хаках. (А именно, надо сопровождать каждое наследование свойством типа .ancestor или .superclass для доступа к конструктору-предку, а функциям — использовать эти данные — прим.перев.)
Подход, предложенный в новой версии JS, решает только первую часть задачи, что самое простое. Здесь мы будем делать то же самое, но введением другого способа определения методов. Да, методов, а не общих функций.
Функции для доступа к свойствам в [[Prototype]] требуют дополнительной информации: объекта, где они записаны. Это требует поискового алгоритма, работающего со статическими данными, но решает наши рекурсивные проблемы.
Введём функцию make_method, которая возвращает функцию которая передаёт эту информацию целевой функции. (Т.е. нужно получить ссылку на прототип, в котором объявлен наш экранированный метод, и это достигается модификацией метода на каждом шаге наследования — прим.перев.)
//(object:Object, fun:Function) → Function
function make_method(object, fun){ //сохранение места объявления в методе при наследовании
return function(){ var args;
args = [].slice.call(arguments);
args.unshift(object); //вставить 'object' первым аргументом
fun.apply(this, args);
};
}
//Все методы будут содержать в первом аргументе объект
// (прототип) их объявления (конечно, теперь нигде
//нельзя использовать позиционный доступ к аргументам - только по имени)
function message(self, message){ var proto;
proto = Object.getPrototypeOf(self);
if(proto && proto.log)
proto.log.call(this, message);
console.log('-- собственное имя прототипа: ' + self.name
+'; видимое name: '+ this.name + '; контекст вызова: '+ message);
}
var A = Object.create(null); //описываем цепочку прототипов C -> B -> A
A.name = 'A';
A.log = make_method(A, message);
var B = Object.create(A);
B.name = 'B';
B.log = make_method(B, message);
var C = Object.create(B);
C.name = 'C';
C.log = make_method(C, message);
//===тестируем вызовами методов===
A.log('~A~');
//=>-- собственное имя прототипа: A; видимое name: A; контекст вызова: ~A~
B.log('~B~');
//=>-- собственное имя прототипа: A; видимое name: B; контекст вызова: ~B~
//=>-- собственное имя прототипа: B; видимое name: B; контекст вызова: ~B~
C.log('~C~');
//=>-- собственное имя прототипа: A; видимое name: C; контекст вызова: ~C~
//=>-- собственное имя прототипа: B; видимое name: C; контекст вызова: ~C~
//=>-- собственное имя прототипа: C; видимое name: C; контекст вызова: ~C~
(Для лучшей наглядности этот пример сильно изменён в формате вывода трассировки по сравнению с оригиналом статьи; исправлены опечатки оригинала — прим.перев.)
jsfiddle (4), для любителей поковырять
Продолжение следует.
Автор: spmbt