Zamknięcia vs prywatne vs #: Co chroni każde z nich w TypeScript i JavaScript
Private w TypeScript to kontrola w czasie kompilacji, pola typu hash są egzekwowane w czasie wykonywania, a zamknięcia przechwytują zakres leksykalny. Porównanie tego, co każda z tych cech robi.
Źródła: Closures vs Private in TypeScript: If We Have private and #, Why Do We Still Need Closures?
TypeScript posiada słowo kluczowe private, a JavaScript – symbol #; dlatego słuszne jest pytanie, dlaczego nadal potrzebne są zamknięcia do ochrony zmiennych. Odpowiedź wydaje się oczywista, dopóki nie sprawdzimy, jak JavaScript zachowuje się w czasie wykonywania, a wtedy staje się ciekawsza. Zamknięcia pojawiają się później. Najpierw – co tak naprawdę robi private w TypeScript?
TypeScript private
Weźmy klasę z jedną prywatną właściwością:
class User {
private secret: string = "hidden";
}
Teraz utwórz instancję i odczytaj właściwość spoza klasy:
const me = new User();
console.log(me.secret);
Kluczowym zdaniem jest to:
console.log(me.secret);
Kompilator tsc odrzuca to z błędem, więc zmienna wydaje się chroniona. Problem polega na tym, że private jest przede wszystkim funkcją systemu typów. Gdy TypeScript zostaje przekształcony w JavaScript, to słowo kluczowe nie zachowuje się jako mechanizm prywatności w czasie wykonywania. Ten kod źródłowy:
class User {
private secret: string = "hidden";
}
może być wyemitowany mniej więcej w następujący sposób:
class User {
constructor() {
this.secret = "hidden";
}
}
W tym momencie JavaScript nie ma pojęcia, że secret miał być prywatny, ponieważ jest to zwykła właściwość. Dlatego następuje:
const me = new User();
console.log(me.secret);
może drukować:
hidden
Różnica ta ma znaczenie. To, co private mówi kompilatorowi, to że kod używający tego typu nie może modyfikować tej właściwości. Nie informuje to jednak środowiska uruchomieniowego, że dostęp do tej właściwości musi być niemożliwy – to zupełnie różne obietnice.
Co zmienia #
TypeScript obsługuje również wbudowane w JavaScript pola klas prywatne:
class User {
#secret: string = "hidden";
}
Próba odczytania pola z zewnątrz teraz kończy się inaczej:
const me = new User();console.log(me.#secret);
To nie jest zwykłe ostrzeżenie kompilatora. Pole to jest również prywatne w czasie wykonywania, ponieważ składnia # tworzy rzeczywiste prywatne pole klasy. Odczytywanie go spoza klasy jest niedozwolone:
console.log(me.#secret);
a odczytanie zwykłej właściwości o tym samym nazwie również do niej nie dotrze:
console.log(me.secret);
ponieważ #secret nie jest zwykłą właściwością o nazwie secret. Może być kuszące wyobrażenie go jako klucza w postaci ciągu znaków:
me["#secret"]
ale to nie działa. # nie jest konwencją nazewniczą, ponieważ w czasie wykonywania istnieje rzeczywiste pojęcie pól prywatnych. Dlatego mamy tu dwie odrębne kwestie. Pierwszą z nich w dużej mierze narzuca system typów:
private secret: string;
a drugi poprzez semantykę JavaScriptu:
#secret: string;
Gdzie pasują zamknięcia
Zanim pojawiły się prywatne pola klas, programiści nadal potrzebowali danych, do których nie można było uzyskać dostępu z zewnątrz, a jednym z najsilniejszych narzędzi już dostępnych w tym języku były zamknięcia. Oto funkcja fabryczna:
function createUser() {
let secret = "hidden";
Zwraca obiekt, który udostępnia jeden dostępnik:
return {
getSecret() {
return secret;
}
};
}const user = createUser();console.log(user.getSecret());
Zawołanie tego dostępnika wyświetla:
hidden
Zwróć uwagę, czego brakuje. Nie ma takiego dostępu do właściwości:
user.secret
Zwrócony obiekt nie ma nawet właściwości secret. Zmienna znajduje się w środowisku leksykalnym utworzonym podczas wywołania createUser(), a zwrócona funkcja getSecret() pamięta to środowisko. To jest istota zamknięcia: funkcja zachowuje dostęp do zmiennych ze swojego kontekstu utworzenia, nawet po zakończeniu działania funkcji zewnętrznej. Po tym wywołaniu:
const user = createUser();
Funkcja zewnętrzna została ukończona. Zwykle oczekiwałby się jej zmiennej lokalnej:
let secret = "hidden";
zniknąć. Mimo to getSecret() nadal do niego dostępuje, ponieważ obejmuje zmienną secret, skąd pochodzi jej nazwa.
Stan prywatny bez jego ujawniania
Zamknięcia mogą przechowywać stan prywatny bez żadnej ujawnionej zmiennej. Dobrym przykładem jest licznik:
function myCounter() {
let count = 0;
return {
increment() {
count++;
}, getCount() {
return count;
}
};
}const counter = myCounter();counter.increment();
counter.increment();console.log(counter.getCount());
Dwukrotne wywołanie metody increment oraz późniejsze zapytanie o liczbę wyświetla:
2
podczas gdy to nie działa:
console.log(counter.count);
ponieważ count nie jest właściwością licznika. To zmienna znajdująca się w uchwyconym środowisku leksykalnym. Obiekt zapewnia określone zachowanie, natomiast rzeczywisty stan jest przechowywany gdzie indziej – to forma inkapsulacji wynikająca z zakresu leksykalnego.
Czy # jest po prostu lepsze?
Niekoniecznie. Rozwiązują one powiązane problemy, ale nie są tą samą funkcją. Oto wersja klasowa:
class User {
#secret = "hidden";
getSecret() {
return this.#secret;
}
}
W tym przypadku #secret to wyraźny stan prywatnej klasy, należący do obiektu, a środowisko wykonawcze o nim wie. W wersji z zamknięciem:
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
Zmienna w ogóle nie jest właściwością obiektu, lecz znajduje się w otaczającym środowisku leksykalnym. Mechanizmy te są zasadniczo różne. Pole # wskazuje, że dany obiekt ma pole prywatne, natomiast zamknięcie funkcji oznacza, że funkcja ta może korzystać ze stanu z otaczającego jej zakresu. Ta różnica wpływa na projekt API.
Stan udostępniany pomiędzy funkcjami
Zamknięcia funkcji oferują coś więcej niż tylko prywatność: kilka funkcji może dzielić się jednym elementem stanu. Przykładem jest konto bankowe:
function createBankAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
}, withdraw(amount) {
balance -= amount;
}, getBalance() {
return balance;
}
};
}
Użycie wygląda w ten sposób:
const account = createBankAccount(1000);
account.deposit(500);console.log(account.getBalance());
a wynik jest następujący:
1500
Nie ma żadnej własności publicznej do odczytania:
account.balance
deposit(), withdraw() oraz getBalance() wszystkie korzystają z tej samej zmiennej lokalnej:
balance
To jeden z powodów, dla których zamknięcia są tak ważne w JavaScript. Nie są jedynie sposobem na ukrywanie zmiennych, ale także metodą utrzymywania stanu pomiędzy wywołaniami.
Korygowanie pierwszej hipotezy
Kuszącym streszczeniem jest to, że zamknięcia dostarczają ukrytego mechanizmu działającego w czasie wykonywania, podczas gdy private i # służą jedynie do wyświetlania ostrzeżeń w czasie kompilacji. To bliskie prawdy, ale nie do końca trafne. private w TypeScript służy głównie do sprawdzania dostępu w czasie kompilacji, natomiast #private w JavaScript zapewnia prywatność w czasie wykonywania. Zamknięcia nie są ani kolejnym słowem kluczowym, ani czysto związane z czasem kompilacji. Wynikają one z zakresu leksykalnego, który umożliwia funkcjom zachowanie dostępu do zmiennych z otaczającego ich środowiska. To może umożliwić implementację inkapsulacji, ale zamknięcia mają znacznie szerszy zakres niż sama prywatność. Przykład fabryki funkcji pokazuje zastosowanie bez żadnej elementu prywatności:
function createMultiplier(x) {
return function (y) {
return x * y;
};
}
const double = createMultiplier(2);console.log(double(10));
Tutaj zwracana funkcja pamięta x, a to jest zamknięcie. Ten sam mechanizm służy do zarządzania prywatnym stanem, fabrykami, funkcjami zwrotnymi, obsługą zdarzeń, memoizacją i wieloma innymi rzeczami.
Model mentalny tych trzech koncepcji
Dla TypeScript private:
class User {
private secret = "hidden";
}
Rozumiej to jako system typów, który uniemożliwia dostęp do danego elementu. W przypadku JavaScript #private:
class User {
#secret = "hidden";
}
Traktuj to tak, jakby czas wykonywania traktował to pole jako naprawdę prywatne. W przypadku zamknięcia:
function createUser() {
let secret = "hidden";
return {
getSecret() {
return secret;
}
};
}
Należy to postrzegać jako funkcję, która pamięta zmienne z zakresu, w którym została utworzona. To ostatnie jest kluczowe. Zamknięcia nie są przestarzałe, skoro istnieje # – #private to funkcja przeznaczona do prywatnych pól klas, podczas gdy zamknięcia wynikają z zakresu leksykalnego i funkcji pierwszej klasy, pełniąc wiele zadań niespowiązanych z prywatnością.
W tle
Pozostaje jeden subtelny punkt. Błędem byłoby twierdzenie, że zarówno private, jak i # kończą jako zwykłe właściwości JavaScript. Dotyczy to private w TypeScript w standardowym wyniku kompilacji, ale nie dotyczy natywnych pól #private:
class User {
#secret = "hidden";
}
To wykorzystuje funkcję działającą w czasie wykonywania. W zależności od celu kompilacji TypeScript może zachować tę składnię lub obniżyć jej poziom do starszego JavaScript, jednak semantyka języka nadal wymaga zachowania specyfiki pól prywatnych. Dlatego nie umieszczaj private i #private w tej samej kategorii: wyglądają podobnie w TypeScript, ale są implementowane inaczej, a zamknięcia to zupełnie inny mechanizm.
Czy więc zamknięcia są nadal potrzebne?
Tak, a lepszym pytaniem niż to, dlaczego potrzebujemy zamknięć pomimo private i #, jest to, jaki problem rozwiązuje każdy z tych mechanizmów. Każdy z nich odpowiada na inne potrzeby – private ogranicza dostęp wewnątrz sprawdzacza typów, #private sprawia, że sam czas wykonywania egzekwuje zasady dotyczące ukrytych pól, natomiast zamknięcia pozwalają funkcjom nadal korzystać ze środowiska, w którym zostały stworzone. Gdy to stanie się jasne, zamknięcia przestaną wyglądać jak stary rozwiązanie problemu zmiennych prywatnych i ukażą swą prawdziwą rolę jako podstawowy element modelu programistycznego JavaScript. Poznanie nowszej funkcjonalności nie czyni starszego pojęcia bezwartościowym; daje natomiast możliwość postawienia bardziej precyzyjnego pytania na jego temat, mianowicie dlaczego dany mechanizm w ogóle był przydatny. W przypadku zamknięć odpowiedź jest znacznie głębsza niż tylko ukrywanie zmiennej.
Literatura pokrewna
- TypeScript Private Fields vs # Syntax: Prywatność w czasie kompilacji czy w czasie wykonywania — Porównuje modyfikator
privatew TypeScript używany w czasie kompilacji z polami#w ECMAScript, które są egzekwowane w czasie wykonywania, aby pomóc w wyborze odpowiedniej strategii zamykania danych dla baz kodu z 2026 roku. - Prawdziwe podstawy JavaScript: prototypy, delegacja i funkcje — Dlaczego klasy są jedynie „cukrem składniowym” w porównaniu z prototypami, w jaki sposób delegacja różni się od klasycznej dziedziczenia, gdzie to prowadzi do problemów oraz co daje styl funkcyjny w JavaScript.