Kolekcje ES6

> Dodaj do ulubionych

ES6 bez tajemnic to cykl artykułów poświęconych nowym składnikom języka JavaScript, które pojawiły się w 6. edycji standardu ECMAScript, w skrócie ES6.

Specyfikacja ES6, o oficjalnej nazwie ECMA-262, 6. edycja, Specyfikacja języka ECMAScript 2015, została już ostatecznie zaakceptowana jako standard Ecma jakiś czas temu. Gratulacje dla zespołu TC39 i wszystkich, którzy mieli swój wkład w pracę nad tym standardem!

Ale najlepsze jest to, że na następną aktualizację standardu nie przyjdzie nam czekać kolejnych sześciu lat. Od teraz komisja standaryzacyjna zamierza wydawać nową wersję co około 12 miesięcy i obecnie trwają już prace nad 7. edycją.

Z tej okazji możemy więc pomówić o składniku JS, o którym marzyłem od dłuższego czasu – i który moim zdaniem wciąż można udoskonalić!

Trudna koewolucja

JS nie da się tak do końca porównać z innymi językami programowania, co czasami przekłada się na jego ewolucję w zaskakujący sposób.

Dobrym przykładem są moduły ES6. Systemy modułowe funkcjonują już w innych językach – sprawdzają się świetnie w języku Racket czy Pythonie. Dlaczego więc, decydując się na wprowadzanie modułów do ES6, komisja standaryzacyjna nie skopiowała po prostu istniejącego systemu?

JS jest inny, ponieważ obsługiwany jest przez przeglądarki internetowe i operacje wejścia/wyjścia mogą zająć sporo czasu. Z tego powodu system modułowy w JS musi obsługiwać asynchroniczne wczytywanie kodu. Nie może również pozwolić sobie na szeregowe wyszukiwanie modułów w wielu katalogach. Skopiowanie istniejących systemów zdałoby się na nic – system modułowy ES6 musiałby być od nich wszechstronniejszy.

Z tym, jak wymaganie to wpłynęło na ostateczny kształt modułów ES6 wiąże się ciekawa historia, jednak nie o tym będziemy dziś mówić.

Niniejszy artykuł dotyczy bowiem składnika, który w standardzie ES6 nosi nazwę „kolekcji z kluczami”. Są to obiekty: Set, Map, WeakSet i WeakMap. Pod wieloma względami są one podobne do tablic skrótów z innych języków. Komisja standaryzacyjna zmodyfikowała je jednak pod kątem JS, ponieważ JS jest inny.

Dlaczego kolekcje?

Każdy, kto zna JS wie, że w języku tym jest już wbudowany element przypominający tablicę skrótów: są to obiekty.

Prosty obiekt jest w końcu niczym innym jak otwartą kolekcją par klucz-wartość. Możemy ustawiać, pobierać i usuwać własności obiektów, a także przeglądać je iteracyjnie – wszystko to potrafią także tablice skrótów. Po co więc w ogóle dodawać nowy składnik języka?

Wiele programów wykorzystuje proste obiekty do przechowywania par klucz-wartość i w przypadku tych programów, w których takie rozwiązanie się sprawdza nie ma potrzeby wprowadzania obiektów Map czy Set. Niemniej jednak korzystanie z obiektów we wspomniany sposób ma kilka wad:

  • Obiekty wykorzystywane jako tablice wyszukiwania nie mogą jednocześnie mieć metod, ponieważ istnieje wówczas ryzyko wystąpienia kolizji.
  • Z tego powodu należy korzystać ze składni Object.create(null) (zamiast {}) bądź zachować ostrożność, by uniknąć błędnej interpretacji wbudowanych metod (np. Object.prototype.toString) jako danych.
  • Klucze własności są zawsze łańcuchami (w ES6 symbolami). Obiekty nie mogą być kluczami.
  • Nie ma dobrego sposobu na sprawdzenie liczby własności obiektu.

W ES6 pojawia się dodatkowy problem: proste obiekty nie są iterowalne, zatem nie działają z pętlą for–of, operatorem ... itd.

Ograniczenie to nie ma znaczenia w wielu programach, gdzie sprawdzą się zwykłe obiekty. W pozostałych zastosowanie mają obiekty Map i Set.

Ponieważ kolekcje ES6 zostały zaprojektowane tak, by unikać kolizji między danymi użytkownika a wbudowanymi metodami, ich dane nie są udostępniane jako własności. Oznacza to, że przy pomocy wyrażeń takich jak obj.key czy obj[key] nie można uzyskać dostępu do danych tablicy skrótów. W tym celu należy posłużyć się metodą map.get(key) . Ponadto pozycje w tablicy skrótów – w przeciwieństwie do własności – nie są dziedziczone poprzez łańcuch prototypów.

Zaletą obiektów Map i Set jest natomiast to, że w przeciwieństwie do zwykłych obiektów mają one własne metody. Ponadto istnieje możliwość bezkonfiktowego dodawania nowych metod, czy to w standardzie, czy we własnych podklasach.

Zbiory

Zbiór (obiekt Set) jest modyfikowalną kolekcją wartości, zatem program może dodawać i usuwać jej elementy w trakcie działania. Cechy te czynią zbiór podobnym do tablicy. Pomiędzy zbiorami a tablicami istnieje jednak tyle samo podobieństw co różnic.

Po pierwsze, w przeciwieństwie do tablic, zbiór nie może zawierać dwóch takich samych wartości. Próba dodania do zbioru istniejącej już w nim wartości nie przyniesie skutku:


> var desserts = new Set("????");
> desserts.size
    4
> desserts.add("?");
    Set [ "?", "?", "?", "?" ]
> desserts.size
    4

W powyższym przykładzie użyłem łańcuchów, jednak zbiór może zawierać dowolny typ wartości JS. Tak jak w przypadku łańcuchów, także ponowne dodanie tego samego obiektu lub liczby zakończy się niepowodzeniem.

Po drugie dane przechowywane w zbiorach są zorganizowane, co przyspiesza sprawdzenie przynależności elementu do kolekcji.


> // sprawdź, czy "zythum" jest słowem
> arrayOfWords.indexOf("zythum") !== -1  // wolny sposób
    true
> setOfWords.has("zythum")               // szybki sposób
    true

W obiektach Set nie funkcjonuje natomiast indeksowanie:


> arrayOfWords[15000]
    "anapanapa"
> setOfWords[15000]   // zbiory nie obsługują indeksowania
    undefined

Oto wszystkie operacje na zbiorach:

  • new Set tworzy nowy, pusty zbiór.
  • new Set(iterable) tworzy nowy zbiór i wypełnia go danymi z dowolnej iterowalnej wartości.
  • set.size — sprawdza liczbę wartości w zbiorze.
  • set.has(value) — zwraca wartość true, jeśli zbiór zawiera daną wartość.
  • set.add(value) — dodaje wartość do zbioru. Jeśli podana wartość jest już w zbiorze, to wówczas nie dzieje się nic.
  • set.delete(value) — usuwa wartość ze zbioru. Jeśli zbiór nie zawiera podanej wartości, nic się nie dzieje. Zarówno metoda .add() jak i .delete() zwracają obiekt Set, zatem można je połączyć w łańcuch.
  • set[Symbol.iterator]() — zwraca nowy iterator przeglądający wartości zbioru. Zwykle nie jest to metoda wywoływana bezpośrednio, lecz to dzięki niej zbiory mogą być iterowalne. Można zatem pisać for (v of set) {...} itd.
  • Działanie metody set.forEach(f) najlepiej wyjaśnić na przykładzie z kodem. To coś w rodzaju skróconej wersji:
    for (let value of set)
        f(value, value, set);

    Metoda ta jest analogiczna do operującej na tablicach metody .forEach().

  • set.clear() usuwa wszystkie wartości ze zbioru.
  • Metody set.keys(), set.values() i set.entries() zwracają różne iteratory. Mają one zapewnić zgodność z obiektem Map, zatem pomówimy o nich teraz.

Ze wszystkich omówionych metod szczególnie potężne możliwości ma konstruktor new Set(iterable), ponieważ operuje na poziomie całych struktur danych. Można za jego pomocą przekonwertować tablicę na zbiór, usuwając powtarzające się wartości za pomocą jednej linijki kodu. Ponadto można mu przekazać generator: zostanie on wykonany, a następnie zwrócone przez niego wartości zostaną umieszczone w nowym zbiorze. Konstruktor ten służy także do kopiowania istniejącego zbioru.

Ostatnio obiecałem, że ponarzekam sobie trochę na nowe kolekcje ES6 – zacznę teraz. Mimo iż obiekt Set jest użytecznym dodatkiem, brakuje w nim kilku metod, które mogłyby wzbogacić kolejne edycje standardu ECMA. Mam na myśli:

  • funkcje pomocnicze już dostępne dla tablic, takie jak .map(), .filter(), .some() czy .every();
  • niemodyfikujące metody set1.union(set2) i set1.intersection(set2);
  • metody mogące operować na wielu wartościach na raz: set.addAll(iterable), set.removeAll(iterable) i set.hasAll(iterable).

Jest jednak i dobra wiadomość: wszystkie wymienione składniki można skutecznie zaimplementować za pomocą metod dostępnych w ES6.

Słowniki

Obiekt Map stanowi kolekcję par klucz-wartość. Oto zakres jego możliwości:

  • new Map — zwraca nowy, pusty słownik.
  • new Map(pairs) — tworzy nowy słownik i wypełnia go danymi z istniejącej kolekcji par [klucz, wartość]. Pary mogą stanowić istniejący już obiekt Map, tablicę dwuelementowych tablic, generator zwracający dwuelementowe tablice itp.
  • map.size — sprawdza liczbę pozycji w słowniku.
  • map.has(key) — sprawdza czy słownik zawiera dany klucz (tak jak key in obj).
  • map.get(key) — pobiera wartość przypisaną do klucza bądź wartość undefined, jeśli taka pozycja nie istnieje (podobnie jak obj[key]).
  • map.set(key, value) — dodaje do słownika pozycję przypisującą klucz do wartości, nadpisując wszelkie istniejące już pozycje o tym samym kluczu (tak jak obj[key] = value).
  • map.delete(key) — usuwa pozycję (tak jak delete obj[key]).
  • map.clear() — usuwa wszystkie pozycje słownika.
  • map[Symbol.iterator]() — zwraca iterator przeglądający pozycje słownika. Iterator wyraża każdą pozycję w postaci nowej tablicy z parami [klucz, wartość].
  • Metoda map.forEach(f) działa tak jak następujący kod:
    for (let [key, value] of map)
      f(value, key, map);

    Dziwny porządek argumentów ponownie wynika z analogii, tym razem do metody Array.prototype.forEach().

  • map.keys() — zwraca iterator przeglądający wszystkie klucze w słowniku.
  • map.keys() — zwraca iterator przeglądający wszystkie wartości w słowniku.
  • map.entries() — zwraca iterator przeglądający pozycje słownika, tak jak map[Symbol.iterator](). Tak naprawdę jest to jedna i ta sama metoda, tyle że pod inną nazwą.

I gdzie tu powody do narzekania? Oto kilka potencjalnie użytecznych elementów funkcjonalności, których nie ma w ES6:

  • narzędzie do obsługi wartości domyślnych, jak np. dostępna w Pythonie klasa collections.defaultdict;
  • funkcja pomocnicza, Map.fromObject(obj), która ułatwiłaby pisanie słowników przy pomocy składni literałów obiektowych.

Dodanie tych elementów funkcjonalności nie jest również kłopotliwe.

OK. Na początku tego artykułu wspomniałem, że na kształt składników JS duży wpływ miała kwestia funkcjonowania kodu w przeglądarkach. Nadszedł moment, by omówić ten temat. Posłużę się trzema przykładami. Oto pierwsze dwa.

JS jest inny, część 1 — tablice skrótów bez skrótów?

Istnieje jeden użyteczny składnik, który, o ile mi wiadomo, nie jest w ogóle obsługiwany przez klasy kolekcyjne ES6.

Załóżmy, że mamy zbiór obiektów URL.

var urls = new Set;
urls.add(new URL(location.href));  // dwa obiekty URL
urls.add(new URL(location.href));  // czy są takie same?
alert(urls.size);  // 2

Oba powyższe obiekty URL powinny być uznane za równe – mają takie same pola. W JavaScripcie są one jednak różne, a do tego nie można przeciążyć operatora równości.

Rozwiązanie to jest obsługiwane w innych językach. W Javie, Pythonie i Ruby przeciążenia operatora równości mogą dokonywać pojedyncze klasy. W wielu implementacjach języka Scheme można utworzyć pojedyncze tablice skrótów wykorzystujące różne relacje równości, zaś w C++ dostępne są oba wspomniane rozwiązania.

Wszystkie te mechanizmy wymagają jednak od użytkownika implementacji własnych funkcji mieszających i wszystkie udostępniają domyślną funkcję mieszającą systemu. Komisja nie zdecydowała się na udostępnienie skrótów w JS – a przynajmniej na razie – ze względu na wciąż nierozwiązane kwestie współdziałania i bezpieczeństwa, które nie są tak priorytetowe w innych językach.

JS jest inny, część 2 — a to niespodzianka! Przewidywalność!

Wydawać by się mogło, że deterministyczny sposób działania komputera nie jest żadnym zaskoczeniem. Często jednak spotykam się ze zdziwieniem, gdy mówię komuś, że iteratory Map i Set przeglądają pozycje kolekcji w kolejności, w jakiej wpisy te zostały dodane. To determinizm.

Przyzwyczailiśmy się, że niektórymi rzeczami w tablicach skrótów rządzi przypadek. Zaakceptowaliśmy ten stan rzeczy. Są jednak dobre powody, by tej przypadkowości unikać. Jak napisałem w 2012 r. :

  • Istnieją dowody na to, że niektórym programistom dowolny porządek iteracji wydaje się z początku zaskakujący lub niejasny. [1][2][3][4][5][6]
  • ECMAScript nie określa porządku wyliczania własności, jednak w celu zapewnienia zgodności z siecią wszystkie większe implementacje stosują kolejność dodawania własności. Istnieje zatem obawa, że jeśli zespół TC39 nie określi deterministycznego porządku iteracji, „Sieć zrobi to za nas”. [7]
  • Porządek iteracji tablicy skrótów może ujawnić fragmenty skrótów obiektu. W związku z tym implementerzy funkcji mieszających muszą przywiązywać niezwykłą wagę do bezpieczeństwa kodu. Przykładowo adres obiektu nie może być dostępny na podstawie ujawnionych fragmentów skrótu. (Choć ujawnienie adresu obiektu niezaufanemu kodowi ECMAScript nie jest niebezpieczne samo w sobie, to w sieci stanowi poważną lukę bezpieczeństwa).

Gdy omawialiśmy wszystkie te kwestie w lutym 2012 r. osobiście opowiedziałem się za dowolnym porządkiem iteracji. Następnie chciałem, na przykładzie eksperymentu, pokazać, że śledzenie porządku dodawania elementów sprawiłoby, że tablica skrótów byłaby zbyt powolna. Napisałem kilka mikrobenchmarków C++. Wyniki eksperymentu były dla mnie zaskoczeniem.

I tak też w JS ostatecznie pojawiły się tablice skrótów śledzące porządek dodawania elementów!

Mocne uzasadnienie dla istnienia słabych kolekcji

W artykule o symbolach ES6 omówiliśmy przykład wykorzystujący bibliotekę animacji JS. Chcieliśmy przechowywać flagę logiczną dla każdego obiektu DOM:

if (element.isMoving) {
  smoothAnimations(element);
}
element.isMoving = true;

Niestety ustawienie własności na obiekcie DOM w powyższy sposób jest złym pomysłem, co uzasadniliśmy już w poprzednim artykule.

Pokazaliśmy też, jak rozwiązać ten problem przy pomocy symboli. Czy jednak nie dałoby się uzyskać podobnego rezultatu za pomocą zbioru? Na przykład:

if (movingSet.has(element)) {
  smoothAnimations(element);
}
movingSet.add(element);

Rozwiązanie to ma jedną wadę: obiekty Map i Set przechowują silną referencję do każdego klucza i każdej wartości, jakie zawierają. Oznacza to, że jeśli element DOM zostanie usunięty z dokumentu i zwolniony, proces sprzątania pamięci nie może odzyskać zajmowanej przez niego pamięci dopóki element nie zostanie usunięty także z kolekcji movingSet. Biblioteki, w najlepszym wypadku, odnoszą połowiczny sukces w narzucaniu użytkownikom rygorystycznych wymagań dotyczących usuwania nieużytków. Taka sytuacja może zatem doprowadzić do wycieków pamięci.

W ES6 znajdziemy na to zaskakujący sposób – wystarczy by movingSet nie był kolekcją Set, lecz WeakSet. Problem wycieku pamięci rozwiązany!

Oznacza to, że możemy poradzić sobie z nim za pomocą słabej kolekcji lub symboli. Które rozwiązanie jest lepsze? Niestety, jeśli chciałbym wyczerpująco przedstawić wszystkie za i przeciw, niniejszy artykuł stałby się nieco za długi. Jeśli masz taką możliwość, pewnie dobrze będzie przez cały czas korzystać na stronie z jednego symbolu. Jeśli zaś w pewnym momencie zechcesz utworzyć wiele tymczasowych symboli, potraktuj to jako ostrzeżenie. Zastanów się, czy w ich miejsce nie możesz użyć kolekcji WeakMap, dzięki czemu unikniesz wycieku pamięci.

WeakMap i WeakSet

Kolekcje WeakMap i WeakSet działają tak samo jak Map i Set z wyjątkiem kilku ograniczeń:

  • WeakMap obsługuje tylko konstruktor new oraz metody .has(), .get(), .set() i .delete().
  • WeakSet obsługuje jedynie konstruktor new oraz metody .has(), .add() i .delete().
  • Wartości przechowywane w kolekcji WeakSet i klucze przechowywane w kolekcji WeakMap muszą być obiektami.

Ponadto zwróć uwagę, że żadna ze słabych kolekcji nie jest iterowalna. Dostęp do pozycji można uzyskać jedynie poprzez bezpośrednie odwołanie się do niej za pomocą interesującego nas klucza.

Dzięki tym dokładnym ograniczeniom system sprzątania pamięci (ang. garbage collector, GC) może oczyścić używane słabe kolekcje z nieużywanych obiektów. Podobny efekt można osiągnąć, stosując słabe referencje lub słowniki ze słabymi kluczami, jednak słabe kolekcje ES6 zapewniają korzyści z zarządzania pamięcią nie ujawniając działania GC na skryptach.

JS jest inny, część 3: ukryć niedeterministyczne działanie GC

Za kulisami słabe kolekcje są implementowane jako tablice efemeryczne (ang. ephemeron tables).

Mówiąc krótko, kolekcja WeakSet nie przechowuje silnej referencji do obiektów, które zawiera. Obiekt pobierany z WeakSet zostaje po prostu usunięty z tej kolekcji. Podobnie jest w przypadku kolekcji WeakMap – nie przechowuje ona silnej referencji do żadnego ze swoich kluczy. Jeśli klucz jest aktywny, to także powiązana z nim wartość jest aktywna.

Dlaczego mielibyśmy się godzić na te ograniczenia? Dlaczego nie można by po prostu dodać do JS słabych referencji?

Raz jeszcze komisja standaryzacyjna nie chciała udostępniać skryptom niedeterministycznego sposobu działania. Problemy ze zgodnością między przeglądarkami to zmora technologii sieciowych. Słabe referencje ujawniają szczegóły implementacji używanego systemu sprzątania pamięci – to typowy przykład nieobliczalnego zachowania charakterystycznego dla danej platformy. Oczywiście aplikacje nie powinny być zależne od szczegółowych cech działania poszczególnych platform, lecz słabe referencje sprawiają, że trudno zorientować się do jakiego stopnia funkcjonowanie naszego kodu zależy od zachowania GC w testowanej przeglądarce.

Słabe kolekcje ES6 mają natomiast mniej elementów funkcjonalności, lecz są niezawodne. Pobranie klucza lub wartości nie jest nigdy obserwowalne, zatem aplikacje nie będą na tej operacji polegać – nawet przypadkowo.

To przypadek, w którym przesłanki sieciowe doprowadziły do zaskakującej decyzji, dzięki której JS stał się lepszym językiem.

Kiedy będę mógł zacząć korzystać z kolekcji?

Wszystkie cztery klasy kolekcyjne są obecnie dostępne w przeglądarkach Firefox, Chrome, Microsoft Edge i Safari. Aby używać kolekcji w starszych przeglądarkach, skorzystaj z wypełniacza, np. es6-collections.

Kolekcja WeakMap została po raz pierwszy zaimplementowana w Firefoksie przez Andreasa Gala, który później pracował w Mozilli na stanowisku dyrektora ds. technologii. Kolekcję WeakSet zaimplementował natormiast Tom Schuster, a ja zaimplementowałem Map i Set. Podziękowania dla Tooru Fujisawy za opracowanie kilku łatek.

Omówiliśmy już wiele tematów, ale wciąż czeka na nas jeszcze kilka najpotężniejszych składników ES6. Odwiedzaj nas zatem regularnie, by nie przegapić kolejnego artykułu.

Autor: Jason Orendorff

Źródło: https://hacks.mozilla.org/2015/06/es6-in-depth-collections/

Tłumaczenie: Joanna Liana

Treść tej strony jest dostępna na zasadach licencji CC BY-SA 3.0