- PVSM.RU - https://www.pvsm.ru -
Все мы давным давно хотим нормальную инкапсуляцию в JS, которую можно было бы использовать без лишних телодвижений. А ещё мы хотим удобные конструкции для объявления свойств класса. И, напоследок, мы хотим что бы все эти фичи в языке появились так, что бы не сломать уже существующие приложения.
Казалось бы, вот оно счастье: class-fields-proposal [1], который спутся долгие годы мучений коммитета tc39 [2] таки добрался до stage 3 и даже получил реализацию в хроме [3].
Честно говоря, я бы очень хотел написать статью просто о том, почему стоит пользоваться новой фишкой языка и как это сделать, но, к сожалению, статья будет совсем не об этом.
Я не буду здесь повторять оригинальное описание [1], ЧаВо [4] и изменения в спецификации [5], а лишь кратко изложу основные моменты.
Объявление полей и использование их внутри класса:
class A {
x = 1;
method() {
console.log(this.x);
}
}
Доступ к полям вне класса:
const a = new A();
console.log(a.x);
Казалось бы всё очевидно и мы уже многие годы пользуемся этим синтаксисом с помощью Babel [6] и TypeScript [7].
Только есть нюанс. Этот новый синтаксис использует [[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 [8]/no-shadow [9], но вероятность того, что кто-то сделает no-shadowed-class-field, стремится к нулю.
Кстати, буду благодарен за более удачный русскоязычный термин для
shadowed.
Несмотря на всё сказанное выше, я не являюсь непримеримым противником новой семантики (хотя и предпочёл бы другую), потому что у неё есть и свои положительные стороны. Но, к сожалению, эти плюсы не перевешивают самый главный минус — мы уже много лет используем [[Set]] семантику, потому что именно она используеться в babel6 и TypeScript, по умолчанию.
Правда, стоит заметить, что в
babel7дефолтное значение было изменено [10].
Больше оригинальных дисскусий на эту тему можно прочитать здесь [11] и здесь [12].
А теперь мы перейдём к самой спорной части этого пропозала. Настолько спорной, что:
правда, из-за довольно слабой аргументации, появились и такие обсуждения (раз [26], два [27])
Объявляются приватные поля следующим образом:
class A {
#priv;
}
А доступ к ним осуществляется так:
class A {
#priv = 1;
method() {
console.log(this.#priv);
}
}
Я даже не буду поднимать тему того, что ментальная модель, стоящая за этим, не очень интуитивна (this.#priv !== this['#priv']), не использует уже зарезервированные слова private/protected (что обязательно вызовет дополнительную боль для TypeScript-разработчиков), непонятно как это расширять для других модификаторов доступа [30], и синтаксис сам по себе не очень красив. Хотя всё это и было изначальной причиной, толкнувшей меня на более глубокое исследование и участие в обсуждениях.
Это всё касается синтаксиса, где очень сильны субъективные эстэтические предпочтения. И с этим можно было бы жить и со временем привыкнуть. Если бы не одно но: тут существует очень существенная проблема семантики...
WeakMapДавайте взглянем на то, что стоит за за существующим пропозалом. Мы можем переписать пример сверху с инкапсуляцией и без использования нового синтаксиса, но сохраняя семантику текущего пропозала:
const privatesForA = new WeakMap();
class A {
constructor() {
privatesForA.set(this, {});
privatesForA.get(this).priv = 1;
}
method() {
console.log(privatesForA.get(this).priv);
}
}
Кстати, на основе этой семантики один из членов коммитета даже построил небольшую утилити библиотеку [31], которая позволяет использовать приватное состояние уже сейчас, для того, что бы показать, что такая функциональность слишком переоценена комитетом. Отформатированный код занимает всего 27 строк.
В целом всё довольно неплохо, мы получаем hard-private, который никак нельзя достать/перехватить/отследить из внешнего кода и при этом можем получить доступ к приватным полям другого инстанса того же класса, например вот так:
isEquals(obj) {
return privatesForA.get(this).id === privatesForA.get(obj).id;
}
Что ж, это очень удобно, за исключением того факта, что эта семантика, помимо самой инкапсуляции, включает в себя ещё и brand-checking (можете не гуглить, что это такое — вряд ли вы найдёте релевантную информацию).
brand-checking — это противоположность duck-typing, в том смысле, что она проверяет не публичный интефрейс объекта, а факт того, что объект был построен с помощью доверенного кода.
У такой проверки, на самом деле, есть определённая область применения — она, в основном, связана с безопасностью вызова недоверенного кода в едином адресном пространстве с доверенным и возможностью обмена объектами напрямую без сериализации.
Хотя некоторые инженеры считают это необходимой частью правильной инкапсуляции.
Несмотря на то, что это довольно любопытная возможность, которая тесно связано с патерном Мембрана (краткое [32] и более длинное [33] описание), Realms-пропозалом [34] и научными работами в области Computer Science, которыми занят Mark Samuel Miller [35] (он тоже член комитета), по моему опыту, в практике большинства разработчиков это почти никогда не встречается.
Я, кстати говоря, таки сталкивался с мембраной (правда тогда не знал, что это), когда переписывал vm2 [36] под свои нужды.
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 [37].
Один из очень важных сценариев использования прокси — это метапрограммирование. Для того, что бы прокси выполняла всю необходимую полезную работу, методы объектов, которые обёрнуты с помощью прокси должны выполняться в контексте прокси, а не в контексте таргета, т.е.:
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 [38] уже использует прокси для этого, Vue.js [39] и Aurelia [40] эксперементируют с этим подходом для следующих релизов).
В целом, до тех пор пока 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 _ ситуацию только усугубляет.
Можете так же почитать подробное обсуждение того, как существующий пропозал ломает прокси [41]. В дискуссии высказались один из разработчиков Aurelia [42] и автор Vue.js [43].
Так же мой комментарий [44], более подробно описывающий разницу между разными сценариями использования прокси, может показатся кому-то интересным. Как и в целом всё обсуждение связи приватных полей и мембраны [45].
Все эти обсуждения имели бы мало смысла, если бы не существовало альтернатив. К сожалению, ни один альтернативный пропозал не попал даже в stage1, и, как следствие, ни имел даже шансов быть достаточно проработанным. Тем не менее, я перечислю здесь альтернативы, которые так или иначе решают проблемы описанные выше.
brand-check, проблем с паттерном мембраны (хотя вот это [47] + это [48] предлагают адекватное решение) и отсутствием удобного синтаксисаПо тону статьи, наверное, может показатся, что я осуждаю комитет — это не так. Мне лишь кажется, что за те годы (в зависимости от того, что брать точкой отсчёта, это могут быть даже десятилетия), которые комитет работал над инкапсуляцией в JS, многое в индустрии изменилось, а взгляд мог замылиться, что привело к ложной растановке приоритетов.
Более того, мы, как комьюнити, давим на tc39 заставляя их выпускать фичи быстрее, при этом даём крайне мало фидбека на ранних стадиях пропозалов, обрушивая своё негодование только в тот момент, когда уже мало что можно изменить.
Есть мнение [51], что в данном случае процесс просто дал сбой.
После окунания в это с головой и общения с некоторыми представителями, я решил, что приложу все усилия, что бы не допустить повторения подобной ситуации — но я могу сделать немного (написать обзорную статью, сделать имплементацию stage1 пропозала в babel и всего-то).
Но самое важное это обратная связь — поэтому я попросил бы вас принять участие в этом небольшом опросе. А я, в свою очередь, постараюсь его донести до комитета.
Автор: Igmat
Источник [52]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/297379
Ссылки в тексте:
[1] class-fields-proposal: https://github.com/tc39/proposal-class-fields
[2] коммитета tc39: https://github.com/tc39
[3] реализацию в хроме: https://www.chromestatus.com/feature/6001727933251584
[4] ЧаВо: https://github.com/tc39/proposal-class-fields/blob/master/PRIVATE_SYNTAX_FAQ.md
[5] изменения в спецификации: https://tc39.github.io/proposal-class-fields/
[6] Babel: https://www.npmjs.com/package/babel-plugin-transform-class-properties
[7] TypeScript: http://www.typescriptlang.org/docs/handbook/classes.html
[8] no-shadowed-variable: https://palantir.github.io/tslint/rules/no-shadowed-variable/
[9] no-shadow: https://eslint.org/docs/rules/no-shadow
[10] дефолтное значение было изменено: https://babeljs.io/docs/en/babel-plugin-proposal-class-properties
[11] здесь: https://github.com/tc39/proposal-class-fields/issues/151
[12] здесь: https://github.com/tc39/proposal-class-public-fields/issues/42
[13] изначальный пропозал для приватных полей: https://github.com/tc39/proposal-private-fields
[14] раз: https://github.com/tc39/proposal-class-fields/issues/144
[15] два: https://github.com/tc39/proposal-class-fields/issues/142
[16] три: https://github.com/tc39/proposal-class-fields/issues/148
[17] четыре: https://github.com/tc39/proposal-class-fields/pull/140#issuecomment-428585587
[18] Allen Wirfs-Brock: https://github.com/allenwb
[19] Kevin Smith: https://github.com/zenparsing
[20] предлагают альтернативы: http://tc39.github.io/tc39-notes/2018-09_sept-26.html#revisiting-private-symbols
[21] текущем репозитории: https://github.com/tc39/proposal-class-fields/issues
[22] оригинальном: https://github.com/tc39/proposal-private-fields/issues
[23] BigInt: https://github.com/tc39/proposal-bigint/issues
[24] негативные комментарии: https://github.com/tc39/proposal-class-fields/issues/100
[25] отдельный тред: https://github.com/tc39/proposal-class-fields/issues/150
[26] раз: https://github.com/tc39/proposal-class-fields/issues/133
[27] два: https://github.com/tc39/proposal-class-fields/issues/136
[28] найти объяснение: https://github.com/tc39/proposal-class-fields/issues/134
[29] подходящую альтернативу: https://github.com/tc39/proposal-class-fields/issues/149
[30] других модификаторов доступа: https://github.com/tc39/proposal-class-fields/issues/122
[31] утилити библиотеку: https://github.com/zenparsing/hidden-state
[32] краткое: https://tvcutsem.github.io/js-membranes
[33] более длинное: https://tvcutsem.github.io/membranes
[34] Realms-пропозалом: https://github.com/tc39/proposal-realms
[35] Mark Samuel Miller: https://github.com/erights
[36] vm2: https://github.com/patriksimek/vm2
[37] Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
[38] MobX 5: https://github.com/mobxjs/mobx
[39] Vue.js: https://vuejs.org/
[40] Aurelia: https://aurelia.io/
[41] подробное обсуждение того, как существующий пропозал ломает прокси: https://github.com/tc39/proposal-class-fields/issues/106
[42] один из разработчиков Aurelia: https://github.com/EisenbergEffect
[43] автор Vue.js: https://github.com/yyx990803
[44] мой комментарий: https://github.com/tc39/proposal-class-fields/issues/158#issuecomment-432809666
[45] всё обсуждение связи приватных полей и мембраны: https://github.com/tc39/proposal-class-fields/issues/158
[46] Symbol.private: https://github.com/zenparsing/proposal-private-symbols
[47] вот это: https://github.com/tc39/proposal-class-fields/issues/158#issuecomment-432289884
[48] это: https://github.com/zenparsing/proposal-private-symbols/issues/7#issuecomment-424859518
[49] Classes 1.1: https://github.com/zenparsing/js-classes-1.1
[50] Использование private как объекта: https://github.com/tc39/proposal-class-fields/issues/90
[51] Есть мнение: https://github.com/tc39/proposal-class-fields/pull/140#issuecomment-428878848
[52] Источник: https://habr.com/post/428119/?utm_campaign=428119
Нажмите здесь для печати.