Понимание ООП на джаваскрипте (ES5), часть 2

от автора

Замечания о переводе

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

Для полноты статьи и единого стиля, перевод начинается с вопросов наследования, несмотря на то, что они уже были упомянуты в конце первой части. Далее рассматриваются разнообразные задачи наследования так, как их рассмотрел автор. Надо сказать, что автор широко использует новые конструкции 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 Что есть объекты? (список свойств)
  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)
Cодержание части 2

3. Прототипное наследование
  3.1 Прототипы
  3.2 Как работает [[Prototype]]
  3.3 Переопределение свойства
  3.4 Миксины (примеси)
  3.5 Доступ к экранированным (‘перезаписанным’) свойствам
План части 3

4. Конструкторы
  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 — это объект, его имя и присваиваемый специальный объект —прим.перев)

продолжение скрипта из примеров к 1-й части статьи

// () → 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.

…пример в кодах

(Не будем далее полностью описывать определения используемых переменных в примерах кодов — они есть в дублях примеров на jsfiddle.net (над разделительной чертой из "=====") или легко дописываются на основе прежних примеров из статьи —прим.перев.)

// (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) ); //=> 'Кристина Белая: Здравствуйте, Михаил' 

     jsfiddle (2)

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), для любителей поковырять

Продолжение следует.

ссылка на оригинал статьи http://habrahabr.ru/post/189432/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *