Что такое прототипы

Что такое прототипы, прототипное наследование


Введение

В программировании мы часто хотим взять что-то и расширить.

Например, у нас есть объект user со своими свойствами и методами, и мы хотим создать объекты admin и guest как его слегка изменённые варианты. Мы хотели бы повторно использовать то, что есть у объекта user, не копировать/переопределять его методы, а просто создать новый объект на его основе.

Прототипное наследование — это возможность языка, которая помогает в этом.

Prototype

В JavaScript объекты имеют специальное скрытое свойство [[Prototype]] (так оно названо в спецификации), которое либо равно null, либо ссылается на другой объект. Этот объект называется «прототип»:

Прототип даёт нам немного «магии». Когда мы хотим прочитать свойство из object, а оно отсутствует, JavaScript автоматически берёт его из прототипа. В программировании такой механизм называется «прототипным наследованием». Многие интересные возможности языка и техники программирования основываются на нём.

Свойство [[Prototype]] является внутренним и скрытым, но есть много способов задать его.

Одним из них является использование __proto__, например так:

let animal = {   
	eats: true }; 
let rabbit = {   
	jumps: true };  
	
	_rabbit.__proto__ = animal;_`

Если мы ищем свойство в rabbit, а оно отсутствует, JavaScript автоматически берёт его из animal.

Например:

let animal = {   
	eats: true }; 
let rabbit = {   
	jumps: true };  
 
rabbit.__proto__ = animal; // (*)_  // теперь мы можем найти оба свойства в rabbit: 
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true`

Здесь строка (*) устанавливает animal как прототип для rabbit.

Затем, когда alert пытается прочитать свойство rabbit.eats (**), его нет в rabbit, поэтому JavaScript следует по ссылке [[Prototype]] и находит его в animal (смотрите снизу вверх):

Здесь мы можем сказать, что ”animal является прототипом rabbit” или ”rabbit прототипно наследует от animal“.

Так что если у animal много полезных свойств и методов, то они автоматически становятся доступными у rabbit. Такие свойства называются «унаследованными».

Если у нас есть метод в animal, он может быть вызван на rabbit:

let animal = {   
	eats: true,   
	_walk() {     
		alert("Animal walk");   
}_ };  
 
let rabbit = {   
	jumps: true,   
	__proto__: animal };  // walk взят из прототипа 
	
_rabbit.walk(); // Animal walk_`

Метод автоматически берётся из прототипа:

Цепочка прототипов может быть длиннее:

let animal = {   
	eats: true,   
	walk() {     
		alert("Animal walk");   } 
	};  
 
let rabbit = {   
	jumps: true,   
	___proto__: animal_ };  
	
let longEar = {   
	earLength: 10,   
	___proto__: rabbit_ };  // walk взят из цепочки прототипов 
 
longEar.walk(); // Animal walk 
alert(longEar.jumps); // true (из rabbit)`

Теперь, если мы прочтём что-нибудь из longEar, и оно будет отсутствовать, JavaScript будет искать его в rabbit, а затем в animal.

Есть только два ограничения:

  1. Ссылки не могут идти по кругу. JavaScript выдаст ошибку, если мы попытаемся назначить __proto__ по кругу.
  2. Значение __proto__ может быть объектом или null. Другие типы игнорируются.

Это вполне очевидно, но всё же: может быть только один [[Prototype]]. Объект не может наследоваться от двух других объектов.

Свойство __proto__ — исторически обусловленный геттер/сеттер для [[Prototype]]

Это распространённая ошибка начинающих разработчиков – не знать разницы между этими двумя понятиями.

Обратите внимание, что __proto__ — не то же самое, что внутреннее свойство [[Prototype]]. Это геттер/сеттер для [[Prototype]]. Позже мы увидим ситуации, когда это имеет значение, а пока давайте просто будем иметь это в виду, поскольку мы строим наше понимание языка JavaScript.

Свойство __proto__ немного устарело, оно существует по историческим причинам. Современный JavaScript предполагает, что мы должны использовать функции Object.getPrototypeOf/Object.setPrototypeOf вместо того, чтобы получать/устанавливать прототип. Мы также рассмотрим эти функции позже.

По спецификации __proto__ должен поддерживаться только браузерами, но по факту все среды, включая серверную, поддерживают его. Так что мы вполне безопасно его используем.

Далее мы будем в примерах использовать __proto__, так как это самый короткий и интуитивно понятный способ установки и чтения прототипа.

Операция записи не использует прототип

Прототип используется только для чтения свойств.

Операции записи/удаления работают напрямую с объектом.

В приведённом ниже примере мы присваиваем rabbit собственный метод walk:

let animal = {  
	eats: true,   
	walk() {     /* этот метод не будет использоваться в rabbit */   } };  
	
let rabbit = {   
	__proto__: animal };  
	
_rabbit.walk = function() {   
	alert("Rabbit! Bounce-bounce!"); };_  
	
rabbit.walk(); // Rabbit! Bounce-bounce!`

Теперь вызов rabbit.walk() находит метод непосредственно в объекте и выполняет его, не используя прототип:

Свойства-аксессоры – исключение, так как запись в него обрабатывается функцией-сеттером. То есть это фактически вызов функции.

По этой причине admin.fullName работает корректно в приведённом ниже коде:

let user = {   
	name: "John",   
	surname: "Smith",    
	set fullName(value) {     
		[this.name, this.surname] = value.split(" ");   },    
		
get fullName() {     
	return `${this.name} ${this.surname}`;   } };  
 
let admin = {   
	__proto__: user,   
	isAdmin: true };  
 
alert(admin.fullName); // John Smith (*)  // срабатывает сеттер! 
admin.fullName = "Alice Cooper"; // (**) 
alert(admin.name); // Alice 
alert(admin.surname); // Cooper``

Здесь в строке (*) свойство admin.fullName имеет геттер в прототипе user, поэтому вызывается он. В строке (**) свойство также имеет сеттер в прототипе, который и будет вызван.

Значение «this»

В приведённом выше примере может возникнуть интересный вопрос: каково значение this внутри set fullName(value)? Куда записаны свойства this.name и this.surname: в user или в admin?

Ответ прост: прототипы никак не влияют на this.

Неважно, где находится метод: в объекте или его прототипе. При вызове метода this — всегда объект перед точкой.

Таким образом, вызов сеттера admin.fullName= в качестве this использует admin, а не user.

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

Например, здесь animal представляет собой «хранилище методов», и rabbit использует его.

Вызов rabbit.sleep() устанавливает this.isSleeping для объекта rabbit:

// методы animal 
let animal = {   
	walk() {     
		if (!this.isSleeping) {       
			alert(`I walk`);     }   },   
			
	sleep() {     
		this.isSleeping = true;   } };  
		
let rabbit = {   
	name: "White Rabbit",   
	__proto__: animal };  // модифицирует rabbit.isSleeping 
	
rabbit.sleep();  
alert(rabbit.isSleeping); // true 
 
alert(animal.isSleeping); // undefined (нет такого свойства в прототипе)``

Картинка с результатом:

Если бы у нас были другие объекты, такие как birdsnake и т.д., унаследованные от animal, они также получили бы доступ к методам animal. Но this при вызове каждого метода будет соответствовать объекту (перед точкой), на котором происходит вызов, а не animal. Поэтому, когда мы записываем данные в this, они сохраняются в этих объектах.

В результате методы являются общими, а состояние объекта — нет.

Цикл for…in

Цикл for..in проходит не только по собственным, но и по унаследованным свойствам объекта.

Например:

let animal = {   
	eats: true };  
 
let rabbit = {   
	jumps: true,   
	__proto__: animal };  _// Object.keys возвращает только собственные ключи 
	
alert(Object.keys(rabbit)); // jumps_  _
// for..in проходит и по своим, и по унаследованным ключам for(let prop in rabbit) 
 
alert(prop); // jumps, затем eats_`

Если унаследованные свойства нам не нужны, то мы можем отфильтровать их при помощи встроенного метода obj.hasOwnProperty(key): он возвращает true, если у obj есть собственное, не унаследованное, свойство с именем key.

Пример такой фильтрации:

let animal = {   
	eats: true };  
 
let rabbit = {   
	jumps: true,   
	__proto__: animal };  
	
for(let prop in rabbit) {   
	let isOwn = rabbit.hasOwnProperty(prop);    
	if (isOwn) {     
		alert(`Our: ${prop}`); // Our: jumps   
		} else {     
		alert(`Inherited: ${prop}`); // Inherited: eats   } 
		}``

В этом примере цепочка наследования выглядит так: rabbit наследует от animal, который наследует от Object.prototype (так как animal – литеральный объект {...}, то это по умолчанию), а затем null на самом верху:

Заметим ещё одну деталь. Откуда взялся метод rabbit.hasOwnProperty? Мы его явно не определяли. Если посмотреть на цепочку прототипов, то видно, что он берётся из Object.prototype.hasOwnProperty. То есть он унаследован.

…Но почему hasOwnProperty не появляется в цикле for..in в отличие от eats и jumps? Он ведь перечисляет все унаследованные свойства.

Ответ простой: оно не перечислимо. То есть у него внутренний флаг enumerable стоит false, как и у других свойств Object.prototype. Поэтому оно и не появляется в цикле.

Почти все остальные методы получения ключей/значений игнорируют унаследованные свойства

Почти все остальные методы, получающие ключи/значения, такие как Object.keysObject.values и другие – игнорируют унаследованные свойства.

Они учитывают только свойства самого объекта, не его прототипа.

Итого

  • В JavaScript все объекты имеют скрытое свойство [[Prototype]], которое является либо другим объектом, либо null.
  • Мы можем использовать obj.__proto__ для доступа к нему (исторически обусловленный геттер/сеттер, есть другие способы, которые скоро будут рассмотрены).
  • Объект, на который ссылается [[Prototype]], называется «прототипом».
  • Если мы хотим прочитать свойство obj или вызвать метод, которого не существует у obj, тогда JavaScript попытается найти его в прототипе.
  • Операции записи/удаления работают непосредственно с объектом, они не используют прототип (если это обычное свойство, а не сеттер).
  • Если мы вызываем obj.method(), а метод при этом взят из прототипа, то this всё равно ссылается на obj. Таким образом, методы всегда работают с текущим объектом, даже если они наследуются.
  • Цикл for..in перебирает как свои, так и унаследованные свойства. Остальные методы получения ключей/значений работают только с собственными свойствами объекта.