Закрытия против приватных переменных против #: что защищает каждый элемент в TypeScript и JavaScript
Private в TypeScript — это проверка на этапе компиляции, поля с хешем обеспечиваются на этапе выполнения, а замыкания захватывают лексический диапазон. Сравнительный обзор того, что делает каждый из этих механизмов.
Источники: Closures vs Private in TypeScript: If We Have private and #, Why Do We Still Need Closures?
В TypeScript существует ключевое слово private, а в JavaScript — символ #; поэтому возникает закономерный вопрос: зачем тогда всё ещё нужны замыкания для защиты переменных. Ответ кажется очевидным, пока вы не посмотрите, как ведёт себя JavaScript во время выполнения, и тогда всё становится интереснее. Замыкания появляются позже. Сначала давайте разберёмся, что на самом деле делает private в TypeScript?
private в TypeScript
Возьмём класс с одним приватным свойством:
class User {
private secret: string = "hidden";
}
Теперь создайте экземпляр и считайте свойство извне класса:
const me = new User();
console.log(me.secret);
Ключевая строка — вот эта:
console.log(me.secret);
Компилятор tsc отклоняет его с ошибкой, поэтому переменная кажется защищённой. Проблема в том, что private в основном является функцией типовой системы. Когда TypeScript преобразуется в JavaScript, это ключевое слово не сохраняется в качестве механизма конфиденциальности во время выполнения. Вот этот исходный код:
class User {
private secret: string = "hidden";
}
может быть выведен примерно так:
class User {
constructor() {
this.secret = "hidden";
}
}
На этом этапе JavaScript не имеет ни малейшего понятия о том, что secret должен был быть приватным, поскольку это обычное свойство. Следовательно:
const me = new User();
console.log(me.secret);
можно распечатать:
hidden
Это различие имеет значение. Признак private говорит компилятору о том, что код, использующий данный тип, не имеет права изменять свойство. Он не указывает на то, что к свойству нельзя получить доступ во время выполнения, и это совершенно разные требования.
Что меняет #
TypeScript также поддерживает встроенные приватные поля классов JavaScript:
class User {
#secret: string = "hidden";
}
Попытка сейчас прочитать значение поля извне завершается иначе:
const me = new User();console.log(me.#secret);
Это не просто предупреждение компилятора. Это поле также является приватным во время выполнения, поскольку синтаксис # создаёт настоящее приватное поле класса. Чтение его извне класса запрещено:
console.log(me.#secret);
И чтение простого свойства с тем же именем также не позволяет к нему обратиться:
console.log(me.secret);
поскольку #secret — это не обычное свойство с именем secret. Может возникнуть искушение представить его как строковый ключ:
me["#secret"]
Но это не сработает. # не является конвенцией именования, поскольку во время выполнения существует реальное понятие приватных полей. Следовательно, у нас есть два отдельных аспекта. Первый в основном обеспечивается системой типов:
private secret: string;
а второй — согласно семантике JavaScript:
#secret: string;
Где применяются замыкания
До появления приватных полей классов разработчикам всё равно требовались данные, к которым нельзя было просто получить доступ извне, и одним из самых мощных инструментов, уже имевшихся в языке, были замыкания. Вот функция-фабрика:
function createUser() {
let secret = "hidden";
Он возвращает объект, предоставляющий один метод доступа:
return {
getSecret() {
return secret;
}
};
}const user = createUser();console.log(user.getSecret());
Вызов этого доступообразователя выводит:
hidden
Обратите внимание, чего не хватает. Нет такого доступа к свойству:
user.secret
Возвращаемый объект даже не имеет свойства secret. Эта переменная находится в лексической среде, созданной при выполнении функции createUser(), а возвращаемая функция getSecret() сохраняет доступ к этой среде. В этом и заключается суть замыкания: функция сохраняет доступ к переменным из той области видимости, в которой она была создана, даже после завершения работы внешней функции. После этого вызова:
const user = createUser();
Внешняя функция завершена. Обычно ожидалось бы наличие её локальной переменной:
let secret = "hidden";
исчезнуть. Однако getSecret() всё равно может к нему обратиться, потому что он сохраняет ссылку на secret, откуда и происходит это название.
Частное состояние без его раскрытия
Закрытия могут хранить частное состояние без каких-либо видимых переменных. Хорошим примером является счётчик:
function myCounter() {
let count = 0;
return {
increment() {
count++;
}, getCount() {
return count;
}
};
}const counter = myCounter();counter.increment();
counter.increment();console.log(counter.getCount());
При двукратном вызове метода увеличения и последующем запросе к количеству выводится:
2
в то время как это не работает:
console.log(counter.count);
Потому что count не является свойством счётчика. Это переменная в захваченной лексической среде. Объект определяет поведение, в то время как фактическое состояние хранится в другом месте — это форма инкапсуляции, основанная на лексическом области видимости.
Является ли # просто лучше?
Не обязательно. Они решают схожие проблемы, но это не одна и та же функция. Вот версия с классом:
class User {
#secret = "hidden";
getSecret() {
return this.#secret;
}
}
В нём #secret представляет собой явное приватное состояние класса, принадлежащее объекту, и среда выполнения знает об этом. В версии с закрытием:
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
Эта переменная вовсе не является свойством объекта и находится в окружающей лексической среде. Механизмы их работы принципиально различаются. Поле # указывает на наличие у объекта приватного поля, тогда как замыкание означает, что функция может использовать состояние из своего окружающего области видимости. Именно это различие влияет на проектирование API.
Состояние, общее для нескольких функций
Замыкания предоставляют нечто большее, чем просто возможность сохранения конфиденциальности: несколько функций могут делиться одним и тем же фрагментом состояния. Примером может служить банковский счёт:
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
}, withdraw(amount) {
balance -= amount;
}, getBalance() {
return balance;
}
};
}
Использование выглядит так:
const account = createBankAccount(1000);
account.deposit(500);console.log(account.getBalance());
И результат следующий:
1500
Нет публичных свойств для чтения:
account.balance
deposit(), withdraw() и getBalance() все обращаются к одной и той же локальной переменной:
balance
Это одна из причин, по которой замыкания так важны в JavaScript. Они — не просто способ скрыть переменные, но и инструмент для сохранения состояния между вызовами функций.
Исправление первоначального предположения
Привлекательным кратким выводом является утверждение, что замыкания обеспечивают скрытый механизм выполнения, в то время как ключевые слова private и # лишь дают предупреждения на этапе компиляции. Это близко к истине, но неточно. Ключевое слово private в TypeScript в основном используется для проверки доступа на этапе компиляции, тогда как #private в JavaScript обеспечивает соблюдение принципа конфиденциальности во время выполнения. Замыкания не являются ни дополнительным ключевым словом, ни чисто этапом компиляции. Они возникают в результате лексического области видимости, которая позволяет функциям сохранять доступ к переменным своей окружающей среды. Это может способствовать реализации инкапсуляции, но замыкания имеют гораздо более широкое применение, чем просто обеспечение конфиденциальности. Пример фабрики функций показывает их использование без элементов конфиденциальности:
function createMultiplier(x) {
return function (y) {
return x * y;
};
}
const double = createMultiplier(2);console.log(double(10));
Здесь возвращаемая функция запоминает x, и это и есть замыкание. Тот же механизм используется для реализации приватного состояния, фабрик, обратных вызовов, обработчиков событий, мемоизации и многого другого.
Ментальная модель трех концепций
Для TypeScript ключевое слово private:
class User {
private secret = "hidden";
}
Считайте это тем, что система типов не позволяет вам обращаться к данному элементу. Для JavaScript #private:
class User {
#secret = "hidden";
}
Это означает, что среда выполнения рассматривает это поле как действительно приватное. Что касается замыкания:
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
Считайте это функцией, запоминающей переменные из области видимости, в которой она была создана. Именно это и является ключевым моментом. Закрытия не устарели с появлением #, потому что #private — это функция, предназначенная для приватных полей классов, тогда как закрытия являются следствием лексической области видимости и функций первого класса и служат множеству целей, не связанных с конфиденциальностью.
Как это работает на самом деле
Остаётся один тонкий момент. Было бы неверно утверждать, что private и # в конечном итоге превращаются в обычные свойства JavaScript. Это верно для private в TypeScript в обычном формате вывода, но не для нативных полей #private:
class User {
#secret = "hidden";
}
Здесь используется функция, доступная во время выполнения. В зависимости от цели компиляции TypeScript может сохранить данную синтаксис или привести его к более старой версии JavaScript, однако семантика языка по-прежнему требует поведения приватных полей. Поэтому не следует объединять ключевые слова private и #private в одну категорию: они выглядят похоже в TypeScript, но реализуются по-разному, причем замыкания представляют собой совершенно другой механизм.
Так нужны ли ещё замыкания?
Да, и лучший вопрос вместо того, почему нам нужны замыкания несмотря на private и #, — это вопрос о том, какую проблему решает каждый из этих механизмов. Каждый из них отвечает на разные потребности: private ограничивает доступ внутри проверщика типов, #private позволяет самой среде выполнения обеспечивать конфиденциальность полей, а замыкания дают функциям возможность продолжать использовать тот область видимости, в которой они были созданы. Как только это становится ясно, замыкания перестают казаться просто устаревшим решением проблемы приватных переменных и показывают свою настоящую роль как основного строительного блока модели программирования JavaScript. Изучение более новых функций не делает старые концепции нерелевантными; оно позволяет задавать более точные вопросы об них, а именно — почему данный механизм вообще был полезен. В случае с замыканиями ответ гораздо глубже, чем просто скрытие переменной.