Прототипное наследование, свойство __proto__

Ранее у нас были занятия по JavaScript стандарта ES6+ где, помимо всего прочего, мы говорили об объектах. И отмечали, что если нам нужно описать некую сущность: назначить ей разные свойства и поведение (методы), то это можно сделать так:

"use strict";
 
let geom = {
         name: "фигура",
         sp: {x: 0, y: 0},
         ep: {x: 100, y: 20}
};

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

for(let key in geom) {
         console.log(key+": "+geom[key]);
}

А дальше сделаем следующий, новый для нас ход: объявим новый объект, который бы расширял функционал уже существующего geom. Это можно сделать с помощью специального свойства

__proto__

которое есть у каждого объекта JavaScript. Именно через него устанавливается связь с базовым (расширяемым) объектом. И в нашем случае это будет выглядеть так:

let rect = {
         draw() {
                   console.log("Рисование прямоугольника: " +
                            this.sp.x+","+this.sp.y+","+this.ep.x+","+this.ep.y);
         }
};
 
rect.__proto__ = geom;

В результате, объект rect получает доступ ко всем свойствам объекта geom и дополнительно объявляет метод draw. Это мы увидим при следующем переборе:

for(let key in rect) {
         console.log(key+": "+rect[key]);
}

А, вот если закомментировать строку:

//rect.__proto__ = geom;

то останется только один метод draw. Давайте уберем комментарий и выполним метод:

rect.draw()

Увидим в консоли сообщение:

Рисование прямоугольника: 0,0,100,20

Здесь следует отметить, что спецификация языка JavaScript предполагает существование свойства __proto__ только в браузерной среде. В других средах – это необязательное свойство но, тем не менее, оно существует во всех популярных средах, в том числе, и серверных, таких как Node.js.

Возвращаясь к нашим объектам, можно сказать, что в них реализуется прототипный механизм наследования. И эту цепочку можно продолжить, например, добавив вот такой объект:

let info = {
         getInfo() {
                   console.log(this.name);
         },
         __proto__: rect
}
 
info.getInfo()

Теперь у нас info наследуется от rect, а rect – от geom:

Разумеется, когда мы используем свойство __proto__, то ему можно присваивать либо другой объект, либо значение null. Все остальные типы будут попросту игнорироваться. Также из этой схемы видно, что множественное наследование реализовать не получится, т.к. свойство __proto__ ссылается лишь на один определенный объект (либо ни на одного при значении null).

Далее, из объекта, указанного в прототипе можно выполнять только чтение свойств и методов. Например, если в объекте rect создать свойство name:

rect.name = "прямоугольник";

то это никак не скажется на свойстве объекта geom:

console.log(geom.name)          // фигура

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

Свойства-аксессоры set и get

В любом объекте можно создавать специальные свойства, которые будут срабатывать в момент присвоения (set) и чтения (get) данных из объекта. Они определяются по следующему синтаксису:

  • set <имя метода>([параметры]) {…} – свойство для записи значений в объект;
  • get <имя метода>([параметры]) {…} – свойство для чтения значений из объекта.

Давайте в качестве примера в объекте geom пропишем два таких свойства:

let geom = {
         name: "фигура",
         sp: {x: 0, y: 0},
         ep: {x: 100, y: 20},
         get nameGeom() {return this.name; },
         set nameGeom(name) {this.name = name; },
};

И, далее, можно с ними работать, следующим образом:

console.log(rect.nameGeom);      // чтение свойства
rect.nameGeom = "Прямоугольник"; // запись свойства
console.log(rect.nameGeom);

Причем, благодаря наличию свойств-аксессоров, при наследовании объекта в rect не создается новое свойство nameGeom, когда ему присваиваются какие-либо данные, так как JavaScript «понимает», что имеется сеттер с именем nameGeom в базовом объекте и именно его следует использовать в такой ситуации.

Поведение this при прототипном наследовании

Следующий момент в этом примере, на который, возможно, кто-то из вас уже обратил внимание: на какой из двух объектов (geom или rect) ссылается this при вызове сеттера:

rect.nameGeom = "Прямоугольник";

Как мы с вами говорили на предыдущих занятиях по JavaScript, указатель this является динамическим и определяется контекстом вызова метода. В данном случае контекст – это объект rect и именно на него ссылается this при вызове сеттера из базового объекта geom. В результате, состояние базового объекта не меняется, а в rect добавляется свойство name:

И в этом легко убедиться, если выполнить следующие строчки:

console.log(rect.nameGeom);
console.log(geom.nameGeom);

Как видите, для объекта geom отображается имя «фигура», тогда как для rect – имя «Прямоугольник». Или, эту проверку можно визуализировать еще лучше, если воспользоваться специальным методом:

obj.hasOwnProperty(key)

который возвращает true, если ключ (key) принадлежит объекту obj и false – в противном случае. В нашем случае можно записать такую конструкцию:

for(let prop in rect)
         if( rect.hasOwnProperty(prop) ) 
                   console.log(prop + ": " + rect[prop]);

На выходе увидим:

draw: function () …
name: Прямоугольник

То есть, свойство name действительно было создано в дочернем объекте rect.

Но здесь есть один тонкий момент. Если проделать ту же операцию с объектами, а не примитивными типами данных, например с sp и ep:

let geom = {
         name: "фигура",
         sp: {x: 0, y: 0},
         ep: {x: 100, y: 20},
         get nameGeom() {return this.name; },
         set nameGeom(name) {this.name = name; },
         get coords() {
                   return [this.sp.x, this.sp.y, this.ep.x, this.ep.y];
         },
         set coords(coords) {
                   this.sp.x = coords[0]; this.sp.y = coords[1];
                   this.ep.x = coords[2]; this.ep.y = coords[3];
         }
};

то операция:

rect.coords = [1,2,3,4];

изменит состояние базового объекта geom, оставив дочерний без изменений:

console.log(rect.coords);
console.log(geom.coords);

Дело в том, что мы здесь обращаемся сначала к свойству sp, которое определено в базовом объекте (и мы автоматически переходим к нему), а затем уже, меняем его свойства x и y. То есть, мы не перезаписываем объект целиком, а работаем с его отдельными свойствами. А если переписать, то оно добавится в дочернем объекте:

         set coords(coords) {
                   this.sp = coords[0]; this.sp = coords[1];
                   this.ep = coords[2]; this.ep = coords[3];
         }

Вот на это следует обращать внимание.