PO Kolekcje wstęp

From Studia Informatyczne

<<< Powrót

Spis treści

Kolekcje

Wprowadzenie

Rzadko kiedy zdarzają się programy na tyle proste, że do działania wystarcza im kilka zmiennych przechowujących pojedyncze wartości. Zwykle programy muszą operować na większej liczbie danych. Wiemy już, że możemy w tym celu użyć tablic. Często jest to wystarczające rozwiązanie, ale ma ono pewne ograniczenia. Tablice mają stały rozmiar, a często przy pisaniu programów występują sytuacje, gdy liczba przechowywanych wartości zmienia się w trakcie działania programu. Co więcej bardzo często chcemy narzucić pewną strukturę przechowywanym wartościom (na przykład chcemy, by były posortowane, albo chcemy utrzymywać powiązanie między dwoma zestawami wartości). Ponieważ takie sytuacje zdarzają się bardzo często, praktycznie każdy współczesny język programowania obiektowego dostarcza jakiś zestaw kolekcji - klas, których obiekty służą do przechowywania zestawów innych obiektów.

Ponieważ kolekcje mają być uniwersalne, to znaczy mają służyć do przechowywania obiektów różnych typów, w naturalny sposób do ich tworzenia wykorzystuje się omówione przez nas wcześniej typy uogólnione. Można nawet zaryzykować stwierdzenie, że kolekcje są głównym powodem, dla którego wprowadza się do języków programowania typy uogólnione.

Inną ciekawą dla nas cechą kolekcji jest ich organizacja. Wszystkie kolekcje służą do przechowywania obiektów. Skoro tak, to są zestawem podobnych do siebie klas. A zatem naturalnie i wygodnie będzie przedstawić je w postaci hierarchii. Dzięki temu nauka posługiwania się kolekcjami jest dużo prostsza. Nie musimy dla każdej kolekcji od nowa uczyć się jej interfejsu (języka jakim mamy się z nią porozumiewać). Jeśli raz nauczymy się ogólnego interfejsu kolekcji, to przy kolejnych członkach tej hierarchii będziemy musieli uczyć się jedynie specyficznych dla danej kolekcji pojęć i operacji. Co więcej, mając hierarchię kolekcji, możemy tworzyć bardzo abstrakcyjne programy, które działają z (nieokreśloną z góry) kolekcją zadaną jako parametr i w zależności od tego, jaką kolekcję otrzymają, inaczej będą działać. Klasycznym przykładem jest tu problem obchodzenia grafu: jeśli program obchodzący graf dostanie jako parametr stos, to dokona obejścia grafu w głąb, jeśli natomiast jako parametr otrzyma kolejkę, to obejdzie graf wszerz.

Kolejnym powodem, dla którego tak dokładnie przyjrzymy się kolekcjom, jest sposób dostępu do zawartych w nich danych. Oczywiście sam fakt przechowywania wartości nie wystarcza, ważne jest by można było wygodnie na nich operować. W tym celu w podejściu obiektowym stosuje się szereg standardowych technik, z których najczęściej spotykaną jest zastosowanie wzorca Iterator.

Nasze rozważania o kolekcjach zakończymy rozważeniem pewnych niebezpieczeństw związanych z ich stosowaniem. Przede wszystkim zastanowimy się, co właściwie chcemy przechowywać w kolekcjach (oryginały czy ich kopie) oraz przyjrzymy się pewnym anomaliom pojawiającym się wtedy, gdy korzystając z niektórych kolekcji zapomnimy o przedefiniowaniu operacji equals lub hashCode.

Kolekcje w Javie

Zacznijmy od sprecyzowania pojęcia kolekcji. Przez kolekcje będziemy rozumieli obiekty służące do przechowywania (innych) obiektów i udostępniające mechanizmy pozwalające na wstawianie, przeglądanie i pobieranie składowanych w nich obiektów. Dodatkowo będziemy też oczekiwać, że kolekcja jest w stanie pomieścić w zasadzie dowolną (to znaczy ograniczoną tylko pojemnością pamięci komputera) liczbę obiektów, choć nie musi to oznaczać, że każda kolekcja ma mieć zmienny rozmiar (być może rozmiar będziemy musieli zadać od razu przy tworzeniu kolekcji). To dodatkowe żądanie oznacza, że np. obiektów klasy Para (z wykładu o typach uogólnionych) nie chcemy traktować jako kolekcji. Dla uproszczenia nazwą kolekcja będziemy również określać klasy obiektów będących kolekcjami i interfejsy definiujące właściwe dla kolekcji zachowania implementowane przez te obiekty.

Uwaga notacyjna: hierarchia interfejsów i klas kolekcji w Javie jest bardzo rozbudowana, przy czym ani dla interfejsów, ani dla klas nie jest drzewem (w obu przypadkach jest lasem). W związku z tym, mimo tego, że w pakiecie java.util występuje interfejs Collection, nie wszystkie omawiane przez nas kolekcje go implementują czy dziedziczą. Tym nie mniej poręczniej będzie nam używać w dalszej części wykładu terminu kolekcja w szerszym znaczeniu (w odniesieniu do obiektów, klas i interfejsów służących do przechowywania grup obiektów), a nie tylko do podinterfejsów oraz klas i obiektów implementujących interfejs Collection.

Java dostarcza bardzo bogaty zestaw kolekcji zawierający interfejsy, klasy abstrakcyjne i oczywiście klasy konkretne. Część z tych kolekcji służy specjalnym zastosowaniom (np. przy tworzeniu aplikacji webowych), część została stworzona z myślą o rozwiązaniu specyficznych problemów (np. związanych z synchronizowaniem dostępu do kolekcji przy programowaniu współbieżnym). W naszych rozważaniach nie będziemy ich brać pod uwagę i skupimy się jedynie na najbardziej ogólnych kolekcjach i ich zastosowaniu.

Kolekcje w Javie zostały umieszczone w pakiecie java.util, zatem na początku programów korzystających z kolekcji zazwyczaj umieszczamy deklarację umożliwiającą dostęp do całego pakietu:

 import java.util.*;

Przegląd interfejsów kolekcji

Nasz przegląd kolekcji zaczniemy od przyjrzenia się najważniejszym pojęciom wprowadzonym przez ich twórców. Oto (uproszczona) hierarchia interfejsów kolekcji w Javie.

grafika:PO_interfejsy_kolekcji.jpg



Po pierwsze zwróćmy uwagę, że jest ona lasem złożonym z dwóch drzew. Większe z nich ma jako korzeń interfejs Iterable<E>. Ten interfejs jest bardzo skromny - składa się tylko z jednej metody:

 public interface Iterable<E>{
   Iterator<E> iterator();
 }

Klasy implementujące ten interfejs zobowiązują się do dostarczenia narzędzia pozwalającego na przeglądanie ich zawartości - iteratora. Jakkolwiek taki mechanizm jest bardzo użyteczny (i będziemy często po niego sięgać), nie stanowi on o byciu (lub nie) kolekcją i dlatego na razie odłożymy jego dokładniejsze omówienie. Więcej na temat klasy Iterator<E> powiemy w dalszej części tego wykładu.

Interfejs Collection

Sama nazwa kolejnego interfejsu - Collection - świadczy o jego kluczowym znaczeniu w hierarchii kolekcji. W rzeczy samej jest to interfejs "typowej" kolekcji. Przypatrzmy się zatem mu dokładniej.

 public interface Collection<E>
        extends Iterable<E>{
 // ...
 }
Badanie rozmiaru kolekcji

Podstawową informacją o kolekcji jest to, czy w ogóle zawiera ona jakieś elementy. Tę informację możemy dostać za pomocą interfejsu Collection aż na dwa sposoby

 boolean isEmpty();
 int size();

Oczywiście druga metoda jest bardziej ogólna (ale i niebezpieczna), gdyż podaje liczbę elementów kolekcji. Jeśli nie wiesz, czemu druga wersja jest niebezpieczna, to zajrzyj do dokumentacji tej metody (wskazówka: kolekcje mogą być potencjalnie dowolnie duże).

Wstawianie elementów

Jeśli okaże się, że kolekcja jest pusta, to pewnie będziemy chcieli coś do niej wstawić, służą do tego poniższe metody

 boolean add(E e)
 boolean addAll(Collection<? extends E> c)

Pierwsza z metod wstawia pojedynczy element do kolekcji. Zwróćmy uwagę, że realizacja tej operacji będzie bardzo się różniła w zależności od kolekcji, do której wstawiamy element, na przykład dla zbioru może się okazać, że po wykonaniu operacji add liczba elementów kolekcji się nie zmieniła (bo element już wcześniej był w zbiorze). To, czego oczekujemy od kolekcji implementującej ten interfejs, to zapewnienie, że wskazany element po wykonaniu tej operacji będzie znajdował się w kolekcji i będzie tam umieszczony zgodnie z jej rodzajem (np. na odpowiednim miejscu w zadanym porządku, jeśli to będzie kolekcja posortowana). Użytkownik tego interfejsu nie może niczego więcej zakładać o realizacji (np. zwiększenia rozmiaru kolekcji). Druga operacja wstawia do jednej kolekcji (tej dla której wywołano addAll) wszystkie elementy drugiej kolekcji (parametru c). Warto zwrócić uwagę na to, że choć na pierwszy rzut oka druga metoda wydaje się bardziej ogólna i można by za jej pomocą zastąpić użycia pierwszej[1], to zwykle postępuje się odwrotnie i drugą z nich implementuje za pomocą pierwszej, gdyż można to zrobić w sposób bardzo ogólny, za pomocą iteratora.

Jeśli wynikiem metody wstawiającej jest true, to znaczy, że w wyniku wykonania operacji wstawiania kolekcja się zmieniła (wstawiono do niej obiekt). Wynikiem metody wstawiającej jest wartość false, jeśli nie dostawiono nowego elementu do kolekcji, bo już tam był (większość kolekcji nie sprawdza, czy wstawiany element już w nich występuje i zezwala na wielokrotne wstawianie tego samego obiektu dając jako wynik true). Jeśli wstawienie nie powiodło się z jakiś innych powodów, to metoda wstawiająca musi zgłosić wyjątek.

Zwróćmy też uwagę na typ metody addAll. Ponieważ typy uogólnione nie są kowariantne (w przeciwieństwie do tablic) ze względu na swoje argumenty, narzucająca się deklaracja

 boolean addAll(Collection<E> c)

byłaby bardzo restrykcyjna, bo nie pozwalałaby dodać kolekcji obiektów podklasy klasy E (czyli np. do kolekcji Osób nie można by było dodać kolekcji zadeklarowanej jako kolekcja Pracowników, nawet gdyby Pracownik byłby podklasą Osoby). Zamiast notacji z dżokerem można oczywiście użyć (z tym samym skutkiem) notacji ze zmienną typową

  <E1 extends E> boolean addAll(Collection<E1> c)
Badanie zawartości kolekcji

Skoro już mamy elementy w kolekcji, będziemy chcieli dowiedzieć się czegoś więcej o jej zawartości. Pełną informację o zawartości kolekcji można uzyskać za pomocą iteratora (o tym za chwilę). Jeśli chcemy dowiedzieć się, czy jakiś element (lub elementy) występuje w kolekcji, to możemy to sprawdzić za pomocą jednej z dwu metod

 boolean contains(Object o)
 boolean containsAll(Collection<?> c)

Wynikiem drugiej metody jest true, jeśli wszystkie obiekty z kolekcji c znajdują się w kolekcji, dla której wywołano metodę containsAll. Przyjrzyjmy się dokładniej specyfikacji metody contains. Zgodnie z dokumentacją wynikiem tej metody jest wartość true wtw gdy kolekcja zawiera choć jeden element e taki, że

  (o==null ? e==null : o.equals(e)). 

Wynika stąd przede wszystkim fakt, że omawiane metody nie sprawdzają, czy w kolekcji jest właśnie ten obiekt, który wskazano jako parametr, lecz czy jest tam jakiś obiekt mu równy. Z tego względu dla klasy Para (zdefiniowaliśmy ją w wykładzie Typy uogólnione) poniższy fragment programu

 Collection<Para<String, String>> kol = new ArrayList<Para<String, String>>();
 Para<String, String> p1 = new Para<String, String>("Ala", "Ola");
 Para<String, String> p2 = new Para<String, String>("Ala", "Ola");
 Para<String, String> p3 = new Para<String, String>("Ula", "Ela");
 kol.add(p1);
 System.out.println("Test zawierania: " + kol.contains(p1) + ", " +
     kol.contains(p2) + ", " + kol.contains(p3));

wygeneruje następujący wynik

 Test zawierania: true, true, false

To bardzo istotna uwaga, bo dalsza część dokumentacji stwierdza, że wynikiem jest wartość true, jeśli kolekcja zawiera wskazany element, co jak widać z dokładnego opisu jest tylko skrótem myślowym.

Zwróćmy też uwagę na specjalne traktowanie wartości null. Oprócz zamieszczonego powyżej zapisu dokumentacja zezwala kolekcji na zgłoszenie wyjątku, gdy obiekt, o który występowanie pytamy, ma typ niezgodny z kolekcją lub jest wartością null, a kolekcja na występowanie w niej tej wartości nie zezwala.

Usuwanie z kolekcji

Kolejną operacją, którą często wykonuje się na kolekcjach jest usuwanie. Interfejs Collection oferuje do tego celu dwie operacje:

 boolean remove(Object o)
 boolean removeAll(Collection<?> c)

Pierwsza z tych operacji usuwa z kolekcji wskazany element (a dokładniej element równy elementowi usuwanemu, analogicznie do metody contains), zaś metoda removeAll usuwa z kolekcji wszystkie elementy występujące w kolekcji c będącej parametrem (precyzyjniej: równe elementom z tej kolekcji). Jeśli w kolekcji jest wiele elementów równych wskazanemu jako parametr operacji remove, to usuwany jest tylko jeden z nich (specyfikacja tej operacji nie określa który). Niestety z niejasnych powodów specyfikacja operacji removeAll w takiej samej sytuacji opisuje inne zachowanie - usuwane są 'wszystkie' wystąpienia elementów znajdujących się w kolekcji c. Porównajmy zachowanie obu tych operacji na poniższym przykładzie: stworzymy kolekcję zawierającą trzy razy ten sam element, następnie usuniemy go raz metodą remove, a raz metodą removeAll z parametrem będącym kolekcją z tym jednym elementem. Dla podkreślenia faktu, że przy określaniu, które elementy mają być usunięte z kolekcji używa się operacji equals, stworzymy drugi, równy wstawianemu, element i jego użyjemy do usuwania, ale oczywiście można by wszędzie stosować ten sam element.

 Collection<Para<String, String>> kol = new ArrayList<Para<String, String>>();
 Collection<Para<String, String>> kol2 = new ArrayList<Para<String, String>>();
 Para<String, String> p1 = new Para<String, String>("Ala", "Ola");
 Para<String, String> p2 = new Para<String, String>("Ala", "Ola");
     
 kol.add(p1);
 kol.add(p1);
 kol.remove(p2);
 System.out.println("Test usuwania pojedynczego elementu: " + kol.size());
  
 kol.add(p1);
 kol.add(p1);
 kol2.add(p2);
 kol.removeAll(kol2);
 System.out.println("Test usuwania wszystkich elementów: " + kol.size());

Jako wynik otrzymamy

Test usuwania pojedynczego elementu: 1
Test usuwania wszystkich elementów: 0

Pętla dla każdego

Jeśli już mamy kolekcję, to często chcemy wykonać jakieś operacje na wszystkich jej elementach. Jest to tak często potrzebne, że w Javie dodano specjalną postać instrukcji for służącą do tego celu.[2] Jako ciekawostkę można dodać, że choć w nazwie tej pętli występuje słowo for to powszechnie nazywa się tę postać pętli for foreach, choć nie ma w Javie takiego słowa kluczowego. Zgodnie ze swą potoczną nazwą pętla foreach, czyli dla każdego, służy do wykonywania operacji dla każdego elementu wskazanej kolekcji. Składnia (nieco uproszczona) tej pętli jest następująca:

 for (Typ Identyfikator: Wyrażenie)
   Instrukcja

Identyfikator jest nazwą, za pomocą której można w Instrukcji odwoływać się do kolejnych elementów kolekcji, która jest wyliczana jako wartość Wyrażenia. Typ musi być typem elementów kolekcji. Zasięgiem zadeklarowanej zmiennej o nazwie Identyfikator jest oczywiście Instrukcja. Przykładowe użycie pętli dla każdego mogłoby wyglądać tak (zakładamy, że kol jest zmienną, której wartością jest jakaś kolekcja napisów)

 for(String s: kol)
   System.out.println(s);

Iteratory

Iterator to obiekt pozwalający poruszać się (iterować) po innym obiekcie (kolekcji). Jest to tak popularne i pożyteczne narzędzie, że doczekało się nawet specjalnego wzorca projektowego. Trudno sobie wyobrazić bibliotekę klas, która by nie wspierała tego wzorca.

Zastanówmy się najpierw nad tym, czy rzeczywiście potrzeba nam aż osobnego obiektu do tego by można było przeglądać zawartość kolekcji. Na pierwszy rzut oka wydaje się, że jest kilka prostszych rozwiązań tego problemu, nie wymagających tworzenia specjalnych klas i obiektów. Załóżmy więc, że mamy kolekcję i chcemy obejrzeć wszystkie jej elementy.

Podejścia alternatywne

Po pierwsze, można całą zawartość kolekcji przekazać w postaci innej kolekcji albo tablicy. Oczywiście przekazanie w postaci innej kolekcji jedynie przesuwa problem w inne miejsce, zamiast go rozwiązać. Ale jeśli zawartość kolekcji otrzymamy w postaci tablicy, to już będziemy mogli wygodnie przeglądać jej zawartość. Co więcej to rozwiązanie daje się zastosować do kolekcji w Javie, bo interfejs Collection<E> zawiera dwie metody o nazwie toArray. Jednak to rozwiązanie ma też wady. Po pierwsze wymaga utworzenia dodatkowej tablicy, a więc oznacza stratę czasu na tworzenie tablicy i kopiowanie jej zawartości, a ponadto wymaga dodatkowej pamięci do przechowywania tej dodatkowej tablicy. Po drugie, jak za chwilę się przekonamy, iterator pamięta swój bieżący stan, czyli to gdzie już dotarł w trakcie przeglądania kolekcji, przy rozwiązaniu z tablicą obowiązek pamiętania bieżącej pozycji spada na użytkownika tego rozwiązania, a to z kolei daje więcej możliwości popełnienia błędu.

Drugie rozwiązanie, czasem zwane iteratorem wbudowanym, polega na wyposażeniu kolekcji w zestaw operacji umożliwiających przeglądanie jej zawartości. Postać takiego iteratora może być bardzo różnorodna, tu przedstawiamy tylko kilka przykładów:

Tylko jedna metoda

 public E dajElt(int i); // daj element o podanym indeksie

To rozwiązanie ma bardzo prosty interfejs (tylko jedna metoda). Wady:

  • wymaga pamiętania bieżącej pozycji przez użytkownika tego iteratora,
  • w większości kolekcji nie da się go efektywnie (tzn. tak by dostęp do obiektu był w czasie stałym) zaimplementować.

Rozwiązanie z dodatkowym typem Pozycja

 public E dajElt(Pozycja poz); // daj element znajdujący się za pozycji poz
 public Pozycja nast(Pozycja poz);  
   // przesuń bieżącą pozycję do następnego elementu za poz (dla poz == null przesuń na początek).
   // uwaga: po wywołaniu dla ostatniej pozycji wynikiem jest null. 

Dość prosty interfejs (dwie metody). W porównaniu z poprzednim rozwiązaniem ma tę zaletę, że daje się je efektywnie zaimplementować nawet dla kolekcji o organizacji listowej (w takim przypadku typ Pozycja to po prostu referencja do bieżącego elementu na liście). Wady:

  • wymaga pamiętania bieżącej pozycji przez użytkownika tego iteratora,
  • wprowadza dodatkowy typ (Pozycja).

Klasyczny interfejs iteratora

 public void naPoczątek(); // ustaw iterator na początek kolekcji 
 public boolean jestElt()  // sprawdź, czy jest jeszcze jakiś element do obejrzenia
 public E dajElt();  // daj kolejny element i przesuń bieżącą pozycję iteratora o jeden element do przodu

To jest dość klasyczny zestaw operacji iteratora. Wady:

  • rozbudowany interfejs (3 operacje).
  • nie nadaje się do jednoczesnego wielokrotnego przechodzenia przez kolekcję. Np. dla wypisania wszystkich par składających się z elementów kolekcji naturalne rozwiązanie polega na zastosowaniu dwu zagnieżdżonych pętli: zewnętrznej po pierwszym elemencie pary i wewnętrznej po drugim elemencie pary, co oznacza jednoczesne przechodzenie kolekcji na dwa sposoby. Wynika to stąd, że bieżąca pozycja iteratora jest pamiętana w kolekcji, więc może być tylko jedna. (P. też Ćwiczenia.)

Trzecie rozwiązanie to oczywiście zastosowanie omawianej wcześniej pętli dla każdego. Tyle tylko, że jest ona realizowana właśnie za pomocą iteratora.


Iteratory w Javie

Hierarchia interfejsów iteratorów w Javie jest dość skromna, składa się tylko z dwu elementów.

grafika:PO_interfejs_iteratora_2.jpg

Przyjrzyjmy się najpierw interfejsowi Iterator<E> (zwróćmy uwagę na podobieństwo do jednej z zaproponowanych przez nas wcześniej wersji iteratora wbudowanego). Oto pełna deklaracja:

 public interface Iterator<E> {
   boolean hasNext();
   E next();
   void remove();
 }

Metoda hasNext informuje, czy w kolekcji są jeszcze elementy (co pozwala się upewnić, że można wywołać metodę next). Metoda next pozwala pobrać kolejny element z kolekcji, tzn. kolejne wywołania tej metody - aż do osiągnięcia stanu, gdy wywołanie metody hasNext da wartość false - przekażą wszystkie elementy kolekcji (każdy dokładnie raz). Zauważmy, że nie ma w tym interfejsie metody pozwalającej wrócić na początek przeglądanej kolekcji. Jeśli będziemy chcieli obejrzeć kolekcję od początku, po prostu stworzymy nowy iterator!

Ostatnia metoda (remove) jest trochę innego rodzaju, jako jedyna modyfikuje kolekcję. Służy do usunięcia z niej elementu, który był jako ostatni przekazany jako wynik metody next. Z używaniem tej metody wiąże się wiele ograniczeń. Nie można jej wywołać przed pierwszym wywołaniem metody next, nie można jej także wywołać dwa razy pod rząd bez wywołania metody next. Co więcej, ta metoda jest opcjonalna (nie wszystkie implementacje tego interfejsu muszą potrafić usuwać).

Pętla dla każdego raz jeszcze

Uzbrojeni w wiedzę o iteratorach możemy teraz podać precyzyjnie znaczenie pętli dla każdego pokazując jej tłumaczenie na zwykłą pętlę for. Znaczeniem pętli

 for (Typ Identyfikator: Wyrażenie)
   Instrukcja

dla Wyrażenia, którego wartość jest podtypem typu Iterable jest

 for (I fv = Wyrażenie.iterator(); fv.hasNext(); ) {
    Typ Identyfikator = fv.next();
    Instrukcja;
  }

gdzie I jest typem iteratora dla wartości Wyrażenia, a fv jest świeżą zmienną (tzn. taką, która nie występuje nigdzie w bieżącym zakresie widoczności) wygenerowaną przez kompilator. Warto dodać, że dla drugiej dozwolonej postaci pętli dla każdego, gdy wyrażenie ma wartość będącą tablicą, również jej znaczenie definiuje się przez sprowadzenie do zwykłej pętli for.

Podsumowanie

Dokonaliśmy przeglądu pojęć związanych z kolekcjami, uważny czytelnik może jednak w tym miejscu postawić zasadne pytanie: po co to robiliśmy, skoro ciągle nie mamy żadnego konkretnego narzędzia (klasy, której obiekty moglibyśmy utworzyć). Odpowiedzi na to pytanie jest nawet kilka. Po pierwsze, konkretne przykłady kolekcji poznamy w następnym wykładzie. Ale wbrew pozorom to nie jest najistotniejsza z odpowiedzi. Jak już wcześniej wspominaliśmy, omawiamy kolekcje w Javie jako przykład organizacji niebanalnej biblioteki klas. To, co jest dla nas przede wszystkim istotne, to sposób w jaki można obiektowo definiować tak bogate zestawy pojęć. Przeanalizujmy więc, co osiągnęli autorzy tej biblioteki klas dzięki zdefiniowaniu interfejsów kolekcji.

Po pierwsze, dzięki wprowadzeniu hierarchii interfejsów ustrukturalizowali (uporządkowali) zestaw pojęć dotyczących klas. Po drugie, ułatwili użytkownikom kolekcji nauczenie posługiwania się nimi. Jako użytkownicy kolekcji wiemy, że na przykład możemy łatwo dostać liczbę elementów zadanej kolekcji. Wiemy także, jak to zrobić w dowolnej kolekcji (metoda size). To olbrzymia zaleta, poznając kolejne kolekcje nie musimy się uczyć wszystkiego o nich od nowa (porównajmy to z codziennym życiem: standardy wprowadzane w najróżniejszych dziedzinach służą właśnie temu celowi, choć niestety nie zawsze są aż tak jednorodne, jak w przypadku zastosowanie dziedziczenia w programowaniu). To ujednolicenie ma jeszcze jedną, niesłychanie ważną zaletę: pozwala programować w kategoriach interfejsów, a nie konkretnych klas. Czemu jest to takie ważne? Bo pozwala oderwać się od nieistotnych szczegółów (np. tego jak jest zaimplementowana dana kolekcja), a skupić się na opisaniu rozwiązania problemu. Pozwala także pisać bardziej ogólne (a więc bardziej uniwersalne) algorytmy. Przykład takiego ogólnego podejścia zobaczyliśmy już przy omawianiu semantyki pętli dla każdego: podaliśmy jej tłumaczenie na zwykłą pętlę dla nie zajmując się w ogóle tym, jaki konkretny iterator zostanie użyty - to po prostu nie miało dla nas żadnego znaczenia, podane tłumaczenie zadziała dla każdej poprawnej implementacji interfejsu Iterator!

Spróbujmy szerzej skorzystać z możliwości, jakie daje programowanie na poziomie interfejsów, wspominaliśmy wcześniej, że metody takie jak addAll dają się zdefiniować w ogólny sposób za pomocą metody add. Mając pętlę dla każdego możemy zaprogramować dodawane kolekcji do kolekcji po prostu tak:

 public void addAll(Collection<? extends T> kol){
   for(T elt: kol)
     add(elt);
 }

(Dla uproszczenia pominęliśmy tu przekazywanie przez metody add i addAll wyniku typu boolean). Trudno nie zachwycić się pięknem tej konstrukcji: do nie wiadomo jakiej kolekcji elementów nieznanego typu dodajemy elementy z kolekcji być może zupełnie innego rodzaju, a w dodatku robimy to w paru wierszach. I dostajemy metodę działającą dla wszystkich klas implementujących interfejs Collection!

Ten przykład jest kolejnym dowodem na to, jak potężnym narzędziem jest abstrakcja. Wydaje się, że tworzenie i używanie abstrakcji informatycznych najrozmaitszych rodzajów jest najważniejszą umiejętnością, jaką powinien posiąść w czasie swoich studiów każdy student informatyki.

Przypisy

  1. Jest możliwe utworzenie kolekcji jednoelementowej za pomocą tablic bez użycia operacji add.
  2. Precyzyjniej: nie tylko do przeglądania kolekcji, ale do przeglądania tablic i wszystkich obiektów implementujących interfejs Iterable. Ponieważ nie wszystkie kolekcje implementują interfejs Iterable (np. słowniki nie), to nie dla wszystkich z nich opisywana postać pętli for działa.