Przygotowując wpis o operatorze using
pomyślałem, że zanim zanurkujemy w nowalijkach, dobrze byłoby przypomnieć sobie, czym jest Symbol
- który, nota bene, stanowi istotną składową using
. Zróbmy zatem krok wstecz, by upewnić się, o czym w ogóle mówimy.
Symbol
nie jest żadną nowością, istnieje on w składni języka już od dobrych kilku lat.
Pierwsza wzmianka pojawiła się w chyba najbardziej przełomowym wydaniu specyfikacji EcmaScript z 2015 roku, okraszonej wersją 6. Mam jednak wrażenie, że nawałnica nowości odebrała mu nieco splendoru. Przypomnijmy, że wraz z ES6 pojawiały się arrow functions, klasy, destrukturyzacja, scope blokowy i wiele innych funkcji, nieco bardziej “życiowych”, niż rzeczony Symbol
.
Twórcy niskopoziomowych rozwiązań jednak często nie wyobrażają sobie bez niego życia. Przyjrzyjmy się więc, co jest w nim takiego niezwykłego.
Czas na konkret. Podstawowym sposobem użycia bohatera dzisiejszego artykułu jest wywołanie go jako funkcję (można podać parametr description
, o którym słówko nieco później):
let anakin = Symbol("Anakin");const yoda = Symbol("Yoda");
Jak widać, w obu przypadkach przypisujemy to samo wykonanie, ale już tutaj pojawia się pewna magia związana z symbolami. Otóż typy obu tych obiektów są różne.
anakin
z powyższego przykładu jest prymitywnego typu… symbol
, co jednoznacznie wskazuje na to, że możemy zamienić jego wartość, po prostu przypisując mu inną wartość typu symbol
:
anakin = Symbol("Lord Vader") // 👌
Tymczasem dla obiektu yoda
przypisanie nie jest możliwe, zgodnie z ogólnymi zasadami dot. const
, jakkolwiek samo typowanie również jest inne i może nawet wydawać się nieco dziwaczne. Typem dla yoda
jest… typeof yoda
.
type yodaType = typeof yoda
Powyższy zapis spowoduje, że typ yodaType
będzie miał wartość… typeof yoda
. Mhm. Wszystko w porządku. 👌
Podsumowując, typem const
dla symbol
jest typeof {nazwa_zmiennej}
.
Ważną właściwością symboli jest to, że są unikalne (wynika to z zachowania, które przedstawiliśmy powyżej). Jeśli zdefiniujemy dwa symbole z tą samą wartością, to pomimo ich prymitywnego typu nie będziemy mogli ich ze sobą zestawić:
let c3po = "C3PO"let c3po_2 = "C3PO"c3po === c3po_2 // 👌 truelet r2d2 = Symbol("R2D2")let r2d2_2 = Symbol("R2D2")r2d2 === r2d2_2 // ❌ false
Nie ma tego problemu, jeśli nadpisalibyśmy r2d2_2
wartością r2d2
- wtedy referencja zadziała tak, jak się tego spodziewamy i r2d2 === r2d2_2
zwróci true
.
Co jednak, gdybyśmy chcieli “reużyć” interesujący nas symbol? Możemy posłużyć się statyczną metodą Symbol.for
, która przeszuka dla nas globalny rejestr symboli i zwróci odpowiednią referencję, jeśli istnieje lub stworzy nowy globalny symbol, jeśli taki się jeszcze nie pojawił.
Ważnym wskaźnikiem tutaj jest “globalny” - dotyczy on bowiem pewnego wycinka zarejestrowanych symboli. A dokładnie tych, które dodajemy za pomocą Symbol.for
.
Zobaczmy przykład:
const s0 = Symbol('1')const s1 = Symbol.for('1')const s2 = Symbol.for('1')console.log(s0 === s1) // ❌ falseconsole.log(s1 === s2) // 👌 true
Co zadziało się powyżej:
s0
z symbolem globalnego rejestru s1
- ich referencje nie wskazują na siebie, zatem false ❌s1
to próba pobrania symbolu 1
, który jeszcze nie istnieje w globalnym rejestrze (ergo: utworzenie nowego), następnie w s2
wykonujemy próbę pobrania z globalnego rejestru symbolu 1
, który już zainicjowaliśmy, dzięki czemu mamy 👌.Możemy również uzyskać nasz klucz, bazując na globalnym rejestrze, przekazując zmienną, która zachowuje referencje do symbolu i przekazując ją do metody keyFor
:
Symbol.keyFor(s1) // '1'
Dla lokalnego rejestru będzie to undefined
.
Dzięki swojej unikalności symbole stają się nieodzownym narzędziem w budowaniu bogatych interfejsów niskopoziomowych. Wykorzystując je np. jako klucze w obiekcie mamy 100% pewność, że nie będą one kolidowały z innymi polami w danym obiekcie w przyszłości - bo przecież nie możemy utworzyć drugiego takiego samego symbolu!
const heroLevel = Symbol('heroLevel')const health = 'health'const paladin = {[heroLevel]: 1,[health]: 100}paladin.heroLevel = 35paladin.health = 40console.log(paladin.heroLevel) // 35 (1️⃣)console.log(paladin[heroLevel]) // 1 (1️⃣)console.log(paladin.health) // 40 (2️⃣)console.log(paladin[health]) // 40 (2️⃣)paladin[heroLevel] = 2console.log(paladin[heroLevel]) // 1 (3️⃣)
Działanie wydaje się być zrozumiałe - kluczem naszego obiektu paladin
jest symbol, zatem jeśli spróbujemy po prostu wyrazić go nazwą pola (paladin.heroLevel
), jedyne, co uzyskamy to nowe pole - wartość przypisana do symbolu pozostanie niezmieniona (1️⃣).
Działanie jest podobne do tego, gdy jako klucz podamy konkretną zmienną, będącą literałem. (2️⃣) Istnieje jednak pewna kluczowa różnica: w przypadku health
możemy dostać się do wartości na różne sposoby, przez: paladin.health
lub paladin[health]
, nie możemy tego jednak zrobić, gdy kluczem jest symbol! Jedynym sposobem na uzyskanie wartości tego pola jest posiadanie referencji do samego symbolu, który został zastosowany jako klucz. (3️⃣)
Jakby tego było mało, pola, których klucze są symbolami nie są widoczne na zewnątrz obiektu:
paladin // 🪄 { health: 100 }Object.getOwnPropertyNames(paladin) // ["health"]Object.getOwnPropertySymbols(paladin) // [Symbol(heroLevel)]
Nasz paladyn posiada widoczny jedynie pasek życia, jego poziom expa został ukryty pod symbolem. Możemy się o tym dowiedzieć korzystając z dodatkowej metody obiektu getOwnPropertySymbols
.
Znamy podstawowe zachowanie symboli w JS, ich typowanie w TS, czas zejść nieco niżej. Przeglądając dokumentację napotykamy na dziwne metody statyczne symboli typu: asyncIterator, hasInstance, isConcatSpreadable
. Nie będziemy się nad nimi wszystkimi pochylać dokładnie, gdyż każdy z nich wymagałby sporego wyjaśnienia, jednak ich częścią wspólną jest to, że należą do zbioru tzw. well-known symbols - i tym zagadnieniem teraz się zajmiemy.
Jak omówiliśmy nieco wyżej - wykorzystanie symboli do budowania obiektów jest szalenie pomocne i bezpieczne, szczególnie gdy oddajemy takie API w ręce osób trzecich. Z tej możliwości skorzystali także twórcy języka i dla statycznych metod w obiektach zaczęli korzystać z symboli właśnie, które ustanawiają połączenie z pewnymi natywnymi operacjami JS. Tak wykorzystane symbole stanowią właśnie grupę well-known symbols.
Dla lepszego zobrazowania, zobaczmy przykład Symbol.hasInstance
, który… oddaje w nasze ręce sposób rozpoznawania instancji naszej klasy!
class TrueJedi {static [Symbol.hasInstance](instance) {return Array.isArray(instance) && instance.includes('Yoda');}}console.log(['Anakin Skywalker'] instanceof TrueJedi); // ❌ falseconsole.log(['Yoda'] instanceof TrueJedi); // 👌 true
Do naszej klasy TrueJedi
dodaliśmy statyczną metodę, której nazwa to Symbol.hasInstance
. Metoda ta przyjmuje argument przekazywany w wyrażeniu instanceof
a rezultatem jest nasz osąd, czy dana instancja odpowiada warunkom, by być rozpoznawaną jako TrueJedi
.
To dzięki wykorzystaniu symbolu, byliśmy w stanie połączyć się z niskopoziomową funkcjonalnością języka jaką jest instanceof
i uzyskać wpływ na to, jak nasza klasa będzie działa w tym kontekście, wprowadzając - choćby tak nierzeczywiste - zmiany. Symbol, dzięki well-known symbols rozszerza możliwości developmentu o bardziej immersywne zachowania, dostarczając nam więcej i więcej opcji do kontrolowania cyklu życia naszych obiektów.
Podróż przez odkrywanie symboli dobiega końca. Tak, jak wskazuje na to tytuł tej sekcji - prawdopodobnie będzie to wiedza, której nie wykorzystasz w praktyce, budując swoje aplikacje.
Nie zmienia to jednak faktu, że znajomość tego typu rozwiązań i sposobu ich działania da ci kontekst do tego, jak działa interpreter JS oraz zbliżające się nowości, takie jak using
i Symbol.dispose
, o którym już niebawem! Z brzydkiego kaczątka Symbol
wyrasta na dojrzałego łabędzia, o którym jeszcze nie raz usłyszymy!
Źródła: