Typy i metody sparametryzowane (generics)
Przedstawiono
tu najistotniejsze informacje o parametryzacji typów (generics). Są
one ważne dla sprawnego posługiwania się wieloma już sparametryzowanymi
interfejsami i klasami ze standardu Javy, a także przydadzą się, gdy będziemy chcieli tworzyć własne typy sprametryzowane.
1. Wprowadzenie
Często konstrukcje różnych klas oraz metod w klasach są funkcjonalnie podobne
(czyli służą wykonaniu tych samych czynności), różnią się natomiast tylko
typami danych na których czynności te są wykonywane.
Po to by nie powielać tego samego kodu dla różnych przypadków do języków
programowania wprowadzono szablony (templates) - klasy oraz metody parametryzowane
typami przetwarzanych danych.
Takie podejście stosowane jest w wielu językach (m.in. C++, Ada).
W Javie - poczynając od wersji 1.5 - pojawił się również odpowiednik szablonów tzw. generics.
Przy wprowadzaniu koncepcji generics do Javy w dużo mniejszym stopniu akcent
położono na uogólnianie kodu (pierwotny motyw szablonów C++). Dość wysoki
stopień uogólniania kodu był i jest bowiem w Javie dostępny poprzez:
- zagwarantowane
dziedziczenie klasy Object,
- implementację interfejsów,
- mechanizmy refleksji (czyli np. dynamiczne, w fazie wykonania programu, odwołania do pól i metod klas) .
Do tych - od dawna dostępnych możliwości - wprowadzenie generics (typów sparametryzowanych) dodaje ułatwienia w postaci:
- unikania konwersji zawężających,
- tworzenia bardziej czytelnego kodu,
- wykrywania błędów w fazie kompilacji i unikania wyjątku ClassCastException.
Zobaczmy.
Można było np. zawsze napisać ogólną klasę reprezentującą dowolne pary:
class ParaObj {
Object first;
Object last;
public ParaObj(Object f, Object l) {
first = f;
last = l;
}
public Object getFirst() { return first; }
public Object getLast() { return last; }
public void setFirst(Object f) { first = f; }
public void setLast(Object l) { last = l; }
}
Ale przy takim podejściu mamy pewne problemy:
- kompilator
nie ma możliwości dokładnego sprawdzenia zgodności typów i błędy
związane z użyciem niewłaściwego typu pojawią się dopiero w fazie
wykonania, być może w odległej przyszłości, w jakimś innym module
systemu,
- jesteśmy zmuszeni do stosowania konwersji zawężających, co czasem może być uciążliwe i zmniejsza czytelność kodu.
Oba problemy obrazuje następujący kod.
ParaObj po = new ParaObj("Ala", new Integer(3));
System.out.println(po.getFirst() + " " + po.getLast());
// Problem 1
// konieczne konwersje zawężające
String name = (String) po.getFirst();
int nr = (Integer) po.getLast();
po.setFirst(name + " Kowalska");
po.setLast( new Integer(nr + 1));
System.out.println(po.getFirst() + " " + po.getLast());
// Problem 2
// możliwe błędy
po.setLast("kot");
System.out.println(po.getFirst() + " " + po.getLast());
// Błąd może być wykryty w fazie wykonania
// późno, czasem w innym module
Integer n = (Integer) po.getLast(); // ClassCastException
Wynik:
Ala 3
Ala Kowalska 4
Ala Kowalska kot
Exception in thread "main" java.lang.ClassCastException: java.lang.String
at GenTest1.main(GenTest1.java:62)
Zastosowanie generics (poprzez parametryzację typów) do dotychczasowych
możliwości uogólniania kodu dodaje rozwiązanie w/w
problemów. Na czym polega parametryzacja typów?
Typ sparametryzowany - to typ (wyznaczany przez nazwę klasy lub interfejsu) z dołączonym jednym lub większą liczbą parametrów.
Definicję typu sparametryzowanego wprowadzamy słowem kluczowym class lub interface
podając po nazwie (klasy lub interfejsu) parametry w nawiasach kątowych.
Parametrów tych następnie używamy w ciele klasy (interfejsu) w miejscu "normalnych"
typów
Typ sparametryzowany - elementy składni
class | interface Nazwa < ParametrTypu1, ParametrTypu2, ... ParametrTypuN> {
//....
}
Przykład podano na wydruku:
class Para<S, T> {
S first;
T last;
public Para(S f, T l) {
first = f;
last = l;
}
public S getFirst() { return first; }
public T getLast() { return last; }
public void setFirst(S f) { first = f; }
public void setLast(T l) { last = l; }
}
Możemy teraz tworzyć różne pary:
Para<String, String> p1 = new Para<String, String> ("Jan", "Kowalski");
Para<String, Data> p2 = new Para<String, Data> ("Jan Kowalski", new Data("2005-01-01"));
Para<Integer, Integer> p = new Para<Integer, Integer>(1,2); // autoboxing działa;
Tutaj <String, String>, <String, Data>, <String, Integer>
oznaczają konkretne typy, które są podstawiane w miejscu parametrów w klasie
Para<S, T> (ale - jak zobaczymy zaraz - tylko chwilowo, w fazie kompilacji). Nazywane są one argumentami typu.
Para<String, String>, Para<String, Data> Para<Integer, Integer> nazywają się konkretnymi instancjami sparametryzowanej klasy Para<S, T>.
Nawiązując do poprzedniego przykładu, testującego uogólnione pary
można podać kod używający sparametryzowanej klasy Para<S, T> -
zob. kod na listingu 5 oraz wynik jego działania na listingu 6.
Para<String, Integer> pg = new Para<String, Integer>("Ala", 3); //autoboxing
System.out.println(pg.getFirst() + " " + pg.getLast());
String nam = pg.getFirst(); // bez konwersji!
int m = pg.getLast(); // bez konwersji!
pg.setFirst(name + " Kowalska");
pg.setLast(m+1); // autoboxing
System.out.println(pg.getFirst() + " " + pg.getLast());
Listing 6 - wynik działania kodu z listingu 5
Ala 3
Ala Kowalska 4
Przy tym błędy są wykrywane w fazie kompilacji
pg.setLast("kot");
GenTest1.java:77: setLast(java.lang.Integer) in Para<java.lang.String,java.lang
Integer> cannot be applied to (java.lang.String)
pg.setLast("kot");
^
1 error
Szczególnie użyteczna jest parametryzacja kolekcji (i był to niewątpliwie główny motyw wprowadzenia generics do Javy).
2. Typy surowe i czyszczenie typów
W Javie - inaczej niż w C++ - po kompilacji dla każdego "szablonu" (typu sparametryzowanego) powstaje
tylko jedna klasa (plik klasowy), współdzielona przez wszystkie instancje
tego typu sparametryzowanego.
Skoro tak, to jaki - w fazie wykonania - będzie typ wyznaczany przez skompilowaną
klasę sparametryzowaną i jaki formalny typ uzyskają parametry typu używane
w definicji tej klasy?
Aby się przekonać jak w fazie wykonania prezentują się klasy
sparametryzowane możemy użyć następującego programiku.
import java.lang.reflect.*;
class Para<S, T> {
static int nr;
S first;
T last;
public static int getNr() { return nr; }
public Para(S f, T l) {
first = f;
last = l;
nr++;
}
public S getFirst() { return first; }
public T getLast() { return last; }
public void setFirst(S f) { first = f; }
public void setLast(T l) { last = l; }
}
public class GenTest2 {
public static void main(String[] args) {
Para<String, Integer> p1 = new Para<String, Integer>("Ala", 3);
System.out.println(p1.getNr());
Para<String, Integer> p2 = new Para<String, Integer>("Ala", 3);
System.out.println(p2.getNr());
Para<String, String> p3 = new Para<String, String>("Ala", "Kowalska");
System.out.println(p3.getNr());
// Co jest - tylko klasa Para
// "Raw Type"
Class p1Class = p1.getClass();
System.out.println(p1Class);
// Metodami refleksji możemy się przekonać, że
// w definicji klasy Para typem fazy wykonania dla parametrów jest Object
// "type erasure"!!!
Method[] mets = p1Class.getDeclaredMethods(); // zwraca tablicę metod deklarwoanych w klasie
for (Method m : mets) System.out.println(m);
// Surowego typu ("Raw Type") możemy też używać
// ale czasem wiąże się to z niuansami
// i kompilator może nas ostrzegać o możliwych błędach
Para p = new Para("B", new Double(3.1));
String f = (String) p.getFirst();
double d = (Double) p.getLast();
System.out.println(f + " " + d);
}
}
Po jego uruchomieniu uzyskamy następujący wydruk.
1
2
3
class Para
public static int Para.getNr()
public java.lang.Object Para.getFirst()
public java.lang.Object Para.getLast()
public void Para.setFirst(java.lang.Object)
public void Para.setLast(java.lang.Object)
B 3.1
Wydruk ten oznacza, że:
- jest tylko jedna klasa Para dla wszystkich instancji klasy sparametryzowanej
Para<S, T>; typ wyznaczany przez tę klasę nazywa się typem surowym ("raw type"),
- z definicji klasy Para zniknęły wszystkie parametry typu i zostały zastąpione przez Object; ten mechanizm nazywa się czyszczeniem typów ("type erasure") ,
- ponieważ jest tylko jedna klasa Para - zmienne reprezentowane przez pola statyczne są wspólne dla
wszystkich instancji typu sparametryzowanego; zmienna nr jest wspólna dla
Para<String, Integer> i Para<String, String> - dlatego zwiększa
się w sposób ciągły (1, 2, 3).
3. Restrykcje
Przyjęte w Javie rozwiązanie (jedna klasa w fazie wykonania, czyszczenie typów) ma swoje zalety i wady.
Do zalet zaliczyć można:
- mniejszą liczbę klas po kompilacji,
- zgodność kodu binarnego z kodem nie
używającym "generics" ("czyszczenie typów" stanowi właśnie o kompatybilności
kodów używających generics z kodami nie używającymi ich).
Wady wydają się jednak przeważać nad zaletami. Do wad zaliczymy:
- ograniczenia na możliwości użycia parametrów typu i wynikające stąd:
- znacznie mniejsze (niż np. w C++) możliwości prostego uogólniania klas
i metod (ale funkcjonalnie uogólnianie jest dostępne na takim samym poziomie
jak w C++, choćby dzięki refleksji),
- pewna trudność w definiowaniu typów sparametryzowanych - trzeba
pamiętać o niuansach wprowadzanych przez wybrany sposób kompilacji,
- ograniczenia na możliwości parametryzacji typów.
Istotnie, w definicjach klas (i metod) sparametryzowanych nie do końca możemy traktować parametry typu jak zwykłe typy.
Możemy:
- podawać je jako typy pól i zmiennych lokalnych,
- podawać je jako typy parametrów i wyników metod,
- dokonywać jawnych konwersji do typów oznaczanych przez nie (ale to
będzie tylko ważne na etapie kompilacji, po to by uniknąć błędów niezgodności
typów, natomiast nie uzyskamy w fazie wykonania faktycznych konwersji np.
zawężających, no bo jak?),
- wywoływać na rzecz zmiennych oznaczanych typami sparametryzowanymi
metody klasy Object (i ew. właściwe dla klas i interfejsów, które stanowią
tzw. górne ograniczenia danego parametru typu).
Nie możemy (w definicjach sparametryzowanych klas i metod):
- tworzyć obiektów typów sparametryzowanych (new T() jest niedozwolone,
no bo na poziomie definicji generics nie wiadomo co to konkretnie jest T),
- używać operatora instanceOf ( z powodu j.w.),
- używać ich w statycznych kontekstach (bo statyczny kontekst jest jeden
dla wszystkich różnych instancji typu sparametryzowanego),
- używać ich w literałach klasowych,
- wywoływać metod z konkretnych klas i interfejsów, które nie są zaznaczone
jako górne ograniczenia parametru typu (w najprostszym przypadku tą górną
granicą jest Object, wtedy możemy używać tylko metod klasy Object).
Również użycie typów sparametryzowanych obarczone jest restrykcjami.
Nie wolno np.:
- używać typów sparametryzowanych przy tworzeniu tablic (podając je jako typ elementu tablicy),
- w obsłudze wyjątków (bo jest to mechanizm fazy wykonania),
- w literałach klasowych (bo oznaczają typy fazy wykonania).
Dlaczego nie możemy mieć tablic elementów sparametryzowanego typu?
Wynika to z istoty pojęcia tablicy oraz ze sposobu kompilacji generics.
Tablica
jest zestawem elementów tego samego typu (albo jego podtypu). Informacja
o typie elementów tablicy jest przechowywana i JVM korzysta z niej w fazie
wykonania, aby zapewnić, że do tablicy nie jest wstawiany element niewłaściwego
typu (wtedy generowany jest wyjątek ArrayStoreException).
Gdyby dopuścić tablice elementów typów sparametryzowanych kontrakt ten zostałby
zerwany (bowiem w fazie wykonania nic nie wiadomo o konkretnych instancjach
typów sparametryzowanych, zatem nie można zapewnić odpowiedniej dynamicznej
kontroli typów).
Zobaczmy.
Para<String, Integer>[] pArr = new Para<String, Integer>[5]; // (1) niedozwolone
Object[] objArr = p;
objArr[0] = new Para<String, String>("A", "B"); // przejdzie, jeśli dopuścimy (1)
a błąd pojawi się (jako ClassCastException) kiedyś później, gdy np. sięgniemy
po pierwszy element tablicy pArr i zapytamy go o drugi składnik pary (pArr[0].getLast()).
Skąd błąd? Bo do tego odwołania kompilator powinien był dopisać konwersję
do Integer, a faktycznie mamy String, a nie Integer.
Oczywiście, to ograniczenie można obejść, stosując następujące rozwiązania:
- tablice typów surowych (niebezpieczne),
- tablice uniwersalnych instancji typów sparametryzowanych (uniwersalna
instancja wprowadzana jest z użyciem parametru typu ?, co oznacza dowolny
typ; zob. dalej); uniwersalne instancje też nie są dobrym rozwiązaniem -
choć semantycznie są zbliżone do typów surowych, to składniowa różnica powoduje,
że są przez kompilator traktowane inaczej niż typy surowe i w wielu przypadkach
zamiast ostrzeżeń "unchecked cast" dostaniemy raczej błędy w kompilacji,
- i rozwiązanie najlepsze - zastosowanie kolekcji (list) konkretnych instancji typu sparametryzowanego.
4. Ograniczenia parametrów typu
Jednym ze sposobów zwiększania
funkcjonalności generics Javy jest użycie (jawnych) ograniczeń parametrów
typu. Dzięki temu w klasach i metodach sparametryzowanych możemy korzystać z metod, specyficznych dla
podanych ograniczeń.
Ograniczenie parametru typu określa zestaw typów, które mogą być używane
jako argumenty typu (i podstawiane w szablonie w miejscu parametrów typu),
a w konsekwencji zestaw metod, które mogą być wołane na rzecz zmiennych oznaczanych
parametrami typu
Ograniczenia parametru typu wprowadzamy za pomocą składni:
ParametrTypu extends Typ1 & Typ2 & Typ3 & ... & TypN
gdzie:
Typ1 - nazwa klasy lub interfejsu
Typ2-TypN - nazwy interfejsów
Uwaga:
- typy Typ1-TypN mogą być sparametryzowane,
- typy ograniczające nie mogą się powtarzać, w tym nie mogą występować
powtórzenia dla typów sparametryzowanych TP<X> TP<Y> (ze względu
na czyszczenie typów).
W przypadku ograniczanych parametrów typu "type erasure" daje typ pierwszego ograniczenia.
Np. w fazie wykonania, w kontekście class A <T extends Appendable>, T staje się Appendable.
Przykład wykorzystania ograniczeń typów pokazuje wydruk, zawierający
uogólniony kod, pozwalający na określanie maksymalnego i minimalnego
elementu tablicy danych dowolnego typu, implementującego interfejs
Comparable (z jedną metodą compareTo pozwalająca na porównywanie
obiektów).
class MinMax<T> {
private T min;
private T max;
public MinMax(T f, T l) {
min = f;
max = l;
}
public T getMin() { return min; }
public T getMax() { return max; }
}
class GenArr<T extends Comparable<T>> {
private T[] arr;
private MinMax<T> minMax;
public GenArr(T[] a) { init(a); }
public void init(T[] a) {
minMax = null;
arr = a;
}
public MinMax<T> getMinMax() {
if (minMax != null) return minMax;
if (arr == null || arr.length == 0) return null;
T min = arr[0];
T max = arr[0];
for (int i=1; i<arr.length; i++) {
if (arr[i].compareTo(min) < 0) min = arr[i]; // dzięki T extends Comparable
if (arr[i].compareTo(max) > 0) max = arr[i];
}
minMax = new MinMax<T>(min, max);
return minMax;
}
}
public class Bounds {
public Bounds() {
Integer[] arr1 = { 1, 2 , 7, -3 };
Integer[] arr2 = { 1, 7 , 8, -10 };
String[] napisy = { "A", "Z", "C" };
GenArr<Integer> ga = new GenArr<Integer>(arr1);
MinMax<Integer> imx = ga.getMinMax();
System.out.println(imx.getMax() + " " + imx.getMin());
ga.init(arr2);
imx = ga.getMinMax();
System.out.println(imx.getMax() + " " + imx.getMin());
GenArr<String> gas = new GenArr<String>(napisy);
System.out.println(gas.getMinMax().getMax() + " " +
gas.getMinMax().getMin());
}
public static void main(String[] args) {
new Bounds();
}
}
Wynik:
7 -3
8 -10
Z A
5. Parametry uniwersalne (wildcards)
Weźmy jakąś kolekcję sparametryzowaną np. listę ArrayList:
ArrayList<Integer> list1 = new ArrayList<Integer>();
Czy ArrayList<Object> jest nadtypem dla typu ArrayList<Integer>?
Gdyby tak było, to moglibyśmy zrobić tak:
ArrayList<Object> list2 = list1; // hipotetyczna konwersja rozszerzająca
a wtedy kompilator nie mógłby protestować przeciwko czemuś takiemu:
list2.add(new Object());
Co jednak doprowadziłoby do katastrofy:
Integer n = list1.get(0); // próba przypisania Object na Integer
I wobec tego konstrukcja: list2 = list1 jest zabroniona w fazie kompilacji ("incompatible types"), co oznacza, że w Javie pomiędzy typami sparametryzowanymi za pomocą konkretnych parametrów nie zachodzą żadne relacje w rodzaju dziedziczenia (typ-nadtyp itp.).
A jednak takie relacje są czasem potrzebne.
Jeśli ArrayList<Integer> i ArrayList<String> nie są podtypami ArrayList<Object>-
to jak stworzyć metodę wypisującą zawartośc dowolnej listy ArrayList?
Do tego służą parametry uniwersalne (wildcards) - oznaczenie "?".
Są trzy typy takich parametrów:
- ograniczone z góry <? extends X> - oznacza "wszystkie podtypy X"
- ograniczone z dołu <? super X> - oznacza "wszystkie nadtypy X"
- nieograniczone <?> - oznacza "wszystkie typy"
Notacja ta wprowadza do Javy wariancję typów sparametryzowanych.
Typ sparametryzowany C<T> jest
kowariantny względem parametru
T, jeśli dla dowolnych typów A i B, takich, że B jest podtypem A, typ sparametryzowany
C<B> jest podtypem C<A> (kowariancja - bo kierunek dziedziczenia
typów sparametryzowanych jest zgodny z kierunkiem dziedziczenia parametrów
typu)
Kowariancję uzyskujemy za pomocą symbolu <? extends X>, co oznacza np. że
List<? extends Number> jest nadtypem wszystkich typów sparametryzowanych,
gdzie parametrem typu jest Number albo typ pochodny od Number.
Typ sparametryzowany C<T> jest
kontrawariantny względem parametru
T, jeżeli dla dowolnych typów A i B, takich że B jest podtypem A, typ sparametryzowany
C<A> jest podtypem typu sparametryzowanego C<B> (kontra - bo
kierunek dziedziczenia jest przeciwny).
Kontrawariancję uzyskujemy za pomocą symbolu <? super X>. Np. Integer
jest podtypem Number, a List<Number> jest podtypem List<? super
Integer>, wobec czego możemy podstawiać:
List<? super Integer> list
= new ArrayList<Number>;
Biwariancja oznacza rownoczesną kowariancję i kontrawariancję typu sparametryzowanego
Biwariancję uzyskujemy za pomocą symbolu <?>, który oznacza wszystkie
typy. Faktycznie ArrayList<?> oznacza wszystkie możliwe listy ArrayList z dowolnym
parametrem typu T. Czyli ArrayList<?> jest nadtypem ArrayList<? extends Integer>
i nadtypem dla ArrayList<? super Integer>.
Kowariancja typów sparametryzowanych umożliwia pisanie uniwersalnych metod
(w rodzaju "wypisz dowolną kolekcję" albo "pokaż dowolną listę",
Np.
void showEmployee(ArrayList <? extends Pracownik>) {
// ...
}
Bez tego nie moglibyśmy jako argumentów przekazywać listy dyrektorów, kierowników, asystentów etc.
( bo między ArrayList<Pracownik> i ArrayList<Asystent> nie ma relacji nadtyp - podtyp).
Ale jeśli mamy gdzieś dostęp do typu sparametryzowanego <? extends X>,
to zabronione jest podstawianie na ten typ konkretniejszych podtypów.
Inaczej mielibyśmy sytuację, w której do przekazanej listy dyrektorów dopisywani mogliby być np. asystenci.
Nie możemy "podstawiać", ale możemy pobierać (dostajemy coś calkiem bezpiecznego
typu - np. typu wyznaczanego przez dolną granicę).
6. Metody sparametryzowane i konkludowanie typów
Parametryzacji mogą podlegać nie tylko klasy czy interfejsy, ale również metody.
Definicja metody sparametryzowanej ma postać:
specyfikatorDostępu [static] <ParametryTypu> typWyniku nazwa( lista parametrów) {
// ...
}
Argumenty typów (podstawiane w fazie kompilacji w miejsce parametrów, choćby po to
by zapewnić zgodność typów oraz automatyczne konwersje zawężające) są określane na podstawie faktycznych typów użytych przy wywołaniu metody. Proces wyznaczania aktualnych argumentów typów nazywa się konkludowaniem typów (ang. type inferring).
Poniższy program zawiera przykład sparametryzowanej metody wyznaczającej
maksimum z tablicy elementów dowolnego typu pochodnego od
Comparable. Konkretne argumenty typu (odpowiadające parametrowi T
użytemu zarówno na liście parametrów metody, jak i jako typ jej wyniku)
są konkludowane z wywołań.
public class Metoda {
public static <T extends Comparable<T>> T max(T[] arr) {
T max = arr[0];
for (int i=1; i<arr.length; i++)
if (arr[i].compareTo(max) > 0) max = arr[i];
return max;
}
public static void main(String[] args) {
Integer[] ia = { 1, 2, 77 };
int imax = max(ia); // w wyniku konkluzji T staje się Integer
Double[] da = {1.5, 231.7 };
double dmax = max(da); // w wyniku konkluzji T staje się Double
System.out.println(imax + " " + dmax);
}
}