Часто на собеседовании опытный разработчик может спросить у начинающего: «Что такое __proto__ и prototype, и чем они отличаются?». Обычно этот вопрос либо ставит в тупик, либо на него отвечают заученной мантрой из видео «50 вопросов на собеседовании»: « __proto__ — это ссылка на prototype, а prototype — это собственно свойство». И этот ответ правильный, только большинство недавно пришедших в профессию разработчиков не понимают, что это значит на самом деле. Причина проста — они не встречают в разработке ни __proto__, ни prototype, потому что современные стандарты JS прячут от него работу с этими свойствами за синтаксический сахар. Эта статья для таких, как я — разработчиков, которые столкнулись с JS в то время, когда никаких __proto__ и prototype на поверхности уже нет, а желание понять, как это устроено "под капотом" остается.
Синтаксический сахар
Историю нужно начать с причины, по которой сейчас разработчик почти не сталкивается с __proto__ и вовсе не сталкивается с prototype. И это вовсе не ключевое слово class и имитация классов, появившиеся в ECMAScript 2015. Первопричина — это синтаксический сахар, механизм, с помощью которого происходит развитие JS. Я решил уделить немного внимания этому понятию, потому что часто о нем говорят, как о само собой разумеющемся знании, но для начинающих разработчиков — это не всегда так. Синтаксический сахар — это механизм, с помощью которого происходит упрощение кода «визуально», но не происходит его изменение «под капотом». Простой пример, в JS есть цикл for — это не синтаксический сахар, это цикл с уникальным поведением, которого нельзя добиться другими инструментами JS. А вот цикл for…of или for…in — это синтаксический сахар, т.е. цикл, который «под капотом» разворачиваются в обычный for. Сделаны эти циклы для визуального упрощения. Итак, главное, что нужно понять — синтаксический сахар не меняет процедур, которые происходят «под капотом», он меняет только визуальное восприятие кода.
//один и тот же код с использованием обычного for и for … of
for (let i = 0; i < arr.length; i += 1) {
console.log(arr[ i ]);
}
for (const element of arr) {
console.log(element);
}
Псевдо-классы и функции-конструкторы в JS
А вот теперь поговорим про классы в JS. Говоря класс, мы должны понимать, что это не тот же самый класс, что в других языках. Классы в JS — это синтаксический сахар поверх прототипного наследования. Сейчас разберемся, о чем идет речь подробнее. Если довольно легко понять суть синтаксического сахара на примере с циклами for и for…of, то вот с классами все не так просто. Разработчик, который начал осваивать JS недавно, просто не сталкивается с той конструкцией, на основе которой сделан класс, потому что классы полностью ее вытеснили.
Прототипное наследование
Сделаем небольшое погружение в прототипное наследование, это поможет нам понять причины, по которым появился этот механизм. Представьте, что вы в 1995 году, у вас компьютер с 8 мегабайтами оперативной памяти и процессором на 66 МГц, и вам нужно написать очень экономный к памяти язык программирования. Вот тогда-то и было внедрено прототипное наследование, которое позволяло даже с вашими 8Мб оперативной памяти спокойно разрабатывать на JS. Суть примерно такова — давайте создадим корневой объект — прародителя, и дадим ему какое-то поведение, после чего, сделаем дочерний объект, у которого будет свое поведение и поведение родителя. А на основе этого дочернего объекта можно будет создать еще одного потомка, который наследует поведение обоих предков. Но вся суть этого в том, что наследуемое поведение для дочернего объекта не создается заново, а просто ссылается на место в памяти, где хранится предок. Сейчас разберемся подробно. Прародитель всех объектов в JS — это функция конструктор Object. У этого объекта есть поведение - мы для примера возьмем метод valueOf(). Потомки этого первого объекта все остальные функции-конструкторы, заранее созданные в JS — например String, Number, Array и т.д. У функции-конструктора Array есть свое поведение, например, методы массивов, а также поведение родителя, например метод valueOf(). Когда вы создаете конкретный массив в коде, вы делаете экземпляр, созданный функцией-конструктором Array. В итоге, при вызове метода valueOf() у этого экземпляра, с точки зрения памяти это выглядит так:
Родитель по отношению к потомку называется прототип, именно отсюда название «прототипное наследование». Таким образом происходит очень экономный расход памяти, и именно из этого механизма появляются понятия «ссылочный тип данных», «this», «цепочка прототипов» и т.д. Ну а называется эта ссылка, которая обращается к предку __proto__. То есть «под капотом» общение происходит через эту ссылку, и вызов метода valueOf() выглядит вот так:
myArr.__proto__.__proto__.valueOf.apply(myArr);
Конечно стоит отметить — что в современном языке так никто не пишет, этот фрагмент кода тут только в качестве наглядного примера.
Небольшой фан-факт: именно из этого механизма появилось знаменитое выражение «В JS все является объектами».
Функции-конструкторы
Следующая задача, которая стоит перед создателями языка в 95-м — дать разработчикам возможность обобщать сущности, наделять их поведением, но при этом точно также быть экономными в отношении памяти. По факту этот механизм уже есть - на его основе происходит создание функции-конструктора Array, которая наследуется от Object. Вы как разработчик хотите создать сущность, на основе которой хотите штамповать экземпляры с одинаковым интерфейсом и поведением, и наверное вы сейчас думаете: «ну это же описание класса в JS», но не забывайте, что мы с вами в 1995 году, а классы появились только в ECMAScript 2015. Так что разработчики получили в свои руки функции-конструкторы. Функция-конструктор — это обычная функция, которая определяет интерфейс вашей новой сущности:
function User(login, email) {
this.login = login;
this.email = email;
}
Вы скажете: “а где же поведение? Где методы этой новой сущности?”. Первое интуитивное решение - это вписать поведение прямо внутрь функции-конструктора:
function User(login, email) {
this.login = login;
this.email = email;
this.changeEmail = function (newEmail) {
this.email = newEmail
}
}
Но такой подход в корне неверный, потому что при таком описании сущности вы будете получать в каждом экземпляре не ссылку на метод changeEmail(), который хранится в памяти функции-конструктора, а будете копировать эту функцию в каждый новый экземпляр, что неэффективно с точки зрения памяти.
И тут спустя 5 тысяч знаков у нас наконец появляется слово prototype. Для того, чтобы хранить методы только в функции-конструкторе, а в экземпляре обращаться к ним по ссылке, есть специальной свойство функции-конструктора, которое называется prototype — это по факту специализированное хранилище для поведения всех экземпляров, созданных функцией-конструктором. В результате наш код преобразуется в следующий:
function User(login, email) {
this.login = login;
this.email = email;
}
User.prototype.changeEmail = function (newEmail) {
this.email = newEmail
}
Теперь, надеюсь, вам понятно, что prototype — это свойство функции конструктора, которое хранит в себе интерфейс предка, к которому через ссылку __proto__ будет обращаться потомок.
Код разработчика до появления классов в ECMAScript 2015 выглядел так:
function User(login, email) {
this.login = login;
this.email = email;
}
User.prototype.changeEmail = function (newEmail) {
this.email = newEmail;
};
function Admin(login, email, team) {
User.call(this, login, email);
this.team = team;
}
Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;
Admin.prototype.changeTeam = function (newTeam) {
this.team = newTeam;
};
В этом куске кода была создана функция-конструктор User, интерфейс которого включает login, email и метод изменения почты changeEmail, а потом сделана функция-конструктор Admin, которая наследует функцию-конструктор User, но расширяет и интерфейс и поведение. Если вы это впервые видите — это выглядит как магия, и страшно непонятно. А все потому, что ровно этот код, но с использованием классов выглядит так:
class User {
constructor(login, email) {
this.login = login;
this.email = email;
}
changeEmail(newEmail) {
this.email = newEmail;
}
}
class Admin extends User {
constructor(login, email, team) {
super(login, email);
this.team = team;
}
changeTeam(newTeam) {
this.team = newTeam;
}
}
Согласитесь, теперь это визуально читается понятнее, но от появления ключевого слова class — в JS не появляются классы, все происходит, как указано в первом фрагменте кода — просто разработчик больше этого не видит. Этот синтаксический сахар, по факту, навеки прячет от нас свойство prototype, поэтому разработчики, не писавшие до выхода ES2015 испытывают такие трудности с пониманием, что же это за штуки такие __proto__ и prototype.
В данной статье есть ряд упрощений, например, я не затронул синтаксический сахар вокруг самой функции-конструктора, а также я целиком выпустил свойство constructor у prototype, это мне кажется излишним усложнением для концептуального объяснения сути __proto__ и prototype. Надеюсь, что эта статья сподвигнет кого-то из вас разобраться с этими вопросами глубже. Мне понравилась статья-размышление о наследовании в JS, и я вам рекомендую начать с нее.
Итоги
Давайте подведем краткие итоги, которые, надеюсь, теперь будут звучать для вас осмысленно.
-
__proto__ — это свойство любого объекта в JS, которое является ссылкой на свойство prototype функции-конструктора:
-
prototype — это свойство функции-конструктора, которое хранит поведение наследуемое потомками:
у каждой функции в JS есть свойство prototype, но только у функций! Класс в JS — это синтаксический сахар вокруг функции-конструктора, следовательно, у классов тоже есть свойство prototype.
Потомок связан с родителем свойством __proto__, которое указывает на свойство prototype родителя, в котором в свою очередь хранится своя ссылка __proto__, указывающая на его родителя. Такая связь называется цепочка прототипов, а сам механизм такого наследования называется прототипное наследование.
Попробуйте самостоятельно написать несколько функций-конструкторов используя старый синтаксис, это расставит все точки над i.
Полезные ссылки
Бесплатный вебинар от OTUS: "Обзор технологий для построения API"
Статья-размышление о наследовании в JS, о которой я упоминал выше
Статья затрагивающая еще и оператор new
Классное видео, где вы можете потренировать полученную информацию
Комментарии (9)
freepad
31.08.2022 14:53Считаю важно отменить, что
Class
в JS это не просто «синтаксический сахар». Не всё что мы можем сделать сClass
-ами, мы можем повторить на прототипах.На хабре уже была статья, JavaScript-классы — это не просто «синтаксический сахар»
rock
31.08.2022 15:59+1Справедливости ради, для ES6+ классы это исключительно синтаксический сахар. С помощью
new.target
(доступен и в обычных функциях), 3го аргументаReflect.construct
,Object.setPrototypeOf
иWeakMap
реализуются все недоступные в ES5 части логики классов.
BruTO8000
31.08.2022 14:54у каждой функции в JS есть свойство prototype, но только у функций! Класс в JS — это синтаксический сахар вокруг функции-конструктора, следовательно, у классов тоже есть свойство prototype.
У стрелочных функций нет prototype
DonVietnam
31.08.2022 15:11Ох, да когда же вы закончите уже с своим синтаксическим сахаром.
Классы - не синтаксический сахар над прототипами, Вот статья.
-
for of работает с объектами итераторами, в какой он там обычный for разворачивается и под каким таким "капотом".
Вы когда подобное пишите, приводите примеры из спецификации языка хотя бы, чтобы не быть голословным "механиком" с "капотом".
rock
31.08.2022 16:01Ну по поводу классов смотрите чуть выше.
DonVietnam
31.08.2022 17:07У вас логика немного извращенная, если позже добавили компоненты, которые позволяют написать то же самое, не используя классов, то это не делает классы синтаксическим сахаром.
rock
31.08.2022 17:14+1Эти компоненты добавили не позже классов, а вместе с ними (а что-то, вроде возможности установки прототипа существующего объекта, было задолго до них - де факто, но не в стандарте). И это делает классы синтаксическим сахаром, смотрим хотя бы на определение из педивикии
Под «синтаксическим сахаром» понимается любой имеющийся в языке программирования синтаксический элемент, механизм, способ описания, который дублирует другой, имеющийся в языке элемент или механизм, но является более удобным в использовании, или более краток, или выглядит естественнее, или более привычен (похож на аналогичные элементы других языков), или просто лучше воспринимается при чтении программы человеком.
rock
Вы ошибаетесь чуть менее, чем во всем. Давайте рассмотрим только "итого":
Нет, это не свойство любого объекта в JS. Это legacy (сейчас в стандарте он опциональный - рекомендуется использовать
Object.setPrototypeOf
/Object.getPrototypeOf
- про которые в посте и слова не сказано) accessor, расположенный наObject.prototype
. Для объектов, не унаследованный отObject.prototype
(например,Object.create(null)
) его не будет. Оно может быть перекрыто собственным свойством объекта. Есть ещё__proto__
в литерале объекта, но это немного другое.Далеко не у каждой функции в JS есть свойство
.prototype
- оно есть только у конструкторов. Его нет у стрелочных функций, функций заданных синтаксисом методов ({ method() { /* ... */ } }
), асинхронных функций, built-in функций и других.Про
__proto__
смотрите первый пункт. А проprototype
- конструкторы не единственный способ наследования - на чейprototype
будет указыватьObject.create({}).__proto__
?И так глаза режет практически каждый абзац статьи.