Закриття просторів імен проти приватних змінних проти #: що захищає кожен елемент у 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?
TypeScript private
Розглянемо клас із однією приватною властивістю:
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. Ознайомлення з новішою функцією не робить стару концепцію нерелевантною; воно дає можливість поставити більш точне запитання щодо неї, а саме — чому цей механізм взагалі був корисним. У випадку замикань відповідь йде набагато глибше, ніж просто приховування змінної.