Хабрахабр

«Class-fields-proposal» или «Что пошло не так в коммитете tc39»

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

Казалось бы, вот оно счастье: class-fields-proposal, который спутся долгие годы мучений коммитета tc39 таки добрался до stage 3 и даже получил реализацию в хроме.

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

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

Поля класса

Объявление полей и использование их внутри класса:

class A
}

Доступ к полям вне класса:

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); }
}

Теперь вызов b.method() выведет в консоль 1 вместо 2. И для [[Set]] семантики это действительно обратно-совместимое изменение, но не для [[Define]]. По сути, в дочернем классе мы затенили свойство x родителя, аналогично тому как мы можем сделать это в лексическом скоупе: А произойдёт это потому что Object.defineProperty переопределяет дексриптор свойства и соответственно гетер/сетер из класса A вызваны не будут.

const x = 1;
{ const x = 2;
}

Правда, в этом случае нас спасёт линтер с его правилами no-shadowed-variable/no-shadow, но вероятность того, что кто-то сделает no-shadowed-class-field, стремится к нулю.

Кстати, буду благодарен за более удачный русскоязычный термин для shadowed.

Но, к сожалению, эти плюсы не перевешивают самый главный минус — мы уже много лет используем [[Set]] семантику, потому что именно она используеться в babel6 и TypeScript, по умолчанию. Несмотря на всё сказанное выше, я не являюсь непримеримым противником новой семантики (хотя и предпочёл бы другую), потому что у неё есть и свои положительные стороны.

Правда, стоит заметить, что в babel7 дефолтное значение было изменено.

Больше оригинальных дисскусий на эту тему можно прочитать здесь и здесь.

Приватные поля

Настолько спорной, что: А теперь мы перейдём к самой спорной части этого пропозала.

  1. несмотря на то, что он уже реализован в Chrome Canary и публичные поля уже включены по умолчанию, приватные всё ещё за флагом;
  2. несмотря на то, что изначальный пропозал для приватных полей был объеденён с нынешним, до сих пор создаются запросы на отделение этих двух фич (например раз, два, три и четыре);
  3. даже некоторые члены комитета (например Allen Wirfs-Brock и Kevin Smith) высказываються против и предлагают альтернативы, несмотря на stage3;
  4. этот пропозал поставил рекорд по количеству issues — 129 в текущем репозитории + 96 в оригинальном, против 126 для BigInt, при чём у рекордсмена это в основном негативные комментарии;
  5. пришлось создать отдельный тред с попыткой хоть как-то суммировать все претензии к нему;
  6. пришлось написать отдельный ЧаВо, который опрадывает эту часть

    правда, из-за довольно слабой аргументации, появились и такие обсуждения (раз, два)

  7. я, лично, тратил всё своё свободное время (а иногда и рабочее) на протяжении длительного периода времени на то, что бы во всём разобраться и даже найти объяснение почему он таков или предложить подходящую альтернативу;
  8. в конце концов, я решил написать эту обзорную статью.

Объявляются приватные поля следующим образом:

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 происходит ВСЕГДА }
}

И самое ужасное, что brand-check здесь неявный и смешан с другой функциональностью — инкапсуляцией. Такой method всегда будет выбрасывать исключение, если вызван не в контексте объекта построенного с помощью конструктора A.

А объединение их в один синтаксис приведёт к тому, что в пользовательском коде появиться очень много неумышленных brand-checkов, когда разработчик намеривался только скрыть детали реализации.
А слоган, который используют для продвижения этого пропозала # is the new _ ситуацию только усугубляет. В то время как инкапсуляция почти необходима для любого кода, brand-check имеет довольно узкий круг применения.

В дискуссии высказались один из разработчиков Aurelia и автор Vue.js. Можете так же почитать подробное обсуждение того, как существующий пропозал ломает прокси.

Как и в целом всё обсуждение связи приватных полей и мембраны. Так же мой комментарий, более подробно описывающий разницу между разными сценариями использования прокси, может показатся кому-то интересным.

К сожалению, ни один альтернативный пропозал не попал даже в stage1, и, как следствие, ни имел даже шансов быть достаточно проработанным. Все эти обсуждения имели бы мало смысла, если бы не существовало альтернатив. Тем не менее, я перечислю здесь альтернативы, которые так или иначе решают проблемы описанные выше.

  1. Symbol.private — альтернативный пропозал одного из членов комитета.
    1. Решает все выше перечисленные проблемы (хотя может имеет и свои, но, в виду отсутствия активной работы над ним, найти их тяжело)
    2. в очередной раз был откинут на последней встрече комитета по причине отсутствия встроенного brand-check, проблем с паттерном мембраны (хотя вот это + это предлагают адекватное решение) и отсутствием удобного синтаксиса
    3. удобный синтаксис можно построить поверх самого пропозала, как показано мной здесь и здесь
  2. Classes 1.1 — более ранний пропозал от того же автора
  3. Использование private как объекта

Мне лишь кажется, что за те годы (в зависимости от того, что брать точкой отсчёта, это могут быть даже десятилетия), которые комитет работал над инкапсуляцией в JS, многое в индустрии изменилось, а взгляд мог замылиться, что привело к ложной растановке приоритетов. По тону статьи, наверное, может показатся, что я осуждаю комитет — это не так.

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

Есть мнение, что в данном случае процесс просто дал сбой.

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

А я, в свою очередь, постараюсь его донести до комитета. Но самое важное это обратная связь — поэтому я попросил бы вас принять участие в этом небольшом опросе.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть