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:
Do tych - od dawna dostępnych możliwości - wprowadzenie generics (typów sparametryzowanych) dodaje ułatwienia w postaci:
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:
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:

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:
Wady wydają się jednak przeważać nad zaletami. Do wad zaliczymy:

Istotnie, w definicjach 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):

Również użycie typów sparametryzowanych obarczone jest restrykcjami.
Nie wolno np.:
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:

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:

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

}