Часто на собеседовании опытный разработчик может спросить у начинающего: «Что такое __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.

Полезные ссылки

Комментарии (9)


  1. rock
    31.08.2022 14:25
    +1

    Вы ошибаетесь чуть менее, чем во всем. Давайте рассмотрим только "итого":

    __proto__ — это свойство любого объекта в JS, которое является ссылкой на свойство prototype функции-конструктора

    Нет, это не свойство любого объекта в JS. Это legacy (сейчас в стандарте он опциональный - рекомендуется использовать Object.setPrototypeOf / Object.getPrototypeOf - про которые в посте и слова не сказано) accessor, расположенный на Object.prototype. Для объектов, не унаследованный от Object.prototype (например, Object.create(null)) его не будет. Оно может быть перекрыто собственным свойством объекта. Есть ещё __proto__ в литерале объекта, но это немного другое.

    у каждой функции в JS есть свойство prototype, но только у функций!

    Далеко не у каждой функции в JS есть свойство .prototype - оно есть только у конструкторов. Его нет у стрелочных функций, функций заданных синтаксисом методов ({ method() { /* ... */ } }), асинхронных функций, built-in функций и других.

    Потомок связан с родителем свойством __proto__, которое указывает на свойство prototype родителя

    Про __proto__ смотрите первый пункт. А про prototype - конструкторы не единственный способ наследования - на чей prototype будет указывать Object.create({}).__proto__?

    И так глаза режет практически каждый абзац статьи.


  1. freepad
    31.08.2022 14:53

    Считаю важно отменить, что Class в JS это не просто «синтаксический сахар». Не всё что мы можем сделать с Class-ами, мы можем повторить на прототипах.

    На хабре уже была статья, JavaScript-классы — это не просто «синтаксический сахар»


    1. rock
      31.08.2022 15:59
      +1

      Справедливости ради, для ES6+ классы это исключительно синтаксический сахар. С помощью new.target (доступен и в обычных функциях), 3го аргумента Reflect.construct, Object.setPrototypeOf и WeakMap реализуются все недоступные в ES5 части логики классов.


  1. BruTO8000
    31.08.2022 14:54

    у каждой функции в JS есть свойство prototype, но только у функций! Класс в JS — это синтаксический сахар вокруг функции-конструктора, следовательно, у классов тоже есть свойство prototype.

    У стрелочных функций нет prototype


  1. DonVietnam
    31.08.2022 15:11

    Ох, да когда же вы закончите уже с своим синтаксическим сахаром.

    1. Классы - не синтаксический сахар над прототипами, Вот статья.

    2. for of работает с объектами итераторами, в какой он там обычный for разворачивается и под каким таким "капотом".

      Вы когда подобное пишите, приводите примеры из спецификации языка хотя бы, чтобы не быть голословным "механиком" с "капотом".


    1. rock
      31.08.2022 16:01

      Ну по поводу классов смотрите чуть выше.


      1. DonVietnam
        31.08.2022 17:07

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


        1. rock
          31.08.2022 17:14
          +1

          Эти компоненты добавили не позже классов, а вместе с ними (а что-то, вроде возможности установки прототипа существующего объекта, было задолго до них - де факто, но не в стандарте). И это делает классы синтаксическим сахаром, смотрим хотя бы на определение из педивикии

          Под «синтаксическим сахаром» понимается любой имеющийся в языке программирования синтаксический элемент, механизм, способ описания, который дублирует другой, имеющийся в языке элемент или механизм, но является более удобным в использовании, или более краток, или выглядит естественнее, или более привычен (похож на аналогичные элементы других языков), или просто лучше воспринимается при чтении программы человеком.


  1. TAZAQ
    31.08.2022 19:27
    +1

    Обколются своими прототипами, а потом со своими полюшенами трахаются