В главах 3 и 4 мы неоднократно упоминали цепочку [[Prototype]]
, но не уточняли что это такое. Пришло время разобраться с тем, как работают прототипы.
Примечание: Любые попытки эмуляции копирования классов, упомянутые в главе 4 как "примеси", полностью обходят механизм цепочки [[Prototype]]
, рассматриваемый в этой главе.
Объекты в JavaScript имеют внутреннее свойство, обозначенное в спецификации как [[Prototype]]
, которое является всего лишь ссылкой на другой объект. Почти у всех объектов при создании это свойство получает не-null
значение.
Примечание: Чуть ниже мы увидим, что объект может иметь пустую ссылку [[Prototype]]
, хотя такой вариант встречается реже.
Рассмотрим пример:
var myObject = {
a: 2
};
myObject.a; // 2
Для чего используется ссылка [[Prototype]]
? В главе 3 мы изучили операцию [[Get]]
, которая вызывается когда вы ссылаетесь на свойство объекта, например myObject.a
. Стандартная операция [[Get]]
сначала проверяет, есть ли у объекта собственное свойство a
, если да, то оно используется.
Примечание: Прокси-объекты ES6 выходят за рамки этой книги (мы увидим их в одной из следующих книг серии!), но имейте в виду, что обсуждаемое нами стандартное поведение [[Get]]
и [[Put]]
неприменимо если используются Proxy
.
Но нас интересует то, что происходит, когда a
отсутствует в myObject
, т.к. именно здесь вступает в действие ссылка [[Prototype]]
объекта.
Если стандартная операция [[Get]]
не может найти запрашиваемое свойство в самом объекте, то она следует по ссылке [[Prototype]]
этого объекта.
var anotherObject = {
a: 2
};
// создаем объект, привязанный к `anotherObject`
var myObject = Object.create( anotherObject );
myObject.a; // 2
Примечание: Чуть позже мы объясним что делает Object.create(..)
и как он работает. Пока же считайте, что создается объект со ссылкой [[Prototype]]
на указанный объект.
Итак, у нас есть myObject
, который теперь связан с anotherObject
через ссылку [[Prototype]]
. Очевидно, что myObject.a
на самом деле не существует, однако обращение к свойству выполнилось успешно (свойство нашлось в anotherObject
) и действительно вернуло значение 2
.
Если бы a
не нашлось и в объекте anotherObject
, то теперь уже его цепочка [[Prototype]]
использовалась бы для дальнейшего поиска.
Этот процесс продолжается до тех пор, пока либо не будет найдено свойство с совпадающим именем, либо не закончится цепочка [[Prototype]]
. Если по достижении конца цепочки искомое свойство так и не будет найдено, операция [[Get]]
вернет undefined
.
По аналогии с этим процессом поиска по цепочке [[Prototype]]
, если вы используете цикл for..in
для итерации по объекту, будут перечислены все свойства, достижимые по его цепочке (при условии, что они перечислимые — см. enumerable
в главе 3). Если вы используете оператор in
для проверки существования свойства в объекте, то in
проверит всю цепочку объекта (независимо от перечисляемости).
var anotherObject = {
a: 2
};
// создаем объект, привязанный к `anotherObject`
var myObject = Object.create( anotherObject );
for (var k in myObject) {
console.log("найдено: " + k);
}
// найдено: a
("a" in myObject); // true
Итак, ссылки в цепочке [[Prototype]]
используются одна за другой, когда вы тем или иным способом пытаетесь найти свойство. Поиск заканчивается при нахождении свойства или достижении конца цепочки.
Но где именно "заканчивается" цепочка [[Prototype]]
?
В конце каждой типичной цепочки [[Prototype]]
находится встроенный объект Object.prototype
. Этот объект содержит различные утилиты, используемые в JS повсеместно, поскольку все обычные (встроенные, не связанные с конкретной средой исполнения) объекты в JavaScript "происходят от" объекта Object.prototype
(иными словами, имеют его на вершине своей цепочки [[Prototype]]
).
Некоторые утилиты этого объекта могут быть вам знакомы: .toString()
и .valueOf()
. В главе 3 мы видели еще одну: .hasOwnProperty(..)
. Еще одна функция Object.prototype
, о которой вы могли не знать, но узнаете далее в этой главе — это .isPrototypeOf(..)
.
Ранее в главе 3 мы отмечали, что установка свойств объекта происходит чуть сложнее, чем просто добавление к объекту нового свойства или изменение значения существующего свойства.
myObject.foo = "bar";
Если непосредственно у myObject
есть обычное свойство доступа к данным с именем foo
, то присваивание сводится к изменению значения существующего свойства.
Если непосредственно у myObject
нет foo
, то выполняется обход цепочки [[Prototype]]
по аналогии с операцией [[Get]]
. Если foo
не будет найдено в цепочке, то свойство foo
добавляется непосредственно к myObject
и получает указанное значение, как мы того и ожидаем.
Однако если foo
находится где-то выше по цепочке, то присваивание myObject.foo = "bar"
может повлечь за собой более сложное (и даже неожиданное) поведение. Рассмотрим этот вопрос подробнее.
Если свойство с именем foo
присутствует как у самого myObject
, так и где-либо выше в цепочке [[Prototype]]
, начинающейся с myObject
, то такая ситуация называется затенением. Свойство foo
самого myObject
затеняет любые свойства foo
, расположенные выше по цепочке, потому что поиск myObject.foo
всегда находит свойство foo
, ближайшее к началу цепочки.
Как уже отмечалось, затенение foo
в myObject
происходит не так просто, как может показаться. Мы рассмотрим три сценария присваивания myObject.foo = "bar"
, когда foo
не содержится непосредственно в myObject
, а находится выше по цепочке [[Prototype]]
объекта myObject
:
- Если обычное свойство доступа к данным (см. главу 3) с именем
foo
находится где-либо выше по цепочке[[Prototype]]
, и не отмечено как только для чтения (writable:false
), то новое свойствоfoo
добавляется непосредственно в объектmyObject
, и происходит затенение свойства. - Если
foo
находится выше по цепочке[[Prototype]]
, но отмечено как только для чтения (writable:false
), то установка значения этого существующего свойства, равно как и создание затененного свойства уmyObject
, запрещены. Если код выполняется вstrict mode
, то будет выброшена ошибка, если нет, то попытка установить значение свойства будет проигнорирована. В любом случае, затенения не происходит. - Если
foo
находится выше по цепочке[[Prototype]]
и является сеттером (см. главу 3), то всегда будет вызываться сеттер. Свойствоfoo
не будет добавлено вmyObject
, сеттерfoo
не будет переопределен.
Большинство разработчиков предполагает, что присваивание свойства ([[Put]]
) всегда приводит к затенению, если свойство уже существует выше по цепочке [[Prototype]]
, но как видите это является правдой лишь в одной (№1) из трех рассмотренных ситуаций.
Если вы хотите затенить foo
в случаях №2 и №3, то вместо присваивания =
нужно использовать Object.defineProperty(..)
(см. главу 3) чтобы добавить foo
в myObject
.
Примечание: Ситуация №2 может показаться наиболее удивительной из трех. Наличие свойства только для чтения мешает нам неявно создать (затенить) свойство с таким же именем на более низком уровне цепочки [[Prototype]]
. Причина такого ограничения по большей части кроется в желании поддержать иллюзию наследования свойств класса. Если представить, что foo
с верхнего уровня цепочки наследуется (копируется) в myObject
, то имеет смысл запретить изменение этого свойства foo
в myObject
. Но если перейти от иллюзий к фактам и согласиться с тем, что никакого наследования с копированием на самом деле не происходит (см. главы 4 и 5), то кажется немного странным, что myObject
не может иметь свойство foo
лишь потому, что у какого-то другого объекта есть неизменяемое свойство foo
. Еще более странно то, что это ограничение действует только на присваивание =
, но не распространяется на Object.defineProperty(..)
.
Затенение при использовании методов приведет к уродливому явному псевдополиморфизму (см. главу 4) если вам потребуется делегирование между ними. Обычно затенение приносит больше проблем и сложностей, чем пользы, поэтому старайтесь избегать его если это возможно. В главе 6 вы увидите альтернативный шаблон проектирования, который наряду с другими вещами предполагает отказ от затенения в пользу более разумных альтернатив.
Затенение может даже произойти неявно, поэтому если вы хотите его избежать, будьте бдительны. Например:
var anotherObject = {
a: 2
};
var myObject = Object.create( anotherObject );
anotherObject.a; // 2
myObject.a; // 2
anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false
myObject.a++; // ой, неявное затенение!
anotherObject.a; // 2
myObject.a; // 3
myObject.hasOwnProperty( "a" ); // true
Хотя может показаться, что выражение myObject.a++
должно (через делегирование) найти и просто инкрементировать свойство anotherObject.a
, вместо этого операция ++
соответствует выражению myObject.a = myObject.a + 1
. В результате [[Get]]
ищет свойство a
через [[Prototype]]
и получает текущее значение 2
из anotherObject.a
, далее это значение увеличивается на 1, после чего [[Put]]
присваивает значение 3
новому затененному свойству a
в myObject
. Ой!
Будьте очень осторожны при работе с делегированными свойствами, пытаясь изменить их значение. Если вам нужно инкрементировать anotherObject.a
, то вот единственно верный способ сделать это: anotherObject.a++
.
Вы уже могли задаться вопросом: "Зачем одному объекту нужна ссылка ссылка на другой объект?" Какой от этого толк? Это очень хороший вопрос, но сначала нам нужно выяснить, чем [[Prototype]]
не является, прежде чем мы сможем понять и оценить то, чем он является, и какая от него польза.
В главе 4 мы выяснили, что в отличие от класс-ориентированных языков в JavaScript нет абстрактных шаблонов/схем объектов, называемых "классами". В JavaScript просто есть объекты.
На самом деле, JavaScript — практически уникальный язык, ведь пожалуй только он имеет право называться "объектно-ориентированным", т.к. относится к весьма немногочисленной группе языков, где объекты можно создавать напрямую, без классов.
В JavaScript классы не могут описывать поведение объекта (учитывая тот факт, что их вообще не существует!). Объект сам определяет собственное поведение. Есть только объект.
В JavaScript есть специфическое поведение, которым долгие годы цинично злоупотребляли для создания поделок, внешне похожих на "классы". Рассмотрим этот подход более подробно.
Специфическое поведение "как бы классов" основано на одной странной особенности: у всех функций по умолчанию есть публичное, неперечислимое (см. главу 3) свойство, называемое prototype
, которое указывает на некий объект.
function Foo() {
// ...
}
Foo.prototype; // { }
Этот объект часто называют "прототипом Foo", поскольку обращение к нему происходит через свойство с неудачно выбранным названием Foo.prototype
. Как мы вскоре увидим, такая терминология обречена приводить людей в замешательство. Вместо этого, я буду называть его "объектом, ранее известным как прототип Foo". Ладно, шучу. Как насчет этого: "объект, условно называемый 'Foo точка prototype'"?
Как бы мы не называли его, что же это за объект?
Проще всего объяснить так: у каждого объекта, создаваемого с помощью вызова new Foo()
(см. главу 2), ссылка [[Prototype]]
будет указывать на этот объект "Foo точка prototype".
Проиллюстрируем на примере:
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true
Когда a
создается путем вызова new Foo()
, одним из результатов (все четыре шага см. в главе 2) будет создание в a
внутренней ссылки [[Prototype]]
на объект, на который указывает Foo.prototype
.
Остановитесь на секунду и задумайтесь о смысле этого утверждения.
В класс-ориентированных языках множественные копии (или "экземпляры") создаются как детали, штампуемые на пресс-форме. Как мы видели в главе 4, так происходит потому что процесс создания экземпляра (или наследования от) класса означает "скопировать поведение из этого класса в физический объект", и это выполняется для каждого нового экземпляра.
Но в JavaScript такого копирования не происходит. Вы не создаете множественные экземпляры класса. Вы можете создать множество объектов, связанных* ссылкой [[Prototype]]
с общим объектом. Но по умолчанию никакого копирования не происходит, поэтому эти объекты не становятся полностью автономными и не соединенными друг с другом, напротив, они весьма связаны.
Вызов new Foo()
создает новый объект (мы назвали его a
), и этот новый объект a
связан внутренней ссылкой [[Prototype]]
с объектом Foo.prototype
.
В результате получилось два объекта, связанных друг с другом. Вот и все. Мы не создали экземпляр класса. И мы уж точно не копировали никакого поведения из "класса" в реальный объект. Мы просто связали два объекта друг с другом.
На самом деле секрет, о котором не догадывается большинство JS разработчиков, состоит в том, что вызов функции new Foo()
практически никак напрямую не связан с процессом создания ссылки. Это всегда было неким побочным эффектом. new Foo()
— это косвенный, окольный путь к желаемому результату: новому объекту, связанному с другим объектом.
Можем ли мы добиться желаемого более прямым путем? Да! Герой дня — Object.create(..)
. Но мы вернемся к нему чуть позже.
В JavaScript мы не делаем копии из одного объекта ("класса") в другой ("экземпляр"). Мы создаем ссылки между объектами. В механизме [[Prototype]]
визуально стрелки идут справа налево и снизу вверх.
Этот механизм часто называют "прототипным наследованием" (мы подробнее рассмотрим код чуть ниже), которое обычно считается вариантом "классического наследования" для динамических языков. Это попытка воспользоваться общепринятым пониманием термина "наследование" в класс-ориентированных языках и подогнать* знакомую семантику под динамический язык.
Термин "наследование" имеет очень четкий смысл (см. главу 4). Добавление перед ним слова "прототипное" чтобы обозначить на самом деле почти противоположное поведение привело к неразберихе в течение двух десятков лет.
Я люблю говорить, что использовать слово "прототипное" перед "наследованием" для придания существенно иного смысла — это как держать в одной руке апельсин, а в другой яблоко и настаивать на том, что яблоко — это "красный апельсин". Не важно, какое прилагательное я добавлю перед ним, это не изменит тот факт, что один фрукт — яблоко, а другой — апельсин.
Лучше называть вещи своими именами. Это позволяет лучше понять как их сходство, так и многочисленные отличия.
Из-за всей этой неразберихи с терминами я считаю, что само название "прототипное наследование" (а также некорректное использование связанных с ним терминов, таких как "класс", "конструктор", "экземпляр", "полиморфизм" и т.д.) принесло больше вреда чем пользы в понимании того, как на самом деле работает JavaScript.
"Наследование" подразумевает операцию копирования, а JavaScript не копирует свойства объекта (по умолчанию). Вместо этого JS создает ссылку между двумя объектами, в результате один объект по сути делегирует доступ к свойствам/функциям другому объекту. "Делегирование" (см. главу 6) — более точный термин для описания механизма связывания объектов в JavaScript.
Другой термин, который иногда встречается в JavaScript - это "дифференциальное наследование". Суть в том, что мы описываем поведение объекта с точки зрения того, что отличается в нем от более общего описания. Например, вы можете сказать, что автомобиль является видом транспортного средства, у которого ровно 4 колеса, вместо того чтобы заново описывать все свойства транспортного средства (двигатель, и т.д.).
Если представить, что любой объект в JS является суммой всего поведения, которое доступно через делегирование, и мысленно объединить все это поведение в одну реальную сущность*, то можно предположить, что "дифференциальное наследование" (вроде как) подходящий термин.
Но как и "прототипное наследование", "дифференциальное наследование" претендует на то, что мысленная модель важнее того, что физически происходит в языке. Здесь упускается из виду факт, что объект B
на самом деле не создается дифференциально, а создается с конкретно заданными характеристиками, а также с "дырами", где ничего не задано. Эти дыры (пробелы или отсутствующие определения) могут заменяться механизмом делегирования, который на лету "заполняет их" делегированным поведением.
Объект по умолчанию не сворачивается в единый дифференциальный объект посредством копирования, как это подразумевается в мысленной модели "дифференциального наследования". Таким образом, "дифференциальное наследование" не слишком подходит для описания реального механизма работы [[Prototype]]
в JavaScript.
Вы можете* придерживаться терминологии и мысленной модели "дифференциального наследования", но нельзя отрицать тот факт, что это лишь упражнение ума в вашей голове, а не реальное поведение движка.
Вернемся к рассмотренному ранее коду:
function Foo() {
// ...
}
var a = new Foo();
Что именно заставляет нас подумать, что Foo
является "классом"?
С одной стороны, мы видим использование ключевого слова new
, совсем как в класс-ориентированных языках, когда создаются экземпляры классов. С другой стороны, кажется, что мы на самом деле выполняем метод конструктора класса, потому что метод Foo()
на самом деле вызывается, так же как конструктор реального класса вызывается при создании экземпляра.
У объекта Foo.prototype
есть еще один фокус, который усиливает недоразумение, связанное с семантикой "конструкторов". Посмотрите на этот код:
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
По умолчанию объект Foo.prototype
(во время объявления в первой строке примера!) получает публичное неперечислимое (см. главу 3) свойство .constructor
, и это свойство является обратной ссылкой на функцию (в данном случае Foo
), с которой связан этот объект. Более того, мы видим, что объект a
, созданный путем вызова "конструктора" new Foo()
, похоже тоже имеет свойство с именем .constructor
, также указывающее на "функцию, создавшую его".
Примечание: На самом деле это неправда. У a
нет свойства .constructor
, и хотя a.constructor
действительно разрешается в функцию Foo
, "конструктор" на самом деле не значит "был сконструирован этой функцией". Мы разберемся с этим курьезом чуть позже.
Ах, да, к тому же... в мире JavaScript принято соглашение об именовании "классов" с заглавной буквы, поэтому тот факт что это Foo
, а не foo
является четким указанием, что мы хотим определить "класс". Это ведь абсолютно очевидно, не так ли!?
Примечание: Это соглашение имеет такое влияние, что многие JS линтеры возмущаются когда вы вызываете new
с методом, имя которого состоит из строчных букв, или не вызываете new
с функцией, начинающейся с заглавной буквы. Удивительно, что мы с таким трудом пытаемся добиться (фальшивой) "класс-ориентированности" в JavaScript, что даже создаем правила для линтеров, чтобы гарантировать использование заглавных букв, хотя заглавные буквы вообще ничего не значат для движка JS.
В примере выше есть соблазн предположить, что Foo
— это "конструктор", потому что мы вызываем её с new
и видим, что она "конструирует" объект.
В действительности, Foo
такой же "конструктор", как и любая другая функция в вашей программе. Функции сами по себе не являются конструкторами. Однако когда вы добавляете ключевое слово new
перед обычным вызовом функции, это превращает вызов функции в "вызов конструктора". На самом деле new
как бы перехватывает любую обычную функцию и вызывает её так, что в результате создается объект, а также выполняется код самой функции.
Например:
function NothingSpecial() {
console.log( "Don't mind me!" );
}
var a = new NothingSpecial();
// "Don't mind me!"
a; // {}
NothingSpecial
— обычная функция, но когда она вызывается с new
, то практически в качестве побочного эффекта создает объект, который мы присваиваем a
. Этот вызов был вызовом конструктора, но сама по себе функция NothingSpecial
не является конструктором.
Иначе говоря, в JavaScript "конструктор" — это любая функция, вызванная с ключевым словом new
перед ней.
Функции не являются конструкторами, но вызовы функций являются "вызовами конструктора" тогда и только тогда, когда используется new
.
Являются ли эти особенности единственными причинами многострадальных дискуссий о "классах" в JavaScript?
Не совсем. JS разработчики постарались симулировать поведение классов настолько, насколько это возможно:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
var a = new Foo( "a" );
var b = new Foo( "b" );
a.myName(); // "a"
b.myName(); // "b"
Этот пример показывает два дополнительных трюка для "класс-ориентированности":
-
this.name = name
: свойство.name
добавляется в каждый объект (a
иb
, соответственно; см. главу 2 о привязкеthis
), аналогично тому как экземпляры классов инкапсулируют значения данных. -
Foo.prototype.myName = ...
: возможно более интересный прием, добавляет свойство (функцию) в объектFoo.prototype
. Теперь работаетa.myName()
, но каким образом?
В примере выше велик соблазн думать, что при создании a
и b
свойства/функции объекта Foo.prototype
копируются в каждый из объектов a
и b
. Однако этого не происходит.
В начале этой главы мы изучали ссылку [[Prototype]]
— часть стандартного алгоритма [[Get]]
, которая предоставляет запасной вариант поиска, если ссылка на свойство отсутствует в самом объекте.
В силу того, как создаются a
и b
, оба объекта получают внутреннюю ссылку [[Prototype]]
на Foo.prototype
. Когда myName
не находится в a
или b
соответственно, она обнаруживается (через делегирование, см. главу 6) в Foo.prototype
.
Вспомните наше обсуждение свойства .constructor
. Кажется, что a.constructor === Foo
означает, что в a
есть реальное свойство .constructor
, указывающее на Foo
, верно? Не верно.
Это всего лишь путаница. На самом деле ссылка .constructor
также делегируется вверх по цепочке в Foo.prototype
, у которого, так уж случилось, по умолчанию есть свойство .constructor
, указывающее на Foo
.
Кажется ужасно удобным, что у объекта a
, "созданного" Foo
, будет доступ к свойству .constructor
, которое указывает на Foo
. Но это ложное чувство безопасности. Лишь по счастливой случайности a.constructor
указывает на Foo
через делегирование [[Prototype]]
по умолчанию. На самом деле есть несколько способов наломать дров, предполагая что .constructor
означает "использовался для создания".
Начнем с того, что свойство .constructor
в Foo.prototype
по умолчанию есть лишь у объекта, создаваемого в момент объявления функции Foo
. Если создать новый объект и заменить у функции ссылку на стандартный объект .prototype
, то новый объект по умолчанию не получит свойства .constructor
.
Рассмотрим:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // создаем новый объект-прототип
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
Object(..)
не был "конструктором" a1
, не так ли? Выглядит так, будто "конструктором" объекта должна быть Foo()
. Многие разработчики думают, что Foo()
создает объект, но эта идея трещит по швам, когда вы думаете что "constructor" значит "был создан при помощи". Ведь в таком случае a1.constructor
должен указывать на Foo
, но это не так!
Что же происходит? У a1
нет свойства .constructor
, поэтому он делегирует вверх по цепочке [[Prototype]]
к Foo.prototype
. Но и у этого объекта нет .constructor
(в отличие от стандартного объекта Foo.prototype
!), поэтому делегирование идет дальше, на этот раз до Object.prototype
— вершины цепочки делегирования. У этого объекта действительно есть .constructor
, который указывает на встроенную функцию Object(..)
.
Разрушаем заблуждение.
Конечно, можно вернуть объекту Foo.prototype
свойство .constructor
, но это придется сделать вручную, особенно если вы хотите, чтобы свойство соответствовало стандартному поведению и было не перечислимым (см. главу 3).
Например:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // создаем новый объект-прототип
// Необходимо правильно "пофиксить" отсутствующее свойство `.constructor`
// у нового объекта, выступающего в роли `Foo.prototype`.
// См. главу 3 про `defineProperty(..)`.
Object.defineProperty( Foo.prototype, "constructor" , {
enumerable: false,
writable: true,
configurable: true,
value: Foo // `.constructor` теперь указывает на `Foo`
} );
Как видите, для исправления .constructor
необходимо много ручной работы. Больше того, все это мы делаем ради поддержания ошибочного представления о том, что "constructor" означает "используется для создания". Дорого же нам обходится эта иллюзия.
Факт в том, что .constructor
объекта по умолчанию указывает на функцию, которая, в свою очередь, имеет обратную ссылку на объект — ссылку .prototype
. Слова "конструктор" и "прототип" лишь наделяются по умолчанию ненадежным смыслом, который позднее может оказаться неверным. Лучше всего постоянно напоминать себе, что "конструктор не значит используется для создания".
.constructor
— это не магическое неизменяемое свойство. Оно является, неперечисляемым (см. пример выше), но его значение доступно для записи (может быть изменено), и более того, вы можете добавить или перезаписать (намеренно или случайно) свойство с именем constructor
в любом объекте любой цепочки [[Prototype]]
, задав ему любое подходящее вам значение.
В силу того как алгоритм [[Get]]
обходит цепочку [[Prototype]]
, ссылка на свойство .constructor
, найденная в любом узле цепочки, может получать значение, весьма отличающееся от ожидаемого.
Видите, насколько произвольным является его значение?
Что в итоге? Некая произвольная ссылка на свойство объекта, например a1.constructor
, не может считаться надежной ссылкой на функцию по умолчанию. Более того, как мы вскоре увидим, в результате небольшого упущения a1.constructor
может вообще указывать на весьма странное и бессмысленное значение.
a1.constructor
— слишком ненадежная и небезопасная ссылка, чтобы полагаться на нее в коде. В общем случае таких ссылок по возможности следует избегать.
Мы увидели некоторые типичные хаки для добавления механики "классов" в программы на JavaScript. Но "классы" JavaScript были бы неполными без попыток смоделировать "наследование".
На самом деле мы уже видели механизм под названием "прототипное наследование", когда объект a
"унаследовал от" Foo.prototype
функцию myName()
. Но обычно под "наследованием" подразумевается отношение между двумя "классами", а не между "классом" и "экземпляром".
Мы уже видели эту схему, на которой показано не только делегирование от объекта (или "экземпляра") a1
к объекту Foo.prototype
, но и от Bar.prototype
к Foo.prototype
, что отчасти напоминает концепцию наследования классов родитель-потомок. Вот только направление стрелок здесь другое, поскольку изображены делегирующие ссылки, а не операции копирования.
Вот типичный пример кода в "прототипном стиле", где создаются такие ссылки:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name,label) {
Foo.call( this, name );
this.label = label;
}
// здесь мы создаем `Bar.prototype`
// связанный с `Foo.prototype`
Bar.prototype = Object.create( Foo.prototype );
// Осторожно! Теперь `Bar.prototype.constructor` отсутствует,
// и это придется "пофиксить" вручную,
// если вы привыкли полагаться на подобные свойства!
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar( "a", "obj a" );
a.myName(); // "a"
a.myLabel(); // "obj a"
Примечание: Чтобы понять, почему this
указывает на a
, см. главу 2.
Самая важная строка здесь Bar.prototype = Object.create( Foo.prototype )
. Object.create(..)
создает "новый" объект из ничего, и связывает внутреннюю ссылку [[Prototype]]
этого объекта с указанным объектом (в данном случае Foo.prototype
).
Другими словами, эта строка означает: "создать новый объект 'Bar точка prototype', связанный с 'Foo точка prototype'".
При объявлении function Bar() { .. }
функция Bar
, как и любая другая, получает ссылку .prototype
на объект по умолчанию. Но этот объект не ссылается на Foo.prototype
, как мы того хотим. Поэтому мы создаем новый* объект, который имеет нужную ссылку, и отбрасываем исходный, неправильно связанный объект.
Примечание: Типичная ошибка — пытаться использовать следующие варианты, думая, что они тоже сработают, но это приводит к неожиданным результатам:
// работает не так, как вы ожидаете!
Bar.prototype = Foo.prototype;
// работает почти так, как нужно,
// но с побочными эффектами, которые возможно нежелательны :(
Bar.prototype = new Foo();
Bar.prototype = Foo.prototype
не создает новый объект, на который ссылалось бы Bar.prototype
. Вместо этого Bar.prototype
становится еще одной ссылкой на Foo.prototype
, и в результате Bar
напрямую связывается с тем же самым объектом, что и Foo
: Foo.prototype
. Это значит, что когда вы начнете присваивать значения, например Bar.prototype.myLabel = ...
, вы будете изменять не отдельный объект, а общий объект Foo.prototype
, что повлияет на любые объекты, привязанные к Foo.prototype
. Это наверняка не то, чего вы хотите. В противном случае вам вообще не нужен Bar
, и стоит использовать только Foo
, сделав код проще.
Bar.prototype = new Foo()
действительно создает новый объект, корректно привязанный к Foo.prototype
. Но для этого используется "вызов конструктора" Foo(..)
. Если эта функция имеет какие-либо побочные эффекты (логирование, изменение состояния, регистрация в других объектах, добавление свойств в this
, и т.д.), то эти побочные эффекты сработают во время привязывания (и возможно в отношении неправильного объекта!), а не только при создании конечных "потомков" Bar
, как можно было бы ожидать.
Поэтому, для правильного привязывания нового объекта без побочных эффектов от вызова Foo(..)
у нас остается лишь Object.create(..)
. Небольшой недостаток состоит в том, что нам приходится создавать новый объект и выбрасывать старый, вместо того чтобы модифицировать существующий стандартный объект.
Было бы здорово если бы существовал стандартный и надежный способ поменять привязку существующего объекта. До ES6 был нестандартный и не полностью кроссбраузерный способ через свойство .__proto__
, которое можно изменять. В ES6 добавлена вспомогательная утилита Object.setPrototypeOf(..)
, которая проделывает нужный трюк стандартным и предсказуемым способом.
Сравните пред-ES6 и стандартизованный в ES6 способ привязки Bar.prototype
к Foo.prototype
:
// пред-ES6
// выбрасывает стандартный существующий `Bar.prototype`
Bar.prototype = Object.create( Foo.prototype );
// ES6+
// изменяет существующий `Bar.prototype`
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
Если отбросить небольшой проигрыш в производительности (выбрасывание объекта, который позже удаляется сборщиком мусора), то способ с Object.create(..)
немного короче и может даже читабельнее, чем подход ES6+. Хотя это всего лишь пустые разговоры о синтаксисе.
Что если у вас есть объект a
и вы хотите выяснить, какому объекту он делегирует? Инспектирование экземпляра (объект в JS) с целью найти его предка (делегирующая связь в JS) в традиционных класс-ориентированных языках часто называют интроспекцией (или рефлексией).
Рассмотрим:
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
Как выполнить интроспекцию a
, чтобы найти его "предка" (делегирующую связь)? Первый подход использует путаницу с "классами":
a instanceof Foo; // true
Оператор instanceof
принимает в качестве операнда слева обычный объект, а в качества операнда справа — функцию. instanceof
отвечает на следующий вопрос: присутствует ли где-либо в цепочке [[Prototype]]
объекта a
объект, на который указывает Foo.prototype
?
К сожалению, это значит, что вы можете получить сведения о "происхождении" некоторого объекта (a
) только имея некоторую функцию (Foo
c её ссылкой .prototype
). Если у вас есть два произвольных объекта, например a
и b
, и вы хотите узнать, связаны ли сами эти объекты друг с другом через цепочку [[Prototype]]
, одного instanceof
будет недостаточно.
Примечание: Если вы используете встроенную утилиту .bind(..)
для создания жестко привязанной функции (см. главу 2), то у созданной функции не будет свойства .prototype
. При использовании instanceof
с такой функцией прозрачно подставляется .prototype
целевой функции, из которой была создана жестко привязанная функция.
Использование функции с жесткой привязкой для "вызова конструктора" крайне маловероятно, но если вы сделаете это, то она будет вести себя так, как если бы вы вызвали целевую функцию. Это значит, что вызов instanceof
с жестко привязанной функцией также ведет себя в соответствии с оригинальной функцией.
Этот фрагмент кода показывает нелепость попыток рассуждать об отношениях между двумя объектами используя семантику "классов" и instanceof
:
// вспомогательная утилита для проверки
// связан ли `o1` (через делегирование) с `o2`
function isRelatedTo(o1, o2) {
function F(){}
F.prototype = o2;
return o1 instanceof F;
}
var a = {};
var b = Object.create( a );
isRelatedTo( b, a ); // true
Внутри isRelatedTo(..)
мы временно используем функцию F
, меняя значение её свойства .prototype
на объект o2
, а затем спрашиваем, является ли o1
"экземпляром" F
. Ясно, что o1
на самом деле не унаследован от и даже не создан с помощью F
, поэтому должно быть понятно, что подобные приемы бессмысленны и сбивают с толку. Проблема сводится к несуразности семантики классов, навязываемой JavaScript, что наглядно показано на примере косвенной семантики instanceof
.
Второй подход к рефлексии [[Prototype]]
более наглядный:
Foo.prototype.isPrototypeOf( a ); // true
Заметьте, что в этом случае нам неинтересна (и даже не нужна) Foo
. Нам просто нужен объект (в данном случае произвольный объект с именем Foo.prototype
) для сопоставления его с другим объектом. isPrototypeOf(..)
отвечает на вопрос: присутствует ли где-либо в цепочке [[Prototype]]
объекта a
объект Foo.prototype
?
Тот же вопрос, и в точности такой же ответ. Но во втором подходе нам не нужна функция (Foo
) для косвенного обращения к её свойству .prototype
.
Нам просто нужны два объекта для выявления связи между ними. Например:
// Просто: присутствует ли `b` где-либо
// в цепочке [[Prototype]] объекта `c`
b.isPrototypeOf( c );
Заметьте, что в этом подходе вообще не требуется функция ("класс"). Используются прямые ссылки на объекты b
и c
, чтобы выяснить нет ли между ними связи. Другими словами, наша утилита isRelatedTo(..)
уже встроена в язык и называется isPrototypeOf(..)
.
Мы можем напрямую получить [[Prototype]]
объекта. В ES5 появился стандартный способ сделать это:
Object.getPrototypeOf( a );
Здесь видно, что ссылка на объект является тем, что мы и ожидаем:
Object.getPrototypeOf( a ) === Foo.prototype; // true
В большинстве браузеров (но не во всех!) давно добавлена поддержка нестандартного альтернативного способа доступа к [[Prototype]]
:
a.__proto__ === Foo.prototype; // true
Загадочное свойство .__proto__
(стандартизовано лишь в ES6!) "магически" возвращает ссылку на внутреннее свойство [[Prototype]]
объекта, что весьма полезно, если вы хотите напрямую проинспектировать (или даже обойти: .__proto__.__proto__...
) цепочку.
Аналогично рассмотренному ранее .constructor
, свойство .__proto__
отсутствует у инспектируемого объекта (a
в нашем примере). На самом деле оно есть (и является неперечисляемым, см. главу 2) у встроенного Object.prototype
, наряду с другими известными утилитами (.toString()
, .isPrototypeOf(..)
, и т.д.).
Более того, .__proto__
выглядит как свойство, но правильнее думать о нем как о геттере/сеттере (см. главу 3).
Грубо говоря, можно представить, что .__proto__
реализовано так (см. главу 3 об определениях свойств объекта):
Object.defineProperty( Object.prototype, "__proto__", {
get: function() {
return Object.getPrototypeOf( this );
},
set: function(o) {
// setPrototypeOf(..) доступно начиная с ES6
Object.setPrototypeOf( this, o );
return o;
}
} );
Таким образом, когда мы обращаемся (получаем значение) к a.__proto__
, это похоже на вызов a.__proto__()
(вызов функции геттера). В этом* вызове функции this
указывает на a
, несмотря на то что функция геттера находится в объекте Object.prototype
(см. главу 2 о правилах привязки this
), так что это равносильно Object.getPrototypeOf( a )
.
Значение .__proto__
можно также изменять, например с помощью функции Object.setPrototypeOf(..)
в ES6, как показано выше. Однако обычно не следует изменять [[Prototype]]
существующего объекта.
Глубоко внутри некоторых фреймворков можно встретить сложные, продвинутые механизмы, позволяющие выполнять трюки наподобие "создания производного класса" от Array
, но обычно подобные вещи не поощряются в повседневной практике, поскольку такой код гораздо труднее понимать и сопровождать.
Примечание: В ES6 добавлено ключевое слово class
, с помощью которого можно делать вещи, напоминающие "создание производных классов" от встроенных объектов, таких как Array
. Обсуждение синтаксиса class
из ES6 см. в Приложении А.
В остальном же единственным исключением будет установка свойства [[Prototype]]
стандартного объекта .prototype
функции, чтобы оно ссылалось на какой-то другой объект (помимо Object.prototype
). Это позволит избежать замены этого стандартного объекта новым объектом. В любой другой ситуации лучше всего считать ссылку [[Prototype]]
доступной только для чтения, чтобы облегчить чтение вашего кода в будущем.
Примечание: В сообществе JavaScript неофициально закрепилось название двойного подчеркивания, особенно перед именами свойств (как __proto__
): "dunder". Поэтому в мире JavaScript "крутые ребята" обычно произносят __proto__
как "dunder proto".
Как мы уже видели, механизм [[Prototype]]
является внутренней ссылкой, существующей у объекта, который ссылается на другой объект.
Переход по этой ссылке выполняется (в основном) когда происходит обращение к свойству/методу первого объекта, и это свойство/метод отсутствует. В таком случае ссылка [[Prototype]]
указывает движку, что свойство/метод нужно искать в связанном объекте. Если и в этом объекте ничего не находится, то происходит переход по его ссылке [[Prototype]]
, и так далее. Эта последовательность ссылок между объектами образует то, что называется "цепочкой прототипов".
Мы подробно объяснили, почему механизм [[Prototype]]
в JavaScript не похож на "классы", и увидели, что вместо этого создаются ссылки между подходящими объектами.
В чем смысл механизма [[Prototype]]
? Почему многие JS разработчики прикладывают так много усилий (эммулируя классы), чтобы настроить эти ссылки?
Помните, как в начале этой главы мы сказали, что Object.create(..)
станет нашим героем? Теперь вы готовы это увидеть.
var foo = {
something: function() {
console.log( "Скажи что-нибудь хорошее..." );
}
};
var bar = Object.create( foo );
bar.something(); // Скажи что-нибудь хорошее...
Object.create(..)
создает новый объект (bar
), связанный с объектом, который мы указали (foo
), и это дает нам всю мощь (делегирование) механизма [[Prototype]]
, но без ненужных сложностей вроде функции new
, выступающей в роли классов и вызовов конструктора, сбивающих с толку ссылок .prototype
и .constructor
, и прочих лишних вещей.
Примечание: Object.create(null)
создает объект с пустой (или null
) ссылкой [[Prototype]]
, поэтому этот объект не сможет ничего делегировать. Поскольку у такого объекта нет цепочки прототипов, оператору instanceof
(рассмотренному ранее) нечего проверять, и он всегда вернет false
. Эти специальные объекты с пустым [[Prototype]]
часто называют "словарями", поскольку они обычно используются исключительно для хранения данных в свойствах, потому что у них не может быть никаких побочных эффектов от делегируемых свойств/функций цепочки [[Prototype]]
, и они являются абсолютно плоскими хранилищами данных.
Для создания продуманных связей между двумя объектами нам не нужны классы. Нам нужно только лишь связать объекты друг с другом для делегирования, и Object.create(..)
дает нам эту связь без лишней возни с классами.
Object.create(..)
была добавлена в ES5. Вам может понадобиться поддержка пред-ES5 окружения (например, старые версии IE), поэтому давайте рассмотрим простенький частичный полифилл для Object.create(..)
:
if (!Object.create) {
Object.create = function(o) {
function F(){}
F.prototype = o;
return new F();
};
}
В этом полифилле используется временно создаваемая функция F
, и её свойство .prototype
переопределяется так, чтобы указывать на объект, с которым нужно создать связь. Затем мы используем new F()
, чтобы создать новый объект, который будет привязан нужным нам образом.
Такой вариант использования Object.create(..)
встречается в подавляющем большинстве случаев, поскольку эту часть можно заменить полифиллом. В ES5 стандартная функция Object.create(..)
предоставляет дополнительную функциональность, которую нельзя заменить полифиллом в пред-ES5. Для полноты картины рассмотрим, в чем она заключается:
var anotherObject = {
a: 2
};
var myObject = Object.create( anotherObject, {
b: {
enumerable: false,
writable: true,
configurable: false,
value: 3
},
c: {
enumerable: true,
writable: false,
configurable: false,
value: 4
}
} );
myObject.hasOwnProperty( "a" ); // false
myObject.hasOwnProperty( "b" ); // true
myObject.hasOwnProperty( "c" ); // true
myObject.a; // 2
myObject.b; // 3
myObject.c; // 4
Второй аргумент Object.create(..)
указывает свойства, которые будут добавлены в создаваемый объект, объявляя дескриптор каждого нового свойства (см. главу 3). Поскольку полифиллинг дескрипторов свойств в пред-ES5 невозможен, эту дополнительную функциональность Object.create(..)
также невозможно реализовать в виде полифилла.
В большинстве случаев используется лишь та часть функциональности Object.create(..)
, которую можно заменить полифиллом, поэтому большинство разработчиков устраивает использование частичного полифилла в пред-ES5 окружениях.
Некоторые разработчики придерживаются более строгого подхода, считая, что можно использовать только полные полифиллы. Поскольку Object.create(..)
— одна из тех утилит, что нельзя полностью заменить полифиллом, такой строгий подход предписывает, что если вы хотите использовать Object.create(..)
в пред-ES5 окружении, то вместо полифилла следует применить собственную утилиту, и вообще воздержаться от использования имени Object.create
. Вместо этого можно определить свою собственную утилиту:
function createAndLinkObject(o) {
function F(){}
F.prototype = o;
return new F();
}
var anotherObject = {
a: 2
};
var myObject = createAndLinkObject( anotherObject );
myObject.a; // 2
Я не разделяю такой подход. Меня полностью устраивает показанный выше частичный полифилл Object.create(..)
и его использование в коде даже в пред-ES5. Решайте сами, какой подход вам ближе.
Существует соблазн думать, что эти ссылки между объектами в основном предоставляют что-то вроде запасного варианта на случай "отсутствующих" свойств или методов. И хотя такой вывод допустим, я не считаю что это верный способ размышления о [[Prototype]]
.
Рассмотрим:
var anotherObject = {
cool: function() {
console.log( "круто!" );
}
};
var myObject = Object.create( anotherObject );
myObject.cool(); // "круто!"
Этот код работает благодаря [[Prototype]]
, но если вы написали его так, что anotherObject
играет роль запасного варианта на случай если myObject
не сможет обработать обращение к некоторому свойству/методу, то вероятно ваше ПО будет содержать больше "магии" и будет сложнее для понимания и сопровождения.
Я не хочу сказать, что такой подход является в корне неверным шаблоном проектирования, но он нехарактерен для JS. Если вы используете его, возможно вам стоит сделать шаг назад и подумать, является ли такое решение уместным и разумным.
Примечание: В ES6 добавлена продвинутая функциональность, называемая Proxy
, с помощью которой можно реализовать что-то наподобие поведения "отсутствующих методов". Proxy
выходит за рамки этой книги, но будет подробно рассмотрена в одной из следующих книг серии "Вы не знаете JS".
Не упустите одну важную, но едва уловимую мысль.
Явно проектируя ПО таким образом, что разработчик может вызвать, к примеру, myObject.cool()
, и это будет работать даже при отсутствии метода cool()
у myObject
, вы добавляете немного "магии" в дизайн вашего API, что может в будущем преподнести сюрприз другим разработчикам, которые будут поддерживать ваш код.
Но вы можете спроектировать API и без подобной "магии", не отказываясь при этом от преимуществ ссылки [[Prototype]]
.
var anotherObject = {
cool: function() {
console.log( "круто!" );
}
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
this.cool(); // внутреннее делегирование!
};
myObject.doCool(); // "круто!"
Здесь мы вызываем myObject.doCool()
— метод, который действительно есть у объекта myObject
, делая наш API более явным (менее "магическим"). Внутри наша реализация следует шаблону делегирования (см. главу 6), используя делегирование [[Prototype]]
к anotherObject.cool()
.
Другими словами, делегирование как правило преподносит меньше сюрпризов, если оно является частью внутренней реализации, а не выставлено наружу в дизайне API. Мы изучим делегирование в мельчайших подробностях в следующей главе.
При попытке обратиться к несуществующему свойству объекта внутренняя ссылка [[Prototype]]
этого объекта задает дальнейшее направление поиска для операции [[Get]]
(см. главу 3). Этот каскад ссылок от объекта к объекту образует "цепочку прототипов" (чем то похожую на цепочку вложенных областей видимости) для обхода при разрешении свойства.
У обычных объектов есть встроенный объект Object.prototype
на конце цепочки прототипов (похоже на глобальную область видимости при поиске по цепочке областей видимости), где процесс разрешения свойства остановится, если свойство не будет найдено в предыдущих звеньях цепочки. У этого объекта есть утилиты toString()
, valueOf()
и несколько других, благодаря чему все объекты в языке имеют доступ к ним.
Наиболее популярный способ связать два объекта друг с другом — использовать ключевое слово new
с вызовом функции, что помимо четырех шагов (см. главу 2) создаст новый объект, привязанный к другому объекту.
Этим "другим объектом" является объект, на который указывает свойство .prototype
функции, вызванной с new
. Функции, вызываемые с new
, часто называют "конструкторами", несмотря на то что они не создают экземпляры классов, как это делают конструкторы в традиционных класс-ориентированных языках.
Хотя эти механизмы JavaScript могут напоминать "создание экземпляров классов" и "наследование классов" из традиционных класс-ориентированных языков, ключевое отличие в том, что в JavaScript не создаются копии. Вместо этого объекты связываются друг с другом через внутреннюю цепочку [[Prototype]]
.
По множеству причин, среди которых не последнюю роль играет терминологический прецедент, "наследование" (и "прототипное наследование") и все остальные ОО-термины не имеют смысла, учитывая то как на самом деле работает JavaScript.
Более подходящим термином является "делегирование", поскольку эти связи являются не копиями, а делегирующими ссылками.