Kolekcje



Programowanie polega na posługiwaniu się strukturami danych i algorytmami. Reprezentowanie struktur danych w programie nie jest łatwe, jeśli wszystko musimy oprogramować sami. Dlatego w wielu językach programowania istnieję swoiste biblioteki gotowych do wykorzystania konkretnych realizacji abstrakcyjnych struktur danych. W Javie nazywa się to Java Collections Framework.

1. Architektura kolekcji (JCF). Interfejsy i implementacje

Zapoznaliśmy się już trochę z kolekcjami (zob. kurs "Podstawy programowania w Javie"). Pora na pogłębienie wiadomości, a także wyrobienie własciwego stylu programowania operacji na kolekcjach. 

Przypomnijmy najpierw podstawową definicję.

Kolekcja jest obiektem, który grupuje elementy danych (inne obiekty) i pozwala traktować je jak jeden zestaw danych, umożliwiając jednocześnie wykonywanie operacji na zestawie danych np. dodawania i usuwania oraz przeglądania elementów zestawu

W pakiecie java.util, zdefiniowano narzędzia, służące do tworzenia i posługiwania się róznymi rodzajami kolekcji.
W materiale wprowadzającym - i później w róznych przykładowych programach - widzielismy np. zastosowanie klasy ArrayList, HashSet, TreeSet... Ale różnych rodzajów kolekcji jest  więcej. Standardowe klasy Javy
dostarczają środków do posługiwania się:

Każda z tych abstrakcyjnych struktur danych ma pewne właściwości, które wyróżniają ją od innych struktur danych. Mówimy, że są to abstrakcyjne struktury danych, bowiem w opisie tych właściwości nie przesądza się o tym w jaki sposób konkretnie je zrealizować.

Abstrakcyjne wlaściwości struktur danych opisywane są przez interfejsy, a konkretne realizacje - inaczej implementacje - tych właściwości znajdujemy w konkretnych klasach.

Zatem ważnym elementem architektury kolekcji w Javie są interfejsy.

Interfejsy kolekcyjne - określają abstrakcyjne typy danych będące kolekcjami i pozwalają na niezalezne od szczegółów realizacyjnych operowanie na kolekcjach. Deklarują one metody, które mogą być uzyte wobec danego rodzaju kolekcji.

Konkretne kolekcje, obiekty na których operujemy są obiektami klas implementujących interfejsy kolekcyjne.
Zatem, drugim ważnym elementem architektury kolekcji są implementacje.

Implementacja kolekcji jest konkretną realizacją funkcjonalności, określanej przez interfejs kolekcyjny. Takie realizacje mogą być różne w szczegółach technicznych, np. realizacja listy jako dynamicznej tablicy lub jako listy z dowiązaniami.

Na przykład, intefejs List określa ogólne właściwości listy (to, że każdy element zestawu danych znajduje się na określonej pozycji) oraz możliwe operacje na liście. Właściwości te i operacje można zrealizować w różny sposób: poprzez użycie tablicy z dynamicznie zmieniającymi się rozmiarami (implementacja tablicowa) lub poprzez implementację podwójnie-dowiązaniową (w której kolejny element listy umieszczony jest w strukturze, która oprócz referencji do samego elementu zawiera dowiązania, czyli odniesienia, wskaźniki do następnego i  poprzedniego elementu listy).
Odpowiednio do tego mamy dwie klasy: ArrayList (implementacja tablicowa) i LinkedList (implementacja podwójnie dowiązaniowa), każda z których realizuje koncepcję listy.
W obu przypadkach mamy do czynienia z listą, ale pewne operacje na liście w każdej z tych implementacji mają inną efektywność (czas wykonania). Zatem w zależności od potrzeb możemy wybierać odpowiednią implementację listy.

Oprócz tego stadardowe klasy Javy dostarczają gotowych środków użycia  rozlicznych algorytmów na kolekcjach (np. sortowania i binarnego wyszukiwania).
Zatem trzecim ważnym elementem architektury kolekcji są algorytmy.

Algorytmy kolekcyjne są metodami wykonującymi użyteczne operacje obliczeniowe na kolekcjach (np. sortowanie i wyszukiwanie). Metody te są polimorficzne tzn. ta sama metoda może być użyta dla wielu róznych implementacji tego samego interfejsu kolekcyjnego

W sumie te trzy elmenty: interfejsy, implementacje i algorytmy stanowią tzw. architekturę kolekcji.

Architektura kolekcji (Collections Framework)
Zunifikowana architektura, służąca do reprezentacji kolekcji i operowania na nich,
Składa się z:
  • interfejsów,
  • implementacji,
  • algorytmów.
Najbardziej znane architektury kolekcji to: STL (Standard Templates Library) w C++, klasy kolekcyjne w SmallTalk'u oraz JCF (Java Collections Framework).  

Java Collections Framework (JCF) jest niezwykle użyteczną składową środowiska Javy. Mamy do dyspozycji wiele gotowych, efektywnych, klas i metod, pozwalających łatwo rozwiązywać wiele problemów związanych z reprezentacją w programie bardziej zaawansowanych struktur danych i operowaniem na nich.
Ale nie tylko na tym polega siła Java Collections Framework. Zastosowana architektura kolekcji sprawia, że można tworzyć metody i programy o uniwersalnym charakterze, operujące na kolekcjach (reprezentujących abstrakcyjne typy danych) w sposób niezależny od implementacji. I, co więcej, architektura kolekcji jest otwarta: korzystając z jej elementów możemy łatwo sami tworzyć nowe rodzaje abstrakcyjnych struktur danych lub nowe implementacje,  niejako wbudowane w istniejącą strukturę kolekcji, co czyni nasze nowe interfejsy i klasy proste w ponownym użytku zgodnie z jednolitymi regułami użycia klas i interfejsów kolekcyjnych.

W tablicy pokazano hierarchię dziedziczenia interfejsó kolekcyjnych oraz ich znaczenie (czyli istotę abstrakcyjnych struktur danych, definiowanych przez te interfejsy)..


Podstawowe interfejsy JCF

Collection  
dowolna kolekcja nie będącą mapą

Listzestaw elementów, z których każdy znajduje się na określonej pozycji; do listy można  wielokrotnie dodać ten sam element i mozna sięgnąc po element na dowolnej pozycji.
Queuekolejka, czyli  sekwencja elementów, do której dodawanie i sięganie po elementy odbywa się na pozycjach, określonych przez zadany porządek (najczęsciej FIFO - first in first out). Nie ma bezpośredniego dostępu do dowolnej pozycji.


Deque  
 
rozszerza Queue: kolejka podwójna, do której dodawanie i sięganie może odbywać się na obu końcach (np. dodaj na początku, dodaj na końcu, usuń z pocżatku, susń z końca)

Set

zestaw niepowtarzających się elementów, pozycje elementów są nieokreślone

SortedSet

rozszerza Set: zbiór uporządkowany                                                
NavigableSetrozszerza SortedSet:  zbiór uporządkowany, dla którego możliwe są operacje uzyskiwania elementów "bliskich" danemu.

Map

mapa - zestaw par: klucz-wartość, przy czym odwzorowanie kluczy w wartości jest jednoznaczne

SortedMap
mapa z uporządkowanymi kluczami (typu SortedSet)

NavigableMap

mapa, w której klucze są typu NavigableSet

Uwagi:
  1. w tablicy pokazano relacje dziedziczenia interfejsów (np. wszystkie interfejsy dziedzicżą, rozszerzają interfejs  Collection),
  2. wszystkie interfejsy kolekcyjne sa sparametryzowane, dla wygody pominięto parametry typu.

Wybrane podstawowe konkretne  implementacje pokazuje poniższa tablica.

Sposób realizacji właściwości okreslanych przez interfejsImplementujące klasyImplementowany
interfejs
Właściwości implementacji
Dynamicznie rozszerzalna tablica
ArrayList
List
szybki bezpośredni dostęp
Lista liniowa z  podwójnymi dowiązaniami
LinkedList
List, Deque
szybkie wpisywanie i usuwanie elementów poza końcem listy; wolny dostęp bezpośredni;
implementacja operacji na kolejkach
Kolejka podwójnaArrayDequeDequekolejka podwójna zrealizowana jako rozszerzalna tablica; szybki dostęp do obu końców, brak dostępu do dowolnej pozycji.
Kolejka z priorytetamiPriorityQueueQueuekolejka, w której pierwszy i ostatni elment jest określany na podstawie ustalonego porządku (porównania elmentó wg jakichś kryteriów) 
Tablica mieszania
(tablica haszująca)
HashSet
HashMap
Set
Map
szybkie wpisywanie i odnajdywanie elementów; kolejność elementów nieokreślona
Drzewo
czerwono-czarne
TreeSet
TreeMap
NavigableSet
NavigableMap
uporządkowanie elementów; dostęp i wyszukiwanie wolniejsze niż w implementacji mieszającej
Tablica mieszania
i  lista liniowa
LinkedHashSet
LinkedHashMap
Set
Map
jak w implememntacji mieszającej, z zachowaniem porządku wpisywania elementów
Uwagi:
  1. Wszystkie klasy są sparametryzowane.
  2. Pominięto klasy kolekcyjne z pakietu java.util.concurrent, służące programowaniu współbieznemu.
  3. Użyte w tablicy pojęcia, dotyczące sposobu implementacji, zostaną wyjasnione w trakcie omawiania konkretnych struktur danych.
Jak widać, mamy np. dwie implementacje dla listy i po trzy implementacje dla zbioru i mapy.
Wybór implementacji zależy od potrzeb naszego programu (w tablicy pokazano podstawowe właściwości implementacji).
Oczywiście, jeśli nasz program tworzy konkretne obiekty klas kolekcyjnych, to zawsze musimy dokonać takiego wyboru. Ale ważną rzeczą jest, by wszelkie inne działania na kolekcjach wykonywać w kategoriach interfejsów, a nie konkretnych klas.
Ponadto należy używać kolekcji sparametryzowanych, a nie surowych typów (List czy ArrayList).


Znaczenie tych dwóch ważnych zasad ilustrują poniższe fragmenty kodu.


 
r

Mamy tu klasę ListUtils, która dostarcza statycznych metod tworzenia listy z elementów tablicy (metoda create),  dodawania do końca istniejącej listy elementów tablicy (append) oraz wypisywania na konsoli elementów listy (show).
W innej klasie (tu nazywa się - na razie niesłusznie - Independence) posługujemy się metodami klasy ListUtils.

Zobaczmy co jest złego we fragmentach kodu z obu klas.
Metoda append z klasy ListUtils jest przystosowana wyłącznie do tablicowej implementacji listy (bo jej parametrem jest ArrayList). Zatem nie będzie działać dla list z dowiązaniami (LinkedList), ani też dla jakichkolwiek innych konkretnych implementacji listy. A przecież ta metoda nie wymaga przesądzania o implementacji.
Co więcej ten sposób programowania jest niebezpieczny, ponieważ pozwala dodawać do list dowolne elementy, bez kontroli w fazie kompilacji i niewygodny, bo przy wyciaganiu elementów musimy używać jawnych konwersji zawężających.. Użycie parametrów typu pozwoli uniknąc sytuacji, w której np. do listy osób przypadkowo dodamy liczbę, a także zaoszczędzi nam kodowania, bo zapewnione będą automatyczne konwersje zawężające do konkretnego typu przy sięganiu po elmenty listy.
Wobec każdej kolekcji możemy użyć metody add, w szczegolności taką metodę zawiera również interfejs List. Powinniśmy zatem w sposób ogólniejszy podać typ parametru metody append. Ma ona dodawać elementy tablicy do dowolnej listy elementów podanego typu T, winna zatem wyglądać tak:

class ListUtils {
  
  static <T> void append(List<T> list, T[] items) {
    for (T item : items) 
      list.add(item);
  }
// ...
}
Uwaga: mamy tu metodę sparametryzowaną typem T (zob. materiał, dotyczący generics).

To samo dotyczy metody show: również powinniśmy w niej podać jako typ parametru nazwę interfejsu List, a nie konkretnej klasy.
  static void show(List<?> list) {
    for (Object o : list)
      System.out.println(o);
  }
Tutaj posłuzylismy się uniwersalnym parametrem typu <?>, co oznacza dowolny typ i sprawia, że metoda będzie dobra dla list elementów dowolnego typu.

Teraz fragment innej klasy (w naszym przypadku pokazanej klasy Indepedence) może elastycznie i uniwersalnie korzystać z tych metod. Możemy użyć ich na rzecz listy napisów implementowanej jako rozszerzalna tablica:
    String[] items = { "Kot", "Pies", "Zebra", "Tygrys" };
    List<String> list1 = new ArrayList<String>();
    ListUtils.append(list1, items);
    ListUtils.show(list1);
a jeśli trzeba - dla zmienionej implementacji listy (listy z dowiązaniami - LinkedList) i innego typu elementów:
    Integer[] arr = {1, 2, 3};
    List<Integer> list1 = new LinkedList<Integer>();
    ListUtils.append(list1, arr);
    ListUtils.show(list1);
    
Przypomnienie: kolekcje mogą zawierać wyłacznie referencje do obiektów; zatem musieliśmy użyć tablicy Integer[], a nie int[], jednak - dzięki autoboxingowi- inicjacja tablicy nie nastręczała kłopotów (automatyczne opakowywanie danych typu int w obiektu klasy Integer).

Zwróćmy uwagę, że całkiem świadomie napisaliśmy tu:

        List<String> list1 = new ...

a nie
        ArrayList<String> list1 = new ...
czy
        LinkedList<Integer> list1 = new ...

Jeśli zdecydujemy się na zmianę implementacji (np. z ArrayList na LinkedList lub odwrotnie), to modyfikacja dotknie tylko jednego miejsca kodu (wywołania konstruktora w wyrażeniu new) i mniejsze będzie prawdopodobieństwo popełnienia błędu.

Gdy w innej klasie (w naszym przypadku - klasie Independence) korzystamy z metody create (klasy ListUtils), która zwraca nowoutworzoną listę z dodanymi do niej elementami przekazanymi w tablicy, również nie powinniśmy "przywiązywać" się do konkretnej implementacji listy zwracanej przez metodę create().
Zamiast pisać:
ArrayList list2 = ListUtils.create(
                            new String[] { "Truskawki", "Banany"}
                            );
powinniśmy napisać:

    List<String> list2 = ListUtils.create(
                           new String[] { "Truskawki", "Banany"}
                           );
Metoda create(...) tworzy nową listę. musi zatem posłużyć się konkretną implementacją. W omawianym tu przypadku jest to klasa ArrayList. Być może nie byłoby błędu, gdybyśmy napisali:
   
    ArrayList<String> list2 = ListUtils.create(...)

Ale gdyby twórca klasy ListUtils (może my sami) - z jakichś powodów - zmienił implementację nowotworzonej listy w metodzie create(..) z ArrayList na jakąś inną, to nasza klasa korzystająca z metody create(...) musialaby być zmodyfikowana i rekompilowana. Dzięki temu, że napisaliśmy:

    List<String> list2 = ListUtils.create(...)

nasza klasa (tu: klasa Independence) jest gotowa do korzystania z dowolnych implementacji list zwracanych przez metodę create(..) i nie musi być rekompilowana przy zmianie tych implementacji.

A czy metoda create() może być przygotowana na inny typ elementów listy (np. Integer)?
Oczywiście - w tym celu nalezy ją sparametryzować, co może wyglądac tak:
  static <T>  List<T> create(T[] items) {
    ArrayList<T> list = new ArrayList<T>();
    append(list, items);
    return list;
  }
Konkretny typ elementu (podstawiany pod parametr typu T) będzie dedukowany z wywołania.
Pisząc metodę create użyliśmy jako typu wyniku interfejsu. Jest to sygnał dla użytkownika klasy ListUtils, że tworzona lista może być dowolnego typu i np. implementacja może być zmieniona.

Ogólnie więc, przy deklaracji parametrów metod oraz przy deklaracji zmiennych, których wartość jest referencją do kolekcji zwracaną przez jakąś metodę, użycie typu, wyznaczanego przez interfejs kolekcyjny, zamiast typu wyznaczanego przez implementację (klasę implementującą ten interfejs), pozwala na  uniknięcie modyfikacji i rekompilacji klasy przy zmianie implementacji kolekcji.
 
Ale zapisów "w terminach" interfejsów nie należy absolutyzować ani stosować "na ślepo".
Wyobraźmy sobie, że zbudowaliśmy klasę NumberedList, reprezentującą listę z numerami elementów. Klasa ta zawiera m.in. metodę addAuto dodającą do listy napisową reprezentację argumentu, poprzedzoną kolejnym numerem. Metoda ma zwracać tę listę na rzecz ktorej została wywołana (po to, by umożliwić "lańcuchowe" wywolania: list.addAuto(...).addAuto(...).addAuto(...)). Nie ma to może za wiele sensu, ale dobrze ilustruje zagadnienie.

Metoda addAuto(..) może wyglądać tak:
import java.util.*;

class NumberedList extends ArrayList<String> {
  // ...
  public NumberedList addAuto(Object o) {
    super.add("Nr." + (size()+1) + " " +o);
    return this;
  }
  // ...
}

Zwróćmy uwagę: typem zwracanego wyniku jest NumberedList, a nie List.
To bardzo słuszne, daje bowiem wołającemu tę metodę odpowiednią elastyczność. Nic nie stoi na przeszkodzie, by użyć jej wyniku w miejscu gdzie wymagane jest wyrażenie typu List (bo NumberedList dziedziczy ArrayList, a przez to implementuje interfejs List). A jednocześnie możliwe jest użycie na rzecz wyniku metody addAuto:
list.addAuto(..).addAuto(...), czego nie moglibyśmy zrobić, gdyby typem wyniku był  "tylko"  List (bo interfejs List nie zawiera metody addAuto()).

Podobnie, w programie korzystającym z tej klasy napiszemy raczej:

NumberedList nls = new NumberedList();

a nie:

List nls = new NumberedList();

po to by móc korzystać z metody addAuto(...). Co w niczym nie przeszkadza podać nls jako argument dowolnej metody przyjmującej List.
Np.
    NumberedList nls = new NumberedList();
    nls.addAuto("Pies").addAuto("Kot").addAuto("Wilk");
    ListUtils.show(nls);
Omawiane zagadnienie wyboru deklarowanego typu zmiennej oznaczającej kolekcję uzyskuje nowy wymiar, jeśli uwzględnimy fakt, że w Javie istniejące interfejsy kolekcyjne tworzą hierarchiczne struktury i że struktury te możemy rozbudowywać tworząc własne interfejsy i klasy implementujące.

Kolekcje listowe i zbiorowe różnią się od kolekcji słownikowych (map):  w pierwszym przypadku do kolekcji dodawany jest element-obiekt, w drugim para obiektów klucz-wartość. Nie da się zatem w naturalny, prosty dla użytkownika sposób, uogólnić funkcjonalności kolekcji listowych i zbiorowych z jednej strony oraz map z drugiej. Dlatego interfejsy kolekcyjne tworzą dwie rozłączne hierarchie: jedna dla kolekcji listowych, kolejkowych i zbiorowych, druga - dla map.

Hierarchie interfejsów listowych, kolejkowych i zbiorowych pokazuje rysunek (o interfejsach map - zob. dalej).

r

Jak widać, wszystkie kolekcje listowe, kolejkowe i zbiorowe dają się przedstawić jako typu Collection. Pozwala to wykonywać pewne ogólne operacje na kolekcjach bez względu na ich rodzaj i - tym bardziej - implementację.

Zanim dokładniej przyjrzymy się tym możliwościom, warto zwrócić uwagę, że przy deklarowaniu typów parametrów metod, służących do operowania na kolekcjach, powinniśmy wybierać typy określane przez interfejsy znajdujące się możliwie najwyżej w hierarchii dziedziczenia. To oczywiście zawsze zależy od kontekstu, od funkcjonalności dostarczanej przez nasze metody. Jeśli  przeznaczeniem metody jest operowanie na listach - typem parametru powinno być List, ale jeśli dotyczy ona dowolnych kolekcji - to raczej typem powinno być Collection. Zauwazmy też


I odwrotnie, typy wyników zwracanych przez metody powinny być jak najbardziej specyficzne, do konkretnej implementacji włącznie. Jeśli np. nasza metoda tworzy zbiór uporządkowany - obiekt klasy TreeSet (co jest bardziej kosztowne niż stworzenie zwykłego zbioru), to powinna zwracać wynik typu TreeSet, po to by użytkownik mógł skorzystać z dodatkowej funkcjonalności (uporządkowania), już przecież okupionej kosztem czasu działania programu.


2. Ogólne operacje na kolekcjach. Operacje opcjonalne i grupowe.

Interfejs Collection definiuje ogólne, wspólne właściwości i funkcjonalność wszystkich kolekcji (poza mapami).
W szczególności, dotyczy to dowolnych kolekcji listowych i zbiorowych, ale nie tylko: możemy mieć kolekcje, które nie są ani listami, ani zbiorami (sami możemy takie definiować, a w JCF przykładem takiej kolekcji jest zestaw wartości mapy (spod kluczy), który nie jest listą, ponieważ elementy w zestawie nie mają pozycji, ani nie jest zbiorem, bowiem te same elementy mogą występować w kolekcji więcej niż jeden raz).

Typ Collection jest zatem swoistym "najmniejszym wspólnym mianownikiem" wszystkich rodzajów kolekcji i używamy go (szczególnie przy przekazywaniu argumentów) wtedy, gdy potrzebna jest największa ogólność działań.

Bardzo podstawowe, ale zawsze mające sens, operacje na kolekcjach to:
Większą operacyjność dają metody dodawania elementów do kolekcji (metoda add(...)) i usuwania elementów z kolekcji (metoda remove(...) ). Są one zawarte w interfejesie Collection, ale ich działanie musi być zdefiniowane tu bardzo ogólnie, z uwzględnieniem rożnych możliwych rodzajów kolekcji.
Mamy przy tym dwa problemy:
Kolekcja jest modyfikowalna, jeśli można dodawać do niej elemementy, usuwać z niej elementy oraz zmieniać wartości elementów. Niemodyfikowalne kolekcje nie pozwalają na dodawanie, usuwanie, zmianę wartości elementów.


Wobec tego twórcy Java Collection Framework stanęli przed wyborem:

Pierwsze rozwiązanie podważyłoby sens istnienia interfejsu Collection (bylby zapewne zbyt ubogi). Drugie było nie do przyjęcia ze względu na to, iż powodowałoby dalszą rozbudowę i tak już skomplikowanej hierarchii interfejsów i klas kolekcyjnych (a weźmy jeszcze pod uwagę różne możliwe warianty "niemodyfikowalności" np. tylko dodawanie bez usuwania, tylko usuwanie bez dodawania, tylko zabronione zmiany rozmiarów, ale zmiany wartości elementów możliwe itp.)
Wybrano zatem rozwiązanie trzecie.

Mianowicie niektóre metody (operacje) zawarte w interfejsach kolekcyjnych określone są jako operacje opcjonalne . Konkretne klasy kolekcyjne mogą dopuszczać lub nie wykonanie takich operacji. Np. klasa definiująca jakąś kolekcję niemodyfikowalną nie może pozwolić na wykonanie operacji dodawania i usuwania elementów.
Ale ponieważ każda klasa kolekcyjna musi implementować interfejs Collection, to musi też zdefiniować metody add i remove. Zdefiniować coś - co, z punktu widzenia dostępnych w danym przypadku operacji, nie powinno być zdefiniowane!
Sprzeczność tę rozwiązano, przyjmując zasadę, że jeśli operacja opcjonalna dla danej implementacji nie jest dopuszczalna, to odpowiednia metoda zglasza wyjątek UnsupportedOperationException.

Np. dla klas, które implementują kolekcje niemodyfikowalne, metody add i remove z interfejsu Collection powinny być zdefiniowane jako:
        public boolean add(T o) {
	    throw new UnsupportedOperationException();
        }
	public boolean remove(T o) {
	    throw new UnsupportedOperationException();
        }
Uwaga: ponieważ kolekcje są sparametryzowane, T oznacza parametr typu.

Czy zamiast tego nie można byłoby dostarczyć po prostu takich definicji metod add i remove, które nie modyfikują kolekcji?
Niestety, nie. Genaralny kontrakt dla metody add brzmi następująco:

Metoda add gwarantuje, że zaraz po jej użyciu podany jako argument obiekt znajduje się w kolekcji. Zwraca true jeśli kolekcja została zmodyfikowana (obiekt dodano) i false w przeciwnym razie (co oznacza, że obiekt już był w kolekcji typu zbiór, czyli takiej, ktora nie dopuszcza duplikatów).

Zatem w przypadku innego od UnsupportedOperationException rozwiązania nie moglibyśmy wiedzieć, czy false zwrócone przez add oznacza, że obiekt jest w zbiorze, czy też, że zbiór jest niemodyfikowalny.
W tym kontekście należy podkreslić, że wynik zwracany przez metodę add(...) jest rozwiazaniem drugiego, wcześniej wspomnianego, problemu: generalizacji operacji dodawania elementów do kolekcji bez duplikatów.

Podobnie jest z metodą remove. Ma ona usunąć podany jako argument obiekt. Zwraca true, jeśli obiekt był w kolekcji i został usunięty (kolekcja zmodyfikowana) oraz false, gdy podanego obiektu w kolekcji nie bylo. Zatem jedynym sposobem zasygnalizowania, że próbowano usunąć jakiś obiekt z kolekcji niemodyfikowalnej pozostaje zgłoszenie wyjątku.

W interfejsach kolekcyjnych występuje szereg innych operacji opcjonalnych. Opcjonalnośc ich wszystkich ma takie samo znaczenie jak w przypadku omówionych operacji add i remove tzn. wszystkie one zgłaszają wyjątek UnsupportedOperationException, jeśli operacja nie jest dozwolona.

Przy dodawaniu elementów do kolekcji mogą powstać i inne wyjątki, zależne od implementacji kolekcji. Implementacje, które nie dopuszczają dodawania elementów o pewnych właściwościach (np. elementów null) będą zgłaszać wyjątek IllegalArgumentException.
Te zaś, które np. czasowo nie dopuszczają dodania elementu (np. w tym momecie jest przekroczony maksymalny limit wielkości kolekcji) zgłaszają wyjątek IllegalStateException.


Interfejs Collection określa również tzw. grupowe operacje na kolekcjach, polegające na wykonywaniu "za jednym razem" pewnych operacji na całych kolekcjach.
Należą do nich:
Ponieważ niektóre z tych operacje te mogą modyfikować kolekcje  (a o tym czy naprawdę nastąpiła modyfikacja - świadczą wyniki zwracane przez metody - true albo false), to - oczywiście - są one operacjami opcjonalnymi.

UWAGA! Operacje, które wymagają porównywania elementów np. contains(jakis_obiekt), containsAll(Collection},  removeAll(..), retainAll(...) używają do tego metody equals() zdefiniowanej w klasach obiektów.

Operacje grupowe są często bardzo użyteczne:  zastępuja one drobne (ale zawsze) fragmenty kodu, które przy braku operacji grupowych musielibyśmy pisać sami. W przypadku zbiorów są one bezpośrednim odzwierciedleniem  operacji na zbiorach: sumy zbiorów, wspólnej częsci itp.
 A - co ważniejsze - są one z reguły bardziej efektywne od tego co moglibyśmy napisać sami, bowiem ich implementacje uwzględniają specyficzne cechy implementacji kolekcji, przy zachowaniu polimorficznego charakteru odwolań.

Niejako kontynuacją operacji grupowych jest możliwość przekształcenia kolekcji danego rodzaju  w dowolną inną kolekcję dowolnego innego rodzaju. Np. listy w zbiór uporządkowany. Co prawda, w interfejsie Collection nie znajdziemy żadnych po temu metod ( i nic dziwnego), ale samo jego istnienie daje taką możliwość.
We wszystkich konkretnych implementacjach kolekcji dostarczono bowiem konstruktorów, mających jako parametr dowolną inną kolekcję (czyli parametr typu Collection).
Jeśli zatem mamy listę, a chcemy z niej zrobić zbiór uporządkowany (w konkretnej implementacji - np. drzewa zrównoważonego), to wystarczy użyć konstruktora odpowiedniej klasy (tu: TreeSet):

  List lista;
  //... utworzenie listy w konkretnej implementacji np.ArrayList lub LinkedList
  Set tset = new  TreeSet(lista);
W ten sposób uzyskamy zbiór (a więc bez powtórzeń elementów), uporządkowany (w naturalnym porządku elementów), którego elementy będą pobrane z dostarczonej listy.

Oczywiście, jeśli nie stosujemy typów surowych (jak wyżej), ale kolekcje sparametryzowane, kompilator zabroni nam dokonywania pewnych przekształceń (np. uzyskania listy napisów List<String> ze zbioru liczb Set<Integer>).

Interfejs List dostarcza także metod, służących do uzyskiwania z kolekcji tablic (to jest interfejs List, a nie Collection, bo tablice są listami). Ich głównym przeznaczeniem jest zapewnienie komunikacji pomiędzy kolekcjami, a tymi fragmentami kodu, które kolekcji nie wykorzystują (zapewne najczęsciej będą to programy, które powstały przed wprowadzeniem Java Collection Framework).
Metoda toArray() zwraca tablicę obiektów (typu Object), będących elementami kolekcji.
Typ wynku - Object[] - nie zawsze będzie nam odpowiadał. Dodatkowym ułatwieniem (a zarazem ograniczeniem dowolności) jest zatem możliwośc użycia metody o tej samej nazwie, która - poprzez parametr -  specyfikuje typ (ustalany w fazie wykonania) zwracanej tablicy - toArray(new Typ[n]).
Różnicę w zastosowaniu obu metod pokazuje poniższy fragment kodu:

    List<String> list = new ArrayList<String>()
    // ... tu dodawanie elementów do listy

    // Pierwszy sposób
    Object[] tab1 = list.toArray();
    for (int i=0; i<tab1.length; i++) {
      int len = ((String) tab1[i]).length();
      System.out.println(len);
    }
    // Drugi sposób
    String[] tab2 = (String[]) list.toArray(new String[0]);
    for (int i=0; i<tab2.length; i++) {
      int len = tab2[i].length();
      System.out.println(len);
    }
  } 
Odwrotnego przekształcenia dostarcza metoda List<T> asList(T ... args) z klasy Arrays, Może ona przyjmować zmienną liczbę argumentów lub tablicę elementów typu T. Wynikiem jest lista niemodyfikowalna.
Np. możemy łatwo uzyskac listę złożoną z kilku napisów:

List<String> lista = Arrays.asList("Ala", "ma", "kota");
Podsumowaniem podanych treści może być następujący programik testowy:
import java.util.*;

public class GroupOp {

  List<String> list1 = new ArrayList<String>();
  Set<String> set1 = new HashSet<String>();

  void add(String ...strings ) {
    for (String s : strings) {
      list1.add(s);
      set1.add(s);
    }
  }
  
  public GroupOp() {
    add("Pies", "Kot", "Tygrys", "Zebra", 'Z' + "ebra");
    System.out.println(list1);
    list1.removeAll(set1);
    System.out.println("Usunięte będą wszystkie elementy, bo działa equals:\n"+ list1);
    list1.addAll(set1);
    System.out.println(list1);
    //List<Integer> list2 = new ArrayList<Integer>(list1);  // błąd w kompilacji
    // Zastosowanie Arrays.asList();
    List<Integer> list2 = Arrays.asList(1,2,3,4,5);
    System.out.println(list2);
    // ale teraz list2 jest fixed-size
    try {
      list2.add(35);
    } catch(Exception exc) {
       System.out.println("Wystąpił wyjątek: " + exc);
       System.out.println("bo lista niemodyfikowalna:\n" + list2);
    }
    System.out.println("Możemy zrobić, aby była modyfikowalna:");
    list2 = new ArrayList<Integer>(Arrays.asList(1,2,3,4,5));
    System.out.println("Teraz możemy dodać coś");
    list2.addAll(Arrays.asList(35, 1, 2, 3));
    System.out.println(list2);
    System.out.println("Gdy chcemy usunąć duplikaty:");
    Set<Integer> set2 = new HashSet<Integer>(list2);
    System.out.println(set2);
    System.out.println("A czy możemy połączyć kolekcje z różnymi typami elementów?\nTak.");
    // Nalezy uzyć typu Object
    List<Object> list3 = new ArrayList<Object>(list1);
    list3.addAll(set2);
    System.out.println(list3);
    // Tyle, że przy iteracjach już musimy posługiwać się typem Object
    // i ew. robić konwersje zawężające
    int sum = 0;
    String txt = "";
    for(Object o : list3) {
      if (o instanceof Integer) sum += (Integer) o;
      else txt += (String) o;
    }
    System.out.println("Suma liczb: " + sum);
    System.out.println("Napis: " + txt);
    // A na koniec sprawdzimy jak z listy zrobić tablicę
    // Najlepiej tak, bo będzie uwzględniony typ elementów
    System.out.println("Tablica z listy");
    String[] tab = (String[]) list1.toArray(new String[0]);
    for (String string : tab) {
      System.out.println(string + " " + string.length());
    }
    
  }

  public static void main(String[] args) {
    new GroupOp();
  }
}
da w wyniku:
[1, 2, 3, 4, 5]
Wystąpił wyjątek: java.lang.UnsupportedOperationException
bo lista niemodyfikowalna:
[1, 2, 3, 4, 5]
Możemy zrobić, aby była modyfikowalna:
Teraz możemy dodać coś
[1, 2, 3, 4, 5, 35, 1, 2, 3]
Gdy chcemy usunąć duplikaty:
[35, 1, 2, 3, 4, 5]
A czy możemy połączyć kolekcje z różnymi typami elementów?
Tak.
[Zebra, Kot, Pies, Tygrys, 35, 1, 2, 3, 4, 5]
Suma liczb: 50
Napis: ZebraKotPiesTygrys
Tablica z listy
Zebra 5
Kot 3
Pies 4
Tygrys 6


3. Iteratory

Bardzo ważną metodą interfejsu Collection (tak naprawdę dzidziczoną z interfejsu Iterable) jest metoda iterator(), ktora zwraca iterator.

Iterator jest obiektem klasy implementującej interfejs Iterator i służy do przeglądania elementów kolekcji oraz ew. usuwania ich przy przeglądaniu


Metody interfejsu Iterator
T next()
Zwraca kolejny element kolekcji lub sygnalizuje wyjątek NoSuchElementExcption, jeśli osiągnięto koniec kolekcji. T jest typem elementów kolekcji.
void remove()
Usuwa element kolekcji, zwrócony przez ostatnie odwolanie do next()  Operacja opcjonalna.
boolean hasNext()
Zwraca true, jeśli  ożliwe jest odwołanie do next() zwracające jakiś element kolekcji.

Klasy iteratorów są  definiowane w klasach kolekcyjnych jako klasy wewnętrzne, implementujące interfejs Iterator<T>. Implementacja metody iterator() z interfejsu Collection zwraca obiekt takiej klasy. Dzięki temu od każdej kolekcji możemy uzyskać iterator za pomocą odwolania:


    Iterator<T> iter = c.iterator();

gdzie:  c - dowolna klasa implementująca interfejs Collection, T - typ elmentów kolekcji.


Dla tych kolekcji, w których elementy nie zajmują ściśle określonych pozycji iteratory są jedynym sposobem na "przebieganie" po kolekcji. Co więcej, dla kolekcji listowych - niezależnie od implementacji - iteratory są efektywnym narzędziem iterowania, czego nie da się powiedzieć we wszystkich przypadkach o pętlach iteracyjnych pobierających elementy z pozycji wyznaczanych przez podane indeksy.

Warto zauważyć, że narzucająca się analogia pomiędzy kursorem i iteratorem może być myląca. O iteratorze należy raczej myśleć jako o wskaźniku ustawianym nie na elemencie kolekcji, ale pomiędzy elementami. Na początku iterator ustawiony jest przed pierwszym elementem. Odwolanie next() jednocześnie przesuwa iterator za pierwszy element i zwraca ten element. Każde kolejne next() przeskakuje kolejny element i zwraca ten  "przeskoczony" element. Zob. rysunek.

r



Metoda iteratora remove() może być bardzo użyteczna: najczęściej stosowana jest do usuwania z kolekcji elementów, które spełniają (nie spełniają) jakichś warunków. Inne sposoby usuwania takich elementów często mogą okazać się bardziej kosztowne.

Szablon użycia remove()  w trakcie iteracji:
Iterator<T> iter = c.iterator(); // c - dowolna kolekcja, typ Collection, T - typ elementów kolekcji
while (iter.hasNext()) {
  Object o = iter.next();
  if (warunek_usunięcia(o)) iter.remove();
}
Powyższy kod jest polimorficzny: nadaje się do zastosowania wobec dowolnych kolekcji dowolnych obiektów. Pod koniec tego punktu zobaczymy nieco bardziej zaawansowany przykład takiego polimorficznego wykorzystania iteratora.

Należy pamiętać, że metoda remove() może usunąć tylko element zwrócony przez next(), i wobec tego może być zastosowana tylko raz dla każdego next().
Zatem nie możemy w jednej iteracji  usunąć kilku elementów:

r
W tym przypadku zostanie zgłoszony wyjątek IllegalStateException.

Istnieją też inne istotne ograniczenia dotyczące modyfikacji kolekcji w trakcie iteracji.

W trakcie iteracji za pomocą iteratora nie wolno modyfikować kolekcji innymi sposobami niż użycie metody remove() na rzecz iteratora. Wyniki takich modyfikacji są nieprzewidywalne


Modyfikacje strukturalne (dodanie, usunięcie elementu) są w trakcie iteracji sprawdzane i wykrywane. Jeżeli w trakcie iteracji zostanie dokonana modyfikacja strukturalna za pomocą innych od remove() dla bieżącego iteratora metod - powstanie wyjątek ConcurrentModificationException.
Dotyczu to zarówno użycia metod add i remove interfejsu Colection, jak i metody remove, aktywowanej na rzecz innego iteratora.

Zatem w takich sytuacjach:
    List<String> l = new ArrayList<String>();
    // .. dodanie do listy jakichś elementów
    Iterator<String> it1 = l.iterator();
    while (it1.hasNext()) {
      it1.next();
      l.add("Coś"); // Błąd fazy wykonania
    }
 
    List<String> l = new ArrayList<String>();
    // dodanie do listy jakichś elementów
    Iterator<String> it1 = l.iterator();
    Iterator<String> it2 = l.iterator();
    while (it1.hasNext()) {
      it1.next();
      it2.next();
      it2.remove();  // Błąd fazy wykonania
    }

w fazie wykonania zostanie zgłoszony wyjątek ConcurrentModificationException.

Jak pamiętamy, rozszerzona instrukcja for ("for-each") pozwala łatwiej iterować po elementach kolekcji, bo jest wygodnym skrótem od:

 Iterable<Typ> classObject = ....;
 ....
 for ( Iterator<Typ> $i = classObject.iterator(); $i.hasNext(); ) {
        Typ id = $i.next();
        stmt
 }

Ponieważ interfejs Collection rozszeraz Iterable, to wszystkie kolekcje są Iterable.

Ale "for-each" nie we wszystkich przypadkach nadaje się do przeglądania kolekcji. Bez bezposredniego zastosowania iteratora nie uzyskamy np. możliwości usuwania elementów z kolekcji przy ich przeglądaniu.


4. Przykład: uniwersalne działania na kolekcjach

Spójrzmy teraz na nieco bardziej rozbudowane i zaawansowany przykład, pokazujące silę interfejsu Collection i iteratorów.
Stworzymy uniwersalną klasy MKolekt, której metody pozwalają zapelniać dowolne kolekcje obiektami dowolnych klas, tworzonymi na podstawie  informacji z plików tekstowych, zapisywać lementy dowolnych kolekcji dowolnych obiektów do plików tekstowych, wypisywać na konsoli elementy dowolnych kolekcji oraz usuwać z dowolnych kolekcji elementy spelniające warunki "do usunięcia".
Zadanie jest trudne, bo różnorodność możliwych elementów kolekcji jest nieograniczona. Potrzebna jest zatem swoista współpraca naszej uniwersalnej klasy MKolekt z klasami konretnych obiektów - elementow kolekcji. Umówmy się więc, że każda z klas konkretnych elementów (dowolnych) kolekcji przetwarzanych przez metody klasy MKolekt musi zdefiniować trzy metody: metodę tworzenia swojego obiektu z podanego napisu (informacje o obiekcie będą w klasie MKolekt pobierane z pliku tekstowego), metodę określającą czy obiekt spełnia warunki umożliwiające usunięcie go z kolekcji i metodę tworzenia napisu z obiektu (być może toString nie wsytarczy, bo przy zapisie do plików będziemy chcieli inaczej formatować informacje o obiektach.
Naturalnym sposobem  zapewnienia tego jest zdefiniowanei odpowiedniego interfejsu i implementowanie go w klasach obiektów-elementów kolekcji. Nazwijmy ten interfejs CHelper.

public interface CHelper<T>  {
  
  // Tworzy obiekt z podanego napisu s i zwraca go
  T makeObject(String s);
  
  // Tworzy String z obiektu - możliwe, że w altrnatywny sposób niż toString
  String makeString(); 

  // Zwraca true, jeśli dane obiektu są uznane za nieaktualne
  boolean isNotUpToDate();

}
Uwaga: interfejs jeste sparametryzowany (parametr typu T). W ten sposób uzyskamy wszystkie zalety generics.

Zatem elementy kolekcji przetwarzanych przez metody klasy MKolekt "będą typu" CHelper.
Klasa MKolekt może wyglądać tak.

import java.io.*;
import java.util.*;

public class MKolekt {

  // Tworzy obiekty klasy
  // określonej przez klasę klasOb
  // na podstawie informacji z pliku tekstowego o nazwie fname
  // i dodaje je do kolekcji col

  public static <T extends CHelper<T>> 
         void makeCollectionFromFile(Collection<T> col, String fname,
                                     Class<T> klasaOb) {
    try {
      CHelper<T> mgr =  klasaOb.newInstance();

      Scanner in = new Scanner(new File(fname));
      while(in.hasNextLine()) {
        T obj = mgr.makeObject(in.nextLine());
        col.add(obj);
      }
      in.close();
    } catch (Exception exc) {
        exc.printStackTrace();
        System.exit(1); 
    }
  }

  // Usuwa z kolekcji c obiekty przeznaczone do usunięcia
  // na podstawie wyniku metodu isNotUpToDate

  public static <T extends CHelper<T>> void iterRemove(Collection<T> c) {
    Iterator<T> iter = c.iterator();
    while (iter.hasNext()) {
      CHelper<T> elt = iter.next();
      if (elt.isNotUpToDate()) iter.remove();
    }
  }

  // Zapisuje do pliku fname, w kolejnych wierszach,
  // kolejne elementy kolekcji col
  public static <T extends CHelper<T>>
    void writeToFile(Collection<T> col, String fname) throws Exception {
      Formatter out = null;
      try {
        out = new Formatter(new File(fname));
        for (T obj : col) {
          String line = obj.makeString();
          out.format("%s%n", line);
        }
      } finally {
          if (out != null) out.close();
      }
    
  }
  
  public static void show(Collection<?> col) {
    for (Object o : col) System.out.println(o);
  }

}
Jest ona uniwersalna, bowiem będzie działać dla obiektów dowolnych klas implementujących CHelper i dla dowolnych kolekcji tych obiektów.
Warto zauwazyć:

Szczególnej uwadze można polecić metodę iterRemove, która w pełni polimorficzny sposób korzysta z całej mocy koncepcji  "usuwania elementów podczas iterowania kolekcji".

Aby przetestowac działanie klasy MKolekt wyobraźmy sobie na przykład, że chcemy stworzyć dwie kolekcje: gazet i studentów.
Elementy kolekcji (obiekty klas Journal i Student) będą tworzone na podstawie informacji zawartych w plikach (gazet i studentów). W pliku gazet podano tytuł i rok wydania, plik studentow zawiera nazwisko i ocenę.
Po stworzeniu kolekcji (gazet i studentów) będziemy chcieli usunąć z nich wszystkie elementy, które spelniają warunek: dla gazet - rok wydania mniejszy od 2003, dla student/ow - ocena mniejsza od 3, a następnie
Program, który wykonuje te działania może wyglądać następująco:

import java.util.*;

public class Test {

  public static void main(String[] args) {
    List<Journal> gazety = new ArrayList<Journal>();
    Set<Student>  studenci = new HashSet<Student>();
    MKolekt.makeCollectionFromFile(gazety, "Journal.in", Journal.class);
    MKolekt.makeCollectionFromFile(studenci, "Student.in", Student.class);
    System.out.println("Oryginalna zawartość kolekcji"); 
    MKolekt.show(gazety);
    MKolekt.show(studenci);
    // ustalenie minimalnego roku wydania
    // gazety wydane wczesniej będą usunięte z kolekcji
    Journal.setLimitYear(2004);
    MKolekt.iterRemove(gazety);
    // ustalenie minimalnej oceny
    // studenci z oceną niższą będa usunięci z kolekcji   
    Student.setLimitMark(3);
    MKolekt.iterRemove(studenci);
    // Pokazujemy po usunięciu
    System.out.println("Nowa zawartość kolekcji");
    MKolekt.show(gazety);
    MKolekt.show(studenci);
    // I zapisujemy do pliku

    try {
      MKolekt.writeToFile(gazety, "Journal.out");
      MKolekt.writeToFile(studenci, "Student.out");
    } catch (Exception exc) {
      exc.printStackTrace();
    }
  }

}

Wynik działania tego programu może być następujący.
Oryginalna zawartość kolekcji
Polityka 2001
Polityka 2002
Polityka 2003
Polityka 2004
Polityka 2005
Polityka 2006
Polityka 2007
Polityka 2008
New Scientist 2003
New Scientist 2004
New Scientist 2005
New Scientist 2006
New Scientist 2007
New Scientist 2006
Kowalski Jan 5.0
Babacki Bronek 2.0
Dadacki Damian 3.0
Cabacki Czesław 2.0
Abacka Anna 4.0
Malinowski Stefan 3.0
Nowa zawartość kolekcji
Polityka 2004
Polityka 2005
Polityka 2006
Polityka 2007
Polityka 2008
New Scientist 2004
New Scientist 2005
New Scientist 2006
New Scientist 2007
New Scientist 2006
Kowalski Jan 5.0
Dadacki Damian 3.0
Abacka Anna 4.0
Malinowski Stefan 3.0

Dzięki wykorzystaniu ogólności architektury kolekcji oraz polimorficznych odwołań podobny - krotki - program możemy napisać dla dowolnych innych klas (nie tylko studentow czy gazet).

Klasy Journal i Student przedstawiają poniższe wydruki (potrzeba implementacji interfejsu Comparable oraz zdefiniowania metod equals() i hashCode zostanie wyjaśniona za chwilę, w tym momencie nie ma to znaczenia):

import java.util.*;

public class Journal implements CHelper<Journal>, Comparable<Journal> {
  private String title;
  private int year;
  private static int retainAfter = 0;  // rok wydania,
                                       // od ktorego ew. zostawiać gazety

  //konstruktor brzparametrowy (wg kontraktu potrzebny do newInstance w MKolekt)
  public Journal() { }  
  
  public Journal(String t, int y) {
    title = t;
    year = y;
  }

  // Implementacja metody stwierdzającej czy Journal można uznać za aktualny
  @Override
  public boolean isNotUpToDate() {
    return year < retainAfter;
  }

  // Tworzy Journal ze Stringu, w którym tytuł jest ujęty w cudzysłow
  // a za tytułem znajduje się rok wydania. Dodaje do kolekcji.
  @Override
  public Journal makeObject(String s){
    String title = "";
    int year = -1;
    try {
      StringTokenizer st = new StringTokenizer(s, "\"");
      title = st.nextToken();
      year = Integer.parseInt(st.nextToken().trim());
    } catch (Exception exc) { }
    Journal j =  new Journal(title, year);
    return j;
  }
  
  // Tworzy String z obiektu
  // inna forma niż toString (dla zapisu do pliku)
  @Override
  public String makeString() {
    return '"' + title + '"' + " " + year; 
  }

  public static void setLimitYear(int y) { retainAfter = y; }

  @Override
  public int hashCode() {
    return this.toString().hashCode();
  }

  @Override
  public boolean equals(Object obj) {
    return toString().equals(obj.toString());
  }

  @Override
  public int compareTo(Journal j) {
    return toString().compareTo(j.toString());
  }

  @Override
  public String toString() { return title + " " + year; }
}

import java.util.*;

public class Student implements CHelper<Student>, Comparable<Student> {
  private String name;
  private double mark;             // ocena
  private static double minMark;   // minimalna ocena

  public Student() {}
  public Student(String nam, double m) {
    name = nam;
    mark = m;
  }

  public void setMark(double m) { mark = m; }

  public boolean isNotUpToDate() {
    return mark < minMark;
  }

  // Tworzy obiekt Student i dodaje do kolekcji
  // format napisy: nazwisko tab ocena
  public Student makeObject(String s) {
    Scanner sc = new Scanner(s).useDelimiter("\t");
    String name = sc.next();
    double mark = 0;
    try {
      mark = sc.nextDouble();
    } catch (Exception exc) {
      exc.printStackTrace();
    }
    Student stud = new Student(name, mark);
    return stud;

  }
  
  @Override
  public String makeString() {
    return name + '\t' + mark;
  }

  // Ustalenie minimalnej oceny.
  // Studenci z niższą oceną mogą być usunięci z kolekcji.  
  public static void setLimitMark(double m) {
    minMark = m;
  }

  public boolean equals(Object obj) {
    return name.equals(obj);
  }

  public int compareTo(Student s) {
    return this.toString().compareTo(s.toString());
  }

  public int hashCode() { return this.toString().hashCode(); }

  public String toString() { return name + " " + mark; }
}

Warto jeszcze raz zwrócić uwagę, że metody klasy MKolekt dzialają dla różnego rodzaju kolekcji (ArrayList i HashSet) elementów dwóch bardzo róznych klas (Student i Journal).
Będą też działać dla dowolnych  innych rodzajów kolekcji (byleby implementowaly interfejs Collection) i dowolnych innych klas elementów (byleby implementowaly interfejs CHelper) i definiowały konstruktor bezparametrowy.


5. Listy i iteratory listowe


Lista jest ciągiem elementów uporządkowanych w tym sensie, że każdy element zajmuje określoną pozycję na liście.


JCF dostarcza dwoch implemantacji listy: tablicowej (klasa ArrayList) i  liniowej z podwójnymi dowiązaniami (klasa LinkedList).

Implementacja tablicowa polega na realizacji listy w postaci tablicy z dynamicznie (w miare potrzeby) zwiększającymi się rozmiarami. Elementy listy są zapisywane jako elementy takiej tablicy.
Ponieważ tablice w Javie mają określone (niezmienne po utworzeniu) rozmiary utworzenie listy tablicowej wymaga alokacji tablicy z jakimś zadaną rozmiarem. Jest on specyfikowany przez initialCapacity (domyślnie 10), który to parametr możemy podać w konstruktorze ArrayList, jeśli inicjalna pojemnośc nam nie odpowiada. Przy dodawaiuu elementow do listy sprawdzane jest czy pojemność tablicy jest wystarczająca, jeśli nie to rozmiar tablicy jest zwiększany, Służy temu metoda ensureCapacity(minCapacity), któeą zresztą możemy wywolać sami, aby w trakcie działania programu zapewnić podaną jako minCapacity pojemność listy.

Uproszczone fragmenty realizacji klasy  ArrayList (autorem jest Josh Bloch, główny twórca JCF oraz Neal Gafter) przedstawiono poniżej.
public class ArrayList<E> implements List<E> // ,  ...
{
    private transient Object elementData[];
    private int size;

    public ArrayList(int initialCapacity) {
      this.elementData = new Object[initialCapacity];
    }

    public ArrayList() {
      this(10);
    }

    public void ensureCapacity(int minCapacity) {
      // ...
      int oldCapacity = elementData.length;
      if (minCapacity > oldCapacity) {
        Object oldData[] = elementData;
        int newCapacity = (oldCapacity * 3)/2 + 1;
        if (newCapacity < minCapacity) newCapacity = minCapacity;
        elementData = new Object[newCapacity];
        System.arraycopy(oldData, 0, elementData, 0, size);
      }
    }

    public boolean add(Object o) {
      ensureCapacity(size + 1);
      elementData[size++] = o;
      return true;
    }
// ...
}
Źrodło: Josh Bloch, Neal Gafter, klasa ArrayList (źródła)

Jak widać,  w przypadku braku miejsca na dodanie nowego elementu rozmiary tablicy zwiększają się  nie o 1, ale o pewien współczynnik. Dzięki temu realokacje tablicy i przepisywanie jej elementów nie muszą być wykonywane zbyt często.

Implementacja liniowa z podwójnymi dowiązaniami umieszcza dane w strukturach danych - nazwiemy je wiązaniami (link ) - które zawierają wskaźniki do poprzedniego i następnego elementu listy (dlatego dowiązania są "podwójne", niejako dwukierunkowe). Zatem elementy listy, które z punktu widzenia programisty są elementami umieszczanych danych (np. nazwisk lub jakichś innych obiektów), technicznie są "linkami", zawierającymi nie tylko dane, ale również  wskaźniki na następny i poprzedni element na liście.
Początek listy dowiązaniowej, zwany głową lub wartownikiem zawiera wskazanie na pierwszy element listy (null, jeśli lista jest pusta).

Ilustruje to poniższy rysunek:
rrrr  
Objaśnienia:
first - wskazanie na pierwszy element listy,
data - refrencja do obiektu (dane elementu),
next - wskazanie na następny element na liście
prev - wskazanie na poprzedni element lsity

Każda lista (niezależnie od implementacji) umożliwia wykonywanie następujących operacji (ktore są specyfikowane przez interfejs List).

Operacje na liście
wszystkie metody interfejsu Collection
Ogóne operacje właściwe dla wszelkich kolekcji (np. add, remove, contains, operacje gupowe)
boolean add(int p, T elt)              Dodaje element typu T na pozycji p. Zwraca true jeśli lista została zmodyfikowana.
boolean addAll(int  p, Collection c)                            Dodaje wszystkie elementy kolekcji c do listy poczynając od pozycji p. Zwraca true jeśli lista została zmodyfikwoana.
T get(int p)Zwraca element na pozycji p (T jest typem elmentu)
int indexOf(T elt)Zwraca pozycję (indeks) piewrszego wystapienia elementu elt
int lastIndexOf(T elt)Zwraca indeks ostatniego wystąpienia elementu elt
ListIterator<T> listIterator()Zwraca iterator listowy, ustawiony na początku listy (przed pierwszym elementem).
ListIterator<T> listIterator(int p)Zwraca iterator listowy ustawiony przed  elementem o indeksie p.
boolean remove(int p)Usuwa element na pozycji p. Zwraca true jeśli lista została zmodyfikwoana.
T set(int p, T val)Zastępuje element na pozycji p podanym elementem elt. Zwraca poprzednio znajdujący się na liście  element.
List<T> subList(int f, int l)Zwraca podlistę zawierającą elementy listy od pozycji f włacznie do pozycji l (wyłącznie).
Uwagi:
  1. wszystkie operacje modyfikującę listę strukturalnie lub pod względem zawartości są opcjonalne,
  2. pozycje na liście numerowane są od 0 (tak jak indeksy w tablicy).
Jak widać, operacje na liście rozszerzają możliwości operowania na kolekcjach o operacje pozycyjne - takie, które uzwględniają pozycję elementów.
Ze względu na znajomość pozycji elementów w kolekcji możliwe staje się iterowanie po kolekcji w obie strony: od początku i od końca. Można też ustawić iterator w taki sposób, by iteracje rozpoczynały się od podanej pozycji, a znając pozycję elementu zwracanego przez iterator można nie tylko go usunąc, ale zamienić lub dodać nowy element na pozycji wyznaczanej przez stan iteratora.
Dlatego właśnie oprócz zwyklego (ogólnego dla wszystkich kolekcji) iteratora, listy udostępniają iteratory listowe, które są obiektami klas implementujących interfesj ListIterator. Ten ostatni jest rozszerzeniem interfejsu Iterator.

Operacje iteratora listowego
 voidadd(T o)
          Dodaje podany element do listy na pozycji wyznaczanej przez iterator. (operacja opcjonalna).
 booleanhasNext()
          Zwraca true, jeśli są jeszcze elementy na liście, w sytuacji iterowania od początku do końca.
 booleanhasPrevious()
          Zwraca true, jeśli są jeszcze elementy na liście w sytuacji iterowania od końca do początku.
 Objectnext()
          Zwraca następny element na liście i przesuwa iterator.
 intnextIndex()
          Zwraca indeks elementu, który byłby zwrócony przez odwołanie do next().
 Objectprevious()
          Zwraca poprzedni element na liście i przesuwa iterator.
 intpreviousIndex()
          Zwraca indeks elementu, któy byłby zwrócony przez odwolanie previous.
 voidremove()
          Usuwa z  listy ostatni element który byl zwrocony przez ostatnie odwołanienext lub previous (operacja opcjonalna).
 voidset(T o)
          Zastępuje element zwrocony prez next lub previous podanym elementem (operacja opcjonalna).
Uwagi:
  1. konkurencyjne modyfikacje wartości elementów listy w trakcie iteracji za pomocą iteratora listowego nie są wykrywane,
  2. w przeciwieństwie  do metody add z interefejsu Collection metoda add iteratora listowego nie zwraca żadnego wyniku: na liście operacja dodawania zawsze jest skuteczna.

Jak pamiętamy, iterator zajmuje pozycję pomiędzy elementami. Kolejne odwołanie do next() lub previous() powoduje, że iterator "przeskakuje" zwrócony element. W tym miejscu wlaśnie zostanie wpisany element, jeśli użyjemy metody add.
Natomiast metoda remove() usuwa  zawsze element zwrócony przez ostatnie odwolanie do next() lub previous().
 
Szczególnie przy stosowaniu iteratora listowego należy pamiętać o tym, że iterator "zajmuje pozycję" pomiędzy elementami



Ilustruje to poniższy program, który pokazuje kolejne postępy iteracji i zmiany na liście na skutek zastosowania metod add i remove.

import java.util.*;
class ListIter1 {

  static <T> void state(ListIterator<T> it) {
    int pi = it.previousIndex(),
        ni = it.nextIndex();
    System.out.println("Iterator jest pomiędzy indeksami: " + pi + " " + ni);
  }


  public static void main(String args[]) {
    List<String> list = new LinkedList<String>(Arrays.asList(
                           new String[] { "E0","E1", "E2", "E3" }
                           ));
    ListIterator<String> it = list.listIterator();
    it.next();
    state(it);
    it.add("nowy1");
    System.out.println(list);
    it.next();
    it.next();
    it.previous();
    state(it);
    it.add("nowy2");
    System.out.println(list);
    it.previous();
    it.previous();
    state(it);
    it.remove();
    System.out.println(list);
  }
}
Wydruk:
Iterator jest pomiędzy indeksami: 0 1
[E0, nowy1, E1, E2, E3]
Iterator jest pomiędzy indeksami: 2 3
[E0, nowy1, E1, nowy2, E2, E3]
Iterator jest pomiędzy indeksami: 1 2
[E0, nowy1, nowy2, E2, E3]

Mimo, że w programach działających na listach posługiwać się będziemy metodami interfejsu List (niejako niezależnie od implementacji), to wybór implementacji listy nie jest obojętny dla efektywności dzialania naszego programu.

Praktycznie implementację liniową z podwójnymi dowiązaniami (LinkedList) powinniśmy wybierać tylko wtedy, gdy na liście będą wykonywane częste operacje wstawiania i/lub usuwania elementów w środku listy (poza końcem listy).
Istotnie na liście typu LinkedList takie operacje polegają na zmianie dwóch dowiązań (prowadzącego do poprzedniego i do następnego elementu) - są więc bardzo szybkie, zaś w implementacji tablicowej (ArrayList) wiążą się z przepisywaniem elementów tablicy, co (zwykle) zabiera więcej  czasu.

Usunięcie elementu na liście LinkedList:
rrrrr  

Dodanie elementu na liście LinkedList:
rr


Taka sama operacja dodania elmentu na ArrayList jest znacznie bardziej kosztowna, o czym przekonuje nas (uproszczony) fragment realizacji klasy ArrayList:
    public void add(int index, Object element) {
	// ...
	ensureCapacity(size+1);
        // Przesunięcie - czyli przekopiowanie elementów tablicy!
	System.arraycopy(elementData, index, elementData, index + 1,
			 size - index);
	elementData[index] = element;
	size++;
    }

Źrodło: Josh Blosh, Neal Gafter, klasa ArrayList (źródła)

Z kolei, operacje  bezpośredniego dostępu do elementów listy są w implementacji tablicowej ArrayList natychmiastowe (polegają na indeksowaniu tablicy), natomiast w implementacji LinkedList są bardzo nieefektywne, gdyż technicznie wymagają przebiegania po elementach listy od samego jej początku lub od końca w kierunku początku (ten ostatni przypadek jest jedyną  optymalizacją dostępu w klasie LinkedList, dokonywaną wtedy, gdy indeks znajduje się "w drugiej polowie" listy).

Poniższy program mierzy czas poszczegolnych operacji dla dwóch implementacji list.

import java.util.*;
import javax.swing.*;

public class Lists {

  long start;

  void setTimer() { start = System.currentTimeMillis(); }
  long elapsed() { return System.currentTimeMillis() - start; }

  public Lists(int n) {
    ArrayList<String>  aList = new ArrayList<String>(n);
    for (int i=0; i < n; i++) aList.add("a");
    LinkedList<String> lList = new LinkedList<String>(aList);
    setTimer();
    randomAccess(aList);
    System.out.println("Swobodny dostęp do ArrayList: " + elapsed() + " ms");
    setTimer();
    randomAccess(lList);
    System.out.println("Swobodny dostęp do LinkedList: " + elapsed() + " ms");
    setTimer();
    insert(aList);
    System.out.println("Wpisywanie na ArrayList: " + elapsed() + " ms");
    setTimer();
    insert(lList);
    System.out.println("Wpisywanie na LinkedList: " + elapsed() + " ms");
  }

  void randomAccess(List<String> l) {
    Random rand = new Random();
    for (int i=0; i<10000; i++) {
      int index = rand.nextInt(l.size());
      String s = l.get(index);
      s = s + "a";
      l.set(index, s);
    }
  }

  void insert(List<String> l) {
    ListIterator<String> iter = l.listIterator();
    int i = 0;
    while (iter.hasNext()) {
      iter.next();
      if (i % 2 != 0) iter.add("b");
      i++;
    }
  }


  public static void main(String args[]) {
    new Lists(new Scanner(JOptionPane.showInputDialog("Ile elementó na liście?")).nextInt());
  }
}
Metoda randomAccess() 10000 razy zmienia elementy pod losowo wybranymi indeksami, a metoda insert() po każdym elemencie o nieparzystym indeksie dopisuje jakiś nowy element.

Wynik dzialania programu (dla list 100000 elementów) pokazano poniżej.
Swobodny dostęp do ArrayList: 31 ms
Swobodny dostęp do LinkedList: 10125 ms
Wpisywanie na ArrayList: 7829 ms
Wpisywanie na LinkedList: 46 ms

Na listach  LinkedList nie należy nigdy stosować operacji get(int index) i set(int index, Object value)

Skoro jednak powinniśmy pisać nasze kody (szczególnie metody) w kategoriach interfejsów kolekcyjnych, a nie konkretnych implementacji, to jak rozpoznać z jaką implementacją mamy do czynienia i czy można wykorzystać jej zalety i uniknąć wad?

Służy temu interfejs znacznikowy RandomAccess. Wszystkie implementacje list, które umozliwiają swobodny dostęp w czasie stałym. a więc szybsze wykonanie pętli:
for (int i=0, n = list.size(); i<n; i++) list.get(i);
niż pętli:
for (Iterator iter = list.iterator(); iter.hasNext(); ) iter.next();
powinny go implementować (np. klasa ArrayList implementuje ten interfejs, a LinkedList - nie)

W naszych kodach możemy sprawdzić, czy implementacja umożliwia efektywny dostęp swobodny poprzez użycie konstrukcji list instanceof RandomAccess.

Wyszukiwanie elementów na listach (czy to klasy ArrayList czy LinkedList) za pomocą ogólnych metod interfejsu Collection (contains(Object)) oraz interfejsu List indexOf(Object)) nie jest efektywne.
Jeśli zależy nam na szybkim odnajdywaniu elementów - powinniśmy albo zastosować inny rodzaj kolekcji (np. zbiory w implementacji tablic mieszania), albo posortować listę (metoda sort) i zastosować wyszukiwanie binarne (metoda binarySearch). Obie te metody są implementacjami algorytmów kolekcyjnych, dostarczanych w klasie Collections.

Obie implementacje list - ArrayList i LinkedList nie są przeznaczone do użycia współbieżnego (nie są wielowątkowo bezpieczne). Zapewnienie możliwości współdzielenia kolekcji przez wiele wątków wymaga synchronizacji, co jest czasowo kosztowne.
Zatem tylko na wyraźne życzenie programisty - poprzez stworzenie synchronizowanego widoku kolekcji (zob. punkt o widokach kolekcji) - można uzyskać listy wielowątkowo bezpieczne.
Istnieje również możliwość wykorzystania klasy Vector, która - od dawna obecna w Javie - jest obecnie niczym innym jak synchronizowaną wersją klasy ArrayList.




6. Kolejki

Stosując kolejki - w przeciwieństwie do list - nie możemy bezposrednio sięgać po dowolne elementy (na dowolnych pozycjach) ani też wstawiać elementów na dowolnych pozycjach.


Kolejka (ang. queue, wym. k:ju) jest sekwencją elementów, na której  operacje wstawiania, pobierania i usuwania elementów są możliwe tylko w określonym porządku.

Najczęściej porządkiem tym jest FIFO - first in - first out, co odpowiada intuicyjnemu pojmowaniu kolejki: pierwszy przyszedł, pierwszy zostanie obsłuzony.
W kolejkach FIFO możliwe są tylko następujące operacje wstawiania, pobierania i usuwania elementów:
Kolejki LIFO - last-in - first-out (inaczej zwane stosami - ang. stack)  dopuszczają operacje:
Jest to niejako porządek odwrotny od FIFO: ten kto przyszedł ostatni, będzie obsłużony pierwszy.

Kolejki z priorytetami pobierają i usuwają elementy z pocżątku, ale początek okreslany jest przez kryteria porównywania obiektów. Sekwencja elementów nie musi być liniowa (a porządek elmentów zwracany przez iterator jest nie określony)


Kolejki mogą być nieograniczone (nie ma limitu na miejsce) lub ograniczone (mieszczące jakąś określoną liczbę elementów).

Wszystkie kolejki oferują następujące operacje, określane przez interfejs Queue.
Operacja
Metody zgłaszające wyjątki
przy braku miejsca lub elementów
Metody nie zgłaszające wyjątków
przy braku miejscalub elementów
Wstawianie
boolean add(T e)

zwraca true gdy sukces
lub zgłasza wyjątek
IllegalStateException
przy braku miejsca


boolean offer(T e)

zwraca:
true - gdy sukces,
false - gdy nie ma miejsca.
Zgłasza wyjątek
IllegalArgumentException,
jesli nie dopuszcza wstawienia
danego rodzaju elementu
Usuwanie
T remove()

zwraca usunięty z początku element typu T
lub zgłasza wyjątek:
NoSuchElementException
jeśli kolejka jest pusta

T poll()

zwraca usunięty z pocżatku element typu T
lub null, jesli kolejka jest pusta

Pobieranie
bez usuwania

T element()

zwraca element z początku
lub zgłasza wyjątek:
NoSuchElementException
jeśli kolejka jest pusta
T peek()

zwraca element z pocżatku 
lub null, jesli kolejka jest pusta



Kolejka podwójna (ang. deque, wym. dek) jest liniową sekwencją elementów, na której  operacje wstawiania, pobierania i usuwania elementów są możliwe na obu końcach sekwencji.


Operacje na kolejce podwójnej są określane przez interfejs Deque, który dodaje do Queue metody o samoobjaśniajacych się nazwach:

Na początku Na końcu
Z wyjątkiem
przy braku miejsca lub elementów
Bez tego wyjątku Z wyjątkiem
przy braku miejsca lub elementów
Bez tego wyjątku
Wstawianie boolean addFirst(e) boolean offerFirst(e) boolean addLast(e) boolean offerLast(e)
Usuwanie T removeFirst() T pollFirst() T removeLast() T pollLast()
Pobieranie
bez usuwania
T getFirst() T peekFirst() T getLast() T peekLast()


Wśród gotowych implementacji kolejek w JCF są dostępne następujące klasy:
Działania na kolejkach ilustruje poniższy program:
import java.util.*;

public class Kolejki {

  public static void main(String[] args) {
    ArrayDeque<Integer> deq = new ArrayDeque<Integer>();
    
    // Działa jak kolejka FIFO
    deq.offer(1);
    deq.add(20);
    deq.add(12);
    
    System.out.println("FIFO: " + deq);
    System.out.println("Podglądamy (z początku): " + deq.peek());
    
    // Działa jak kolejka LIFO
    deq.clear();
    deq.addFirst(1);
    deq.addFirst(20);
    deq.addFirst(12);

    System.out.println("LIFO: " + deq);
    System.out.println("Podglądamy (z początku): " + deq.peek());

    // Kolejka z priorytetami
    PriorityQueue<Integer> pq = new PriorityQueue<Integer>();
    pq.add(1);
    pq.add(20);
    pq.add(12);
    System.out.println("PRIOR QUE: " + pq); 

    System.out.println("Z kolejki z priorytami pobieramy\nkolejne elementy\nzawsze z początku");
    System.out.println("Początek - domyślnie najmniejszy element");
    Integer n;
    while((n = pq.poll()) != null) {
      System.out.println(n);
    }
  }

}
który da w wyniku:
FIFO: [1, 20, 12]
Podglądamy (z początku): 1
LIFO: [12, 20, 1]
Podglądamy (z początku): 12
PRIOR QUE: [1, 20, 12]
Z kolejki z priorytami pobieramy
kolejne elementy
zawsze z początku
Początek - domyślnie najmniejszy element
1
12
20


7. Zbiory. Haszowanie i porządkowanie.


Zbiór jest kolekcją elementów bez ustalonego porządku ich występowania w kolekcji oraz bez duplikatów, tj. kolekcja nie zawierającą dwóch takich samych elementów


Z punktu widzenia operowania na zbiorach mamy do dyspozycji metody interfejsu Set (które są takie same jak omówione wcześniej metody interfejsu Collection). Jedyne uszczegółowienie polega na tym, że w przypadku zbiorów, metody dodające elementy do zbiorów zwracają wartość false, jeśli dodawane elementy już w zbiorze występują.

Zatem przy dodawaniu elementu do zbioru musi nastąpić sprawdzenie czy zbiór nie zawiera już dodawanego elementu. 
Słowo "mieszanie" używane w polskiej literaturze jako tłumaczenie słowa "hash" jest w tej chwili stosowane równolegle lub nawet wypierane przez słowo "haszowanie"
Są przynajmniej dwa sposoby efektywnego sprawdzania tego. Pierwszy (użycie tablicy mieszającej) jest zrealizowany w klasie HashSet, drugi (wyszukiwanie binarne z użyciem drzewa czerwono-czarnego) - w klasie TreeSet.

Tablica mieszania (hashtable) jest strukturą danych specjalnie przystosowaną do szybkiego odnajdywania elementów. Dla każdego elementu danych wyliczany jest kod numeryczny (liczba całkowita) nazywany kodem mieszania (hashcode), na podstawie którego obliczany jest indeks w tablicy, pod którym będzie umieszczony dany element. Może się zdarzyć, że kilka elementów otrzyma ten sam indeks, zatem elementy tablicy mieszającej stanowią listy, na których znajdują się elementy danych o takim samym indeksie, wyliczonym na podstawie ich kodów mieszania.

Każda lista - element tablicy mieszania nazywa się kubełkiem (bucket).
Aby umieścić nowy element w tablicy mieszania, wyliczany jest jego hashcode , po czym na podstawie jego wartości obliczany jest indeks w tablicy mieszania. Indeks taki stanowi resztę z dzielenia wartości kodu mieszania przez liczbę kubełków. Element umieszczany jest w kubełku pod tym indeksem.

Istotne jest w tej procedurze, by:

W Javie można wyliczyć kod mieszania dla każdego obiektu za pomocą zastosowania metody hashCode() . Metoda ta, zdefiniowana w klasie Object, daje - ogólnie - jako wynik adresy obiektów. To, oczywiście, nie jest zbyt użyteczne, bowiem nie bierze pod uwagę zawartości obiektów (wszystkie obiekty mają rożne kody mieszania). Ale w standardowych klasach Javy metoda hashCode() została przedefiniowana, tak, by zwracała kod na podstawie "treści" obiektu, np. napisu stanowiącego "zawartość" obiektu klasy String. Dwa takie same napisy będą miały te same kody mieszania.

Zobaczmy na przykładzie jak wygląda umieszczanie i wyszukiwanie elementow w tablicy mieszania. Napisy "a", "b", "c", "d", "e", "f'", "g", "h", "i" będą umieszczone w tablicy o 7 kubełkach w następujacy sposób.

r

Odpowiednie kody mieszania dla kolejnych napisów, oraz odpowiadające im indeksy  tablicy (reszta z dzialenia kodu przez liczbę kubełków) są następujące:

a kod: 97 indeks: 6
b kod: 98 indeks: 0
c kod: 99 indeks: 1
d kod: 100 indeks: 2
e kod: 101 indeks: 3
f kod: 102 indeks: 4
g kod: 103 indeks: 5
h kod: 104 indeks: 6
i kod: 105 indeks: 0

Jak widać, indeksy dla 'b' oraz 'i', a także dla 'a' oraz 'h' są takie same, wobec tego napisy znajdują się pod tym samym indeksem (w tym samym kubelku) jako kolejne elementy listy.

Łatwo zauważyć, że wyszukanie elementu w tablicy mieszania jest bardzo efektywne. Wystarczy obliczyć indeks tablicy na podstawie kodu mieszania szukanego elementu i jeżeli w danym kubełku jest tylko jeden element, to - nawet nie wykonując żadnych porównań - zwrócić ten element (lub wartość true - że jest). Jeżeli w kubełku jest kilka elementów, to musimy wykonać ich porównanie z szukanym elementem, ale i tak liczba porównań będzie zwykle bardzo niewielka w stosunku do liniowego czy nawet binarnego wyszukiwania.

Prostą, ilustracyjną  implementację zbioru jako tablicy mieszania pokazuje poniższy program, będący jednocześnie narzędziem do śledzenia co dzieje się z elementami, jakie mają kody mieszania, jak są wstawiane i jak mogą być odszukane.

import java.util.*;

public class Hash {

  final int NB = 7; // liczba kubełków

  LinkedList[] hashTab = new LinkedList[NB];	// tablica mieszania

  public Hash() {
    for (int i=0; i<NB; i++) hashTab[i] = new LinkedList();
    String[] napisy = { "a", "b", "c", "d", "e", "f", "g", "h", "i", "a" };
    for (int i=0; i<napisy.length; i++) insert(napisy[i]);
    for (int i=0; i<NB; i++) {
      String report = i + " - ";
      Iterator it = hashTab[i].iterator();
      while (it.hasNext()) {
        report += " - " + it.next();
      }
      System.out.println(report);
    }
    System.out.println("Czy zawiera *a* ? - " + contains("a"));
    System.out.println("Czy zawiera *k* ? - " + contains("k"));
  }

  public void insert(String s) {
    int hashCode = s.hashCode();
    int index = hashCode % NB;
    boolean isThere = isThere(index, s);
    if (!isThere) hashTab[index].add(s);
    System.out.println(s + " kod: " + hashCode +
                           " indeks: " + index +
                           (isThere ? " już tam jest" : " został dodany") );
  }

  public boolean contains(String s) {
     return isThere(s.hashCode() % NB, s);
  }

  private boolean isThere(int index, String s) {
    for (Iterator i = hashTab[index].iterator(); i.hasNext(); )
        if (s.equals(i.next())) return true;
    return false;
  }

  public static void main(String args[]) {
    new Hash();
  }
}
Uwaga: w programie użyto typów surowych (LinkedList i Iterator), ponieważ w Javie nie można tworzyć tablic elementó typów sparametryzowanych; dla szybkiej ilustracji wybrano tablicę list, mniej klarowną alternatywą byłaby lista list..

Wyniki działania programu:

a kod: 97 indeks: 6 został dodany
b kod: 98 indeks: 0 został dodany
c kod: 99 indeks: 1 został dodany
d kod: 100 indeks: 2 został dodany
e kod: 101 indeks: 3 został dodany
f kod: 102 indeks: 4 został dodany
g kod: 103 indeks: 5 został dodany
h kod: 104 indeks: 6 został dodany
i kod: 105 indeks: 0 został dodany
a kod: 97 indeks: 6 już tam jest
0 -  - b - i
1 -  - c
2 -  - d
3 -  - e
4 -  - f
5 -  - g
6 -  - a - h
Czy zawiera *a* ? - true
Czy zawiera *k* ? - false


W podobny sposób mógłby być implementowany  HashSet (tak naprawdę mamy tam listę liniową z pojedyńczymi dowiązaniami, w programie ilustracyjnym skorzystaliśmy dla wygody z gotowej klasy LinkedList).

Zwróćmy szczególną uwagę (również na przykładzie ilustracyjnego programu), że oprócz wyliczania kodów mieszania do prawidłowego dodawania i odnajdywania elementów potrzebne jest odpowiednie zdefiniowanie metody equals(), porownującej "treść" dwóch obiektów.

Jeżeli prawdziwe jest a.equals(b),
to musi być spełniony warunek
a.hashCode() == b.hashCode().


Oczywiście, zarowno hashCode() jak i equals() są dobrze zdefiniowane w standardowych klasach Javy.

Tworząc wlasne klasy musimy sami zadbać o właściwe zdefiniowanie metod hashCode() i equals().

Należy pamiętać, że przedefiniowujemy metodę equals z klasy Object. A tam jej parametrem jest Object. Użycie innego typu parametru prowadzi do przeciążenia (a nie przedefiniwonia) metody i uniemożliwia polimorficzne do niej odwołania.


Metodę hashCode definiujemy w klasie jako:

public int hashCode() {
// obliczenie kodu mieszania na podstawie wartości pól klasy (elementów obiektu)
// dla pól obiektowych użyjemy metody hashCode() z ich klas
// staramy się przy tym zapewnić odpowiednie zróżnicowanie kodów
}

Metodę equals definiujemy w następujący sposób:

public boolean equals(Object obj) {  
   // jeżeli porównywane obiekty należą do różnych klas - nie są równe!
    if (obj == null || getClass() != obj.getClass()) return false;
   // tu porównujemy zawartość obiektów i zwracamy true lub false
}



Uwaga.
Przynależność do różnych klas jest - w programowaniu obiektowym - pojęciem względnym, Mówimy przecież, że jeśli klasa B dziedziczy klasę A to jej obiekty są również obiektami klasy A. Jeśli teraz dodatkowo C dziedziczy A, to - zgodnie z przyjętą konwencją - obiekty klasy C są również  obiektami klasy A, czyli tej samej klasy co obiekty klasy B. Zatem ogólniejsza  konstrukcja metody equals wygląda tak:

class JakasKlasa {

public boolean equals(Object obj) {  
   // jeżeli porównywane obiekty należą do różnych klas - nie są równe!
    if (!(obj instanceof JakasKlasa)) return false;
   // tu porównujemy zawartość obiektów i zwracamy true lub false
}
//...
}

Teraz (w następujących przykładach) przyjmiemy jej bardzą ścisłą intrepretację (na poziomie dokładnej zgodności klas).

Również definiowanie metody hashcode() ma swoje niuanse. Istotna jest nie tylko wspomniana wcześniej zgodność z equals, ale również to, by całkowitoliczbowy kod  zwracany prze tę metodę był (możliwie najbardziej) unikalny dla róznych (pod względem zawartości) obiektów. Nie wchodząc w szczegóły, możemy podać tu swoisty przepis na dobrą metodę hascode().

  1. Ustal wartość stałej całkowitoliczbowej wynik (liczba całkowita, najlepiej pierwsza np 19) .
  2. Dla każdego pola klasy, branego pod uwagę przy porównywaniu obiektów, wylicz całkowitoliczbowy kod c:
    1. jeżeli pole f jest typu boolean: c = (f ? 0 : 1).
    2. jeżeli pole f jest typu byte, char, short, albo int: c = (int)f.
    3. jeżeli pole f jest typu long: c = (int)(f ^ (f >>> 32)).
    4. jeżeli pole f jest typu float: c = Float.floatToIntBits(f).
    5. jeżeli pole f jest typu double, wylicz long l =  Double.doubleToLongBits(f), a następnie zastosuje przekształcenie c = (int)(l ^ (l >>> 32)).
    6. jeżeli pole f jest referencją, zastosuj (jeśli trzeba rekursywnie, albo po odpowiednim przekształceniu referencji) metodę hashcode() z klasy obiektu wskazywanegop przez tę referencję, najprościej c = f.hashcode(),
    7. jeżeli pole f oznacza tablicę, zastosuj opisaną wyżej procedutę rekursywnie (dla każdego elementu tablicy) i wylicz kod wynikowy jak poniżej (następny punkt)
  3. Składaj wyniki powyższych obliczeń w formule: wynik = 37*wynik + c;
  4. Zwróć ten wynik (jako wynik metody hashcode())

Środowiska uruchomieniowe (m.in. Eclipse) dają możliwość automatycznej generacji kodów metod hashCode() i equals() poprzez wybór opcji z menu.

Podkreślmy jezcze raz: wynik metody hashcode musi być zgodny z equals. Równość obiektów implikuje równość hash-kodów.

Przykład.

import java.util.*;

class Worker {
  private String lname;
  private String fname;
  private int salary;

  public Worker(String fn, String ln, int sal) {
    lname = ln;
    fname = fn;
    salary = sal;
  }

  public int hashCode() {
    int wynik = 17;
    wynik = 37*wynik + lname.hshcode();
    wynik = 37*wynik + fname.hashcode();
    return wynik;
  }

  public boolean equals(Object obj) {
    if (obj == null || getClass() != obj.getClass()) return false;
    Worker w = (Worker) obj;
    return fname.equals(w.fname) && lname.equals(w.lname);
  }

  public String toString() {
    return fname + " " + lname + " " + salary;
  }


  public static void main(String args[]) {
    Worker[] p = { new Worker("Jan", "Kowalski", 1000),
                   new Worker("Jan", "Malinowski", 1200),
                   new Worker("Jan", "Kowalski", 1400)
                  };
    Set set = new HashSet();
    for (int i=0; i < p.length; i++) set.add(p[i]);
    System.out.println(set.toString());
  }
}
Obiekty klasy Worker różnią się, jeśli robotnicy mają inne imiona i/lub nazwiska (w realnym programowaniu powinniśmy użyć jakiejś bardziej jednoznacznej identyfikacji). Kod mieszania jest zgodny z equals(), bo równe kody implikują true jako wynik equals. To jednak nie znaczy, że rózne obiekty nie mogą mieć takich samych kodów mieszania. O przynależności do zbioru ostatecznie decyduje metoda equals(), hashcode() jest tylko po to, by umieścić obiekt w tablicy mieszania.
Program da następujący wynik:
[Jan Malinowski 1200, Jan Kowalski 1000]

Widzimy, że do zbioru nie zostały dodane "duplikaty" (dwa obiekty "Jan Kowalski"), i że o tym zdecydowała metoda equals.
A czy to naprawdę są duplikaty? Może to dwóch róznych  Kowalskich (przecież pensje są różne).
Gdyby equals() była zdefiniowana tak, że porównuje również pensje otrzymalibyśmy w zbiorze wszystkich trzech robotników.

Porządek elementów kolekcji HashSet nie jest okreslony (w innym przebiegu. programu moglibyśmy uzyskać najpierw Kowalskiego, a później Malinowskiego).

Klasa LinkedHashSet, dziedzicząc klasę HashSet udostępnia wygodną często właściwość: zachowania porządku, w jakim elementy dodane były do zbioru. Gdybyśmy zatem w poprzednim programie zmienili kod na:
    Set set = new LinkedHashSet();
    for (int i=0; i < p.length; i++) set.add(p[i]);
    System.out.println(set.toString());
to otrzymalibyśmy wynik odtwarzający "historię" zapełniania zbioru:
[Jan Kowalski 1000, Jan Malinowski 1200]


Inna podstawowa implementacja koncepcji zbioru - klasa TreeSet, opiera się na strukturze danych zwanej drzewem czerwono-czarnym. Dla potrzeb korzystania z klasy TreeSeet wystarczy wiedzieć, że zapewnia ona szczególne uporządkowanie elementów zbioru, które pozwala szybko odnajdywać w nim podany element.
Sczegółowe omówienie tej struktury danych wykracza poza ramy niniejszego materiału, nie ma też miejsca by przedstawić tu pojęcie drzewa (zainteresowanym można polecić książkę L. Banachowskiego, K.Diksa, W. Ryttera. Algorytmy i struktury danych, WNT 2001).

Zwróćmy tylko uwagę (dla zainteresowanych), że drzewo czerwono-czarne jest drzewem wyszukiwań binarnych (BST) o następujących właściwościach:
  1. Z każdym węzłem drzewa związana jest wartość
  2. Wartość ta jest większa od wartości lewego potomka i mniejsza od wartości prawego potomka
  3. Z każdym węzłem związany jest atrybut koloru - czerwony lub czarny
  4. Każdy czerwony węzeł, ktory nie jest liściem ma tylko czarnych potomków
  5. Każda ścieżka od korzenia do liścia zawiera tyle samo czarnych węzłów
  6. Korzeń jest czarny.
Dzięki tym właściwościom drzewo czerwono-czarne może zachowywać właściwość zrównoważenia przy modyfikacjach (tzn. równoważenie nie jest kosztowne), co w konsekwencji daje szybkie operacje dodawania i wyszukiwania elementów.
Inaczej mówiąc, elementy są dodawane do drzewa w określony sposób, taki mianowicie, że wysokość drzewa nie jest zbyt wielka (zrównoważenie), a jednocześnie wyszukiwanie może korzystać z "porządku symetrycznego", okreslanego przez punkt 2 w/w właściwości drzewa czerwono-czarnego.

Na przykładzie z poniższego rysunku dla odnalezienia elementu 7 potrzebne są tylko 3 porównania.

r
Źrodło: John Morris, Data Structures and Algorithms, University of Western Australia, 1998

Jak widać, struktura ta pozwala na "odtwarzanie" danych w określonym porządku elementów.

Dlatego klasa TreeSet realizuje nie tylko koncepję zbioru "w ogóle" , ale również zbioru uporządkowanego, implementując interfejs SortedSet (ściślej NavigableSet), który rozszerza interfejs Set.

Widzieliśmy już jak iterowanie po TreeSet zwraca elementy w porządku rosnącym.
Np. poniższy fragment:
    String[] s = {"ala", "pies", "kot" };
    Set set = new TreeSet();
    for (int i=0; i < p.length; i++) set.add(s[i]);
    System.out.println(set.toString());
wypisze:
[ala, kot, pies]



Ale skąd wiadomo jaki ma być porządek elementów? Zapewne zależy to od klasy obiektów, będących elementami zbioru. Dla klasy String było wszystko w porządku (uzyskaliśmy alfabetyczny porządek elementów zbioru). Ale co by się np. stało, gdyby w poprzednim programie (przykład klasy Worker) zmienić implementację zbioru z HashSet na TreeSet?
Cóż, spotkałaby nas niemiła niespodzianka: wyjątek ClassCastException.
Widać czegoś naszej kalsie Worker brakuje.

Dochodziimy w ten sposób do ważnego tematu - porównywania obiektów przy porządkowaniu kolekcji.


8. Porównywanie obiektów

Dodawanie elementów do zbiorów uporządkowanych (np. TreeSet), iterowanie po kolekcjach uporządkowanych, pobieraniee elementó z kolejek z priorytetami, a także sortowanie kolekcji i tablic algorytmami kolekcyjnymi odbywa się w oparciu o:

Naturalny porządek określany jest przez implementację interfejsu Comparable<T> w klasie obiektów i dostarczenie definicji metody compareTo tego interfejsu, porównującej dwa obiekty typu T.

Metoda compareTo ma następującą sygnaturę:

    public int compareTo(T otherObject)

gdzie T jest typem obiektu,

i zwraca:


Wiele klas standardu Javy implementuje interfejs Comparable (np. klasa String, Date, klasy opakowujące typy proste). We własnych klasach musimy o to zadbać sami.

Zatem, każda nasza klasa, której obiekty mogą być elementami kolekcji uporządkowanych powinna określać naturalny porządek swoich obiektów.

Np. w klasie Worker moglibyśmy ustalić, że naturalną kolejnością obiektów jest alfabetyczna kolejność nazwisk w porządku rosnącym poprzez dostarczenie definicji metody compareTo :

class Worker implements Comparable<Worker> {
  private String lname;
  private String fname;
  // ...
  public int compareTo(Worker otherObj) {
    return this.lname.compareTo(otherObj.lname);
  }   
  // ...
}

Po tej modyfikacji klasy i jej wykorzystaniu w poniższym fragmencie:
    Worker[] p = { new Worker("Jan", "Kowalski", 1000),
                   new Worker("Jan", "Malinowski", 1200),
                   new Worker("Jan", "Kowalski", 1400),
                   new Worker("Jab", "Kowalewski", 2000),
                 };

    Set<Worker> set = new TreeSet<Worker>();
    for (int i=0; i < p.length; i++) set.add(p[i]);
    System.out.println(set.toString());
otrzymamy (naturalnie) uporządkowany wynik:
[Jab Kowalewski 2000, Jan Kowalski 1000, Jan Malinowski 1200]


No, dobrze, ale jak uzyskać różne zbiory, uporządkowane wedlug różnych kryteriów, np. według nazwisk lub według pensji? Albo porządek odwrotny (np. w malejącym alfabetycznym porządku nazwisk) ?

Tu przychodzi na pomoc koncepcja komparatora.

Komparator jest obiektem porównującym inne obiekty


Komparatory w Javie są realizowane jako obiekty klas implementujących interfejs Comparator<T>, gdzie T jest typem porównywanych obiektów

Interfejs ten zawiera ważną metodę:

    int compare(T o1, T o2)

której implementacja winna  porównywać dwa swoje argumenty i zwracać wynik 0 jeśli oba obiekty są równe, liczbę mniejszą od 0, jeśli o1 jest "mniejszy" od o2 oraz liczbę większą od 0, jeśli o1 jest "większy" od o2.


Obiekt komparator może być podany jako argument konstruktora klasy TreeSet (i wtedy to on, a nie naturalny porządek, decyduje o sposobie wpisywania elementów do zbioru) , może rownież występowac jako argument metod sortowania z klasy Collections i Arrays, konstruktorów klasy PriorityQueue oraz konstruktorów innych kolekcji uporządkowanych (np. TreeMap).

Specjalny komparator, odwracający naturalny porządek, uzyskiwany jest za pomocą statycznej metody klasy Collections - Collections.reverseOrder().


Zobaczmy przykład różnego porządkowania zbioru robotników (klasa Worker).
import java.util.*;

class Worker implements Comparable<Worker> {
  private String lname;
  private String fname;
  private int salary;

  public Worker(String fn, String ln, int sal) {
    lname = ln;
    fname = fn;
    salary = sal;
  }

  public int getSalary() { return salary; }

  public int compareTo(Worker otherObj) {
    return this.lname.compareTo(otherObj.lname);
  }

  public String toString() {
    return fname + " " + lname + " " + salary;
  }


  public static void main(String args[]) {
    Worker[] workers = { new Worker("Jan", "Kowalski", 1000),
                   new Worker("Jan", "Malinowski", 1200),
                   new Worker("Jan", "Kowalski", 1400),
                   new Worker("Jan", "Kowalewski", 2000),
                   new Worker("Stefan", "Zwierz", 2000), 
                 };
    // Ziór uporządkowany wg porządku naturalnego (rosnąca kolejność nazwisk)
    Set<Worker> set = new TreeSet<Worker>();
    for (Worker w : workers) set.add(w);
    System.out.println(set);

    // Zbiór uporządkowany wg rosnących pensji
    Set<Worker> set2 = new TreeSet<Worker>(new Comparator<Worker>() {
                   public int compare(Worker o1, Worker o2) {
                     // różnica pensji wystarczy do porownania?:
                     return o1.getSalary() - o2.getSalary();
                   }
               });
    for (Worker w : workers) set2.add(w);
    System.out.println(set2);

    // Zbiór uporządkowany w malejącym porządku naturalnym (malejące nazwiska)
    Set<Worker> set3 = new TreeSet<Worker>(Collections.reverseOrder());
    for (Worker w : workers) set3.add(w);
    System.out.println(set3);

  }
}
Program wyprowadzi:
[Jan Kowalewski 2000, Jan Kowalski 1000, Jan Malinowski 1200, Stefan Zwierz 2000]
[Jan Kowalski 1000, Jan Malinowski 1200, Jan Kowalski 1400, Jan Kowalewski 2000]
[Stefan Zwierz 2000, Jan Malinowski 1200, Jan Kowalski 1000, Jan Kowalewski 2000]


Ciekawe, że w powyższym przykładzie w klasie Worker nie było metody equals() ani hashCode(). Po prostu, TreeSet ich nie potrzebuje.
Zwróćmy też uwagę, że komparator przesądza nie tylko o kolejności iterowania po zbiorze, ale również o tym, czy obiekty uznawane są za nierówne i czy wobec tego mogą stanowić elementy zbioru (przykład z powtórzeniem nazwiska Jan Kowalski i - bolesna - utrata Stefana Zwierza, gdy komparator porównywał pensje),

Należy pamiętać, że:


Oczywiście, nasze klasy powinny być przygotowane do tego, że ich obiekty mogą znaleźć się w zbiorach typu HashSet albo TreeSet, zatem powinny definiować wszystkie wspomniane metody. A do tego w sposob spójny: jeśli equals zwraca true, to compareTo winno zwracać zero, a hashCode te same wartości dla obu porównywanych obiektów.

Warto zauważyć, że dla zbiorów uporządkowanych (naturalnie lub za pomocą komparatora) dostępna jest możliwośc uzyskiwania "pierwszego" lub "ostatniego" (w zdefiniowanym porządku) elementu, a także podzbiorów, które zawierają elementy "od" - "do" (w zdefiniowanym porządku). Służą temu metody interfejsu SortedSet (pośrednio implementowanego przez TreeSet) m.in. headSet(), tailSet() i subSet(),

Interfejs NavigableSet bezpośrednio implementowany przez TreeSet dostarcza dodatkowych metod m.in.
higher(...), lower(...), ceiling(...), floor(....), które pozwalają uzyskiwac elmenty "bliskie" w porządku wobec danego (porszę zobaczyć dokumentację).

Należy też zauważyć ciekawe możliwości jakie daje metoda comparator(). Nie mamy co prawda możliwości dynamicznych zmian komparatora (już po utworzeniu obiektu klasy TreeSet), ale dzięki dostępowi do obiektu-komparatora i odpowiedniej konstrukcji jego klasy możemy zmieniać charakterystyki jego działania niejako "w locie" (już po utworzeniu obiektu TreeSet i związaniu go z tym komparatorem).


9. Mapy

Mapa jest jednoznacznym odwzorowaniem zbioru kluczy w zbiór wartości.


Słowo mapa kojarzy się raczej z mapą geograficzną. Również w języku angielskim znaczenie rzeczownika map jest ograniczone do geografii. Idąc śladem twórców JCF, którzy słowu map przypisują znaczenie wywodzące się od mapping (odwzorowanie), również tutaj zastosujemy zgrabne słowo mapa na oznaczenie odwzorowania zbioru kluczy w zbiór wartości
O mapach możemy myśleć jako o takich kolekcjach par: klucz - wartość, które zapewniają odnajdywanie wartości związanej z podanym kluczem.
Przykladem takiej kolekcji może być zestaw danych, który zawiera nazwiska i numery telefonów i pozwala na odnajdywanie numeru telefonu (poszukiwanej wartości) po nazwisku (kluczu).
Zarówno klucze, jak i wartości mogą być referencjami do dowolnych obiektów (jak również wartościami null).
Oczywiście, wartości kluczy nie mogą się powtarzać (odwzorowanie musi być jednoznaczne). Natomiast pod różnymi kluczami można zapisać te same wartości (odwzorowanie nie musi być wzajemnie jednoznaczne).

Aby lepiej zrozumieć możliwe zastosowania map w tablicy podano kilka przykładów.

Klucz
Wartość
NIP
Dane o podatniku
Nazwisko
Numer telefonu
Alias
e-mail
Kraj
Stolica
Port lotniczy, data i pora dnia wylotu (obiekt hipotetycznej klasy Departure)
Lista możliwych polączeń (obiekt klasy, definiującej listę, której elementami są obiekty klasy opisującej połączenia - linie lotnicze, przesiadki, taryfy)

Mapy są niezwykle użytecznym narzędziem programistycznym, pozwalają bowiem prosto i efektywnie programować wiele realnych problemów.

Istotą zastosowania map jest możliwość łatwego i jednocześnie szybkiego odnajdywania informacji w powiązanych zestawach danych


Weźmy najprostszy przyklad: program, który ma pokazywać stolicę podanego przez użytkownika kraju.
Nie stosując w ogóle JFC moglibyśmy napisać go tak (zakładamy, że zestaw krajów i stolic zawarty jest w pliku, a nazwy krajów i stolic oddzielone są znakiem /).

import javax.swing.*;
import java.io.*;
import java.util.*;

class CapitalsNoJFC {

  private final int NC = 300;
  String[] country = new String[NC],
           capital = new String[NC];

  public CapitalsNoJFC() {
    readInfo();
    String in;
    while ((in = JOptionPane.showInputDialog("Kraj")) != null) {
      String cap = getCapital(in);
      if (cap == null) cap = "Brak danych";
      JOptionPane.showMessageDialog(null, "Kraj: " + in + " Stolica: " + cap);
    }
  }

  private void readInfo() {
    try {
      BufferedReader in = new BufferedReader(
                              new FileReader("stolice.txt"));
      String line;
      int i=0;
      while((line = in.readLine()) != null) {
        StringTokenizer st = new StringTokenizer(line, "/");
        country[i] = st.nextToken();
        capital[i] = st.nextToken();
        i++;
      }
      in.close();
    } catch (Exception exc) {
        System.out.println(exc.toString());
        System.exit(1);
      }
  }

  private String getCapital(String kraj) {
    for (int i=0; i<country.length && country[i] != null; i++)
      if (kraj.equals(country[i])) return capital[i];
    return null;
 }

  public static void main(String args[]) {
     new CapitalsNoJFC();
  }
}

To podejście  nie nadaje się do uogólnienia: metoda wyszukiwania informacji (getCapital) zwraca String i ma parametr String (zatem przy innych zestawach danych musimy ją modyfikować), operujemy tablicami, których rozmiary są ustalone, a  wyszukiwanie jest niefektywne (bo liniowe).
Zaważmy jednak przede wszystkim, że  w tym rozwiązaniu musieliśmy trochę czasu poświęcić na oprogramowanie metody getCapital(String kraj), która zwraca stolicę podanego kraju.

Zastosowanie list zamiast tablic zwiększa możliwości uniwersalizacji kodu i nieco oszczędza kodowania (poniżej pokazano tylko fragmenty różniące się od poprzedniego kodu):

class CapitalsList {

  List country = new ArrayList(),
       capital = new ArrayList();

  // ...
 
  private void readInfo() {
      // ...
      String line;
      while((line = in.readLine()) != null) {
        StringTokenizer st = new StringTokenizer(line, "/");
        country.add(st.nextToken());
        capital.add(st.nextToken());
      }
      // ...
  }

  private String getCapital(String kraj) {
    int index = country.indexOf(kraj);
    if (index != -1) return (String) capital.get(index);
    else return null;
 }

Ale to rozwiązanie nadal wymaga dostarczenia własnej metody, która pozwala myśleć w kategoriach klucze-wartości (chodzi o metodę getCapital), a także mnoży struktury danych (mamy dwie listy, powiązane ze sobą tylko dzięki "dobrej woli" programisty), co nie sprzyja zapewnieniu spójności danych.
Rozwiązanie listowe nie usuwa też problemu niefektywności wyszukiwania (metoda indexOf  stosuje wyszukiwanie liniowe). Moglibysmy co prawda uzyskać poprawę efektywności wyszukiwania, ale wymaga to już znaczących nakładów dodatkowej pracy.

Tymczasem wszystko już jest gotowe: uniwersalna struktura danych - mapa - pozwalająca na jednolite, spójne programowanie w kategoriach klucze-wartości i - co również ważne - efektywne odnajdywanie wartości na podstawie podawanych kluczy.

W JCF ze względy na  specyfikę dzialania na mapach (jako zestawach par klucze-wartości)  implementują one interfejs Map, a nie Collection.
Podstawowe operacje na mapie (niezależnie od jej implementacji) są określone przez metody tego interfejsu. Należą do nich: dodawanie pary klucz-wartość do mapy (metoda put), uzyskiwanie wartości "pod" podanym kluczem (metoda get) oraz usuwanie pary klucz-wartośc z mapy (metoda remove).
Zanim przyjzrymy się hierarchii dostępnych map, zobaczmy najpierw jak wyglądałby nasz poprzedni program, gdybyśmy zastosowali mapę (w implementacji HashMap).

import javax.swing.*;
import java.io.*;
import java.util.*;

class CapitalsMap {

  // Mamy teraz jeden spójny zestaw danych: kraje-stolice
  Map<String, String> countryCapital = new HashMap<String, String>();

  public CapitalsMap() {
    readInfo();
    String in;
    while ((in = JOptionPane.showInputDialog("Kraj")) != null) {
      // Uzyskiwanie wartości po kluczu
      String cap = countryCapital.get(in);
      if (cap == null) cap = "Brak danych";
      String msg = "Kraj: " + in + "\nStolica: " + cap;
      System.out.println(msg);
      JOptionPane.showMessageDialog(null, msg);
    }
  }

  private void readInfo() {
    try {
      Scanner in = new Scanner(new File("stolice.txt"));
      while(in.hasNextLine()) {
        Scanner line = new Scanner(in.nextLine()).useDelimiter("/");
        // Dodawanie do mapy pary: kraj - stolica
        countryCapital.put(line.next(),line.next());
      }
      in.close();
    } catch (Exception exc) {
        exc.printStackTrace();
        System.exit(1);
      }
  }

  public static void main(String args[]) {
     new CapitalsMap();
  }
}
Programowanie jest dużo łatwiejsze i mniej zawodne niż poprzednio, a uzyskiwanie informacji bardziej efektywne.
Wynik programu (na podstawie zapytań wprowadzanych w dialogach):

Kraj: Polska
Stolica: Warszawa
Kraj: Pppolska
Stolica: Brak danych

Uwaga: przy tworzeniu map podajemy dwa argumenty typu (typ klucza i typ wartości) np.
Map<String, Integer>
W tym przypadku metoda get(...) zwróci obiekt właściwego typu i nie będą potrzebne konwersje zawęzające.
Jesli używamy surowego typu Map lub argumenty typu są Object, to metoda get(...) będzie zwracać Object, zatem uzyskując wartość "po kluczu" musimy dokonać konwersji zawężającej do odpwiedniego typu.


Hierarchie dostępnych map (interfejsy i gotowe implementacje) pokazuje poniższy rysunek:

r

W mapach istotne jest szybkie odnajdywanie kluczy. Klucze (poprzez umieszczenie ich razem z wartościami w odpowiednie strukturach danych) związane są już bezposrednio i niejako natychniastowo z wartościami.

Podobnie zatem jak w przypadku zbiorów istnieją dwie podstawowe implementacje, pozwalające na szybkie wyszukiwanie kluczy - implementacja oparta na tablicy mieszającej (HashMap) oraz na drzewie czerwono-czarnym (TreeMap).
Niejako ubocznym skutkiem zastosowania tej ostatniej jest możliwość przeglądania  kluczy mapy w naturalnym porządku rosnącym lub w porządku, określanym przez komparator podany przy konstrukcji mapy. Reguły stosowania komparatorów są takie same jak w przypadku zbiorów uporządkowanych. Mówimy, że mamy do czynienia z mapą uporządkowaną, a zatem mamy - tak jak w przypadku zbiorów uporządkowanych - dodatkowe właściwości takie jak "pierwszy" i "ostatni" klucz lub ich podzbiór "od" -"do" (określane przez metody interfejsu SortedMap) oraz operacje znajdowania bliskich kluczy (z interfejsy NavigableMap).

Implementacja LinkedHashMap pozwala na odtworzenie kolejności dodawania elementów do mapy, natomiast WeekHashMap (oparta na implementacji mieszającej) ma pewne specyficzne właściwości, o których za chwilę.

Jak już wspomniano, wszystkie w/w klasy implementują interfejs Map. Niewątpliwie warto przyjrzeć się dokładniej jego metodom.

Podstawowe metody interfejsu Map.
 voidclear()
          Usuwa wszystkie elementy mapy (operacja opcjonalna).
 booleancontainsKey(Object key)
          Zwraca true jeżeli mapa zawiera podany klucz (i związaną z nim wartość). 
 booleancontainsValue(Object value)
          Zwraca true jeśli mapa zawiera podaną wartość (do ktorej może prowadzić wiele kluczy). 
 Set<Map.Entry<K,V>>entrySet()
          Zwraca widok na zbiór par klucze-wartości jako zbiór obiektów typu Map.Entry<K,V>. K jest typem klucza, V - typem wartości
 Vget(Object key)
          Zwraca wartość pod podanym kluczem (lub null, co może oznaczać, że odwzorowania dla podanego klucza w mapie nie ma). V jest typem wartości.
 booleanisEmpty()
          Czy pusta?
 Set<K>keySet()
          Zwraca widok na zbiór kluczy.
 Vput(K key, V value)
          Dodaje od mapy klucz-wartość (lub zastępuje poprzednie odwzorowanie tego klucza w wartość - podanym), zwraca  obiekt, który uprzednio znajdował się pod danym kluiczem lub null (operacja opcjonalna).
 voidputAll(Map<...> t)
    Wszystkie elementy mapy t dodaje do tej mapy         
 (operacja opcjonalna).
 Vremove(Object key)
          Usuwa odwzorowanie: klucz-wartość z tej mapy (operacja opcjonalna). Zwraca starą wartość. 
 intsize()
          Zwraca liczbę par klucz-wartość w mapie
 Collection<V>values()
          Zwraca widok na kolekcję wartości.

Uwaga. Metody put... powodują zastąpienie istniejących w mapie odwzorowań, które mają takie same klucze jak dodawane odwzorowania. Istotnie - przecież odwzorowania muszą być jednoznaczne!



Jeszcze przed stworzeniem JCF w Javie istniała klasa Hashtable, będąca w zasadzie mapą. Obecnie implementuje ona interfejs Map, ale różni się od innych gotowych implementacji tego interfejsu dwoma właściwościami:
 
Ciekawość może wzbudzić w tym zestawie metoda containsKey(..). Na pierwszy rzut oka wydaje się niepotrzebna (duplikuje jakby działanie metody get). Ale pamietajmy, że mapy mogą zwierać pod danym kluczem wartość null. Wtedy metoda get zwraca null i nie wiemy czy oznacza to brak odwzorowania w mapie, czy też odwzorowanie klucza w wartość null. Metoda containsKey  pozwala to rozstrzygnąć: zwróci true jeśli odwzorowanie jest (nawet jeśli wartością "pod" kluczem jest null), natomiast zwrócona wartość false świadczy na pewno o tym, że  w mapie nie ma poszukiwanego odwzorowania.

Drugim ciekawym elementem są tu metody do uzyskiwania widoków na klucze, wartości oraz pary klucze-wartości.
Ze względu na to, że mapy nie implementują interfejsu Collection, nie można od nich uzyskać bezpośrednio iteratorów. Można natomiast uzyskać kolekcje, które są widokami na zestawy kluczy (Set, bo bez powtórzeń), zestawy wartości (Collection, bo bez porządku i z możliwością powtórzeń elementów), oraz par klucze-wartośći (Set, bo bez powtórzeń). Od tych kolekcji - naturalnie - możemy uzyskać iteratory,
Zobaczmy te metody w dzialaniu, przyglądając się jednocześnie różnicom pomiędzy różnymi implementacjami map, zastosowaniu komparatora dla map uporządkowanych oraz - analogicznej jak dla list i zbiorów - możliwości uzyskiwania dowolnej implementacji mapy z jakiejś innej implementacji mapy (poprzez wykorzystanie argumentu konstruktora).
import java.io.*;
import java.util.*;

class CapitalsMap2 {

  Map<String, String> countryCapital = new HashMap<String, String>();

  public CapitalsMap2() {
    readInfo();
    show("Mapa inicjalna - HashMap", countryCapital);
    show("Mapa w porządku naturalnym - TreeMap",
          new TreeMap<String, String>(countryCapital)
         );
    // Pusta mapa z komparatorem odwracającym naturalny porządek
    Map<String, String> mapRo = new TreeMap<String, String>(Collections.reverseOrder());
    // Wpisujemy do niej pary z mapy countryCapital
    mapRo.putAll(countryCapital);
    show("Mapa w porządku odwrotnym (komparator reverseOrder)",
          mapRo
         );

    // Uzyskanie iteratora po zestawie kluczy
    // Pokazanie wszystkich par, których klucze zawierają r
    // Usunięcie ich
    Iterator<String> it = countryCapital.keySet().iterator();
    while (it.hasNext()) {
      String key = it.next();
      if (key.indexOf("r") != -1) {
        System.out.println(key + " " + countryCapital.get(key));
        it.remove();
      }
    }
    show("Mapa po zmianach", countryCapital);
  }

  public void show(String msg, Map<String, String> map) {
    System.out.println(msg);
    Set<String> keys = map.keySet();
    System.out.println("Klucze: " + keys);
    Collection<String> vals = map.values();
    System.out.println("Wartości: " + vals);
    Set entries = map.entrySet();
    System.out.println("Pary: " + entries);
  }
  
  private void readInfo() {
    try {
      Scanner in = new Scanner(new File("stolice.txt"));
      while(in.hasNextLine()) {
        Scanner line = new Scanner(in.nextLine()).useDelimiter("/");
        // Dodawanie do mapy pary: kraj - stolica
        countryCapital.put(line.next(),line.next());
      }
      in.close();
    } catch (Exception exc) {
        exc.printStackTrace();
        System.exit(1);
      }
  }
  
  public static void main(String[] args) {
    new CapitalsMap2();
  }

}
Ten fragment kodu wyprowadzi następujące informacje:

Mapa inicjalna - HashMap
Klucze: [Malezja, Bali, Polska, Hiszpania, Rosja, Francja]
Wartości: [Kuala Lumpur , Denpasar, Warszawa, Madryt, Moskwa, Paryż]
Pary: [Malezja=Kuala Lumpur , Bali=Denpasar, Polska=Warszawa, Hiszpania=Madryt, Rosja=Moskwa, Francja=Paryż]
Mapa w porządku naturalnym - TreeMap
Klucze: [Bali, Francja, Hiszpania, Malezja, Polska, Rosja]
Wartości: [Denpasar, Paryż, Madryt, Kuala Lumpur , Warszawa, Moskwa]
Pary: [Bali=Denpasar, Francja=Paryż, Hiszpania=Madryt, Malezja=Kuala Lumpur , Polska=Warszawa, Rosja=Moskwa]
Mapa w porządku odwrotnym (komparator reverseOrder)
Klucze: [Rosja, Polska, Malezja, Hiszpania, Francja, Bali]
Wartości: [Moskwa, Warszawa, Kuala Lumpur , Madryt, Paryż, Denpasar]
Pary: [Rosja=Moskwa, Polska=Warszawa, Malezja=Kuala Lumpur , Hiszpania=Madryt, Francja=Paryż, Bali=Denpasar]
Francja Paryż
Mapa po zmianach
Klucze: [Malezja, Bali, Polska, Hiszpania, Rosja]
Wartości: [Kuala Lumpur , Denpasar, Warszawa, Madryt, Moskwa]
Pary: [Malezja=Kuala Lumpur , Bali=Denpasar, Polska=Warszawa, Hiszpania=Madryt, Rosja=Moskwa]


Widok na klucze jest typu wyznaczanego przez klasę, implementującą interfejs Set, ale jest to inna klasa niż znane nam HashSet i TreeSet
Jak widać, uzyskana kolekcja kluczy jest istotnie widokiem (a  nie jakimś nowym obiektem) na klucze mapy. Możemy nie tylko po nich iterować, ale za pomocą metody remove iteratora usuwać odwzorowania z mapy.

Kolekcja par klucze-wartości (widok na klucze-wartości) jest typu wyznaczanego przez klasę implementującą interfejs Map.Entry (wewnętrzny interfejs interfejsu Map). Dzięki temu - ale tylko w trakcie iteracji po elementach tej kolekcji - mamy dodatkowe możliwości działania, a mianowicie: uzyskanie klucza dla danej pary (metoda getKey()), uzyskanie wartości dla danej pary (metoda getValue()), zmiana wartości dla danej pary (metoda setValue(Object).
Przykładowy fragment kodu, który - w trakcie iteracji - zmienia wielkość liter w pisowni stolic, pokazuje sposób zastosowania Map.Entry.
    // Zmiana wielkości liter w pisowni stolic
    Iterator<Map.Entry<String, String>> pit = countryCapital.entrySet().iterator();
    while (pit.hasNext()) {
      // To jedyny sposób uzyskania dostępu do obiektu Map.Entry !!!
      Map.Entry<String, String> entry =  pit.next();
      String cap =  entry.getValue();
      entry.setValue(cap.toUpperCase());
    }
    show("Po zmianie wielkości liter", countryCapital);
  }
Dostaniemy w wyniku:
Po zmianie wielkości liter
Klucze: [Malezja, Bali, Polska, Rosja, Hiszpania]
Wartości: [KUALA LUMPUR , DENPASAR, WARSZAWA, MOSKWA, MADRYT]
Pary: [Malezja=KUALA LUMPUR , Bali=DENPASAR, Polska=WARSZAWA, Rosja=MOSKWA, Hiszpania=MADRYT]

I na koniec omawiania map - dwa słowa o klasie WeekHashMap.
Jej zadaniem jest współpraca z odśmiecaczem (garbage collcctor), która może nam pomóc w następującej sytuacji. Wyobraźmy sobie, że referencja do klucza, który związany jest z jakąś wartością w mapie zniknęła z naszego programu. Nie mamy więc żadnego sposobu, by dostać się do tej wartości. Ta para w mapie jest już praktycznie bezużyteczna i powinna być usunięta. Ale ponieważ nie mamy klucza nie możemy tego zrobić, a odśmiecacz nie może usunąć tego elementu mapy, bowiem struktura, ktora go opisuje nadal w mapie "żyje".
Właśnie w takiej sytuacji pomocna jest klasa WeekHashMap: pary w takiej mapie są automatycznie usuwane przez odśmiecacz wtedy, gdy w innych częściach programu nie ma już żadnej referencji wskazującej na klucze tych par.
Klasy WeekHashMap powinniśmy używać przy potencjalnie dużych mapach, kiedy struktura fragmentów programu, operujących na mapie jest złożona i nie bardzo potrafimy zagwarantować usuwania odwzorowań z mapy, gdy klucze prowadzące do tych odwzorowań przestają istnieć.

10. Algorytmy, widoki, fabrykatory kolekcji

Java Collection Framework zawiera dwie użyteczne klasy, wspomagające dzialania na kolekcjach (a także tablicach) - klasę Collections i klasę Arrays.

Klasa Collections dostarcza statycznych metod, operujących na kolekcjach lub zwracających kolekcje. Do metod tych należą:
Wybrane algorytmy kolekcyjne
static intbinarySearch(List< ... > list, K key)
          Wyszukiwanie binarne na liście. Zwraca indeks elementu, który jest równy podanemu obiektowi key (wedle metody compareTo z klas(y) elementów listy, przy czym zarówno elementy listy, jak i obiekt key muszą być porównywalne). Lista musi być najpierw posortowana według naturalnego porządku np. metodą sort(List).  Przy braku elementu zwraca wartość < 0 ( a nie -1 !)
static intbinarySearch(List <...>, K, Comparator<...> c)
          Wyszukiwanie binarne według podanego komparatora (porównywanie obiektu key z elementami listy następuje za pomocą metody compare komparatora). Lista musi być posortowana zgodnie z porządkiem definiowanym przez komparator np. metodą sort(List, Comparator). Zwraca indeks odnalezionego elementu lub wartość < 0, gyd elementu brak.
static voidsort(List<T> list)
          Sortowanie listy według naturalengo porządku elementów (rosnąco)
static voidsort(List<T> list, Comparator<...> c)
          Sortowanie listy wedle porządku określanego przez komparator.

Uwaga: wszystko o czym mowa była przy okazji przedstawiania pojęć naturalnego porządku oraz  komparatorów - stosuje się do w/w algorytmów.

W klasie Arrays dostarczono statycznych metod, m.in. implementujących algorytmy sortowania i binarnego wyszukiwania dla tablic zarówno typów pierwotnych, jak i obiektów (w tym ostatnim przypadku również z możliwością użycia komparatorów).

Metody klasy Collections zwracające widoki kolekcji nakladają na kolekcje "opakowania" w celu zmiany ich niektórych właściwościi.
W szczególności możemy uzyskać niemodyfikowalne oraz synchronizowane widoki dowolnej kolekcji.

Przypomnijmy, że niemodyfikowalność kolekcji oznacza niemożliwość wszelkich modyfikacji (zarówno strukturalnych - dodawanie usuwanie elementów, jak i zmian wartości elementó).
Czasami jest to potrzebne, gdy chcemy zagwarantować, by po utworzeniu kolekcja w ogóle nie mogła podlegać zmianom, lub gdy chcemy zróżnicować prawa dostępu do kolekcji dla różnych  grup użytkowników naszej aplikacji (ktoś może zmieniać kolekcje, kto inny - tylko przeglądać).
Gotowe implementacje kolekcji w JCF są modyfikowalne.

Niemodyfikowalne widoki kolekcji możemy uzyskać za pomocą metod (dla ułatwienia pominięto parametry typu):
public static Collection unmodifiableCollection(Collection c);
public static Set unmodifiableSet(Set s);
public static List unmodifiableList(List list);
public static Map unmodifiableMap(Map m);
public static SortedSet unmodifiableSortedSet(SortedSet s);
public static SortedMap unmodifiableSortedMap(SortedMap m);
Szczególnym rodzajem niemodyfikowalności jest brak możliwości zmian rozmiaru kolekcji. Takich kolekcji (których elementy możemy zmieniać, ale nie możemy do nich dodawać nowych elementów ani usuwać istniejących) dostarcza statyczna metoda asList z klasy Arrays, zwracająca listę, elementami której są elementy tablicy przekazanej jako argument.
Metoda ta może służyć nie tylko do łatwego tworzenia list z istniejących tablic, ale również do tworzenia nowych, pustych list o niezmiennych rozmiarach:
// Tworzy listę o ustalonym (i niezmiennym) rozmiarze 1000 elementów
List l = Arrays.asList(new Object[1000]);

Gotowe kolekcje w JCF (za wyjątkiem "starych" Vector i Hashtable) nie są synchronizowane.
Poprawia to efektywność korzystania z kolekcji (jak pamiętamy, synchronizacja jest kosztowna), ale jednocześnie unniemożliwia bezpieczny dostęp do nich przez kilka wątków naraz. Jeśli chcemy udostępnić kolekcje w środowisku wielowątkowym, powinniśmy utworzyć ich synchronizowane widoki. Służą temu metody:
public static Collection synchronizedCollection(Collection c);
public static Set synchronizedSet(Set s);
public static List synchronizedList(List list);
public static Map synchronizedMap(Map m);
public static SortedSet synchronizedSortedSet(SortedSet s);
public static SortedMap synchronizedSortedMap(SortedMap m);
Uwaga: dla ułatwienia pominięto argumenty typu.

Podkreślmy raz jeszcze, że gdy mowa o widokach (lub "opakowaniach") kolekcji, to tak naprawdę kolekcja jest jedna (nie jest tworzony nowy obiekt, nie są kopiowane wartości elementów). Wszystkie operacje wykonywane są na tej jednej kolekcji,
Jeśli zatem mamy np. listę i jej synchronizowany widok:
List list1 = new ArrayList();
List list2 = Collections.synchronizedList(list1);   
to zmienne list1 i list2 oznaczają tę samą (co do treści) kolekcję.
A przy współbieżności ważne jest, by dostęp z każdego wątku był synchronizowany. Nie powinniśmy zatem pozostawiać możliwości niesynchronizowanego dostępu poprzez  referencję do kolekcji z niesynchronizowanym dostępem (referencję list1 w powyższym przykładzie). Powinniśmy raczej napisać:
List list = Collections.synchronizedList(new ArrayList());
Same synchronizowane widoki nie zapewniają jednak bezpieczeństwa przy iterowania po kolekcjach. Iterowanie składa się z wielu operacji, które winny być potraktowane atomistycznie (w przeciwnym razie - przy użyciu iteratora przez inny wątek - wystąpi wyjątek ConcurrentModificationException). Zatem zarówno uzyskanie iteratora jak i jego użycie winno być umieszczone w bloku synchronizowanym:

Collection c = Collections.synchronizedCollection(jakasKol);
synchronized(c) {
    Iterator i = c.iterator(); 
    while (i.hasNext()) {
     // ... i.next() itp.;
    }
 }


Nieco inaczej wygląda sprawa z synchronizowanymi mapami. Tutaj synchronizacja powinna dotyczyć samej mapy - a nie jej widoków: kluczy,  wartości, czy par (mimo, że własnie na tych kolekcjach wykonywane są iteracje).

Map map = Collections.synchronizedMap(new HashMap());
// ...
Set keys = map.keySet();  // Nie musi być w bloku synchronizowanym
//...
synchronized(map) {  // Synchronizacja na map, a nie na keys
    Iterator i = keys.iterator(); // Musi być w bloku synchronizowanym!
    while (i.hasNext()) {
      // ... i.next() itp.
    }
}

I jeszcze dwie ważne uwagi, dotyczące widoków kolekcji:


W klasie Collections  znajdziemy także wygodne metody fabrykujące specjalne kolekcje np. z powielonym n razy podanym elementem.
Prosże zapoznać się z dokumentacją klasy Collections.


11. Własne implementacje kolekcji

Architektura kolekcji jest pomyślana również pod kątem tworzenia nowych własnych rodzajów i implementacji kolekcji.
Ułatwieniem jest przy tym możliwość skorzystania z gotowych klas abstrakcyjnych, implementujących wszystkie interfejsy kolekcyjne, co upraszcza tworzenie implementacji, bowiem nie trzeba definiować wszystkich metod interfejsów (częsć z tych metod została w klasach abstrakcyjnych zdefiniowana, pozostaje nam tylko zdefiniowanie metod abstrakcyjnych).
Wszystkie te klasy znajdują się w pakiecie java.util, a ich nazwy zaczynają się słowem Abstract...
Oczywiście, gotowe implementacje różnych rodzajow kolekcji dziedziczą te abstrakcyjne klasy, co przedstawia poniższy rysunek (pokazujący częsciową hierarchię dziedziczenia).

r