Personal tools

PO Kolekcje - przegląd

From Studia Informatyczne

<<< Powrót

Spis treści

Kolekcje - przegląd

Wprowadzenie

Poznaliśmy już ogólny sposób posługiwania się kolekcjami i wiemy, ze pozwala on pisać bardzo ogólne programy, które odwołują się jedynie do interfejsów, a nie do ich implementacji. To niesłychanie ważna i cenna możliwość, pozwalająca pisać ogólniejsze programy, a przez to znacznie ułatwiające ich późniejsze modyfikowanie. Jeśli tylko można, to powinno się programować w kategoriach używanych interfejsów, a nie konkretnych klas.

Są jednak sytuacje, gdy koniecznie musimy wiedzieć z jaką konkretną klasą mamy do czynienia. Czasami dlatego, że spośród wielu klas implementujących jakiś interfejs koniecznie chcemy wybrać tę, która ma najlepsze specyficzne własności, na przykład szybkość działania albo zużywa najmniej pamięci. No i oczywiście zawsze wtedy, gdy już napisawszy program w kategoriach interfejsów musimy dostarczyć mu konkretne obiekty.

Dlatego w tym wykładzie przyjrzymy się różnym implementacjom kolekcji. Zbadamy jakie możliwości oferują poszczególne klasy (na przykład które są szybsze w działaniu i jaką za to płacimy cenę). Będziemy też przy tej okazji zastanawiali się nad całą konstrukcją tej części biblioteki klas. Na koniec zajmiemy się typowymi problemami związanymi z korzystaniem z kolekcji.

Przegląd klas implementujących kolekcje

Na załączonym rysunku przedstawimy hierarchię klas implementujących kolekcje w Javie.[1]

grafika:PO_kolekcje.jpg

Po pierwsze widzimy, że klasy wszystkich kolekcji są klasami uogólnionymi. To bardzo cenne, bo oznacza, że możemy się nimi posługiwać w sposób bezpieczny ze względu na typy. Po drugie, zwraca uwagę duża liczba klas, których nazwy zaczynają się od słowa "Abstract". Mimo że większość użytkowników Javy raczej nie będzie miała okazji z nich (bezpośrednio) skorzystać, my zaczniemy nasz przegląd klas właśnie od nich, bo ilustrują ciekawe z obiektowego punktu widzenia podejście.

Klasy abstrakcyjne

Jak już wiemy klasy abstrakcyjne, to klasy które nie mają (i nie mogą mieć) obiektów. Mimo tego ograniczenia są niesłychanie użyteczne przy budowaniu hierarchii pojęć (klas). Ze względu na brak wielodziedziczenia w Javie, klasy abstrakcyjne pełnią tu jednak znacząco mniej istotną rolę, niż np. w C++. Bardzo często zamiast nich używamy w Javie interfejsów.

Oba rozwiązania (klasy abstrakcyjne i interfejsy) mają swoje zalety i wady, warto używać obu tych rozwiązań w swoich programach. Jednak na pierwszy rzut oka może dziwić, czemu hierarchia kolekcji jest trójwarstwowa:

  • interfejsy,
  • klasy abstrakcyjne,
  • klasy konkretne.

Wbrew pozorom nie jest to ani dziwne, ani rzadkie rozwiązanie.

Pierwsza warstwa (interfejsów) jest przeznaczona przede wszystkim dla użytkowników - tu jest opisane jakich pojęć dostarcza hierarchia (bibliotek) i jak się nimi posługiwać. Jak pokazywaliśmy wcześniej, znajomość tej warstwy praktycznie wystarcza do programowania w kategoriach narzędzi i usług dostarczanych przez daną bibliotekę. Podkreślaliśmy nawet, że należy dążyć do tego, by tworzone programy nie starały się sięgać poniżej tej warstwy (poza przypadkami, gdzie tworzymy obiekty i musimy wskazać ich konkretne klasy).

Dla kogo zatem jest druga warstwa? Dla twórców biblioteki - zarówno jej wersji pierwotnej jak i dla tych, którzy ją rozbudowują. Dlatego jest to dla nas tak ważna - wszak nie tylko po to tak dokładnie omawiamy tę bibliotekę, by poznać szczegóły jej używania, ale przede wszystkim po to, by nauczyć się konstruować własne biblioteki i hierarchie pojęć.

Interfejsy są znakomite do opisywania tego, jak posługiwać się pojęciami (klasami) dostarczanymi przez biblioteki (właściwie jedyne co można im zarzucić, to fakt, że nie pozwalają opisywać konstruktorów). Często jednak przy tworzeniu hierarchii klas okazuje się, że jesteśmy w stanie opisać na bardzo wysokim poziomie abstrakcji implementację niektórych z operacji udostępnianych przez bibliotekę. Bardzo dobrym przykładem takiej sytuacji jest metoda addAll z poprzedniego wykładu. Oczywiście nie miałoby sensu w każdej klasie implementującej interfejs Collection zapisywać tę implementację na nowo. Nie można tego również zrobić w warstwie interfejsów. Pozostaje zatem dodanie warstwy klas abstrakcyjnych, w której zawarta zostanie ta część implementacji, która nie zależy od konkretnych rozwiązań przyjętych w klasach konkretnych.

W hierarchii kolekcji te klasy pełnią jeszcze dodatkową funkcję. Gdybyśmy chcieli dodać własną kolekcję (nie jest to wprawdzie bardzo częsta sytuacja ze względu na bogactwo klas już istniejących), to nie musimy w tym celu pisać wiele metod - wystarczy aby nowa klasa dziedziczyła po odpowiedniej klasie abstrakcyjnej. Na przykład żeby stworzyć nową realizację sekwencji elementów za pomocą listy wystarczy stworzyć klasę dziedziczącą po klasie AbstractSequentialList i zaimplementować zaledwie dwie metody: listIterator i size. Z kolei dostarczenie odpowiedniego iteratora (dla listy której nie będziemy modyfikować) wymaga zaimplementowania w nim jedynie metod hasNext, next, hasPrevious, previous, nextIndex i previousIndex.

Obecność trzeciej warstwy jest oczywista. Zwróćmy jednak uwagę na to, że dzięki wprowadzeniu pierwszej i drugiej warstwy warstwa implementacji jest bardzo ustrukturalizowana, a dzięki drugiej warstwie jej implementacja jest znacznie uproszczona - definiując nowe klasy definiujemy tylko te ich cechy, które są różne w stosunku do pozostałych klas.

Przyjrzyjmy się jeszcze bliżej klasom abstrakcyjnym wymienionym na naszym diagramie kolekcji.

AbstractCollection<E>

Już na tak wysokim poziomie abstrakcji jest sporo metod, które można zaimplementować. To bardzo istotne i pouczające - budujmy własne hierarchie klas tak, by wyodrębniać te operacje, którymi istotnie się różnią przedstawione w naszej hierarchii pojęcia, a resztę funkcjonalności budujmy w kategoriach tych pojęć.

Pamiętajmy też o tym, ze podanie implementacji w klasie abstrakcyjnej nie oznacza, że wszystkie podklasy są skazane na tę właśnie implementację. Metody możemy swobodnie przedefiniowywać w podklasach, a dzięki polimorfizmowi mamy pewność, że zawsze zostanie wywołana właściwa wersja metody.

Niektóre z zaimplementowanych tu metod są bardzo proste, jak na przykład isEmpty:

 public boolean isEmpty() {
   return size() == 0;
 }

Piękno rozwiązań obiektowych wykorzystujących dziedziczenie polega nie tylko na tym, że jesteśmy w stanie zdefiniować pojęcie bycia pustą kolekcją, nie mając jeszcze żadnej konkretnej kolekcji, tak jak to zrobiono w powyższym przykładzie (zaczerpniętym zresztą z oryginalnej implementacji klasy AbstractCollection), ale też na tym, że gdy w jakiejś kolekcji okaże się, że policzenie liczby jej elementów jest kosztowną operacją, a my uznamy, że użytkownicy tej kolekcji stosunkowo często będą badać jej pustość, to wystarczy, że w tej kolekcji na przykład dodamy atrybut pusta, będziemy go stosownie modyfikować przy operacjach wstawiania i usuwania i przekazywać jako wynik operacji isEmpty. Dziedziczenie w połączeniu z polimorfizmem daje wielką elastyczność, pozwala proponować gotowe rozwiązania, ale nie zmusza do ich stosowania.

Oczywiście w klasie AbstractCollection znajdziemy też ciekawsze metody, na przykład contains:

 public boolean contains(Object o) {
   Iterator<E> e = iterator();
   if (o==null) {
     while (e.hasNext())
       if (e.next()==null)
         return true;
   } else {
     while (e.hasNext())
       if (o.equals(e.next()))
         return true;
   }
   return false;
 }

Zwróćmy uwagę na zastosowanie iteratora, dzięki niemu możemy opisać przejście całej kolekcji, mino że nie znamy jej implementacji. Drugim ciekawym elementem w tej implementacji jest traktowanie wartości null. Biblioteka kolekcji Javy nie jest do końca jednorodna, jeśli chodzi o traktowanie tej wartości. Czasem ta wartość jest traktowana jako pełnoprawna i dozwolona, czasem z kolei jest używana do sygnalizowania (jako wartość wyniku metod) różnych nietypowych sytuacji. W niektórych językach obiektowych wartość null jest też obiektem, co znacznie ułatwia jej jednorodne traktowanie. W Javie null oznacza pustą referencję, nie ma więc tu obiektu, którego metody można by wywoływać. W szczególności nie można wywołać metody equals i to jest powód, dla którego w powyższej metodzie są dwie wersje wyszukiwania, jedna specjalnie dla wartości null.

Oczywiście można by zapytać, po co w ogóle wstawiać do kolekcji wartość null, wszak kolekcje mają służyć do przechowywania obiektów, a nie informacji o ich braku. Jednak jest kilka powodów, dla których dobrze jest móc czasami umieścić w kolekcji tę wartość. Po pierwsze często wstawia się do kolekcji nie konkretne, dopiero co utworzone obiekty, lecz obiekty wyliczane przez inne metody. Często te metody jako wynik potrafią przekazać wartość null. We fragmencie programu takim jak poniżej:

 ...
 p = inny_obiekt.metoda();
 kolekcja.add(p);

trudno jest stwierdzić, czy nie następuje wstawienie do kolekcji pustej referencji. Można oczywiście dodać tu sprawdzenie za pomocą instrukcji warunkowej, ale wymaganie, żeby przy każdym wstawianiu do kolekcji dokonywać takich sprawdzeń byłoby bardzo kłopotliwe i przez wiele programów nie byłoby zachowywane. Ponadto czasami wygodne jest wstawianie do kolekcji specjalnych znaczników (na przykład w algorytmie przechodzenia grafu wszerz ze zliczaniem minimalnej odległości odwiedzanych wierzchołków od wierzchołka startowego wygodnie jest zaznaczać koniec jednej warstwy odwiedzonych wierzchołków) a wartość null jest w tej roli bardzo poręczna.

Na zakończenie rozważań na temat wartości null w kolekcjach warto zauważyć, że problemy z tą wartością dotyczą nie tylko kolekcji, i że podejmowane są próby przeniesienia części sprawdzeń tego, czy referencja w danym miejscu programu nie jest pusta na poziom sprawdzania zgodności typów (na przykład przez wprowadzanie dwu wersji typu referencyjnego, zwykłej i takiej, która nie obejmuje wartości null). Ten bardzo ciekawy temat wykracza jednak poza ramy naszego wykładu.

AbstractList<E>

Kolejna abstrakcyjna klasa AbstractList (podklasa klasy AbstractCollection) jest prawie kompletną implementacją interfejsu List<E>. Omówimy kilka ciekawych zagadnień związanych z jej implementacją.

Zacznijmy od bardzo prostej metody add (jak zwykle korzystamy z oryginalnej treści metod udostępnianej przez producenta):

 public boolean add(E e) {
   add(size(), e);
   return true;
 }


Zwróćmy uwagę na to, że kolejne pojęcia są definiowane - o ile to tylko możliwe - w kategoriach innych pojęć (w tym przypadku jest tak, że metodę dodającą element na zadanej pozycji ma zdefiniować użytkownik, o ile chce by kolekcja pozwalała na dodawanie nowych elementów). Ta definicja jedynie precyzuje, że wstawianie do listy oznacza wstawianie na koniec. Jeśli z jakiś powodów zmienimy implementację listy, to tej metody zmieniać nie będziemy musieli, będziemy więc mieć nie tylko mniej pracy, ale także mniej okazji do popełnienia błędów.

Przy okazji, implementacja wstawiania na zadanej pozycji w tej klasie wygląda następująco:

 public void add(int index, E element) {
   throw new UnsupportedOperationException();
 }

Zatem użytkownik tej klasy, który chce mieć kolekcję bez operacji wstawiania nie musi tej operacji implementować. To rozwiązanie ma niestety też i wady. Jeśli użytkownik tej klasy jednak gdzieś w programie przez pomyłkę wywoła operacje wstawienia, to błąd ten nie zostanie wykryty przez kompilator i dopiero podczas działania programu będzie wygenerowany wyjątek.

Ciekawą cechą tej klasy jest również to, że implementuje ona własny iterator (a dokładniej dwa iteratory, jeden zwykły (Iterator) i drugi listowy (ListIterator)). To typowe rozwiązanie, każda kolekcja implementuje specyficzny dla siebie iterator w postaci zagnieżdżonej klasy.

Zwróćmy też uwagę na przykład ciekawego wykorzystania interfejsów - jako interfejsów znacznikowych - w metodzie sublist:

 public List<E> subList(int fromIndex, int toIndex) {
   return (this instanceof RandomAccess ?
           new RandomAccessSubList<E>(this, fromIndex, toIndex) :
           new SubList<E>(this, fromIndex, toIndex));
 }

RandomAccess jest pustym interfejsem (nie zawiera żadnej deklaracji). Zgodnie z dokumentacją interfejsu List<E> te jego implementacje, które oferują szybki (czyli praktycznie w czasie stałym) dostęp do elementów kolekcji mogą wskazać, że realizują interfejs RandomAccess, co pozwala potem twórcom algorytmów listowych wybierać realizację lepiej dostosowaną do struktury danych. Tu mamy akurat przykład operacji dającej podlistę listy istniejącej. Nie wnikając w szczegóły zastosowanych tu klas RandomAccessSubList<E> i SubList<E> możemy zauważyć, że typ tworzonej (pod)listy zależy od własności listy, na której działamy. Metody tworzące nowe obiekty typu zależnego od pewnych warunków pozwalają na tworzenie bardzo wygodnych i elastycznych rozwiązań, bo zauważmy, że użytkownik tej metody sam nie musi podawać, jaka podlista ma być stworzona, decyzję podejmuje metoda subList w sposób niewidoczny dla użytkownika.[2] Zauważmy też, że mamy tu kolejny przykład sytuacji prowadzącej do polimorfizmu - użytkownik tej metody nie wie, jaka będzie konkretna klasa jej wyniku, ale wie, że będzie to klasa realizująca interfejs List<E>, co w zupełności wystarcza do operowania na tym wyniku.

Implementacje pozostałych klas abstrakcyjnych nie są w tej chwili dla nas istotne, więc ich omówienie pomijamy (odsyłając zainteresowanych czytelników do lektury kodu źródłowego).

Klasy konkretne

Nie będziemy tu podawać szczegółowego opisu poszczególnych kolekcji Javy (taki opis można bez trudu znaleźć w dokumentacji), zrobimy jedynie przegląd najważniejszych cech poszczególnych implementacji pojęcia kolekcja.


Implementacje interfejsu Set<E>

HashSet<E> implementacja interfejsu Set<E>. Gdy potrzebna jest implementacja pojęcia zbioru, najczęściej jest wybierana klasa HashSet<E>. Wiąże się to z zastosowanym w jej realizacji haszowaniem, które sprawia, że dostęp do poszczególnych elementów zbioru odbywa się praktycznie w czasie stałym. Czemu to takie istotne (przecież tę własność ma także dostęp do elementów tablic)? Dlatego, że w przeciwieństwie do tablic zbiory mogą dynamicznie zmieniać swój rozmiar. A połączenie możliwości dynamicznego zmieniania rozmiaru i bardzo szybkiego dostępu do dowolnych elementów jest bardzo trudne implementacyjnie. Ta implementacja nie pozwala nic zakładać o kolejności elementów (co jest oczywiście w pełni akceptowalne dla zbiorów).

LinkedHashSet<E> implementacja interfejsu Set<E> (uwaga: nie SortedSet<E> !), która zapewnia prawie tak dobrą szybkość operacji jak HashSet<E> a jednocześnie zapewnia, że przechodząc (za pomocą iteratora) elementy zbioru, dostaniemy je w kolejności wstawiania do zbioru[3] Ten efekt osiągnieto łącząc haszowanie z listami dwukierunkowymi. Ponieważ zbiory z definicji (matematycznej) nie są uporządkowane, ta realizacja zbiorów jest zdecydowanie rzadziej używana.

TreeSet<E> implementacja interfejsu SortedSet<E>. Tym razem jako nośnika do przechowywania zawartości zbioru użyto drzew czerwono-czarnych, czyli jednej z implementacji drzew binarnych wyszukiwań. Wprawdzie ta realizacja zbioru (interfejs SortedSet<E>dziedziczy po interfejsie Set<E>) jest wolniejsza niż HashSet<E>, ale za to pamięta swoje elementy w kolejności posortowanej.[4]

Mówiąc o sortowaniu elementów musimy zadać porządek (liniowy), według którego owo sortowanie ma być wykonywane. Java daje dwie możliwości definiowania takiego porządku. Po pierwsze może to być porządek naturalny, czyli porządek wyznaczony przez operację compareTo z interfejsu Comparable<T> (w tym przypadku wymaga się, by elementy kolekcji implementowały ten interfejs). Zwróćmy uwagę, że w przeciwieństwie do wielu innych języków obiektowych Java nie pozwala na przeciążanie operatorów, w związku z czym narzucające się rozwiązanie, by porządek naturalny był zdefiniowany operatorem <=, nie jest możliwe w Javie.

Druga możliwość to podanie specjalnego obiektu definiującego operację porównywania. Jest to często potrzebne nawet wtedy, gdy przechowywane w kolekcji obiekty mają zdefiniowany porządek naturalny, bo często potrzebne jest sortowanie tych samych wartości według różnych kryteriów. Np. pary można sortować wg pierwszej współrzędnej (a dopiero w przypadku równości pierwszej współrzędnej porównywać drugie), albo drugiej, albo np. pierwiastka z sumy kwadratów współrzędnych (co oznacza porządkowanie według odległości od pary (0,0) przy geometrycznej interpretacji par jako punktów na płaszczyźnie). Obiekty definiujące relację porównywania implementują interfejs Comparator<T>. W tym interfejsie występują dwie metody:

  • int compare(T o1, T o2)
  • boolean equals(Object obj)

Pierwsza z nich ma dawać jako wynik wartość ujemną, zero lub wartość dodatnią stosownie do tego, czy pierwszy argument jest mniejszy, równy bądź większy od drugiego.

Implementacje interfejsu List<E>

ArrayList<E> podstawowa realizacja interfejsu List<E>, zapewnia stały czas wykonywania operacji size, isEmpty, get, set, iterator i listIterator oraz zamortyzowany stały czas operacji add. Jest to najczęściej używana realizacja interfejsu List<E>.

LinkedList<E> implementacja interfejsu List<E> wykorzystująca listy dwukierunkowe. W związku z tym realizacja dostępu do elementów tej kolekcji za pomocą indeksów jest nieefektywna. Natomiast może być szybsza od ArrayList<E> w przypadku częstego usuwania i wstawiania elementów. Ponadto implementuje interfejsy Queue<E> i Dequeue<E>, więc świetnie się nadaje do implementowania stosów i kolejek.

Implementacje interfejsu Map<K,V>

HashMap<K,V> podstawowa implementacja interfejsu Map<K,V>. Zgodnie z nazwą stosuje w swojej implementacji haszowanie. Zapewnia dostęp do elementów (metody get i put) w czasie stałym (o ile funkcja haszująca równomiernie rozrzuci klucze).

LinkedHashMap<K,V> implementacja interfejsu Map<K,V> zapewniająca dodatkowo, że iterowanie po zawartości tej kolekcji pozwala przeglądać jej elementy w kolejności w jakiej były wstawiane[5]. W tym celu wykorzystywana jest lista dwukierunkowa (oprócz haszowania). Zapewnia prawie tak dobry czas działania jak HashMap<K,V>.

TreeMap<K,V> implementacja interfejsu Map<K,V> zapewniająca dodatkowo, że iterowanie po zawartości tej kolekcji pozwoli przeglądać jej elementy w kolejności wyznaczonej przez pewien liniowy porządek zdefiniowany na kluczach. Tak jak w przypadku TreeSet<E> ten porządek może być porządkiem naturalnym bądź porządkiem zdefiniowanym przez obiekty klasy Comparator<T>. Zapewnia logarytmiczny czas wykonywania operacji containsKey, get, put oraz remove.

Czy do działania na kolekcji potrzebna jest kolekcja?

Pytanie wcale nie jest tak niezasadne, jak mogłoby się wydawać na pierwszy rzut oka. Oczywiście mając kolekcję łatwo wykonywać na niej różne operacje, ale okazuje się, że wcale nie jest to konieczne, a nawet czasami nie jest wcale wygodne. Jeśli zadaniem metody, którą właśnie mamy napisać, ma być sekwencyjne przeglądanie zawartości kolekcji, to jedyne czego potrzebujemy to iterator!

Porównajmy treści dwu poniższych metod:

 public <T> void przegląd1(Collection<T> kol){
   for(T elt: kol) 
     System.out.print(elt + ", ");
   System.out.println();
 }
 public <T> void przegląd2(Iterator<T> it){
   while (it.hasNext())
     System.out.print(it.next() + ", ");
   System.out.println();
 }

Obie służą do tego samego: przejrzenia zawartości pewnej kolekcji, z tym że w pierwszej metodzie ta kolekcja jest jawnie podana, w drugiej zaś parametrem jest sam iterator. Obie metody można dość podobnie wywoływać:

 Integer[] tabi = {1, 2, 3, 4};
 String[] tabs = {"wiosna", "lato", "jesień", "zima"};
 
 List<Integer> l1 = new ArrayList<Integer>(Arrays.asList(tabi));
 Set<String> s1 = new HashSet<String>(Arrays.asList(tabs));
 
 przegląd1(l1);
 przegląd1(s1);
 
 przegląd2(l1.iterator());
 przegląd2(s1.iterator());
 

Podobnie możemy obejrzeć zawartość kolekcji będących implementacjami interfejsu Map<K,V>, trzeba tylko pamiętać, że one same nie udostępniają iteratorów, więc najpierw trzeba pobrać kolekcję par (lub kluczy czy wartości):

 Map<String, Integer> m1 = new HashMap<String, Integer>();
 
 for(String s: tabs)
   m1.put(s, s.length());
 przegląd1(m1.entrySet());
 przegląd2(m1.entrySet().iterator());

Już zobaczyliśmy, że w wielu przypadkach zamiast kolekcji wystarczy przekazać iterator, kiedy jednak to rozwiązanie jest lepsze od przekazywania kolekcji? Właśnie wtedy, gdy nie mamy kolekcji! O ile kolekcje muszą implementować odpowiednie interfejsy (Collection<E> lub Map<K,V>), o tyle klasy mające swoje iteratory mogą być w dowolnym miejscu hierarchii klas, wystarczy że mają iterator implementujący interfejs Iterator<E>. Można więc tworzyć bardzo różne klasy (generator liczb pierwszych, klasa pobierająca z Internetu wyniki notowań giełdowych itp.) dla których sekwencyjne przeglądanie wyników ma sens i przetwarzać je tak samo jak kolekcje, dzięki iteratorowi (p. ćwiczenia).

Problemy związane z korzystaniem z kolekcji

Niestety z używaniem kolekcji związanych jest szereg problemów. Tu je tylko wymieniamy:

  • problem tego co jest przechowywane w kolekcjach

Innymi słowy jaka jest semantyka wstawiania do kolekcji, czy wstawiamy obiekty czy wartości. Wstawianie obiektów (a dokładniej referencji do nich) lepiej pasuje do podejścia obiektowego (mówimy wszak, że wstawiamy do kolekcji obiekt). Z drugiej strony z punktu widzenia obiektu będącego kolekcją dość niepokojące jest, ze musi zarządzać obiektami, które są dostępne z zewnątrz. Może to prowadzić do pewnych anomalii (na przykład gdy obiekt znajdujący się w posortowanej kolekcji zostanie zmieniony z zewnątrz). W przeciwieństwie do C++ Java nie ma aż tak rozbudowanych mechanizmów pozwalających na precyzyjne określanie, czy w momencie wstawiania do kolekcji obiekty mają być (czy nie) kopiowane. Jedyne co można robić, to wywoływać metodę clone() (zdefiniowaną w klasie Object i często przedefiniowywaną).

  • związany z poprzednim problem modyfikowania z zewnątrz obiektów zawartych w kolekcjach,
  • problem modyfikacji kolekcji podczas przeglądania
    .

Przypisy

  1. Na przedstawionym diagramie nie zamieściliśmy kompletu kolekcji z Javy (jest ich o wiele więcej), wybraliśmy jedynie te najbardziej znaczące.
  2. Podkreślmy też w tym miejscu, że użycie instanceof w programach obiektowych powinno być zawsze starannie przemyślane. Zwykle zamiast kodu stosującego tę operację można stworzyć dużo krótszy i czytelniejszy zapis korzystając z polimorfizmu.
  3. Za wstawienie nie jest uważane w tym kontekście ponowne wstawienie, czyli wstawienie elementu już znajdującego się w zbiorze.
  4. Oczywiście nasuwa się tu pytanie o to, czy jest sens mówić o kolejności elementów w zbiorze, ale twórcy tej hierarchii uznali, że warto wyodrębnić pewną grupę zbiorów o takiej własności.
  5. Podobnie jak w przypadku LinkedHashSet<E> na tę kolejność nie ma wpływu ponowne wstawianie elementów będących już w kolekcji