Все мы давным давно хотим нормальную инкапсуляцию в JS, которую можно было бы использовать без лишних телодвижений. А ещё мы хотим удобные конструкции для объявления свойств класса. И, напоследок, мы хотим что бы все эти фичи в языке появились так, что бы не сломать уже существующие приложения.
Казалось бы, вот оно счастье: class-fields-proposal, который спутся долгие годы мучений коммитета tc39 таки добрался до stage 3
и даже получил реализацию в хроме.
Честно говоря, я бы очень хотел написать статью просто о том, почему стоит пользоваться новой фишкой языка и как это сделать, но, к сожалению, статья будет совсем не об этом.
Описание текущего пропозала
Я не буду здесь повторять оригинальное описание, ЧаВо и изменения в спецификации, а лишь кратко изложу основные моменты.
Поля класса
Объявление полей и использование их внутри класса:
class A {
x = 1;
method() {
console.log(this.x);
}
}
Доступ к полям вне класса:
const a = new A();
console.log(a.x);
Казалось бы всё очевидно и мы уже многие годы пользуемся этим синтаксисом с помощью Babel и TypeScript.
Только есть нюанс. Этот новый синтаксис использует [[Define]]
, а не [[Set]]
семантику, с которой мы жили всё это время.
На практике это означает, что код выше не равен этому:
class A {
constructor() {
this.x = 1;
}
method() {
console.log(this.x);
}
}
А на самом деле эвивалентен вот этому:
class A {
constructor() {
Object.defineProperty(this, "x", {
configurable: true,
enumerable: true,
writable: true,
value: 1
});
}
method() {
console.log(this.x);
}
}
И, хотя для примера выше оба подхода делают, по сути, одно и то же, это ОЧЕНЬ СЕРЬЁЗНОЕ отличие, и вот почему:
Допустим у нас есть такой родительский класс:
class A {
x = 1;
method() {
console.log(this.x);
}
}
На его основе мы создали другой:
class B extends A {
x = 2;
}
И спользовали его:
const b = new B();
b.method(); // это выведет 2 в консоль
После чего по каким-либо причинам класс A
был изменён, казалось бы, обратно-совместимым способом:
class A {
_x = 1; // для упрощения, опустим тот момент, что в публичном интерфейсе появилась новое свойство
get x() { return this._x; };
set x(val) { return this._x = val; };
method() {
console.log(this._x);
}
}
И для [[Set]]
семантики это действительно обратно-совместимое изменение, но не для [[Define]]
. Теперь вызов b.method()
выведет в консоль 1
вместо 2
. А произойдёт это потому что Object.defineProperty
переопределяет дексриптор свойства и соответственно гетер/сетер из класса A
вызваны не будут. По сути, в дочернем классе мы затенили свойство x
родителя, аналогично тому как мы можем сделать это в лексическом скоупе:
const x = 1;
{
const x = 2;
}
Правда, в этом случае нас спасёт линтер с его правилами no-shadowed-variable
/no-shadow
, но вероятность того, что кто-то сделает no-shadowed-class-field
, стремится к нулю.
Кстати, буду благодарен за более удачный русскоязычный термин для
shadowed
.
Несмотря на всё сказанное выше, я не являюсь непримеримым противником новой семантики (хотя и предпочёл бы другую), потому что у неё есть и свои положительные стороны. Но, к сожалению, эти плюсы не перевешивают самый главный минус — мы уже много лет используем [[Set]]
семантику, потому что именно она используеться в babel6
и TypeScript
, по умолчанию.
Правда, стоит заметить, что в
babel7
дефолтное значение было изменено.
Больше оригинальных дисскусий на эту тему можно прочитать здесь и здесь.
Приватные поля
А теперь мы перейдём к самой спорной части этого пропозала. Настолько спорной, что:
- несмотря на то, что он уже реализован в Chrome Canary и публичные поля уже включены по умолчанию, приватные всё ещё за флагом;
- несмотря на то, что изначальный пропозал для приватных полей был объеденён с нынешним, до сих пор создаются запросы на отделение этих двух фич (например раз, два, три и четыре);
- даже некоторые члены комитета (например Allen Wirfs-Brock и Kevin Smith) высказываються против и предлагают альтернативы, несмотря на stage3;
- этот пропозал поставил рекорд по количеству issues — 129 в текущем репозитории + 96 в оригинальном, против 126 для BigInt, при чём у рекордсмена это в основном негативные комментарии;
- пришлось создать отдельный тред с попыткой хоть как-то суммировать все претензии к нему;
- пришлось написать отдельный ЧаВо, который опрадывает эту часть
правда, из-за довольно слабой аргументации, появились и такие обсуждения (раз, два)
- я, лично, тратил всё своё свободное время (а иногда и рабочее) на протяжении длительного периода времени на то, что бы во всём разобраться и даже найти объяснение почему он таков или предложить подходящую альтернативу;
- в конце концов, я решил написать эту обзорную статью.
Объявляются приватные поля следующим образом:
class A {
#priv;
}
А доступ к ним осуществляется так:
class A {
#priv = 1;
method() {
console.log(this.#priv);
}
}
Я даже не буду поднимать тему того, что ментальная модель, стоящая за этим, не очень интуитивна (this.#priv !== this['#priv']
), не использует уже зарезервированные слова private
/protected
(что обязательно вызовет дополнительную боль для TypeScript-разработчиков), непонятно как это расширять для других модификаторов доступа, и синтаксис сам по себе не очень красив. Хотя всё это и было изначальной причиной, толкнувшей меня на более глубокое исследование и участие в обсуждениях.
Это всё касается синтаксиса, где очень сильны субъективные эстэтические предпочтения. И с этим можно было бы жить и со временем привыкнуть. Если бы не одно но: тут существует очень существенная проблема семантики...
Cемантика WeakMap
Давайте взглянем на то, что стоит за за существующим пропозалом. Мы можем переписать пример сверху с инкапсуляцией и без использования нового синтаксиса, но сохраняя семантику текущего пропозала:
const privatesForA = new WeakMap();
class A {
constructor() {
privatesForA.set(this, {});
privatesForA.get(this).priv = 1;
}
method() {
console.log(privatesForA.get(this).priv);
}
}
Кстати, на основе этой семантики один из членов коммитета даже построил небольшую утилити библиотеку, которая позволяет использовать приватное состояние уже сейчас, для того, что бы показать, что такая функциональность слишком переоценена комитетом. Отформатированный код занимает всего 27 строк.
В целом всё довольно неплохо, мы получаем hard-private
, который никак нельзя достать/перехватить/отследить из внешнего кода и при этом можем получить доступ к приватным полям другого инстанса того же класса, например вот так:
isEquals(obj) {
return privatesForA.get(this).id === privatesForA.get(obj).id;
}
Что ж, это очень удобно, за исключением того факта, что эта семантика, помимо самой инкапсуляции, включает в себя ещё и brand-checking
(можете не гуглить, что это такое — вряд ли вы найдёте релевантную информацию).
brand-checking
— это противоположность duck-typing
, в том смысле, что она проверяет не публичный интефрейс объекта, а факт того, что объект был построен с помощью доверенного кода.
У такой проверки, на самом деле, есть определённая область применения — она, в основном, связана с безопасностью вызова недоверенного кода в едином адресном пространстве с доверенным и возможностью обмена объектами напрямую без сериализации.
Хотя некоторые инженеры считают это необходимой частью правильной инкапсуляции.
Несмотря на то, что это довольно любопытная возможность, которая тесно связано с патерном Мембрана
(краткое и более длинное описание), Realms
-пропозалом и научными работами в области Computer Science, которыми занят Mark Samuel Miller (он тоже член комитета), по моему опыту, в практике большинства разработчиков это почти никогда не встречается.
Я, кстати говоря, таки сталкивался с мембраной (правда тогда не знал, что это), когда переписывал vm2 под свои нужды.
Проблема brand-checking
Как уже было сказано ранее, brand-checking
— это противоположность duck-typing
. На практие это означает, что имея такой код:
const brands = new WeakMap();
class A {
constructor() {
brands.set(this, {});
}
method() {
return 1;
}
brandCheckedMethod() {
if (!brands.has(this)) throw 'Brand-check failed';
console.log(this.method());
}
}
brandCheckedMethod
может быть вызван только с инстансом класса A
и даже если таргетом выступает объект, сохраняющий инварианты этого класса, этот метод выкинет исключение:
const duckTypedObj = {
method: A.prototype.method.bind(duckTypedObj),
brandCheckedMethod: A.prototype.brandCheckedMethod.bind(duckTypedObj),
};
duckTypedObj.method(); // тут исключения не будет и метод вернёт 1
duckTypedObj.brandCheckedMethod(); // а здесь будет выброшенно исключение
Очевидно, что этот пример довольно синтетический и польза подобного duckTypedObj
сомнительна, до тех пор, пока мы не вспоминаем про Proxy
.
Один из очень важных сценариев использования прокси — это метапрограммирование. Для того, что бы прокси выполняла всю необходимую полезную работу, методы объектов, которые обёрнуты с помощью прокси должны выполняться в контексте прокси, а не в контексте таргета, т.е.:
const a = new A();
const proxy = new Proxy(a, {
get(target, p, receiver) {
const property = Reflect.get(target, p, receiver);
doSomethingUseful('get', retval, target, p, receiver);
return (typeof property === 'function')
? property.bind(proxy)
: property;
}
});
Вызов proxy.method();
сделает полезную работу объявленную в прокси и вернёт 1
, в то время как вызов proxy.brandCheckedMethod();
вместо того, что бы дважды сделать полезную работу из прокси, выкинет исключение, потому что a !== proxy
, а значит brand-check
не прошёл.
Да, мы можем выполнять методы/функции в котексте реального таргета, а не прокси, и для некоторых сценариев этого достаточно (например для реализации паттерна Мембрана
), но этого не хватит для всех случаев (например для реализации реактивных свойств: MobX 5 уже использует прокси для этого, Vue.js и Aurelia эксперементируют с этим подходом для следующих релизов).
В целом, до тех пор пока brand-check
нужно делать явно, это не проблема — разработчик просто осознанно должен решить какой trade-off он совершает и нужен ли он ему, более того в случае явного brand-check
можно его реализовать таким образом, что бы ошибка не выбрасывалась на довереных прокси.
К сожалению, текущий пропозал лишает нас этой гибкости:
class A {
#priv;
method() {
this.#priv; // в этой точке brand-check происходит ВСЕГДА
}
}
Такой method
всегда будет выбрасывать исключение, если вызван не в контексте объекта построенного с помощью конструктора A
. И самое ужасное, что brand-check
здесь неявный и смешан с другой функциональностью — инкапсуляцией.
В то время как инкапсуляция
почти необходима для любого кода, brand-check
имеет довольно узкий круг применения. А объединение их в один синтаксис приведёт к тому, что в пользовательском коде появиться очень много неумышленных brand-check
ов, когда разработчик намеривался только скрыть детали реализации.
А слоган, который используют для продвижения этого пропозала # is the new _
ситуацию только усугубляет.
Можете так же почитать подробное обсуждение того, как существующий пропозал ломает прокси. В дискуссии высказались один из разработчиков Aurelia и автор Vue.js.
Так же мой комментарий, более подробно описывающий разницу между разными сценариями использования прокси, может показатся кому-то интересным. Как и в целом всё обсуждение связи приватных полей и мембраны.
Альтернативы
Все эти обсуждения имели бы мало смысла, если бы не существовало альтернатив. К сожалению, ни один альтернативный пропозал не попал даже в stage1, и, как следствие, ни имел даже шансов быть достаточно проработанным. Тем не менее, я перечислю здесь альтернативы, которые так или иначе решают проблемы описанные выше.
- Symbol.private — альтернативный пропозал одного из членов комитета.
- Решает все выше перечисленные проблемы (хотя может имеет и свои, но, в виду отсутствия активной работы над ним, найти их тяжело)
- в очередной раз был откинут на последней встрече комитета по причине отсутствия встроенного
brand-check
, проблем с паттерном мембраны (хотя вот это + это предлагают адекватное решение) и отсутствием удобного синтаксиса - удобный синтаксис можно построить поверх самого пропозала, как показано мной здесь и здесь
- Classes 1.1 — более ранний пропозал от того же автора
- Использование private как объекта
Вместо заключения
По тону статьи, наверное, может показатся, что я осуждаю комитет — это не так. Мне лишь кажется, что за те годы (в зависимости от того, что брать точкой отсчёта, это могут быть даже десятилетия), которые комитет работал над инкапсуляцией в JS, многое в индустрии изменилось, а взгляд мог замылиться, что привело к ложной растановке приоритетов.
Более того, мы, как комьюнити, давим на tc39 заставляя их выпускать фичи быстрее, при этом даём крайне мало фидбека на ранних стадиях пропозалов, обрушивая своё негодование только в тот момент, когда уже мало что можно изменить.
Есть мнение, что в данном случае процесс просто дал сбой.
После окунания в это с головой и общения с некоторыми представителями, я решил, что приложу все усилия, что бы не допустить повторения подобной ситуации — но я могу сделать немного (написать обзорную статью, сделать имплементацию stage1
пропозала в babel
и всего-то).
Но самое важное это обратная связь — поэтому я попросил бы вас принять участие в этом небольшом опросе. А я, в свою очередь, постараюсь его донести до комитета.
Автор: Igmat