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

в 10:03, , рубрики: inheritance, javascript, наследование в javascript, ооп, ооп js, Проектирование и рефакторинг

Понимание ООП на джаваскрипте (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 немного ограничена, и для обхода трудностей эти приёмы будут временами избыточны выносить мозг.Понимание ООП на джаваскрипте (ES5), часть 2

3.1. Прототипы

Идея наследования в джаваскрипте крутится вокруг клонирования методов объекта и дополения его собственным поведением. Объект, который клонируется, называется прототипом (не путать со свойством prototype у функций).

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

Однако, клонирование не означает, что вы будете иметь различные копии функций или данных. На самом деле, в JS реализовано наследование через делегирование: все свойства хранятся у родителя, а наследникам дают попользоваться.

Наш пример пока что хорошо укладывается в эту модель. Например, методы имени и приветствия могут быть описаны в отдельном объекте и показаны там, где надо. Что приводит нас к следующей модели:

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

Она описывается в 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 — определяют собственные. Кроме того, добавим ещё одного человека, чтобы увидеть разницу.

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

Заметьте, что 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 в произвольных сочетаниях. Этот случай не укладывается в прототипную модель, поэтому пойдём на небольшой трюк. (На самом деле, можно заменить миксины переменной цепочкой наследований с прототипами, поэтому выбор миксина — это вопрос удобства и оптимальной реализации, а не следствие невыполнимости в модели прототипов. — прим.перев.)

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

Для работы миксинов, прежде всего, скомпонуем разные объекты в один. 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 видит всегда ближайший контекст функции и будет попадать на один и тот же родительский объект, как проиллюстрировано:

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

Простое решение — брать прототип из родительского объекта, а не из текущего. Последний пример становится такимПонимание ООП на джаваскрипте (ES5), часть 2:

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

Источник

Поделиться

* - обязательные к заполнению поля