Внедрение приватных полей в JavaScript

Архивный материал Mozilla (июнь 2021 г.). Указанные версии, результаты и планы относятся к публикации источника. Первоисточник.

Реализация приватных полей в JavaScript

Эта запись дублирует материал из блога Мэттью Годе (Matthew Gaudet)

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

Приватные поля — пример того, где язык спецификации и реальность реализации расходятся, по крайней мере в SpiderMonkey — движке JavaScript, обеспечивающем работу Firefox. Чтобы понять лучше, я объясню, что такое приватные поля, приведу несколько моделей для их понимания и расскажу, почему наша реализация отличается от формулировок спецификации.

Приватные поля — это языковая возможность, добавляемая в JavaScript через процесс предложения в TC39, как часть предложения по полям класса; это предложение находится на этапе 4 в процессе TC39. Мы выпустим приватные поля и приватные методы в Firefox 90.

Предложение по приватным полям вводит строгую концепцию «приватного состояния» в язык. В следующем примере #x может быть доступно только экземплярам класса A:

(в тексте исходной статьи был приведён кодовый пример, показывающий приватное поле #x внутри класса A)

Это означает, что вне класса доступ к этому полю невозможен. В отличие от публичных полей, как показывает следующий пример:

(в исходном тексте был пример с публичным полем)

Даже различные другие инструменты, которые предоставляет JavaScript для исследования объектов, не могут получить доступ к приватным полям (например, Object.getOwnProperty{Symbols,Names} не перечисляют приватные поля; нельзя использовать Reflect.get, чтобы получить к ним доступ).

При обсуждении возможности в JavaScript часто играют роль три разных аспекта: мысленная модель, спецификация и реализация.

Мысленная модель даёт высокоуровневое представление, которым, как правило, пользуются программисты. Спецификация, в свою очередь, даёт детализированные семантики, требуемые для функции. Реализация может выглядеть совершенно иначе по сравнению с текстом спецификации, при условии что семантика, определённая в спецификации, соблюдается.

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

Рассмотрим приватные поля с точки зрения этих трёх аспектов.

Самая базовая мысленная модель для приватных полей — это то, что написано в названии: поля, но приватные. В JavaScript поля становятся свойствами объектов, поэтому мысленная модель может быть «свойства, которые нельзя получить извне класса».

Однако при появлении прокси (Proxy) эта модель начинает давать сбои; специфицировать семантику «скрытых свойств» и прокси сложно (что происходит, когда Proxy пытается обеспечивать контроль доступа к свойствам, если приватные поля не должны быть видимы через Proxy? Могут ли подклассы получить доступ к приватным полям? Участвуют ли приватные поля в прототипном наследовании?). Чтобы сохранить желаемые свойства приватности, альтернативная мысленная модель стала той, которую использовал комитет.

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

Удивительно, но модель WeakMap — не то, как функция описана в спецификации, но она важна для понимания замысла дизайна. Позже я расскажу, где эта мысленная модель проявляется на практике.

Фактические изменения в спецификации даны в предложении по полям класса, конкретно — в изменениях текста спецификации. Я не буду разбирать весь текст спецификации, но выделю отдельные аспекты, чтобы прояснить различия между текстом спецификации и реализацией.

Во-первых, спецификация вводит понятие [[PrivateName]], которое представляет собой глобально уникальный идентификатор поля. Эта глобальная уникальность нужна, чтобы два класса не могли получить доступ к полям друг друга только потому, что у них одинаковые имена.

Спецификация также добавляет новое «внутреннее слотовое» состояние на уровне спецификации, ассоциированное с объектом, называемое [[PrivateFieldValues]] для всех объектов. [[PrivateFieldValues]] — это список записей вида:

(в оригинале был указан формат записей)

Чтобы манипулировать этим списком, спецификация добавляет четыре новых алгоритма:

(в оригинале перечислены алгоритмы PrivateFieldAdd, PrivateFieldGet, PrivateFieldSet и т. п.)

Эти алгоритмы в основном работают ожидаемо: PrivateFieldAdd добавляет запись в список (однако, чтобы по возможности рано выдавать ошибки, если запись с совпадающим PrivateName уже существует, будет выброшен TypeError — позже показано, как это может произойти). PrivateFieldGet извлекает значение из списка по заданному PrivateName и т. д.

Когда я впервые прочитал спецификацию, меня удивило, что PrivateFieldAdd может выбрасывать исключение. Поскольку он вызывается только из конструктора для создаваемого объекта, я ожидал, что объект будет свежесоздан и беспокоиться о том, что поле уже существует, не нужно.

Оказалось, что это возможно — побочный эффект обработки спецификацией значений, возвращаемых конструктором. Для наглядности приведён пример, показанный Андре Баргуллом (André Bargull), который демонстрирует это в действии.

Stamper — класс, который может «штамповать» своё приватное поле на любой объект:

(в оригинале был приведён пример класса, который присваивает приватное поле другим объектам)

Это означает, что когда мы добавляем приватные поля к объекту, мы не можем предполагать, что их там нет. Именно здесь вступает в силу проверка существования в PrivateFieldAdd.

Эта возможность штамповать приватные поля на произвольные объекты взаимодействует с моделью WeakMap. Например, поскольку вы можете штамповать приватные поля на любой объект, вы также можете штамповать приватное поле на запечатанный (sealed) объект:

Если представить приватные поля как обычные свойства, это неудобно, потому что вы модифицируете объект, который программист намеренно запечатал для предотвращения дальнейших изменений. Однако в модели WeakMap это полностью приемлемо, потому что вы используете запечатанный объект только как ключ в WeakMap.

PS: То, что вы можете штамповать приватные поля на произвольные объекты, не означает, что так нужно делать: пожалуйста, не делайте этого.

При реализации спецификации существует напряжение между выполнением буквы спецификации и внесением изменений ради улучшения реализации в каком‑то измерении.

Где возможно реализовать шаги спецификации напрямую, мы предпочитаем это делать — так поддерживать код проще при изменениях спецификации. SpiderMonkey следует этой практике во многих местах. Вы увидите участки кода, которые являются транскрипцией алгоритмов спецификации с номерами шагов в комментариях. Следование букве спецификации также полезно, когда спецификация очень сложна и небольшие отклонения могут привести к рискам совместимости.

Иногда, однако, есть веские причины отклониться от языка спецификации. Реализации JavaScript были доведены до высокой производительности годами оптимизаций, и применено много трюков для достижения этой производительности. Иногда пересмотреть часть спецификации в терминах уже написанного и оптимизированного кода — правильный выбор, потому что новый код тогда унаследует характеристики производительности уже существующего кода.

Язык спецификации для PrivateNames уже во многом совпадает с семантикой Symbol, который уже существует в SpiderMonkey. Поэтому добавить PrivateNames как специальный вид Symbol — достаточно простое решение.

Согласно спецификации, реализация приватных полей могла бы добавить дополнительный скрытый слот ко всем объектам SpiderMonkey, содержащий ссылку на список пар {PrivateName, Value}. Однако прямая реализация этого подхода имеет ряд явных недостатков:

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

Альтернативный вариант — отклониться от буквального следования спецификации и реализовать только семантику, а не сами алгоритмы спецификации. В большинстве случаев приватные поля действительно можно воспринимать как специальные свойства объектов, скрытые от внешней рефлексии или интроспекции вне класса.

Если мы моделируем приватные поля как свойства, а не как отдельный боковой список, мы можем воспользоваться тем, что манипуляция свойствами уже крайне оптимизирована в движке JavaScript.

Однако свойства подвержены рефлексии. Поэтому, если приватные поля моделировать как свойства объектов, нужно гарантировать, что API рефлексии не будут их раскрывать и нельзя будет получить к ним доступ через Proxies.

В SpiderMonkey мы выбрали реализацию приватных полей как скрытых свойств, чтобы воспользоваться всем оптимизированным механизмом работы со свойствами, уже существующим в движке. Когда я начинал реализовывать эту функцию, Андре Баргулл — длительный участник разработки SpiderMonkey — передал мне серию патчей, в которых уже была проделана значительная часть работы по приватным полям; я очень благодарен ему за это.

Используя наши специальные символы PrivateName, мы фактически десахаризуем приватные поля в скрытые свойства.

У приватных полей, однако, несколько иная семантика по сравнению с обычными свойствами. Они спроектированы так, чтобы выдавать ошибки в случаях, которые, как ожидается, являются программными ошибками, вместо того чтобы молча их принимать. Например:

  • Доступ к свойству на объекте, у которого это свойство отсутствует, возвращает undefined. Приватные поля по спецификации должны бросать TypeError при попытке доступа (PrivateFieldGet).
  • Установка свойства на объекте, у которого это свойство отсутствует, просто добавляет свойство. Приватные поля должны бросать TypeError при попытке установки (PrivateFieldSet).
  • Добавление приватного поля в объект, которое уже имеет такое поле, также бросает TypeError в PrivateFieldAdd. См. «трюк с переопределением конструктора» выше для примера, как это может произойти.

Чтобы учесть эти отличия семантики, мы модифицировали генерацию байткода для обращений к приватным полям. Мы добавили новый байткод CheckPrivateField, который проверяет, что объект имеет корректное состояние для данного приватного поля. Это означает выброс исключения, если свойство отсутствует или присутствует, в зависимости от операции Get/Set/Add. CheckPrivateField генерируется непосредственно перед использованием обычного пути «вычисляемого имени свойства» (тот путь, который используется для A[someKey]).

CheckPrivateField спроектирован так, чтобы мы могли легко реализовать inline‑кеш (IC) с помощью CacheIR. Поскольку мы храним приватные поля как свойства, мы можем использовать Shape объекта в качестве охранного условия (guard) и просто возвращать соответствующее логическое значение. Shape объекта в SpiderMonkey определяет, какие у объекта есть свойства и где они расположены в хранилище объекта. Объекты с одинаковой Shape гарантированно имеют одинаковые свойства, и это идеальная проверка для IC в CheckPrivateField.

Другие изменения в движке включали исключение приватных полей из протокола перечисления свойств и разрешение расширения запечатанных объектов, если мы добавляем приватное поле.

Прокси представили новую задачу. Конкретно, используя класс Stamper выше, вы можете добавить приватное поле напрямую к Proxy:

(в исходном тексте был пример присваивания приватного поля прокси-объекту)

Это меня сперва удивило. Я ожидал, что, как и другие операции, добавление приватного поля будет проходить через прокси к целевому объекту. Однако, когда я усвоил мысленную модель WeakMap, этот пример стал понятнее. Трюк в том, что в модели WeakMap ключом в #x WeakMap выступает сам Proxy, а не целевой объект.

Такая семантика создавала проблему для выбранного способа реализации приватных полей как скрытых свойств, потому что прокси в SpiderMonkey — это сильно специализированные объекты, в которых нет места для произвольных свойств. Чтобы поддержать этот кейс, мы добавили в прокси новый зарезервированный слот для «expando» объекта. Expando — это объект, выделяемый лениво, который служит держателем для динамически добавляемых свойств на прокси. Такой паттерн уже используется для DOM‑объектов, которые обычно реализованы как C++ объекты без места для дополнительных свойств. Поэтому если вы пишете document.foo = «hi», это выделяет expando‑объект для document и помещает туда свойство foo и его значение. Возвращаясь к приватным полям: когда #x обращается к Proxy, код прокси знает, что нужно искать это свойство в expando‑объекте.

Приватные поля — пример реализации языковой возможности JavaScript, где прямая реализация спецификации «как написано» была бы менее производительной, чем переработка спецификации в терминах уже оптимизированных примитивов движка. Тем не менее сама эта переработка может требовать решения задач, которых нет в спецификации.

В итоге я довольно доволен принятыми решениями по реализации приватных полей и рад видеть, что эта возможность наконец появилась!

Особая благодарность Андре Баргуллу, который предоставил первый набор патчей и проложил отличный путь для меня. Его работа значительно упростила завершение приватных полей, поскольку он уже проделал большую работу по принятию решений.

Джейсон Орендорфф был отличным и терпеливым наставником в ходе работы над этой реализацией, включая две отдельные реализации байткода для приватных полей и две отдельные реализации поддержки прокси.

Спасибо Каролайн Каллен и Эйану Ирланду за помощь в прочтении черновиков этой записи, и Стиву Финку за исправление множества опечаток.

Спасибо! Пожалуйста, проверьте ваш почтовый ящик, чтобы подтвердить подписку.

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

Комментарий читателя: «Спасибо за разбор. Очень интересно узнать о внутреннем механизме. Я не знал, что трюк с конструктором вообще возможен. Интересно, сколько людей действительно его используют и почему не изменить спецификацию, чтобы лучше соответствовать наиболее вероятному использованию.»

Мой ответ на это: я изучал причины существования этого поведения, когда обнаружил его, и писал об этом; по сути всё это связано с попытками сохранить совместимость с некоторыми паттернами, используемыми до введения class. Комментарии к этой статье закрыты.