«Сложно о простом». Функции-конструкторы — как объекты,(prototype). Объекты,(__proto__). constructor, ={}, как функция-конструктор new Object()

в 14:19, , рубрики: __proto__, constructor, javascript, prototype, Веб-разработка, конструктор, Программирование, функции, метки: , , , , ,

В прошлый раз мы попытались разобраться со следующими вещами:

  • 1. Не смотря на расхожее мнение «всё в JS является объектами» — это не так, мы выяснили, что из 6 доступных программисту типов данных аж 5 является примитивами и лишь один представляет тип объектов.
  • 2. Про объекты мы узнали, что это такая структура данных, которая содержит в себе пары «ключ-значение». Значением может быть любой из типов данных (и это будет свойство объекта) или функция (и это будет метод объекта).
  • 3. А вот примитивы – это не объекты. Хотя с ними и можно работать как с объектом (и это вызывает заблуждение что примитив – это объект), но…
  • 4. Переменные можно объявить как по простому (литерально) (var a = ‘str’), так и через функцию-конструктор (обёртка)(var a = new String(‘str’)). Во втором случае мы получим уже не примитив, а объект созданный конструктором String(). (что за магический оператор new и что такое функция-конструктор мы узнаем дальше).
  • 5. Узнали, что именно за счёт создания обёртки над примитивом (new String(‘str’)) c ним можно работать как с объектом. Именно эту обёртку создаёт интерпретатор вокруг примитива, когда мы пытаемся работать с ним как с объектом, но после выполнения операции она разрушается (поэтому примитив никогда не сможет запомнить свойство, которое мы ему присвоим a.test = ‘test’- свойство test исчезнет с обёрткой).
  • 6. Узнали, что у объектов есть метод toString() который возвращает строковое представление объекта (для типа number valueOf() – вернёт числовое значение).
  • 7. Поняли, что при выполнении операций конкатенации или математических операциях примитивы могут переопределить свой тип в нужный. Для этого они используют функции-обёртки своих типов, но без оператора new (str = String(str)).(в чём разница и как это работает, поговорим дальше)
  • 8. И наконец, узнали, что typeof берёт значения из жёстко зафиксированной таблицы (вот откуда ещё одно заблуждение, основанное на typeof null //object).

Сегодня я бы хотел рассказать о работе с функциями-конструкторами и свойстве prototype, а так же о работе с порождаемыми ими объектами и их свойством __proto__. Это часто нужно при попытках организовать наследование в JS и для понимание некоторых процессов происходящих в нём. Это даст также понимание какой-то части прототипного наследования. В качестве примера для разбора(пока он может быть не всем понятен) возьмём такой код:

Пример1:

function A() {}
A.prototype.x = 10;

a = new A();
console.log(a.x); //10
console.log(a.y); //undefined

A.prototype.y = 20;
console.log(a.y); //20
/*То есть при таком подходе A.prototype.<свойство> добавления свойств в прототип
все эти свойства стразу появляются у экземпляра*/

Пример2:
function B() {}
B.prototype.x = 10;

b = new B();
console.log(b.x); //10
console.log(b.y); //undefined

B.prototype = {constructor:B, x:10, y:20};

console.log(b.x); //10
console.log(b.y); //undefined
/*А при таком подходе B.prototype = {constructor:B, x:10, y:20}; добавления свойств в прототип
с экземпляром ничего не происходит*/

b1 = new B();
console.log(b1.x); //10
console.log(b1.y); //20
/*Зато у последующих потомков появляются все новые свойства*/

b instanceof B; //false
b1 instanceof B //true
/*Более того оказывается что придедущие потомки уже и не потомки вовсе*/
Функции-конструкторы, как объекты, prototype.

Начнём по порядку. Рассмотрим простую на первый взгляд строчку кода.

function A() {}

Самое первое что можно сказать: «Мы объявили функцию с именем A». Совершенно верно. Но здесь есть нюансы.
1. Не забываем что в JS — практически всё есть Объект. Функция, как оказалось не исключение(это даже два объекта связанные ссылкой).
2. Её можно использовать как функцию-конструктор.
В JavaScript нет того, что принято называть классами. Работу классов в JavaScript выполняют функции-конструкторы, которые создают объекты с определенными заданными свойствами.
В общем-то говоря любая объект-функция в JS может быть конструктором( я говорю о пользовательских функциях). Их условно можно поделить на три (DF(Функция декларация), FE(Функция выражение), функции созданные конструктором Function()). У всех этих функций есть свои особенности(по этому они разделены на разные группы), но о них я здесь рассказывать не буду, если кому интересно я отвечу лично или напишу отдельно про них в другой раз. Однако у них есть и одна общая черта, которая позволяет им быть конструкторами — это наличие внутренних свойств [[Construct]] и [[Call]], а также явного свойства prototype(о нем ниже).
Именно внутренний метод [[Construct]] отвечает за выделения памяти под новый объект и его инициализацию. Однако — это не значит что вызов функции приведёт к созданию объекта, конечно нет. Для этого перед вызовом функции нужно поставить оператор new. Именно new запускает метод [[Construct]] и связанные с ним процессы.

function A(){}
A(); //просто вызов функции
var a = new  A(); //вызов функции-конструктора. Экземпляр (a) созданный функцией-конструктором (A)

3. Так же можно сказать что это функция декларация(DF) и прочее, но остальное пока не важно.

Итак Функция «A» (из первой строчки первого примера) — это функция-конструктор и по совместительству объект. Раз это объект — она может иметь свойства. Так оно и есть. А раз это функций-конструктор, то она имеет свойство prototype. Свойство prototype — это ссылка на объект, который хранит свойства и методы которые перейдут к экземплярам созданному этой функцией-конструктором. Давайте попробуем всё это отобразить графически.

«Сложно о простом». Функции конструкторы — как объекты,(prototype). Объекты,(  proto  ). constructor, ={}, как функция конструктор new Object()

По умолчанию объект prototype «пустой» (ну почти пустой, но об это ниже). Выше я сказал что всё что лежит в этом объекте перейдёт в экземпляр, а так же будет доступно потомкам. То есть по умолчанию(если ничего в prototype не дописывать), то в экземпляр «ничего» не перейдёт от функции-конструктора «A». То есть при выполнении кода:

function A(){}
var a = new  A();

мы получим «обычный»( насколько это можно в JS ) объект «а».
В JS уже встроено много функций-конструкторов. Это например Number(), String() и т. д. Давайте отвлечёмся ненадолго от примера и поговорим о встроенных функциях-конструкторах и об Объектах в целом.

Объекты(__proto__).

Из прошлой статьи, мы знаем, что при создании (явно или не явно) объектов одним из встроенных конструкторов Number(), String() или Boolean(), экземпляр получает доступ к некоторым методам характерным данному типу. Например для Number() есть метод toPrecision(). Если посмотреть в консоли на объект созданный конструктором new Number(2), то Вы не обнаружите там этого метода(Вы вообще не обнаружете там методов). Откуда же он берётся? Как раз он и подобные ему методы(к которым должен иметь доступ потомок) и содержатся в prototype-объекте родителя. Но как экземпляр получает к ним доступ? У экземпляра есть свойство __proto__ — это ссылка на prototype-объект родителя. Если при вызове метода, метод не находится в самом экземпляре, происходит переход по ссылке __proto__ в prototype-объект родителя и поиск продолжается там. На самом деле так продолжается и дальше пока не будет встречен null.
Попробуем всё это нарисовать:

«Сложно о простом». Функции конструкторы — как объекты,(prototype). Объекты,(  proto  ). constructor, ={}, как функция конструктор new Object()

Подведя итог можно сказать, что пока всё не сложно: Есть родитель(функция-конструктор), у которой есть ссылка в свойстве prototype на некий объект где хранятся все методы и свойства к которым потомок должен иметь доступ. И есть, собственно, потомок которому при создании через вызов new от родителя передаётся ссылка в свойство __proto__ на тот самый объект с общими свойствами и методами.

Для закрепления попробуем рассмотреть пример:

function A() {} //Мы создаём функцию-конструктор (пока с «пустым» prototype)
A.prototype.x = 10;//Теперь мы добавляем в prototype(через ссылку в сам объект) свойство (x) равное 10
a = new A(); //Создаём экземпляр у которого свойство __proto__ станет ссылкой на объект prototype свойством (x==10)
console.log(a.x); //Свойство (x) не будет обнаружено в самом экземпляре (a), но пройдя по ссылке __proto__ интерпретатор найдёт его в объекте prototype.
console.log(a.y); // А вот (y) нет ни там ни там.
A.prototype.y = 20; //Однако добавив свойство (y) в prototype(через ссылку родителя в сам объект)
console.log(a.y); //20 //Интерпретатор сможет найти и это свойство через ссылку __proto__ потомка
constructor.

Я всегда брал слово (пустой) в кавычки когда говорил («пустой» prototype). Мол когда мы создаём функцию-конструктор function A(){}, то создаётся свойство prototype с ссылкой на «пустой» prototype-объект. На самом деле нет. В prototype всё же кое-что лежит. Во-первых поскольку как я уже говорил prototype — это «простой» Объект, то там лежит свойство __proto__ с ссылкой на prototype функции-конструктора Object() (именно она создаёт всё «простые», самые элементарные объекты), а во-вторых там лежит свойство constructor. Свойство constructor туда добавляет интерпретатор, когда понимает что создаётся функция-конструктор а не просто объект Для начала давайте дополним наш первый рисунок с учётом этих двух фактов.

«Сложно о простом». Функции конструкторы — как объекты,(prototype). Объекты,(  proto  ). constructor, ={}, как функция конструктор new Object()

Всё что нарисовано серым, нам сейчас особо не нужно — это для более полной картины. Сосредоточимся на свойстве constructor. Как видно из рисунка constructor указывает на саму функцию-конструктор для которой изначально было создано это «хранилище», этот объект. То есть между свойством prototype функции-конструктора и свойством constructor объекта-prototype появляется цикличность — они указывают на объекты друг-друга.
Через свойство constructor (если оно всё ещё указывает на конструктор, а свойство prototype конструктора, в свою очередь, всё ещё указывает на первоначальный прототип) косвенно можно получить ссылку на прототип объекта: a.constructor.prototype.x. А можно полуть ссылку к самой функции-конструктору и её свойствам которые были присвоены не в prototype-объект, а конкретно к ней. Например:

function A(){}
A.own = 'I am A!'; //А как я говорил функция — тоже объект и мы можем добавлять свойства
a = new A();
a.own //undefined
a.constructor.own // 'I am A!';

={} — как функция-конструктор (new Object()).

Отлично, вроде как всё встало на свои места. Есть «общее хранилище», у родителя и потомка есть ссылки на это хранилище, если свойства нет в самом экземпляре, то интерпретатор перейдя по ссылке поищет его в «общем хранилище». В чём загвоздка?? Посмотрим Пример2:

function B() {}
B.prototype.x = 10;

b = new B();
console.log(b.x); //10
console.log(b.y); //undefined

B.prototype = {constructor:B, x:10, y:20};

console.log(b.x); //10
console.log(b.y); //undefined

Вроде как всё должно работать. Мы создали функцию-конструктор, задали «общему хранилищу» (prototype(через ссылку)) свойство (x), создали экземпляр, свойство (x) у него есть — всё нормально. Потом мы вообще переопределили свойство родителя prototype, добавив свойства (x) и (y) указали верный constructor. Всё должно работать в «общем хранилище лежит» оба этих свойства, но нет, (y) интерпретатор не находит. WTF?!?

Что же здесь за магия происходит? Почему мы не видим этих изменений из потомка этого конструктора? Почему потомок не видит y? Ну во-первых мы переопределяем свойство prototype функции-конструктора(B) и оно перестаёт быть ссылкой и становится объектом (связь с первоначальным объектом prototype разорвана). Во-вторых обычное присвоение переменной объекта, типа: var a = {}, интерпретатором на самом деле выполняется как var a = new Object(). А это значит, что свойство prototype функции-конструктора теперь содержит совершенно новый объект у которого ссылка constructor отсутствует и чтоб не потерять родителя мы самостоятельно дописываем туда свойство constructor и присваиваем ему самого родителя.
А экземпляр сделанный ранее содержит ссылку __proto__ на старый объект prototype где свойства (y) нет. То есть в отличии от Примера1 здесь мы не «добавили в хранилище свойство» и даже не «переписали хранилище заново», мы просто создали новое, разорвав связь с старым, а экземпляр об этом ничего не знает, он всё ещё пользуется старым по своей старой ссылке __proto__. Выглядит это вот так:

«Сложно о простом». Функции конструкторы — как объекты,(prototype). Объекты,(  proto  ). constructor, ={}, как функция конструктор new Object()

Чёрным цветом — это то что не изменилось и после B.prototype = {constructor:B, x:10, y:20};
Красным — то что удалилось
Зелёным — то что добавилось

Так же можно добавить немного об instanceof. Как ни странно, но в данном примере b1 будет принадлежать функции-конструктору B, а b1 — нет. Всё очень просто. Дело в том что instanceof ищет выполнения следующего условия — что бы объект указанный по ссылке __proto__(на любом уровне цепочки)(кружочек с цифрой 1) был равен объекту на который ссылается свойство prototype искомого родителя(кружочек с цифрой 2)(сравните на рисунке чёрный цвет и зелёный). В чёрном цвете это условие уже не выполняется, а в зелёном — выполняется.
В нашем случае у экземпляра (b) эта связь разорвана, так как новое свойство prototype искомого родителя(B) ссылается уже на новый объект, а не как раньше. Зато у экземпляра (b1) с этим как видим всё в порядке.

Вдогонку

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

function A(str){
	this.val = str;
}
a = new A('test'); //Сдесь свойство val добавится в экземпляр, так как при вызове функции как конструктора this всегда будет указывать на создаваемый экземпляр

A('test'); //А тут this — это глобальный объект. И теперь у него добавится свойство val
console.log(val) //'test'

Как же узнать как вызвали функцию? Через new или нет? Это делается очень просто:

function A(str){
	if( this instanceof A) //Если this является экземпляром A(то есть вызов через new)
		this.val = str; //добавить экземпляру свойство val
	else 		 	
		retur str; //Иначе(при простом вызове функции без new) вернуть строку
}

Примерно таким образом реализован механизм приведения типов. При выполнении например 1+'1' интерпретатор воспринимает + как конкатенацию строк и пытается привести число 1 в строку. Это происходит с помощью неявного вызова String(1)(без new). А в конструкторе String написана примерно та же конструкция что у нас выше. То есть если вызов произошел без new просто вернуть строку(неявный вызов метода toString()). Таким образом без создания каких либо объектов происходит преобразование типов.
Так же хочу добавить следующее, что бы добавить свойство к функции(именно к функции а не к prototype) нужно обратится к ней как к объекту. Например

function A(){}
A.val = 'str';

Это свойство будет недоступно потомку, так как оно не лежит в prototype, а потомок имеет доступ только туда. Но как говорится «если сильно хочется то можно». Тут то нам и пригодится свойсто объекта prototype — constructor. Оно как мы помним ссылается на саму функцию(если конечно этого специально не меняли). Тогда чтоб получить переменную val нужно обратится к ней так:

function A(){}
A.val = 'str';
a = new A();
a.constructor.val; //'str'

Автор: drinkcsis


  1. Александр:

    Молодец. Видно когда пишет человек , понимающий тему. Особенно спасибо за ключевую фразу:

    ” Не забываем что в JS — практически всё есть Объект. Функция, как оказалось не исключение (ЭТО ДАЖЕ ДВА ОБЪЕКТА СВЯЗАННЫЕ ССЫЛКОЙ).”

    С этого и начинается понимание ООП в JavaScript

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js