Есть несколько аспектов объект-ориентированного программирования, которые можно рассмотреть.
В этой статье разбираются механизмы работы ООП в джаваскрипте. И тут нужно понять три отдельные вещи:
__proto__
;prototype
;this
.Слово прототип я использую сейчас первый и последний раз, потому что под ним понимают и __proto__
, и prototype
. Зачем себя путать.
__proto__
У каждого объекта в джаваскрипте есть свойство __proto__
. Даже если вы его не создавали. В этом свойстве может лежать либо объект, либо null. JavaScript обращается к этому полю, когда не находит ключ в самом объекте.
Ничего особенного в названии поля __proto__
нет. Символ подчеркивания является валидным символом для названия перменных (через _
обозначают неиспользуемые переменные в аргументах функции, через SCREAMING_SNAKE_CASE
обозначают константы, а через _cache
обозначали приватные поля в классах до появления нативного синтаксиса c символом #
).
__proto__
работает на чтениеЕсли у объекта нет какого-то свойства, то мы идем в его __proto__
и ищем там. Если там нет, то идем дальше, пока не дойдем до null.
const a = {x: 1, y: 2, __proto__: null}; const b = {x: 10, z: 30, __proto__: a}; const c = {t: 400, __proto__: b}; console.log(c.t, "t" in c, Object.hasOwn(c, "t")); // 400, поле существет и является собственным полем console.log(c.x, "x" in c, Object.hasOwn(c, "x")); // 10, поле существет, но не является собственным полем // не найдя "x" в c, идет в c.__proto__ === b, находим там console.log(c.y, "y" in c, Object.hasOwn(c, "y")); // 2, поле существет, но не является собственным полем // не найдя "y" в c, идет в c.__proto__ === b, не находим там // тогда идем в b.__proto__ === a, находим там console.log(c.q, "q" in c, Object.hasOwn(c, "q")); // undefined, поле не существет и не является собственным полем // не найдя "y" в c, идет в c.__proto__ === b, не находим там // тогда идем в b.__proto__ === a, не находим там // дальше идем в a.__proto__, он равен null, останавливаем поиск
__proto__
не работает на изменениеdelete c.x; // не удалит поле "x" из c (нечего удалять) // но и не удалит из b (не из него удаляли) console.log(c.x, b.x); // 10 10 c.z = 300; // запишет в c поле "z", но в b.z так же будет лежать 300 console.log(c.z, b.z); // 300 30
__proto__
Свойство __proto__
можно указать несколькими способами (напомню, что записать туда можно null или объект):
Object.create(x)
создаст пустой объект с __proto__
равным x
.Object.setPrototypeOf(o, x)
у объекта o
установит __proto__
равным x
.o.__proto__ = x
__proto__
устанавливается специальным образом, если сипользовать оператор new
. Об этом дальше.__proto__
Если я хочу использовать джаваскриптовый объект как словарь (структуру данных для хранения в формате ключ-значение), то «лишние» свойства из __proto__
могут мне навредить.
const obj = { "toNumber": 3, "toArray": 4 }; console.log("toNumber" in obj); // true — есть ключ, я его добавил console.log("toArray" in obj); // true — есть ключ, я его добавил console.log("toString" in obj); // true — но я этот ключ не добавлял!
Именно поэтому, например, в стандартном статическом методе Object.groupBy
создается так называемый null prototype object. В node.js, например, он выводится как [Object: null prototype]
. Это объект, у которого поле __proto__
содержит null.
const npo = Object.groupBy([], () => "x"); console.log(Object.getPrototypeOf(npo)); // null
Если мы хотим создать свой null prototype object с нуля, то можно это сделать с помощью Object.create(null)
.
Если вы только изучаете ООП, переходите сразу к разделу 2.
Теперь для тех, кто остался. Если вы возьмете любой объект, в наличии __proto__
у которого вы не сомневаетесь, то ваша какртина мира может быть разрушена.
const arr = ["a", "b", "c"]; console.log(arr.__proto__); // объект со всеми методами массивов console.log(Object.hasOwn(arr, "__proto__")); // такого поля нет!
На самом деле поле __proto__
ищется через механизм делегирования, которое само оно и реализует. В современных реализациях джаваскрипта __proto__
реализовано как геттер, находящийся в Object.prototype
, к которому есть доступ у всех объектов (кроме объектов, где __proto__
равно null).
Более того, также реализован сеттер! Если вы попробуете обновить __proto__
вручную, то поле все равно не появится.
console.log(Object.getOwnPropertyDescriptor(Object.prototype, "__proto__")); // выводятся геттер и сеттер const obj = {}; console.log(obj.__proto__); // в поле __proto__ лежит какой-то объект console.log(Object.hasOwn(obj, "__proto__")); // а самого поля нет obj.__proto__ = {}; // записал в поле какое-то значение console.log(Object.hasOwn(obj, "__proto__")); // поля все равно нет
В связи с этим возникает парадоксальная ситуация и с Object.create(null)
. Эта функция по определению создает объект с определенным __proto__
. Но если мы обратимся к этому полю напрямую, то там будет лежать undefined
.
const npo = Object.create(null); console.log(npo.__proto__); // undefined console.log(Object.getPrototypeOf(npo)); // null
Разгадка кроется в том, что я дал упрощенное объяснение механизма делегирования через __proto__
. В самой спецификации вообще используется запись [[Prototype]]
(к нему нет доступа у разработчика), которая очень близка по смыслу с __proto__
, который мы можем пощупать сами. Обращу внимание, что не нужно путать [[Prototype]]
с полем prototype
функций-конструкторов, о котором речь пойдет дальше.
prototype
Если вы создаете обычную функцию в джаваскрипте, то у нее изначально присутствует несколько свойств. Это возможно, так как функция в джаваскрипте является объектом (как и массив, например).
Что это за свойства?
function foo(x, y, z) { } console.log(Object.getOwnPropertyNames(foo)); // ['length', 'name', 'arguments', 'caller', 'prototype'] console.log(foo.name); // "foo" — название функции console.log(foo.length); // 3 — количество аргументов console.log(foo.prototype); // {} — выглядит как обычный объект
В контексте разговора об ООП нас интересует свойство prototype
. Кстати, оно есть не у любой функции. Например, у стрелочных функций и методов объекта (синтаксис method definitions), поля prototype
нет. Оно и понятно, мы не собираемся использовать их в качестве конструктора.
const obj = { f() {} }; const g = () => {} Object.getOwnPropertyNames(obj.f); // ['length', 'name'] Object.getOwnPropertyNames(g); // ['length', 'name']
Таким образом у всех функций (кроме исключений выше) есть поле prototype
с самого начала. Можно еще сказать, что любая функция-конструктор (т.е. функция, вызываемая с помощью new
) содержит поле prototype
. Но штука в том, что в момент создания функции она еще не знает, будет ли она использоваться как конструктор :)
У обычных объектов поля prototype нет. Но будет, только если вы сами его туда не запишете.
prototype
и __proto__
В чем разница между объектами obj1
и obj2
? Оба содержат поля x и y с значениями 5 и 10.
function foo1(x, y) { this.x = x; this.y = y; } function foo2(x, y) { return { x, y }; } const obj1 = new foo1(5, 10); // { x: 5, y: 10 } const obj2 = foo2(5, 10); // { x: 5, y: 10 }
Для того, чтобы увидеть, в чем разница, нужно рассмотреть, как работает оператор new
и что скрывается за синтаксом {}
(литерал объекта) при создании объекта.
function xf(x, y) { this = { __proto__: foo1.prototype }; // ← происходит неявно this.x = x; this.y = y; return this; // ← происходит неявно } const obj1 = new foo1(5, 10);
В тот момент, когда функция foo1 вызывается с оператором new, в код фуннции как бы добавляются две строчки. В начале идет создание переменной this, в которую кладется объект. А в конце — возврат этого this.
Единственная особенность этого объекта в том, что у него поле __proto__
равно полю foo1.prototype
. Иными словами функция-конструктор создает объект, у которого поле __proto__
равно полю prototype
этой функции. Это едиснтвенная связь __proto__
и prototype
.
Функции-конструкторы называются с большой буквы, потому что есть такая договоренность между разработчиками. Если называть их с маленнькой буквы, то выхов через new
будет работать точно так же. В некоторых примерах здесь я даже так и делаю для иллюстрации. В рабочем коде называйте функции с большо буквы (это еще называют PascalCase).
На самом деле запись x = {}
равнозначна записи x = new Object()
. А это значит, что для обычного объекта работает то же самое правило, которое мы вывели в разделе выше.
Переменная x
— результат работы функции-конструктора Object
, вызванной с помощью оператора new
. А значит, x.__proto__ === Object.prototype
.
function foo1() {} const o1 = new foo1(); // o1.__proto__ === foo1.prototype const o2 = new Object(); // o2.__proto__ === Object.prototype
В объекте foo1.prototype
нашей функции foo1
почти ничего нет. Единствненное поле, которое по умолчанию лежит в этом объекте, — constructor
, ссылающееся на саму foo1
. А вот в Object.prototype
есть несколько полей, с некоторыми из которых вы даже знакомы.
Object.getOwnPropertyNames(Object.prototype) === [ "constructor", "__defineGetter__", "__defineSetter__", "hasOwnProperty", "__lookupGetter__", "__lookupSetter__", "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf", "__proto__", "toLocaleString" ]
Например, hasOwnProperty
позволяет определять, есть ли в объекте какой-то ключ, а благодаря toString
у нас не падает программа, когда мы складываем друг с другом не только числа и строки.
Поэтому, например, если мы создадим объект через {}
, мы сможем на нем вызывать метод hasOwnProperty
.
const obj = { p: 1 }; console.log(obj.hasOwnProperty("p")); // true
Более того, мы можем создать объект своего класса и также вызвать метод hasOwnProperty
. За счет цепочки из __proto__
мы найдем нужный метод. Это называется наследование!
function foo() { this.p = 5; } const obj = new foo(); console.log(obj.hasOwnProperty("p")); // true — метод работает console.log(Object.hasOwn(obj, "hasOwnProperty")); // false // obj.__proto__ === foo.prototype console.log(Object.hasOwn(obj.__proto__, "hasOwnProperty")); // false // obj.__proto__.__proto__ === Object.prototype console.log(Object.hasOwn(obj.__proto__.__proto__, "hasOwnProperty")); // true
Для массивов и функций литералы работают так же.
const arr1 = ["a", "b", "c"]; const arr2 = new Array("a", "b", "c"); const fn1 = function(x, y) { return x + y; }; const fn2 = new Function("x", "y", "return x + y;");
Таким образом массивы, созданные через []
, фактически создаются через конструктор Array
, а функции — конструктор Function
. Не удивительно, что в __proto__
у них лежат Array.prototype
и Function.prototype
соответственно.
this
не зависит от того от места объявлении функцииТеперь давайте посмотрим на то, как вычисляется this
внутри метода. Он там есть наверняка. Ведь если бы this
не было, то почему эта функция оформлена как метод.
function A() { } A.prototype.print = function() { console.log(this); } const a1 = new A(1); a1.print();
Что происходит в момент вызова a2.print()
? JavaScript ищет поле print
в объекте a1
, но не находит. Тогда по правилу из раздела 1 он идет в a1.__proto__
. Этот объкт по правилу из раздела 2 равен A.prototype
, а в нем уже есть поле print
, потому что мы его туда положили.
Таким образом фактически вызывается функция, находящаяся по адресу A.prototype.print
. Но то, чему равен this
, определяется в момент вызова функции. В данном случае мы видим, что «слева от точки» стоит объект a1
, а значит, this
равен a1
.
this
this
зависит от способа вызова функцииfoo()
this === undefined
в строгом режиме ("use strict"
)this === globalThis
(window
в браузере или global
в node.js) в нестрогом режимеobj.foo()
или arr[0]()
this
равен объекту «слева от точки»: this === obj
или this === arr
obj.foo.call(obj2)
или obj.foo.apply(obj2)
this === obj2
— аргумент call
или apply
foo
мы взяли из obj
new foo()
this === { __proto__: foo.prototype }
— новый объект с определенным полем __proto__
bar = foo.bind(obj3)
bar()
this равен obj3
bind
или положим bar
в какой-то объект и вызовем как его метод, все равно this
будет равен obj3
Даже если функция объявлена как метод, ее this
все равно определяется в момент вызова (см 3.3.). Но часто бывает, что мы сами явно функцию не вызываем (ни одним из способов, перечисленных выше).
const obj = { foo() { console.log(this); }, }; // мы сами вызываем функцию, this === obj obj.foo(); // мы передаем функцию в setTimeout и он ее вызывает через 100 мс const timeout = setTimeout(obj.foo, 100);
Когда функцию вызывает кто-то другой, то именно он определяет, чему равен this
. Например, в случае setTimeout
— в зависимости от среды — this
определяется по-разному. Если мы запускаем в node.js, то this === timeout
. Это объект класса Timeout
, который можно будет потом передать в clearTimeout
. Если мы запускаем в браузере, this === window
. Но в обоих случаях this
не равен obj
.
Чтобы obj.foo
вызвалась с this === obj
, можно изменить передаваемую функцию.
// создали новую функцию, у которой this жестко зафиксирован (и равен obj) const boundObj = obj.foo.bind(obj); setTimeout(boundObj, 100); // передали новую анонимную функцию, внутри которой вызывается obj.foo(); // а так как сам вызов с объектом obj «слева от точки», то this === obj setTimeout(function() { obj.foo(); }, 100); // при этом сама функция может быть и стрелочной // нас же не this этой стрелочной функции интересует setTimeout(() => obj.foo(), 100);
Еще один пример функции, принимающей колбэк — метод массива map
. У него есть второй опциональный аргумент, принимающий значение this
для колбэка. Сам колбэк передается первым аргументом.
const obj = { x: 2, foo(num) { return num * this.x; }, } console.log([1, 2, 3].map(obj.foo)); // this потерялся console.log([1, 2, 3].map(num => obj.foo(num))); // исправили через функцию-обертку console.log([1, 2, 3].map(obj.foo.bind(obj))); // исправили через bind console.log([1, 2, 3].map(obj.foo, obj)); // воспользовались вторым аргументом метода map