<

2. Typy sparametryzowane (generics)


Pojęcie szablonu (template) z języka C++ zrobiło oszałamiającą karierę. W różnych innych językach są podobne konstrukcje. Również w Javie - od wersji 1.5 - mamy możliwość tworzenia czegoś na podobieństwo szablonów z C++.  Mechanizm generics z Javy różni się jednak znacząco od szablonów znanych z innych języków. Czym są generic w Javie? Jak je definiował? Jakie mają ograniczenia? I jakie użyteczne zastosowania? To tematy niniejszego wykładu.


1. Szablony - 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 - w wersji 1.5 - pojawił się również odpowiednik szablonów tzw. generics.

Uwaga. Generics Javy bardzo różnią się od szablonow (templates) w C++


Przy wprowadzaniu koncepcji szablonów 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:

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 kłopoty:

     ParaObj po = new ParaObj("Ala", new Integer(3));
     System.out.println(po.getFirst() + " " + po.getLast());

     // Problem 1
     // konieczne konwersja
     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 wykonaie
     // późno, czasem w innym module
     Integer n = (Integer) po.getLast();   // ClassCastException

Wydruk działania powyższego fragmentu:

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.


Parametryzowane mogą być:



2. Typy sparametryzowane i ich instancje. Typy surowe. 

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 kluczowm 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:
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ą argumentami typu.

Para<String, String>, Para<String, Data> Para<Integer, Integer> nazywają się konkretnymi instancjami sparametryzowanej klasy Para<S, T>.


Zastosowanie typów sparametryzowanych pozwala na:
Nawiązując do  poprzedniego przykładu, testującego uogólnione pary:
     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());

Wynik:

Ala 3
Ala Kowalska 4


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



W Javie - inaczej niż w C++ - po kompilacji dla każdego szablonu 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();
     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


Co oznacza ten wydruk:

Zobacz multimedialną prezentację (Para<S,T>, "raw type", "type erasure")

Jak widzieliśmy, możemy wykorzystywać w kodzie typy surowe. Ale zwróćmy uwagę, że wykorzystanie typów surowych nie jest bezpieczne - kompilator nie jest w stanie sprawdzić zgodności typów. Dlatego w fazie kompilacji wyda ostrzeżenie (aby zobaczyć czego dotyczą ostrzeżenia należy przy kompilacji podać opcję  -Xlint:unchecked):


GenTest2a.java:56: warning: [unchecked] unchecked call to Para(S,T) as a member
of the raw type Para
     Para p = new Para("B", new Double(3.1));
              ^
1 warning


I oczywiście ma racje. W programie nie korzystamy z typu Para<String, Double>, ale stworzyliśmy taki obiekt - zapewne przez pomyłkę.

Uwaga: użycie typów surowych może być zabronione w póżniejszych wersjach Javy.

Mamy więc odpowiedź na pytanie o typy fazy wykonania.
W najprostszym przypadku parametrów takich jak <S, T> parametry w skompilowanej klasie uzyskują jedyny możliwy typ - ogólny nadtyp wszystkich innych - Object.
Czyli - praktycznie nasza klasa Para<S,T> po kompilacji wygląda niemal tak samo jak wcześniej pokazana klasa ParaObj.

Uwaga: gdy będzie mowa o ograniczeniach parametrów typu z góry, zobaczymy, że typy fazy wykonania mogą być bardziej specyficzne niż Object.

Jaka jest więc różnica pomiędzy kodem z generics i bez nich? Dzięki parametryzacji kompilator - przy tworzeniu instancji - może sprawdzić zgodność typów i w kodzie, wykorzystującym te instancje automatycznie dopisać zawężające konwersje.

Przyjęte w Javie rozwiązanie (jedna klasa w fazie wykonania, czyszczenie typów) ma swoje zalety i wady.

Zalety:
Wady:

Uwaga - nie można parametryzować:


Typy sparametryzowane mogą być używane prawie tak samo jak zwykłe typy.
Nie wolno jedynie:
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 elementow 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 odwolania kompilator powinien był dopisać konwersje do Integer, a faktycznie mamy String, a nie Integer.
 
Rozwiązanie problemu:


3. Argumenty i parametry typu

Parametry typu są symbolicznymi oznaczeniami typów, ale przy definiowaniu szablonów - inaczej niż w C++ - nie możemy traktować ich dokładnie tak samo jak normalne typy.
Wynika to z omówionego przed chwilą sposobu kompilacji "generics".


Przyjęte rozwiązanie oznacza, że nie możemy używać jako argumentów typu typów prostych (int, double itp.), co jest jednak łagodzone (ale tylko na poziomie wykorzystania instancji klas sparametryzowanych) autoboxingiem.

Możemy używać jako argumentów typu:
Jak wspomniano, w definicji klas (i metod) sparametryzowanych nie do końca możemy traktować parametry typu jak zwykłe typy.

Możemy:
Nie możemy (w definicjach sparametryzowanych klas i metod):

Ograniczenia te powodują, że np. funkcjonalność szablonu Para<S, T> nie może być zbyt wysoka.
Na pewno możemy dodać metody toString(), hashCode() i equals() - bo występują w klasie Object i kompilator nie będzie protestował.

Na przykład:

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; }

  public boolean equals(Para<S,T> p) {
    return first.equals(p.first) && last.equals(p.last);
  }

  public String toString() {
    return first + " " + last;
  }
}

public class GenTest3 {

  public static void main(String[] args) {
     Para<String, Integer> p1 = new Para<String, Integer>("Ala", 3);
     Para<String, Integer> p2 = new Para<String, Integer>("Ala", 3);
     System.out.println(p1 + "\n" + p2);
     System.out.println(p1.equals(p2));
  }


}
Wynik:

Ala 3
Ala 3
true


Możemy też dodać wszystko to co dotyczy przypisań  np. metodę swap, która przestawia elementy pary.

class Para<S, T> {
  // ...
  public void swap() {
    T temp = (T) first;
    first = (S) last;
    last = temp;
  }

}
i teraz:
     Para<String, String> p3 = new Para<String, String>("Ala", "Kowalska");
     p3.swap();
     System.out.println(p3);

da:

Kowalska Ala


Ale metoda swap() jest potencjalnie niebezpieczna, bo jeśli zastosujemy ją wobec <String, Integer> nastąpi ClassCastException.

Kompilator ostrzega nas o takiej możliwości pisząc:
"unchecked unsafe operation"
co dotyczy właśnie - potrzebnych dla uniknięcia błędu w kompilacji - jawnych  konwersji  (S) i (T).


W tym przypadku aby uniknąć ew. błędu kończącego program możemy przy niezgodności typów generować wyjątek UnsupportedOperationException (i obsługiwać go), albo zwracac wartość boolean - czy przestawienie się udało.

Jednak pewnie lepiej będzie stworzyć inny szablon np.

class ParaSame<S> {
   S first;
   S last;
   ...
}

i tylko dla niego dopuścić operację swap, która tutaj będzie w pełni bezpieczna.


Mimo ograniczeń, wynikających ze sposobu kompilacji być może w naszym szablonie chcielibyśmy wprowadzić większą (ogólną) funkcjonalność, np.  mieć operacje kopiowania par oraz dodawania ich do siebie. Czy i jak  można to zrobić?



4. Wykorzystanie refleksji w szablonach

Wszelkie uogólnienia klas sparametryzowanych, których nie można wprowadzić bezpośrednio ze względu na ograniczenia związane z zastosowaniem parametrów typu - można wprowadzić pośrednio za pomocą środków refleksji


Przykład: dodajemy do szablonu konstruktor kopiujący oraz metodę dodawania dwóch par.

import java.lang.reflect.*;

class Para<S, T> {
  S first;
  T last;

  public Para() {}

  public Para(S f, T l) {
    first = f;
    last = l;
  }

  // konstruktor kopiujący
  public Para(Para<S,T> p) {
    // nie możemy użyć new, ale możemy zastosować refleksję
    try {
      first = (S) getInstance(p.first);  // unchecked, ale jest gwarancja
      last = (T) getInstance(p.last);    // że typy będą właściwe
    } catch (Exception exc) {
        throw new UnsupportedOperationException("Copy constructor not available",
                                                 exc.getCause());
    }
  }

  private Object getInstance(Object o) throws Exception {
     Class type = o.getClass();
     Constructor c = null;
     Object arg = null;
     try {  // czy jest konstruktor kopiujący?
       c = type.getConstructor(type);
       arg = o;
     } catch (Exception exc) {  // nie ma kopiującego
          if (type.getSuperclass() == java.lang.Number.class) { // może to boxy?
c = type.getConstructor(java.lang.String.class);
arg = o.toString();
}
}
if (c == null) { // ani kopiujący, ani ze Stringa nie jest bezpiecznie // utworzyć obiekt za pomocą konstruktora bezparametrowego (newInstance) // pobrać od klasy Properties (PropertyDescriptor[]) // dla wszystkich setterów wywołać odpowiednie gettery na oryginale, // a zwrócone wartości podać jako argumenty setterom // (i wołać je po kolei na kopii) throw new UnsupportedOperationException("Valid constructor not found in" + type); // bo tego nie robimy } else return c.newInstance(arg); } // dodawanie par public Para<S, T> add(Para<S, T> p) { Para<S, T> wynik = new Para<S, T>(); // nie można new T(), ale można new X<T>! try { wynik.first = (S) addObjects(first, p.first); // unchecked, wynik.last = (T) addObjects(last, p.last); // ale typ jest gwarantowany return wynik; } catch(Exception exc) { throw new UnsupportedOperationException("Addition not allowed", exc.getCause()); } } private Object addObjects(Object o1, Object o2) throws Exception { if (o1 instanceof String) return (String) o1 + o2; // konkatenacja if (o1 instanceof Number) { // działania na klasach opakowujących typy proste   double d = ((Number) o1).doubleValue() + ((Number) o2).doubleValue(); String s = String.valueOf(d); Constructor c = null; try { // wynik musi być specyficznego typu (np. Integer) c = o1.getClass().getConstructor(java.lang.String.class); return c.newInstance(s); } catch(Exception exc) { // np. gdy new Integer("1.0"); int l = s.indexOf('.'); // bierzemy tylko cyfry przed kropką s = s.substring(0, l); return c.newInstance(s); } } // Ani String ani Number - więc musi mieć metodę add(...) Class typ = o1.getClass(); Method m = typ.getDeclaredMethod("add", typ); return m.invoke(o1, o2); } 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 String toString() { return first + " " + last; } } class Value { int val; Value(int n) { val = n; } public Value add(Value v) { return new Value(val + v.val); } public String toString() { return "" + val; } } public class GenTest4 { public static void main(String[] args) { Para<String, Integer> p1 = new Para<String, Integer>("A", 1); Para<String, Integer> p2 = new Para<String, Integer>(p1); System.out.println(p2); Para<String, Integer> wynik = p1.add(p2); System.out.println(wynik); // Para <String, String> ps = new Para <String, String>("c", "d"); // wynik = p1.add(ps); <=== kompilator wykrywa błędy Para<Value, Value> v1 = new Para<Value, Value>(new Value(1), new Value(2)); Para<Value, Value> v2 = new Para<Value, Value>(new Value(3), new Value(4)); Para<Value, Value> vv = v1.add(v2); System.out.println(vv); } }
Wynik:

A 1
AA 2
4 6


Przy kompilacji tego przykładu dostaniemy sporo ostrzeżeń "unchecked cast". Starannie napisany kod pozwala jednak ustrzec się błędów w fazie wykonania.

Zobacz demo programu  


Refleksja daje nam mocne  możliwości uogólnień. Połączenie jej z generics umożliwia natomiast wykrywanie - w fazie kompilacji -  wiele błędów przy korzystaniu z klasy sparametryzowanej  (przykład w kodzie: p1.add(ps)), no i oczywiście - eliminuje żmudne konwersje zawężające.


5. Ograniczenia parametrów typu

Innym (od refleksji) sposobem zwiększania funkcjonalności szablonow Javy jest użycie (jawnych) ograniczeń parametrów typu. Dzięki temu w szablonach 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 parametrow 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:

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.
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



6. Metody sparametryzowane

Oprócz klas i interfejsów możemy również parametryzować metody.

Definicja metody sparametryzowanej ma postać:



        specDost [static] <ParametryTypu> typWyniku nazwa( lista parametrów) {
        // ...
        }


Argumenty typów (podstawiane do szablonu w fazie kompilacji, choćby po to by zapewnić zgodność typów oraz automatyczne konwersje zawężające) są określane  (inferred) na podstawie faktycznych typów argumentów wywołania.

Przykład:
public class Metoda {

 public static <T extends Comparable> 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);

    Double[] da = {1.5, 231.7 };
    double dmax = max(da);

    System.out.println(imax + " " + dmax);
  }

}



7. Parametry uniwersalne (wildcards)

Weżmy jakąś kolekcję  sparametryzowaną np. listę:

List<Integer> list1 = new ArrayList<Integer>();

Czy List<Object> jest nadtypem dla typu List<Integer>?
Gdyby tak  było, to moglibyśmy zrobić tak:

List<Object> list2 = list1; // hipotetyczna konwersja rozszerzająca

a wtedy kompilator nie mógłby protestowac 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 List<Integer> i List<String> nie są podtypami List<Object>- to jak stworzyć metodę wypisującą zawartośc dowolnej listy?

Do tego służą parametry uniwersalne (wildcards) - oznaczenie "?".

Są trzy typy takich parametrów: 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 List<?> oznacza wszystkie możliwe listy z dowolnym parametrem T. Czyli List<?> jest nadtypem List<? extends Integer> i nadtypem dla List<? super Integer>.


Kowariancja typów sparametryzowanych umożliwia pisanie uniwersalnych metod (w rodzaju "wypisz dowolną kolekcję" albo "pokaż dowolną listę",
Np.

void showEmployee(List <? extends Pracownik>) {
// ...
}

Bez tego nie moglibyśmy jako argumentów przekazywać listy dyrektorów, kierowników, asystentów etc.
( bo między List<Pracownik> i List<Asystent> nie ma relacji podtyp - nadtyp).

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ć asystenci.
Nie możemy "podstawiać", ale możemy pobierać (dostajemy coś calkiem bezpiecznego typu - np. typu wyznaczanego przez dolną granicę).

To samo dotyczy biwariancji, ale przy kontrawariancji mamy sytuację odwrotną.


8. Użyteczność szablonów (generics) w Javie

Czy użyteczność szablonów w Javie jest ograniczona wyłącznie do kontroli typów w fazie kompilacji oraz unikania konieczności dokonywania konwersji zawężających?


Zacznijmy od pułapek "cudu uogólnienia". Bardzo chcemy, by generics były tym, czym szablony w językach bywają: środkiem uogólnienia kodu.

Myśląc w ten sposób możemy dojść do nieopatrznego wniosku: nareszcie - dzięki generics - możemy pisać fragmenty kodu o uogólnionym działaniu, np. takie, które wprowadzają jakieś nasze wymyślone ułatwienia do operowania na dowolnych komponentach Swingu:
import java.awt.*;
import javax.swing.*;
import static java.awt.Color.*;


class MComp <T extends JComponent> {

   private T comp;

   public MComp(T c) { comp = c; }

   public T getComponent() { return comp; }

   public MComp setColors(Color f, Color b) {
     comp.setForeground(f);
     comp.setBackground(b);
     return this;
   }

   public MComp setFont(String name, int ... args) {
     int style = Font.PLAIN, size = 12;
     if (args.length >= 1) size = args[0];
     if (args.length >= 2) style = args[1];
     comp.setFont(new Font(name, style, size));
     return this;
   }

   public MComp setPreferredSize(int w, int h) {
     comp.setPreferredSize(new Dimension(w, h));
     return this;
   }

}


public class Bounds0 extends JFrame {

  public Bounds0() {
    JButton b = new JButton("Generics");
    MComp<JButton> bWrap = new MComp<JButton>( b );
    bWrap.setColors(YELLOW, BLUE).setFont("Dialog",20).setPreferredSize(500,100);
    setLayout(new FlowLayout());
    add(b);
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

    pack();
    setVisible(true);
  }


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


}

Ale, zaraz! To samo mogliśmy napisać od zawsze, nie korzystając z generics. Wystarczyło jako typ zmiennej comp w klasie MComp podać JComponent!
Ten fragment - z generics - przydaje się tylko do jednej rzeczy: kiedy z "wrappera" pobieramy komponent (metodą getComponent()), to od razu uzyskamy obiekt właściwego typu (np. JButton). W tym przypadku nie jest to wielka korzyść w zamian za konieczność pisania:

class MComp<T extends JComponent>

a co gorsza (bo już dotyczy korzystania z klasy, a nie jej budowy, w trakcie której  utrudnienia, prowadzące do ułatwień w korzystaniu są usprawiedliwione):

MComp<JButton> bWrap = new MComp<JButton>( b );

Zdaje się, że nawet Bruce Eckel wpadł w pułapkę szukania sensu generics w uogólnianiu kodu.
(zob. Bruce Eckel. Thinking About Computing 10-8-04 Templates vs. Generics - weblog).
Zadając sobie pytanie, czy w Javie za pomocą generics można robić to samo co w C++ za pomocą szablonów - odpowiada: w większości przypadków tak, a często nawet krócej i bardziej elegancko (ale dzięki innym od generics środkom językowym Javy).

Oto przykład napisanej przez niego metody statycznej apply (w  klasie Apply), która wobec wszystkich elementów przekazanego zestawu obiektów seq, wywołuje podaną metodę z podanymi argumentami.

public class Apply {
  public static <T, S extends Iterable<? extends T>>
  void apply(S seq, Method f, Object... args) {
    try {
      for(T t: seq)
        f.invoke(t, args);
    } catch(Exception e) {
      // Failures are programmer errors
      throw new RuntimeException(e);
    }
  }
}
 
Bardzo ładna idea: automatyczne wywołanie dowolnej metody na rzecz zestawu dowolnych obiektow.
Tylko, że w Javie osiągalna nie dzięki generics, a dzięki refleksji i wprowadzonemu w wersji 5 interfejsowi Iterable (który omawialiśmy przy okazji instrukcji "for-each").

Więcej nawet - w tym przykładzie metody apply - generics nie mają żadnego sensu.

Należałoby to raczej napisać tak:
class Apply {
  public static
  void apply(Iterable seq, Method f, Object... args) {
    try {
      for(Object t : seq)
        f.invoke(t, args);
    } catch(Exception e) {
      // Failures are programmer errors
      throw new RuntimeException(e);
    }
  }
}
Osiągamy dokładnie ten sam efekt - włącznie z (ograniczoną w obu przypadkach) kontrolą zgodności typów, a unikamy żmudnego "generyzowania", do tego trochę zawikłanego:
<T, S extends Iterable<? extends T>>
Tutaj, oczywiście S musi mieć jako ograniczenie Iterable, abyśmy mogli sekwencję seq zastosować w instrukcji for-each. Natomiast - nieco bardziej tajemnicze zastosowanie uniwersalnego znaku ? do oznaczenia wszystkich podtypów T jest wymuszone potrzebą zapewnienia możliwości  zwracania przez iterator podtypów T (a zatem możliwości zawarcia w sekwencji seq obiektów typu T jak również ew.obiektów jego podtypów).

W oparciu o dwa w/w przykłady moglibyśmy wysnuć wniosek, że w istocie generics niewiele dają. Ale już Bruce Eckel w dalszej części swoich przemyśleń pokazuje dość subtelny i ładny przykład mocy generics.
Czyni to pod hasłem: jak można wybrnąć z problemu czyszczenia typów w generics Javy przy próbie pisania kodów uogólnionych, w których nie ma gwarancji, że stosujemy metody z istniejących interfejsów, a jednocześnie chcemy zachować dużą dozę weryfikacji zgodności typów w fazie kompilacji i wynikające stąd ułatwienia pomijania konwersji zawężających.

Problem:
napisać metodę uogólnioną która wypełnia kontener (=obiekt przechowujący zestaw danych) referencjami do nowotworzonych obiektów danego typu.

Jak wypełniać taki kontener? Dodając do niego za pomocy metody add(...).
Ale w szablonie nie możemy użyć tej metody na rzecz różnych klas (nie będących w jednej hierarchii dziedziczenia). Do kolekcji możemy dodawać za pomocą add (bo interfejs Collection ma metodę add), ale kontenery to nie tylko kolekcje. Jak poszerzyć uniwersalność ew. kodu?

Otóż można się umówić, że kontenery, do których możemy dodawać obiekty powinny implementować interfejs Addable. I użyć tego interfejsu jako typu podstawowego opisującego kontener w uogólnionej metodzie fill.

Taką metodę jest wtedy napisać dość łatwo.


// Metoda fill
// Autor: Bruce Eckel
// + moje rozszerzenia na konstruktor z argumentem

import java.util.*;
import java.lang.reflect.*;

// Dzięki implementacji tego interfejsu
// kontenery (obiekty-zestawy danych) czynić będziemy "wypełnialnymi"

interface Addable<T> { void add(T t); }

class ContainerOp {

  // Dodaje do zestawu (kontenera) cont
  // obiekty typu T (lub dowolnych jego podtypów)
  // tworzone za pomocą konstruktora z argumentem typu V
  // wywoływanego dla kolejnych argumentów  args

  public static <T, V> void fill(Addable<T> cont,
                              Class<? extends T> klasa, // aby wstawiać podtypy!
                              V ... args )
  {
    for(V arg : args)
      try {
        Constructor cons = klasa.getDeclaredConstructor(arg.getClass());
        cont.add((T)cons.newInstance(arg));
    } catch(Exception e) {
        throw new RuntimeException(e);
    }
  }

  // j.w. tylko n egzemplarzy stworzonych konstruktorem bezparemetrowym
  public static <T> void fill(Addable<T> cont,
                              Class<? extends T> klasa,
                              int n )
  {
    for (int i=0; i<n; i++)
      try {
        cont.add(klasa.newInstance());
      } catch(Exception e) {
          throw new RuntimeException(e);
      }
  }

}



Ale kto implementuje Addable? Nie ma takiej klasy!

Wobec tego należy użyć adapterów.

Adapter tworzymy po to - by dostosować gotowy interfejs klasy do jakichś nowych wymagań, zmodyfikować go ad hoc


Adaptery możemy tworzyć przez kompozycję i przez dziedziczenie.

// Użycie adapterów
// Autor: Bruce Eckel
// + moje drobne modyfikacje

// Kto implementuje Addable. Nie ma takiej klasy!!!!
// są jedenak klasy, w których występuje metoda add

// Aby uczynić każdą podklasę klasy X (w ktorej występuje metoda add) "addable"
// nalezy użyć kompozycji: pole oznaczające zestaw jest klasy X
// implememntujemy metodę add jako polimorficzne wywołanie na rzecz X

class AddableCollection<E> implements Addable<E> {
  private Collection<E> c;
  public AddableCollection(Collection<E> c) {
    this.c = c;
  }
  public void add(E item) { c.add(item); }
}



// Ale mogą być przypadki, gdzie jest wiele konkretnych typow-kontenerow
// dla których dodawanie jest wyrażane w różny sposób (i oczywiście nie implementują
// one interfejsu Addable)
class SomeContainer<T> implements Iterable<T> {
  private ArrayList<T> storage = new ArrayList<T>();
  public void addElement(T item) { storage.add(item); } // tutaj "addElement"
  public Iterator<T> iterator() { return storage.iterator(); }
}

// Nie warto dla każdego takiego przypadku tworzyć (wyraźnie)
// oddzielnej klasy poprzez dziedziczenie czy też kompozycję
// B. Eckel proponuje "abstract composition adapter"

abstract class AddableAdapter<S, T> implements Addable<T> {
  private S seq;
  public AddableAdapter(S sequence) { seq = sequence; }
  public S getSequence() { return seq; }
  public abstract void add(T item);
}

class Zwierz {}
class Pies extends Zwierz {}
class Kot extends Zwierz {}


class Test {

  public static void main(String[] args) {
    // Adaptacja kolekcji:
    List<String> words = new ArrayList<String>();
    ContainerOp.fill(new AddableCollection<String>(words),
                     String.class,
                     "Ala", "ma", "kota"
                    );
    for (String w : words) System.out.println(w);
    System.out.println("----------------------");
    List<Integer> ints = new ArrayList<Integer>();
    ContainerOp.fill(new AddableCollectionAdapter<Integer>(ints),
                     Integer.class,
                     "1", "2", "33"
                    );
    for (int i : ints) System.out.println(i);
    System.out.println("----------------------");


    // Użycie abstrakcyjnego adaptera
    SomeContainer<Zwierz> con = new SomeContainer<Zwierz>();


    // I tu uwaga: generics pięknie zadziałały na klasie wewn.
    // Naprawdę łatwo jest ad hoc tworzyć konkretne adaptery
    // (kompilator wie jakiego typu jest wynik getSequence()
    // i bez castingu możemy wołać metody!

    Addable<Zwierz> zwierzeta =
      new AddableAdapter<SomeContainer<Zwierz>, Zwierz>(con) {
        public void add(Zwierz item) { getSequence().addElement(item); }
      };

    ContainerOp.fill(zwierzeta, Pies.class, 7);
    ContainerOp.fill(zwierzeta, Kot.class, 3);

    for (Zwierz z : con) System.out.println(z);
  }
}

Wynik

Ala
ma
kota
----------------------
1
2
33
----------------------
Pies@1cd2e5f
Pies@19f953d
Pies@1fee6fc
Pies@1eed786
Pies@187aeca
Pies@e48e1b
Pies@12dacd1
Kot@1ad086a
Kot@10385c1
Kot@42719c



Tu już mamy trochę użyteczności z generics, rzecz do zastosowania na różne okazje - nie tylko Addable.

Następny (i na razie ostatni) przykład wymyśliłem zastanawiając się nad sposobami ułatwienia pisania klas JavaBeans, gdzie występują właściwości związane i ograniczane (natchnienie znalazłem w ciekawym artykule "A Generic MVC Model in Java" autorstwa Arjan Vermeij. gdzie rzecz dotyczyła uogólniania modeli, przy okazji było trochę o właściwościach; w sumie też b. dobry przykład praktycznej użyteczności generics - zob. artykuł w O'Reilly Network).
A jak by ten pomysł nieco rozwinąć i zastosować do JavaBeans?
Wydaje mi się, ze tu będzie dość wyraźnie widać ile roboty generics mogą nam zaoszczędzić, przynajmniej w niektórych przypadkach.

Co było żmudne:
a) prowadzenie słuchaczy (Change i Veto support + metody add/remove)
b) settery (trochę kodu do napisania)

Zróbmy więc klasę abstrakcyjnego ziarna, która jest gotowa do obsługi słuchaczy.

abstract class AbstractBean implements Serializable {

  protected PropertyChangeSupport chg = new PropertyChangeSupport(this);
  protected VetoableChangeSupport veto = new VetoableChangeSupport(this);

  public void addPropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }

  public void removePropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }
//... i vetoable też
// no i coś jeszcze, bo na razie nie wiele zaoszczędzamy
}
Kluczowe jest definiowanie właściwości.
Wprowadzimy więc do klasy AbstractBean - jako wewnętrzną - klasę sparametryzowaną opisującą właściwości ograniczane typu T:

abstract class AbstractBean implements Serializable {

  protected PropertyChangeSupport chg = new PropertyChangeSupport(this);
  protected VetoableChangeSupport veto = new VetoableChangeSupport(this);

  public void addPropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }

  public void removePropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }

  // pominąłem dodawanie i usuwanie vetoableChangeListenerów

  // Opis właściwości ograniczanej

  class BoundedProperty<T>  {
    private String name;
    private T value;

    public BoundedProperty(String name) {
      this.name = name;
    }

    public T getValue() { return value; }

    public void setValue(T newValue) {
      T old = value;
      value = newValue;
      chg.firePropertyChange(name, old, value);
    }
  }

// ...
}

Ta klasa jest wewnętrzna, aby móc odwoływać się do changeSupport i propagować zdarzenie zmiany właściwości po wszystkich przyłączonych słuchaczach.

Okaże się, że dość łatwo będziemy mogli teraz tworzyć klasy JavaBean zawierające właściwości ograniczane. Np.

class TestBean extends AbstractBean {
  private BoundedProperty<Integer> count =
                                   new BoundedProperty<Integer>("count");
  int getCount()  { return count.getValue(); }
  void setCount(int n) { count.setValue(n); }
  // void setCount(String s) { count.setValue(s); } błąd w kompilacji (i o to chodziło)
}

Tutaj dodajemy - zgodnie z konwencją JavaBeans - metody ustalania i pobierania właściwości (akcesory); przy tym kompilator nie pozwoli nam popełnić pomyłki w argumentach.

Gdybyśmy chcieli i mogli zrezygnować z tej konwencji to możemy jeszcze bardziej sobie uprościć życie. Cała nasza klasa JavaBean, opisująca dwie właściwości ograniczane może wyglądać tak:
class TestBean1 extends AbstractBean {  // Przyjemniejsza wersja
  public final BoundedProperty<Integer> count =
                                   new BoundedProperty<Integer>("count");
  public final BoundedProperty<Color> color =
                                   new BoundedProperty<Color>("color");
} 
a korzystanie z niej jest dość intuicyjne i przyjemne:

    TestBean1 b1 = new TestBean1();
    b1.addPropertyChangeListener(this);
    b1.count.setValue(1333);
    System.out.println(b1.count.getValue());
    b1.color.setValue(RED);
    Color color = b1.color.getValue(); // mamy typy i automatyczne konwersje
    System.out.println(color);
    // b1.color.setValue(11);   <-- mamy sprawdzanie typów w kompilacji


Tu uwaga: możemy spokojnie zmienne oznaczające właściwości uczynić publicznymi, bo jednocześnie deklarujemy je jako final (co oczywiście nie wyklucza zmian właściwości, zmienne oznaczają referencje do obiektów klasy BoundedProperty, opakowujących właściwości i udostępniających właściwy interfejs operowania na nich).

Dalsze ułatwienia (a nawet rozszerzenia) możemy wprowadzić odnośnie do obsługi zdarzeń zmian właściwości.

Normalnie JavaBeans nie daje możliwości powiązania danego słuchacza wyłącznie z obsługą zmian konkretnej właściwości. Teraz to zmienimy, dodatkowo ułatwiając pisanie tej obsługi, bo wiele szczegółów zapisując raz w klasie BoundedProperty.

Zobaczmy nową wersję, w której (oczywiście dla specyficznych przypadków, ale pozostawiając także dowolną ogólność) upraszczamy życie użytkownika przez to, że
nie musi zajmować się PropertyChnnaageListenerami i ich przyłączaniem; sam obiekt klasy BoundedProperty jest słuchaczem swoich zmian i deleguje obsługę tych zmian do ustalonej przez użytkownika metody.

import java.beans.*;
import java.io.*;
import java.util.*;
import java.lang.reflect.*;

abstract class AbstractBean implements Serializable {

  protected PropertyChangeSupport chg = new PropertyChangeSupport(this);
  protected VetoableChangeSupport veto = new VetoableChangeSupport(this);

  public void addPropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }

  public void removePropertyChangeListener(PropertyChangeListener pcl) {
    chg.addPropertyChangeListener(pcl);
  }

  class BoundedProperty<T>  implements PropertyChangeListener {
    private String name;
    private T value;
    private Object chgHandlerObject;
    private Method changeHandler;

    public BoundedProperty(String name) {
      this.name = name;
    }

    public T getValue() { return value; }

    public void setValue(T newValue) {
      T old = value;
      value = newValue;
      chg.firePropertyChange(name, old, value);
    }

    public void propertyChange(PropertyChangeEvent e) {
      if (!e.getPropertyName().equals(name)) return;
      if (changeHandler == null) return;
      try {
        changeHandler.invoke(chgHandlerObject);
      } catch(Exception exc) {
          exc.printStackTrace();
      }
    }

    public void setChangeHandler(Object handl, String mname) {
      try {
        Method m = handl.getClass().getDeclaredMethod(mname);
        chgHandlerObject = handl;
        changeHandler = m;
        chg.addPropertyChangeListener(this);
      } catch(Exception exc) {
          exc.printStackTrace();
          return;
      }
    }

    public void setChangeHandler(Object ohandler) {
      try {
        Method m = ohandler.getClass().getDeclaredMethod(name+"Change");
        chgHandlerObject = ohandler;
        changeHandler = m;
      } catch(Exception exc) {
          exc.printStackTrace();
          return;
      }
      chg.addPropertyChangeListener(this);
    }

    public void removeChangeHandler() {
      changeHandler = null;
      chgHandlerObject = null;
      chg.removePropertyChangeListener(this);
    }
  }


}

I bardzo łatwe wykorzystanie:

import java.beans.*;
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import static java.awt.Color.*;

class TestBean extends AbstractBean {
  public final BoundedProperty<String> text =
                                   new BoundedProperty<String>("text");
  public final BoundedProperty<Color> color =
                                   new BoundedProperty<Color>("color");
}

public class ExtProps extends JFrame {

  JButton b = new JButton();
  TestBean bean = new TestBean();

  public ExtProps() {
    bean.text.setValue("Dalej");
    bean.color.setValue(BLACK);

    b.setText(bean.text.getValue());
    b.setForeground(bean.color.getValue());

    b.addActionListener( new ActionListener() {
         public void actionPerformed(ActionEvent e) {
           bean.text.setValue(bean.text.getValue() + "+");
           bean.color.setValue(bean.color.getValue().brighter());
         }
      });
    bean.color.setChangeHandler(this);
    bean.text.setChangeHandler(this);

    add(b);
    setSize(200,200);
    setVisible(true);
  }

  public void colorChange() {
    b.setForeground(bean.color.getValue().brighter().brighter());
  }

  public void textChange() {
    b.setText(bean.text.getValue());
  }



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


}



Zobacz demo programu



9. Podsumowanie

W tym wykładzie zapoznaliśmy się z mechanizmem generics w Javie.
W szczególności, dowiedzieliśmy się:

Można powiedzieć, że generics w Javie nie zawsze są łatwe w użyciu. A jednocześnie brakuje im mocy uogólniania kodu, znanej choćby z szablonów C++. Mimo to, są przypadki, w których generics Javy świetnie się sprawdzają, ułatwiając programowanie, czyniąc je bardziej bezpiecznym, a kody - bardziej czytelnymi.

10. Zadania

Zadanie 1 

Stworzyć klasę sparanetryzowaną, której obiekty reprezentują tablice liczb dowolnego typu.
Dostarczyć w niej operacji:
Klasę przetestować dla tablic: Integer, Double, BigDecimal.


Zadanie 2 

Zgeneryzować JavaBean zawierającą właściwości ograniczane (constrained).

Zadanie 3

Wymyślić ciekawe (= oszczędzające kodowania, czytelne, efektywne) zastosowanie generics Javy.