Архивный материал 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. Комментарии к этой статье закрыты.
