1. Прототип объекта

ООП в JavaScript построено на прототипном наследовании. Объекты можно организовать в цепочки так, чтобы свойство, не найденное в одном объекте, автоматически искалось бы в другом. Связующим звеном выступает специальное скрытое свойство [[Prototype]], в консоли оно отображается как __proto__.

1.1. Прототип

Если один объект имеет специальную ссылку в поле __proto__ на другой объект, то при чтении свойства из него, если искомое свойство отсутствует в самом объекте, оно ищется в объекте на который ссылается __proto__.

const animal = { eats: true };
const dog = { barks: true };

dog.__proto__ = animal;

// В dog можно найти оба свойства
console.log(dog.barks); // true
console.log(dog.eats); // true

Первый лог работает очевидным образом — он выводит свойство barks объекта dog. Второй лог хочет вывести dog.eats, ищет его в самом объекте dog, не находит и продолжает поиск в объекте по ссылке из dog.__proto__, то есть, в данном случае, в animal.

Объект, на который указывает ссылка в __proto__, называется прототипом. В данном примере animal - это прототип для dog.

Если мы добавим объекту dog свойство eats и присвоим ему false, то результат будет следующим.

const animal = { eats: true };
const dog = { barks: true, eats: false };

dog.__proto__ = animal;

console.log(dog.barks); // true
console.log(dog.eats); // false, свойство взято из dog

Другими словами, прототип — это резервное хранилище свойств и методов объекта, автоматически используемое при поиске. У объекта, который выступает прототипом может также быть свой прототип, у того свой, и так далее. При этом свойства будут искаться по цепочке наследования.

В спецификации свойство __proto__ обозначено как [[Prototype]]. Двойные квадратные скобки здесь важны, они указывают на то, что это внутреннее, служебное свойство.

Несмотря на то, что свойство __proto__ на сегодняшний день стандартизовано и проще для объяснения материала, его изменение напрямую является грубым нарушением. На практике используются методы для манипуляции с прототипами, такие как Object.create(), Object.getPrototypeOf(), Object.setPrototypeOf() и другие.

1.2. Object.create()

Для того чтобы правильно задать прототип объекта, можно использовать метод Object.create(obj), передав параметром obj ссылку на объект, который мы хотим сделать прототипом для создаваемого объекта.

const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;

console.log(dog.barks); // true
console.log(dog.eats); // true

На рисунке ниже видно то, что называется цепочкой прототипов (prototype chain). В свойство __proto__ объекта dog записана ссылка на объект animal, в свойство __proto__ которого, в свою очередь, записана ссылка на родителя всех объектов Object. Именно поэтому мы можем вызывать методы вроде hasOwnProperty или toString, хотя мы не определяли их для dog или animal.

proto chain

Механизм поиска свойства работает до первого совпадения. Интерпретатор ищет свойство по имени в объекте, если не находит, то обращается к свойству [[Prototype]], т.е. переходит по ссылке к объекту-прототипу, а затем и прототипу прототипа.

В конце этой цепочки находится null. В случае первого совпадения будет возвращено значение свойства. Если интерпретатор доберется до конца цепочки и не найдет свойства с таким ключом, то вернет undefined.

proto chain schema

Данный механизм называется динамическая диспетчеризация (dynamic dispatch) или делегация (delegation). В отличие от статической диспетчеризации, когда ссылки разрешаются во время компиляции, динамическая диспетчеризация всегда разрешает ссылки во время исполнения программы.

1.3. Object.prototype.hasOwnProperty()

После того как мы узнали о том, как происходит поиск свойств объекта, должно стать понятно, почему цикл for...in не делает различия между свойствами объекта и его прототипа.

const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;

for (const key in dog) {
  console.log(key); // barks, eats
}

Именно поэтому мы используем метод obj.hasOwnProperty(prop), который возвращает true, если свойство prop принадлежит самому объекту obj, а не его прототипу, иначе false.

const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;

for (const key in dog) {
  if (!dog.hasOwnProperty(key)) continue;

  console.log(key); // barks
}

Метод Object.keys(obj) вернет массив только собственных ключей объекта obj, поэтому рекомендуется использовать именно его.

const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;

const dogKeys = Object.keys(dog);

console.log(dogKeys); // ['barks']

2. Свойство Function.prototype

Мы уже знаем, что такое прототип объекта, свойство __proto__, как происходит поиск отсутствующих свойств объекта по цепочке и методы указания прототипа для одного объекта. Но в реальных задачах объекты создаются динамически, то есть функцией-конструктором, через new. Давайте разберемся как происходит указание прототипа в этом случае.

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

const Guest = function (name, room) {
  this.name = name;
  this.room = room;
};

Так как функция - это тоже объект, у каждой функции, кроме стрелочных, есть свойство prototype в котором изначально хранится объект с единственным полем constructor, указывающим на саму функцию-конструктор.

У стрелочных функций нет свойства prototype, потому что их нельзя вызвать через new, и, соответственно, в нем нет необходимости.

Свойство Function.prototype:

  • Является объектом
  • В него можно записывать свойства и методы
  • Свойства и методы prototype будут доступны по ссылке __proto__ объекта
  • У свойства prototype изначально есть метод constructor
const Guest = function (name, room) {
  this.name = name;
  this.room = room;
};

console.log(Guest.prototype); // {constructor: ƒ}

При создании объекта через new в его поле __proto__ записывается ссылка на объект, хранящийся в свойстве prototype функции-конструктора.

operator new

const Guest = function (name, room) {
  this.name = name;
  this.room = room;
};

const mango = new Guest('Mango', 28);

console.log(mango);

object prototype

Эту особенность мы можем использовать для того, чтобы добавлять в объект prototype методы, которые будут доступны по ссылке абсолютно всем объектам, созданным через new Guest(...).

Причем если мы создадим миллион экземпляров гостя, набор методов будет не у каждого свой, а всего один, общий, хранящийся в свойстве Guest.prototype и доступный всем потомкам по ссылке, которая записывается в поле __proto__ объекта при создании из-за прототипного наследования и цепочки прототипов.

const Guest = function (name, room) {
  this.name = name;
  this.room = room;
};

Guest.prototype.showGuestInfo = function () {
  console.log(`name: ${this.name}, room: ${this.room}`);
};

const mango = new Guest('Mango', 28);
const poly = new Guest('Poly', 36);

mango.showGuestInfo(); // name: Mango, room: 28
poly.showGuestInfo(); // name: Poly, room: 36

prototypes

Так как в свойстве prototype лежит объект, то при прототипном наследование происходит присвоение по ссылке, поэтому если мы изменим значение у свойства prototype, то это новое значение получат и все свойства, имеющие ссылку на объект prototype.

2.1. Свойство constructor

Было упоминание того, что по умолчанию, объект в свойстве prototype уже содержит поле constructor. Запишем это поле явно:

const Guest = function (name, room) {
  this.name = name;
  this.room = room;
};

Guest.prototype = {
  constructor: Guest,
};

В коде выше мы создали свойство Guest.prototype вручную, но абсолютно такой же объект генерируется автоматически. А свойство constructor содержит ссылку на саму функцию-конструктор.

constructor

Свойство [[Prototype]] объекта называют скрытым свойством, потому что прямой доступ к нему ограничен средствами самого языка. Метод, который делает запись ссылки в объект в момент создания имеет такой доступ. Находится этот метод в объекте prototype функции и называется constructor 🙂.

Understanding Prototypes in JavaScript

3. Наследование и конструкторы

Конструктор - это функция для создания объектов по шаблону. Оператор new создает объект и вызывает функцию-конструктор в контексте этого объекта. На выходе получаем объект с полями указанными в функции-конструкторе через this и полем [[Prototype]], которое содержит ссылку на поле prototype функции-конструктора.

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

Например мы пишем игру в стиле RPG, и нам необходимо подготовить логику для классовой системы персонажей где есть общий конструктор Hero с дефолтными полями общими для всех классов, вроде имени, здоровья, количества опыта и т. п. После чего нам необходимо сделать конструкторы для Warrior и Wizard, экземпляры которых также должны иметь доступ к полям Hero, но в тоже время иметь свои собственные.

Давайте реализуем это используя прототипное наследование.

const Hero = function (name, xp) {
  this.name = name;
  this.xp = xp;
};

/*
 * Теперь у нас есть конструктор базового класса героя,
 * добавим в prototype какой-то метод.
 */
Hero.prototype.gainXp = function (amount) {
  console.log(`${this.name} gained ${amount} experience points`);
  this.xp += amount;
};

const mango = new Hero('Mango', 1000);
console.log(mango); // Hero { name: 'Mango', xp: 1000 }

// Так как mango это экземпляр Hero, то ему доступны методы из Hero.prototype
mango.gainXp(500); // Mango gained 500 experience points
console.log(mango); // Hero { name: 'Mango', xp: 1500 }

Далее необходимо создать класс Warrior, так как нет смысла добавлять в Hero абсолютно все поля всех классов. Поэтому нам необходимо создать еще функцию-конструктор, но при этом она должна быть как-то связана с Hero.

Для решения этой задачи мы можем использовать метод call(), вызвав функцию-конструктор Hero и передав ей объект, создающийся в Warrior как контекст.

const Warrior = function (name, xp, weapon) {
  /*
   * Во время выполнения функции Warrior вызываем функцию Hero
   * в контексте объекта создающегося в Warrior, а так же передаем
   * аргументы для инициализации полей this.name и this.xp
   *
   * this это будущий экземпляр Warrior
   */
  Hero.call(this, name, xp);

  // Тут добавляем новое свойство - оружие
  this.weapon = weapon;
};

// Сразу добавим метод для атаки в prototype воина
Warrior.prototype.attack = function () {
  console.log(`${this.name} attacks with ${this.weapon}`);
};

const poly = new Warrior('Poly', 200, 'sword');

console.log(poly); // Warrior {name: "Poly", xp: 200, weapon: "sword"}
poly.attack(); // Poly attacks with sword

Вроде все хорошо, но что произойдет если мы попробуем вызвать у poly метод gainXp(), который объявлен на Hero.prototype? — будет ошибка

poly.gainXp(); // Uncaught TypeError: poly.gainXp is not a function

Дело в том, что поля из Hero.prototype не добавляются в цепочку прототипов по умолчанию. Необходимо явно указать связь поля Warrior.prototype и Hero.prototype. Сделать это очень легко, но важно понимать как и почему это работает.

/*
 * Используем Object.create() для того чтобы изначально записать
 * в Warrior.prototype пустой объект, в __proto__ которого будет
 * ссылка на Hero.prototype. Это необходимо сделать до того
 * как добавлять методы
 */
Warrior.prototype = Object.create(Hero.prototype);

// Не забываем добавить в Warrior.prototype свойство constructor
Warrior.prototype.constructor = Warrior;

// Добавим метод для атаки
Warrior.prototype.attack = function () {
  console.log(`${this.name} attacks with ${this.weapon}`);
};

// Попробуем теперь
poly.gainXp(300); // Poly gained 300 experience points

В результате мы получили цепочку прототипов. При вызове poly.gainXp(), идет поиск поля gainXp в самом объекте poly, если такового нет, тогда идет поиск в том объекте, который указан в поле poly.__proto__ - это ссылка на Warrior.prototype.

Если же его нету и там, то поиск идет в поле __proto__ того объекта, что указан в poly.__proto__, то есть в poly.__proto__.__proto__, а это ссылка на Hero.prototype, где есть метод gainXp.

Полный код примера.

const Hero = function (name, xp) {
  this.name = name;
  this.xp = xp;
};

Hero.prototype.gainXp = function (amount) {
  console.log(`${this.name} gained ${amount} experience points`);
  this.xp += amount;
};

const Warrior = function (name, xp, weapon) {
  Hero.call(this, name, xp);

  this.weapon = weapon;
};

Warrior.prototype = Object.create(Hero.prototype);
Warrior.prototype.constructor = Warrior;

Warrior.prototype.attack = function () {
  console.log(`${this.name} attacks with ${this.weapon}`);
};

const poly = new Warrior('Poly', 200, 'sword');

4. Дополнительные материалы

results matching ""

    No results matching ""