TypeScript wprowadza w wersji 5.2 nowy operator using
. Część z czytelników pewnie doświadcza flashbacków, jeśli mieliście styczność np. z C# czy C++. Dzisiaj zagłębimy się w tę nowalijkę i zobaczymy, w czym może nam ona pomóc na co dzień. Zaczynajmy.
Zanim przejdziesz dalej, warto, byś zapoznał się z mechanizmem Symboli w JS, opisanym niedawno tutaj na blogu. Jeśli jednak nie ma on dla Ciebie tajemnic - zapraszam do podsumowania nowego ficzerka poniżej.
Cała historia rozpoczęła się od proposala Explicit Resource Management, który w swoich założeniach ma oddawać większą kontrolę nad cyklami życia obiektów w ręce developerów. W przypadku using jest to kontrola nad przydzielaniem i zwalnianiem zasobów.
Propozycja o której mowa jest na ostatnim etapie walidacji TC39, stąd wysokie prawdopodobieństwo, że wkrótce pojawi się w oficjalnych standardach ECMAScript. TypeScript adaptuje takie proposale nieco szybciej, dlatego już teraz (z pomocą niewielkiej konfiguracji), możemy skorzystać z ciekawostek, jakie daje nam using
.
Zacznijmy od tego, że do zabawy z using
potrzebujemy małego przygotowania. W notce release’owej zamieszczono bowiem informację, że jeszcze nie wszystkie runtime’y obsługują ten ficzerek i żeby z niego korzystać, musimy wpierw dodać kilka rozszerzeń.
Przede wszystkim musimy mieć pewność, że nasz TypeScript jest w najnowszej wersji:
npm install typescript@5.2tsc --version # Version 5.2.2
Jeśli środowisko jest zainstalowane, możemy przystąpić do ostatecznej konfiguracji. Zerknijmy na nasz tsconfig.json
:
{"compilerOptions": {"target": "es2022","lib": ["es2022", "esnext.disposable", "dom"],"module": "es2022"}}
Widzimy tutaj pewną nowość. Korzystamy z esnext.disposable
, który dostarczy nam obsługę keyworda using
oraz interfejs Disposable
dla klas.
Na koniec, z uwagi na wczesny etap życia tej funkcjonalności musimy ubezpieczyć się na brak odpowiednich symboli (dispose
oraz asyncDispose
), więc na początku naszego skryptu dodajemy sprawdzenie:
Symbol.dispose ??= Symbol("Symbol.dispose");Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
Od tego momentu możemy już testować naszą funkcjonalność.
Jeśli pamiętacie poprzedni wpis o symbolach, na pewno skojarzyliście, że dispose
i asyncDispose
nie pojawiają się przypadkowo w temacie zarządzania zasobami. Jak wspomniałem wtedy, Symbole często w natywnej bibliotece stanowią pomost pomiędzy naszym kodem a niskopoziomowymi instrukcjami interpretera.
Nie inaczej jest i tym razem.
Za pomocą Symbol.dispose
możemy znacznie efektywniej kontrolować cykl życia naszego zasobu. Wiemy bowiem, kiedy dokładnie zostanie on “zwolniony” i przestanie być już używany. Możemy wykonać wtedy na nim pożądane akcje, unikając wycieków pamięci. Na pewno w głowie kiełkuje Wam kilka ciekawych use case’ów, zacznijmy jednak od czegoś prostego.
(Symbol as any).dispose ??= Symbol("Symbol.dispose");(Symbol as any).asyncDispose ??= Symbol("Symbol.asyncDispose");const droidSearcher = () => { // 🛠️console.log("Running droid searcher...");return {findDroids: () => {console.log("I'm looking for droids!")},[Symbol.dispose](): void {console.log("There are no droids we're looking for!");}}}// 🚀using resource = droidSearcher()resource.findDroids()// Output 🖨️//// Running droid searcher...// I'm looking for droids!// There are no droids we're looking for!
Powyższy listing przedstawia trywialne wykorzystanie Symbol.dispose
, jednak dosyć jasno pokazuje, co tak naprawdę osiągamy. Na początek (🛠️) tworzymy funkcję, która zaloguje nam o jej inicjalizacji, a następnie zwróci jedną metodę oraz unikalną metodę dla klucza [Symbol.dispose]
- obie również logują o swoim statusie.
Na pierwszy rzut oka, jeśli jesteśmy zaznajomieni ze składnią symboli, nie ma tutaj nic nadzwyczajnego. Rzućmy jednak okiem na drugą część (🚀), w której korzystamy z naszego obiektu. Zaczynamy od słowa kluczowego using
, dla podkreślenia, że chcemy traktować stworzony obiekt jako zasób. To kluczowe słowo dla tego kontekstu, przewijające się w dokumentacji, ponieważ zależy nam na traktowaniu naszych obiektów z uwzględnieniem ich cyklu życia.
Po inicjalizacji wywołujemy metodę i kończymy program.
Jeśli nasz droidSearcher
utrzymywałby połączenie z bazą danych, bądź handlery do otwartych plików, to w normalnych warunkach stworzylibyśmy zagrożenie wycieku pamięci, bo zapomnieliśmy o zamknięciu tych połączeń. Tutaj z pomocą przychodzi właśnie Symbol.dispose
. Jak widać na wyjściu (🖨️), logujemy trzy aktywności:
Running droid searcher...
- gdy inicjujemy nasz zasóbI'm looking for droids!
- gdy wywołujemy metodę findDroids()
There are no droids we're looking for!
- 🪄 nie wywołaliśmy tego, jednak interpreter sam zadbał o to, by na końcu życia naszego zasobu uruchomić metodę zadeklarowaną w obiekcie jako [Symbol.dispose]
. W tym miejscu możemy umieścić np. zakończenie połączenie z bazą bądź zamknięcie pliku!Gdybyśmy mieli do czynienia z operacjami asynchronicznymi, składnia wyglądałaby podobnie. Jedyną różnicą byłoby wykorzystanie async [Symbol.asyncDispose]() {}
oraz inicjalizacja zasobu jako await using resource = ...
.
using
nie wprowadza rewolucji w kontroli cyklu życia obiektów, a jedynie sprawia, że możemy to robić w sposób elegancki, bezpośrednio korzystając z możliwości niskopoziomowych interpretera.
Jeśli zerkniemy w przekompilowany kod powyższego listingu zobaczymy, że podobny efekt uzyskujemy korzystając z dobrze nam znanego try-catch
. Odchudzając go ze zbędnego szumu wyglądałby on np. tak:
try {resource = droidSearcher();resource.findDroids();}catch (e) { ... }finally {resource.cleanUpMethod();}
Duża część tych operacji spoczywa na developerze, który korzysta z danego zasobu. To on musi pamiętać, żeby opakować wywołanie w try-catch
oraz to, żeby po zakończonych operacjach uruchomić operację czyszczenia.
Jak widać, using
dostarcza spore usprawnienie w ramach developer experience języka, jednocześnie uszczelniając go na potencjalne wycieki pamięci. Może dziwić nieco składnia, która potrafi przybierać komiczne formy:
await using use = useUsing() // 😉
ale korzyści, które zyskujemy korzystając z tego rozwiązania są niewspółmierne. Warto o tym pamiętać, szczególnie, gdy często upuszczamy piłeczkę z napisem memory management.
Pomimo tego, że konfiguracja nie jest oczywista i czasem może przysporzyć nam kłopotów, to jednak developerzy już teraz chętnie sięgają po to rozwiązanie. Ciekawe zastosowanie znaleźli dla niego programiści pracujący przy popularnej bibliotece Apollo Client, którzy wykorzystali using
podczas… pisania testów. Po szczegóły odsyłam do pull requesta, oraz do dokumentacji, gdzie możemy znaleźć przykłady pracy na plikach czy z bazą danych.
Pomyślności! 🫡
Zdjęcie Tim Mossholder z Unsplash