- PVSM.RU - https://www.pvsm.ru -

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

Все мы давным давно хотим нормальную инкапсуляцию в 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].

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

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

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

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

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

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

class A {
    #priv;
}

А доступ к ним осуществляется так:

class A {
    #priv = 1;

    method() {
        console.log(this.#priv);
    }
}

Я даже не буду поднимать тему того, что ментальная модель, стоящая за этим, не очень интуитивна (this.#priv !== this['#priv']), не использует уже зарезервированные слова private/protected (что обязательно вызовет дополнительную боль для TypeScript-разработчиков), непонятно как это расширять для других модификаторов доступа [30], и синтаксис сам по себе не очень красив. Хотя всё это и было изначальной причиной, толкнувшей меня на более глубокое исследование и участие в обсуждениях.

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

Cемантика 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, и, как следствие, ни имел даже шансов быть достаточно проработанным. Тем не менее, я перечислю здесь альтернативы, которые так или иначе решают проблемы описанные выше.

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

Вместо заключения

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