PO Kolekcje - przegląd
Kolekcje - przegląd
Wprowadzenie
Poznaliśmy już ogólny sposób posługiwania się kolekcjami i wiemy, że 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ąca 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 najmniejsz zużycie pamięci. No i oczywiście zawsze wtedy, gdy napisawszy już 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 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.<ref name="PO_kol_liczba">Na przedstawionym diagramie nie zamieściliśmy kompletu kolekcji z Javy (jest ich o wiele więcej), wybraliśmy jedynie te najbardziej znaczące.</ref>
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 jak i dla tych, którzy ją rozbudowują. Dlatego jest to dla nas tak ważne - 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, że 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 wydefiniować 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 proponowac 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 mimo, że nie znamy jej implementacji. Drugim ciekawym elementem w tej implementacji jest traktowanie wartości null.
Klasy konkretne
Czy do działania na kolekcji potrzebna jest kolekcja?
Problemy związane z korzystaniem z kolekcji
Przypisy
<references/>