Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP7 min czytania1514 słów

Ź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