Personal tools

PO Typy uogólnione

From Studia Informatyczne

<<< Powrót

Spis treści

Typy uogólnione

Wprowadzenie

Siłą programowania obiektowego jest łatwość opisywania w nim i tworzenia abstrakcji. Pojęcie klasy pozwala tworzyć abstrakcje, zaś mechanizm dziedziczenia ułatwia tworzenie nowych abstrakcji na podstawie już istniejących.

Czasami jednak okazuje się, że te narzędzia nie zaspokajają jeszcze wszystkich naszych potrzeb. Niejednokrotnie tworzymy pojęcia, które chcielibyśmy nie tylko sparametryzować występującymi w nich wartościami (co czynimy wprowadzając do klas atrybuty), czy czynnościami (co pozwalają nam osiągnąć metody), lecz także typami przechowywanych lub przetwarzanych obiektów.

Na pierwszy rzut oka może się wydawać, że sam fakt dysponowania mechanizmem dziedziczenia i regułą podstawiania pozwala na abstrahowanie od typu przetwarzanych obiektów. Rzeczywiście możemy zadeklarować, że dane przechowywane w naszej klasie są typu Object i dzięki regule podstawiania umieszczać w tak zadeklarowanych atrybutach wartości dowolnego typu referencyjnego [1] Zobaczmy, dlaczego takie rozwiązanie jest niedobre na przykładzie fragmentu deklaracji klasy Stos. Chcemy mieć tradycyjny stos przechowujący wartości dowolnego typu referencyjnego. Możemy w tym celu stworzyć listę elementów stosu. Elementy Stosu zapamiętamy w obiektach klasy EltStosu, zaś sam obiekt klasy Stos będzie pamiętał jedynie pierwszy element listy.

Oto przykładowa implementacja (z pominiętymi mniej istotnymi elementami):

public class Stos {
    
    private EltStosu wierzch;
  
    public Stos() {
        wierzch = null;
    }
    
    public boolean pusty(){
        return wierzch == null;
    }
    public void wstaw(Object elt){
        wierzch = new EltStosu(elt, wierzch);
    }
    public Object pobierz() throws PustyStos {
        if (pusty()) 
           throw new PustyStos();
        Object wynik = wierzch.elt;
        wierzch = wierzch.nast;
        return wynik;
    }
 }
 
 class EltStosu {
    
    public final Object elt;
    public final EltStosu nast;
    // Ponieważ obiekty klasy EltStosu są ukryte w Stosie, nie
    // ma potrzeby deklarowania ich atrybutów jako prywatnych.
    // Również fakt, że klasa EltStosu nie jest publiczna, oraz deklaracja
    // final pozwalają tu na wyjątek od ogólnej reguły, zgodnie z którą 
    // atrybuty zawsze powinny by prywatne lub chronione.
    
    public EltStosu(Object elt, EltStosu nast) {
        this.elt = elt;
        this.nast = nast;
    }
 }
 
 class PustyStos extends Exception{}

Na razie wszystko wygląda dobrze, ale spróbujmy skorzystać z naszej implementacji stosu. Załóżmy, że mamy klasę Osoba, zawierającą m.in. pole imię (pozostałe atrybuty i metody w tym miejscu pomijamy):

public class Osoba {
    private String imię;
 
    public Osoba(String imię) {
        this.imię = imię;
    }
    
    public String imię(){
        return imię;
    }   
 }

Teraz spróbujmy utworzyć Osobę, przechować na stosie i pobrać do dalszego przetwarzania:

Osoba os1 = new Osoba("Jasio");
 Stos s = new Stos();
        
 s.wstaw(os1);
 Osoba os2 = s.pobierz();

Niestety w ostatnim wierszu występuje błąd kompilacji. Powód jest prosty - metoda pobierz ma typ wyniku Object, a my potrzebujemy obiektu klasy Osoba. My wprawdzie wiemy, że na stosie jest Osoba, ale kompilator tego nie wie.[2]

Jak można temu zaradzić? Jedynym rozwiązaniem dostępnym we wcześniejszych wersjach Javy było zastosowanie rzutowania typów:

Osoba os2 = (Osoba) s.pobierz();

Jest to jednak bardzo niedobre rozwiązanie - w tym momencie rezygnujemy z automatycznego sprawdzania typów wykonywanego przez kompilator i każemy mu uwierzyć (a nie wyliczyć), że typ obiektu pobieranego ze stosu jest właściwy.

Dlatego w wielu językach programowania ze statyczną kontrolą zgodności typów wprowadzono pojęcie klas lub funkcji uogólnionych (ang. generics). Pomysł jest bardzo prosty - chcemy sparametryzować fragment kodu typem przetwarzanych danych, więc dodajemy nowy rodzaj parametrów: parametry opisujące typy.

Zanim dokładniej przyjrzymy się rozwiązaniu tego problemu w Javie, warto zaznaczyć, że realizacje klas uogólnionych we współczesnych językach obiektowych dość istotnie różnią się od siebie.[3] Na przykład rozwiązanie przyjęte w C++ opiera się na bardzo prostej idei (z grubsza rzecz biorąc sprowadza się do rozwijania makr), ma natomiast olbrzymią siłę wyrazu (m.in. umożliwia metaprogramowanie, czyli pisanie programów wykonywanych przez kompilator - sic!). Kwestią do dyskusji pozostaje, czy tak duża siła wyrazu tego mechanizmu rzeczywiście jest potrzebna. W Javie zastosowano zupełnie inne rozwiązanie, oparte na idei wymazywania typów (ang. type erasure), co było wymuszone koniecznością zachowania zgodności z wcześniejszymi wersjami (przede wszystkim chodziło o zgodność z dotychczasową maszyną wirtualną). Zaproponowane rozwiązanie jest zgrabne i wystarczające w większości przypadków. Ma jednak także szereg irytujących ograniczeń, których przyczyn nie można zrozumieć bez odwołania się do zastosowanego mechanizmu wymazywania typów. Z kolei C# oferuje rozwiązanie bardzo podobne do tego z Javy, jednak dzięki temu, że twórcy nie byli zmuszeni do zachowania zgodności z poprzednimi wersjami maszyny wirtualnej, jest ono bardziej spójne.

Klasy parametryzowane typami

Spróbujmy zdefiniować w Javie pojęcie pary. Klasa Para powinna mieć dwa atrybuty przechowujące elementy pary, metody pozwalające co najmniej na odczytywanie elementów pary (u nas będzie także możliwe podmienianie elementów pary) i konstruktor pozwalający na tworzenie par. Ponieważ tworzymy pojęcie ogólne, nie chcemy decydować jakiego typu mają być elementy poszczególnych par. Z powodów omówionych wcześniej nie chcemy również gubić informacji o ich typie (czyli deklarować elementy pary jako wartości typu Object). Dlatego zdefiniujemy parę jako typ uogólniony:

public class Para<T1, T2> {
   private T1 pierwszy;
   private T2 drugi;
   public Para(T1 pierwszy, T2 drugi) {
     this.pierwszy = pierwszy;
     this.drugi = drugi;
   }
 
   public T1 pierwszy() {
     return pierwszy; 
   }
    
   public T2 drugi() {
     return drugi; 
   }
    
   public void pierwszy(T1 pierwszy) {
     this.pierwszy = pierwszy;
   }
    
   public void drugi(T2 drugi) {
     this.drugi = drugi;
   }
 }

Składniowo deklaracja klasy uogólnionej różni się od zwykłej deklaracji klasy tylko jednym elementem - wskazaniem typów będących parametrami tej klasy. Parametry te podaje się po nazwie klasy, oddzielając je od siebie przecinkami. Cała lista parametrów jest otoczona nawiasami kątowymi (na wzór składni przyjętej w C++). Parametrów tych można używać w treści klasy uogólnionej tak, jakby były zwykłymi typami.

Sens powyższej deklaracji jest następujący: zdefiniowaliśmy szablon klasy, według którego można teraz tworzyć konkretne typy par, podając, jakie mają być typy elementów pary. Użycie takiego szablonu polega na podaniu jego nazwy, a po niej, w nawiasach kątowych, typów pooddzielanych przecinkami, będących argumentami szablonu. To, że w naszej klasie uogólnionej użyliśmy dwu nazw typów, pozwala nam definiować pary składające się na przykład z osoby i napisu:

Para< Osoba, String >

Gdyby zależało nam, żeby pary były jednorodne, tzn. oba elementy pary miały ten sam typ, to nagłówek klasy zapisalibyśmy tak:

public class ParaJednorodna<T> {

i wszędzie w treści klasy zastąpilibyśmy T1 i T2 parametrem T. Wówczas oczywiście zapis

ParaJednorodna< Osoba, String >

zostałby odrzucony przez kompilator, natomiast moglibyśmy używać par takich jak:

ParaJednorodna< Osoba >

Zanim dokładniej przyjrzymy się własnościom typów uogólnionych, poczyńmy kilka uwag dotyczących naszej realizacji pojęcia pary:

  • Ze względu na słabość implementacji typów uogólnionych w Javie parametry typów uogólnionych (tu T1 i T2) muszą być typami referencyjnymi (więc np. nie można podać jako parametru typu int, ale można za to podać typ Integer). W naszych przykładach często będziemy korzystać z automatycznego konwertowania typów prostych na odpowiadające im typy referencyjne i w drugą stronę (ang. autoboxing), dzięki czemu to ograniczenie nie będzie specjalnie kłopotliwe.
  • Ze względu na semantykę Javy, obiekty klasy Para przechowują jedynie referencje do oryginalnych obiektów (a nie ich kopie). Zatem obiekt umieszczany w parze nie jest do niej kopiowany.
  • W Javie dla prostych akcesorów zwykle stosuje się konwencję nazewniczą polegającą na dodawaniu przedrostków get i set do nazwy pola (czyli tu byłoby np. getPierwszy i setPierwszy). W tym przykładzie naturalniejsze było zastosowanie samej nazwy pola i skorzystanie z przeciążania nazw metod.
  • Zdefiniowane tu pojęcie pary jest użyteczne w wielu sytuacjach, na przykład wtedy, gdy tworzymy metodę dającą jako wynik dwie wartości. Ponieważ w Javie nie ma przekazywania parametrów przez zmienną[4] wyniki trzeba przekazać jako wynik metody. Ale wynikiem metody może być tylko jedna wartość, zatem wyniki trzeba przed przekazaniem na zewnątrz zapakować w parę.

Pary z różnymi typami parametrów są oczywiście różne, zatem poniższy fragment programu nie da się skompilować:

Para<String, Integer> p1 = null;
  Para<Integer, String> p2 = p1;  // nie kompiluje się

Wynika z tego, że typem nie jest sam szablon Para, lecz dopiero jego ukonkretnienie z podanymi parametrami.

Jak już wspominaliśmy, jako parametrów typów uogólnionych nie można podawać typów prostych, czyli pierwszy wiersz z poniższego przykładu nie da się skompilować, ale następne już tak (dzięki automatycznemu opakowywaniu i rozpakowywaniu typów prostych).

Para<int, int> para = new Para<int, int>(7, 13); // to się nie kompiluje
  Para<Integer, Integer> para = new Para<Integer, Integer>(7, 13);
  int p1 = para.pierwszy();

Mając pojęcie pary, chcielibyśmy móc je szerzej stosować definiując szereg nowych metod dla klasy Para, ale tu pojawiają się ograniczenia związane z istotą typów uogólnionych. Definiując typ uogólniony nie wiemy jakimi konkretnymi typami będzie on sparametryzowany, więc próba zapisu takiego jak poniżej zostanie (słusznie) odrzucona przez kompilator:

public class Para<T1, T2> {
   // ...
   public void błędna(){
     System.out.println(pierwszy.imię());
   }

Kompilator oczywiście nie wie, że planujemy wywoływać metodę błędną tylko dla tych par, których pierwszym elementem jest obiekt klasy Osoba. I całe szczęście, że nie chce zaakceptować takiej metody, bo po pierwsze jest niemal pewne, że przy kolejnych modyfikacjach programu pojawiłoby się wywołanie tej metody dla nieodpowiedniej pary, a po drugie (i jeszcze ważniejsze) mieliśmy stworzyć ogólne pojęcie pary, a zupełnie nie widać powodów, dla których mielibyśmy przyjąć, że w pierwszych elementach par mają występować obiekty akurat klasy Osoba.

Kompilator przyjmuje zatem, że typ będący parametrem szablonu może być dowolną podklasą klasy Object[5] (czyli, innymi słowy, dowolnym typem referencyjnym). (W dalszej części tego wykładu zobaczymy bardzo ciekawy mechanizm pozwalający na doprecyzowanie, jakich typów parametrów oczekuje klasa uogólniona.) Oznacza to, że możemy wykonywać dla obiektów typu będącego parametrem tylko takie operacje, jakie są zdefiniowane w klasie Object. Nie jest ich wiele, ale tym nie mniej jesteśmy w stanie nieco rozbudować naszą klasę, tak by była bardziej funkcjonalna.

Pierwszy brak naszej implementacji zauważymy uruchamiając poniższy fragment programu.

Para<Integer, Integer> para = new Para<Integer, Integer>(7, 13);
  System.out.println("Para: " + para);

Na ekranie wypisze się coś w tym rodzaju:

 Para: przykładydowykładu.Para@61de33

czyli zamiast zawartości pary zobaczymy nazwę pakietu, nazwę klasy i wartość funkcji haszującej obiektu. Dzieje się tak dlatego, że nie przedefiniowaliśmy metody toString odziedziczonej po klasie Object. Możemy i powinniśmy to zrobić, właśnie dlatego, że zamiana na napis jest jedną z metod klasy Object i mamy prawo oczekiwać jej sensownego działania dla każdej klasy.

@Override
  public String toString(){
    return "Para(" + pierwszy + ", " + drugi + ")";
  }

Teraz przedstawiony poprzednio fragment programu wygeneruje oczekiwany opis zawartości pary:

 Para: Para(7, 13)

Dużo trudniej jest przedefiniować inną metodę z klasy Object, a mianowicie equals. W klasie Object ta metoda jest zdefiniowana jako identyczność, więc następujące porównanie da wynik false:

System.out.println("Równość par: " + 
      new Para<Integer, Integer>(0,0).equals(new Para<Integer, Integer>(0,0)));

Oczywiście nie jest to zachowanie, którego byśmy oczekiwali, na szczęście możemy je poprawić i to mimo tego, że nic nie wiemy o typach elementów pary (zakładamy jedynie, że pary złożone z równych sobie elementów są równe).

@Override
 public boolean equals(Object o) {
   if (!(o instanceof Para<?,?>)) return false;
   Para<T1, T2> p = (Para<T1,T2>)o;  // Ostrzeżenie kompilatora: unchecked cast
   if ((pierwszy() != null) && (drugi() != null))
     return  pierwszy().equals(p.pierwszy()) &&
       drugi().equals(p.drugi());
   else
     if ((pierwszy() == null) && (drugi() == null))
       return (p.pierwszy() == null) && (p.drugi() == null);
     else // dokładnie jeden z elementów pary jest równy null
       if (pierwszy() == null)
         return (p.pierwszy()==null) && (drugi().equals(p.drugi()));
       else // drugi()==null
         return pierwszy().equals(p.pierwszy()) && (p.drugi()==null);
 }

Powyższa metoda jest nadspodziewanie skomplikowana, co więcej, to, czy powinno się w niej stosować instanceof czy getClass, budzi gorące dyskusje. Omówmy więc po kolei co się w niej dzieje.

Pierwszy problem związany z tą metodą wynika z samego jej nagłówka. W klasie Object parametr tej metody jest zadeklarowany z typem Object (tam jak najbardziej naturalnym). Ponieważ przedefiniowując metodę musimy zachować jej nagłówek, również w naszej klasie musimy być przygotowani na przyjęcie jako argumentu dowolnego obiektu. Jest to wygodne z punktu widzenia użytkownika tej metody (może porównywać dwa dowolne obiekty), ale dla implementującego stanowi to pierwszą przeszkodę, należy upewnić się, że drugi porównywany obiekt jest dobrej klasy. Tu powstaje dalece niebanalne pytanie, czy aby dwa obiekty były równe mają mieć ten sam typ? Na pierwszy rzut oka wydaje się, że oczywiście tak (i wtedy powinniśmy zamiast instanceof użyć getClass), z drugiej strony skoro hierarchia dziedziczenia odzwierciedla relację bycia-czymś, czyli obiekty podklasy są też obiektami nadklasy, to może się okazać, że czasem chcemy traktować obiekty dwu spokrewnionych klas jako równe. Dla przykładu wyobraźmy sobie, że mamy dwie implementacje napisów, różniące się efektywnością realizacji i będące podklasami jednej klasy Napis. Wówczas moglibyśmy w klasie Napis zdefiniować wspólną dla wszystkich napisów metodę badającą równość, podobnie jak zrobiliśmy to w naszej klasie Para. W naszych dalszych przykładach stworzymy klasy ParaInt i ParaJednorodna, dla których tu zdefiniowana metoda equals będzie mogła dać w wyniku wartość true.

Zwróćmy jeszcze uwagę na to, że jeśli parametr o jest pustą referencją (null), to test z instanceof da wynik negatywny.

Kolejny problem to typ pojawiający się po prawej stronie operatora instanceof. Zgodnie z tym, co napisano powyżej, czasami chcielibyśmy powiedzieć, że parametr ma być jakąś parą - nie koniecznie sparametryzowaną tymi samymi typami (np. możemy chcieć uznać parę <1,1> za równą parze <1.0, 1.0>). Takie właśnie jest znaczenie zapisu instanceof Para<?,?>. Niestety fakt pominięcia parametrów typów nie wynika tylko z naszych zamiarów. Implementacja typów uogólnionych w Javie nie pozwala na przechowanie pełnej informacji o typie podczas wykonywania programu (czyli wtedy, gdy operator instanceof jest wyliczany). Jedyne co daje się odczytać podczas działania programu, to fakt, że obiekt pochodzi z jednej z klas uzyskanych z klasy uogólnionej Para (informacja o typach parametrów jest tracona). Więcej o dżokerach (czyli symbolach ?) powiemy w dalszej części wykładu.

Często na początku metody equals dodaje się test identyczności:

if (o == this) return true;

tu go pominęliśmy (jeśli parametr będzie tym samym co this, to dalsze testy w naszej metodzie dadzą poprawny wynik).

Jeśli już wiemy, że parametr jest parą, to możemy bezpiecznie zrzutować go do typu Para (kompilator generuje ostrzeżenie, że nie jest w stanie wygenerować kodu sprawdzającego poprawność tego rzutowania). Robimy to po to, by móc odwoływać się do elementów pary.

Następnie żmudnie sprawdzamy, czy oba składniki obu par są sobie odpowiednio równe. To sprawdzenie jest utrudnione przez fakt, że elementy pary mogą być puste (null), a wtedy nie można wywołać dla nich metody equals.

Pisząc implementację metody equals powinniśmy zadbać o to, by definiowała ona relację równoważności (tzn. była zwrotna, symetryczna i przechodnia) dla niepustych referencji.

Jak zobaczymy w dalszej części wykładu, samo przedefiniowanie metody equals zwykle nie wystarcza, należy również przedefiniować metodę hashCode. Używając kolekcji obiektów musimy zadbać, by a.equals(b) implikowało a.hashCode() == b.hashCode(). W naszym przypadku można to zrobić na przykład tak:

public int hashCode(){
    return pierwszy().hashCode() & drugi().hashCode();
  }

Istotne jest to, że typy uogólnione mogą być stosowane niezależnie od dziedziczenia, co oznacza, że zarówno zwykłe klasy mogą dziedziczyć po (ukonkretnionych) klasach uogólnionych, jak i klasy uogólnione mogą dziedziczyć po zwykłych klasach (nieliczne wyjątki omawiamy w podrozdziale o ograniczeniach związanych ze stosowaniem typów uogólnionych w Javie). Dla przykładu możemy zdefiniować klasę par liczb całkowitych:

public class ParaInt extends Para<Integer, Integer>{
   public ParaInt(Integer pierwszy, Integer drugi)  {
     super (pierwszy, drugi);
   }
 }

Oczywiście również klasy uogólnione mogą dziedziczyć po klasach uogólnionych. Zdefiniujmy na przykład parę jednorodną jako podklasę klasy Para.

public class ParaJednorodna<T> extends Para<T,T>{
   public ParaJednorodna(T pierwszy, T drugi) {
     super (pierwszy, drugi);
   }
 }

Teraz można by zdefiniować klasę ParaInt jako podklasę ParyJednorodnej (czego już nie będziemy robić). Zauważmy jeszcze, że dla poniższego fragmentu programu nasza realizacja metody equals z klasy Para da, zgodnie z oczekiwaniem, dwukrotnie wynik true:

ParaJednorodna<Integer> pj = new ParaJednorodna<Integer>(13, 13);
 ParaInt pi = new ParaInt(13, 13);
 System.out.println("Równość par różnych klas: " +
                    pi.equals(pj) + " i " + pj.equals(pi));

Oczywiście argumentami dla klas uogólnionych mogą być dowolnie złożone typy, w szczególności ukonkretnienia tych samych klas uogólnionych:

Para< Para<String, Integer>, String> dużaPara =
   new Para<Para<String, Integer>, String>(
     new Para<String, Integer>("Ola ma psa", 13), "Ala ma kota" );

Metody parametryzowane typami

Można tworzyć nie tylko typy uogólnione, ale i takie metody. Metody te mogą występować w zwykłych klasach. Ich deklaracje poprzedzamy podaniem parametrów (jak zwykle w postaci listy ujętej w kątowe nawiasy). Różnicą w stosunku do klas uogólnionych jest to, że przy użyciu metody uogólnionej nie podajemy już listy typów, kompilator dedukuje owe typy na podstawie typów argumentów, z którymi wywołano metodę.[6] Jako przykład rozpatrzmy prostą klasę z metodą uogólnioną tworzącą parę par zawierających ten sam element (twórzParęPar) i metoda testującą (test):

public class TestMetodyUogólnionej {
   <T> ParaJednorodna<ParaJednorodna<T>> twórzParęPar(T elt){
     return new ParaJednorodna<ParaJednorodna<T>>
      (new ParaJednorodna<T>(elt, elt), new ParaJednorodna<T>(elt, elt));
   }
  
  void test(){
    System.out.println("Para par Integer" + twórzParęPar(3));
    System.out.println("Para par String" + twórzParęPar("Ala ma kota"));
  }
 }

Definiowanie ograniczeń na parametry typów uogólnionych

Definiowanie typów uogólnionych, sparametryzowanych typami elementów składowych, jest niezwykle wygodnym mechanizmem i z punktu widzenia użytkownika klasy w zasadzie zupełnie wystarczającym. Jednak gdy zaczniemy implementować klasy, wkrótce zauważymy, że bardzo ogólne parametry klas często są niewygodne.

Załóżmy dla przykładu, że chcemy mieć pary jednorodne, których elementy są posortowane (to znaczy pierwszy element jest mniejszy bądź równy drugiemu). Gdybyśmy zapisali klasę par posortowanych po prostu tak:

public class ParaPosortowana <T> extends ParaJednorodna<T>{   
  public ParaPosortowana(T pierwszy, T drugi) {
    super(pierwszy, drugi );    
      
    if (pierwszy().compareTo(drugi()) > 0){  // nie kompiluje się
      T pom = pierwszy();
      pierwszy(drugi());
      drugi(pom);
    }        
}

to kompilator zaprotestowałby przeciwko użyciu metody compareTo . Oczywiście słusznie, wszak nie jest prawdą, że każdy typ w Javie ma zaimplementowane porównywanie. Chcielibyśmy móc wskazać, że parametrami naszej pary mogą być jedynie takie typy, dla których zaimplementowano operację porównywania.[7]. Nie mamy wprawdzie w Javie mechanizmu pozwalającego wyłuskać takie typy (tzn. nie możemy bezpośrednio wskazać, że chodzi nam o typy z zaimplementowaną metodą compareTo), ale możemy definiując typ poinformować, że jego wartości dają się porównywać (co jest lepszym rozwiązaniem, bo sam fakt zadeklarowania metody o nazwie compareTo nie koniecznie musi oznaczać, że jej zamierzone znaczenie rzeczywiście dotyczy porównywania wartości obiektów). Rozwiązanie polega na zadeklarowaniu, że nasza klasa implementuje interfejs "Comparable", czyli interfejs obiektów dających się ze sobą porównywać (chodzi o naturalny dla danej klasy porządek liniowy). Ów interfejs jest bardzo skromny - składa się tylko z jednej metody - lecz interesująca jest sygnatura tej jedynej metody. Nie jest to

int compareTo(Object o)

jak moglibyśmy podejrzewać na podstawie naszych wcześniejszych doświadczeń z metodą equals, lecz (zamieszczamy tu też deklarację całego interfejsu ze względu na jego parametry):

public interface Comparable<T>{
  public int compareTo(T o);
}

Można więc tu wskazać, z obiektami jakiego typu będzie dozwolone porównywanie. Dzięki temu można podać, że metodą compareTo wolno porównywać ze sobą tylko obiekty tej samej klasy (i jej podklas). Teraz możemy już zapisać poprawną wersję naszej klasy posortowanych par:

public class ParaPosortowana <T extends Comparable<T>> extends ParaJednorodna<T>{
    
  public ParaPosortowana(T pierwszy, T drugi) {
    super(pierwszy, drugi );
        
    if (pierwszy().compareTo(drugi()) > 0){
      T pom = pierwszy();
      pierwszy(drugi());
      drugi(pom);
    }        
  }
}

W tym przykładzie zastosowaliśmy zmienne typowe (T) z ograniczeniami (extends Comparable<T>). Zgodnie z definicja języka, nawet gdy nie podajemy jawnie ograniczenia, domyślnie będzie przyjęte ograniczenie extends Object, tak więc z formalnego punktu widzenia zawsze stosujemy zmienne typowe z ograniczeniami.

Oczywiście przy powyższej definicji pary posortowanej nie da się już zdefiniować posortowanej pary osób (dopóki nie zdefiniujemy w klasie Osoba odpowiedniej metody porównującej i nie zaimplementujemy interfejsu Comparable), można natomiast zdefiniować na przykład posortowaną parę napisów.

ParaPosortowana<Osoba> ps1 = 
    new ParaPosortowana<Osoba>(new Osoba("Małgosia"), new Osoba("Jaś"));
    // Posortowana para osób nie kompiluje się
ParaPosortowana<String> pp5 = new ParaPosortowana<String>("ma", "Ala");
    // Posortowana para napisów będzie zawierać jako pierwszy element
    // słowo "Ala", a jako drugi słowo "ma"

Omawiany mechanizm pozwala także podawać kilka ograniczeń dla jednej zmiennej typowej. Załóżmy przez chwilę, że zdefiniowaliśmy bibliotekę par, wśród których część implementuje interfejs Comparable, a część (nie koniecznie ta sama) Serializable, na przykład:

class Para<T1,T2>{...}
class ParaJednorodna<T> extends Para<T,T> 
                        implements Serializable { /*...*/ }
class ParaPosortowana<T> extends ParaJednorodna<T> 
                         implements Comparable<ParaJednorodna<T>> { /*...*/ }

Zdefiniujemy teraz klasę ParaWymagająca, sparametryzowaną typem elementów obu pól, który musi być klasą:

  • będącą parą jednorodną (lub jej podklasą),
  • implementującą porównywanie,
  • implementującą serializację.

Oto rozwiązanie:

public class ParaWymagająca
  <T1, T2 extends ParaJednorodna<T1> & Serializable & Comparable<T2>> 
  extends ParaJednorodna<T2> {
    public ParaWymagająca(T2 pierwszy, T2 drugi) {
      super(pierwszy, drugi);
    }    
}
 
// ...
ParaPosortowana<String> pp = new ParaPosortowana<String>("jeden","dwa");
ParaWymagająca<String, ParaPosortowana<String>> pw =
   new ParaWymagająca<String, ParaPosortowana<String>>(pp, pp);

Zwróćmy uwagę na zastosowaną składnię. Zapis z & oznacza typ, który jest częścią wspólną wszystkich wymienionych typów[8]. Zatem typ T2 z powyższego przykładu rzeczywiście spełnia postawione przez nas postulaty. Przy deklarowaniu ograniczeń klasę możemy podać co najwyżej raz (co jest oczywistą konsekwencją tego, że w Javie nie ma wielodziedziczenia po klasach) przy czym musi ona (o ile w ogóle jest) wystąpić na pierwszej pozycji.

Typ T1 pełni tu rolę pomocniczą. Zamiast niego można użyć dżokera ?. Upraszcza to używanie klas wymagających.

public class ParaWymagająca2
  <T extends ParaJednorodna<?> & Serializable & Comparable<T>> 
  extends ParaJednorodna<T> {
    public ParaWymagająca2(T pierwszy, T drugi) {
        super(pierwszy, drugi);
    }
}
// ...
ParaWymagająca2<ParaPosortowana<String>> pw2 =
  new ParaWymagająca2<ParaPosortowana<String>>(pp, pp);

Mechanizm zadawania ograniczeń jest dość rozbudowany, nie będziemy tu omawiać wszystkich jego możliwości, zasygnalizujemy tylko jeszcze niektóre z nich.

  • Przy pomocy zmiennych typowych można wyrażać różnorodne zależności pomiędzy typami parametrów, np. pisząc poniższą deklarację metody uogólnionej
public <T1, T2 extends T1> void wstaw(Collection<T1> a, T2 b){/*...*/}

informujemy, że do kolekcji obiektów pewnej klasy wolno wstawiać jedynie obiekty jej podklas.

  • Java pozwala na stosowanie dżokerów (?) w miejscu parametrów typów uogólnionych, wówczas gdy nie chcemy podawać ich konkretnych wartości. Dwie poniższe deklaracje metody ileElementów są z grubsza[9] sobie równoważne:
public int IleElementów(Collection<?> c){/*...*/}
  public <T> int IleElementów(Collection<T> c){/*...*/}
  • Stosując dżokery można nie tylko zadawać górne ograniczenie na parametr typu uogólnionego (co dotąd robiliśmy), lecz także dolne, na przykład:
public <T> void wstaw(Collection<? super T> kol, T elt);
  • Można też (ze względu na zachowanie zgodności z istniejącymi bibliotekami) stosować typy uogólnione jako typy surowe (ang. raw types), czyli pozbawione parametrów. Standard języka wyraźnie podkreśla, że ta możliwość jest jedynie tymczasowa.

Problemy związane z typami uogólnionymi w Javie

Realizacja typów uogólnionych w Javie nie jest idealna, ze względu na konieczność zachowania zgodności z istniejącymi już maszynami wirtualnymi. Twórcy rozszerzenia Javy o typy uogólnione nie mogli korzystać ze wsparcia maszyny wirtualnej dla tych typów, zastosowali więc rozwiązanie oparte na usuwaniu typów (ang. type erasure).

Pomysł z grubsza można by opisać tak: stosując rzutowania i zmienne typu Object można było w starszych wersjach Javy osiągnąć elastyczność oferowaną przez typy uogólnione kosztem bezpieczeństwa (rzutowania nie są sprawdzane przez kompilator). Zastosujmy więc dodatkową notację, pozwalającą kompilatorowi sprawdzić bezpieczeństwo operacji w programie, a potem usuńmy te wszystkie dodatkowe informacje i kompilujmy program po staremu.

Pomysł dobry, choć ma pewną wadę: podczas wykonywania programu nie ma w nim żadnej informacji o typach uogólnionych. Zatem wszystkie mechanizmy, które dotyczą czasu wykonywania (np. refleksja) nie działają dobrze z typami uogólnionymi. Poniżej zamieszczamy skrótowy przegląd problemów związanych z przyjętym w Javie rozwiązaniem.

  • Typy proste nie mogą być parametrami typów uogólnionych. Wynika to stąd, że nie dziedziczą one po typie Object i są inaczej reprezentowane niż typy referencyjne. Dzięki rozbudowanemu w wersji 1.5 Javy mechanizmowi konwertowania typów prostych do odpowiadających im typów referencyjnych (np. int do Integer) i z powrotem (ang. autoboxing), nie jest to aż tak kłopotliwe, jak by się wydawało na pierwszy rzut oka.
  • Nie można stosować operatora instanceof do typów uogólnionych z podanymi argumentami (argumenty można pominąć bądź podać zamiast nich dżokery). Wynika to stąd, że instanceof jest wyliczany (głównie) podczas wykonywania programu, czyli wtedy, gdy już nie ma pełnej informacji o typach.
  • Nie można tworzyć obiektów typu będącego parametrem typu uogólnionego. Wynika to stąd, że ani mechanizm typów uogólnionych i ich ograniczeń ani mechanizm dziedziczenia nie nakładają żadnych ograniczeń na konstruktory. Innymi słowy, nie można wyspecyfikować, że typ będący parametrem typu uogólnionego ma konstruktor o pożądanej przez nas sygnaturze. Zatem kompilator nie ma możliwości zweryfikowania, czy tworząc obiekt podano właściwe parametry konstruktora.
  • Rzutowania do typów uogólnionych generują ostrzeżenia kompilatora. Ten problem występuje w zamieszczonym wcześniej przykładzie metody equals. Znów problem bierze się z braku wystarczających informacji podczas wykonywania programu. Poprawność rzutowania jest sprawdzana podczas wykonywania programu, ale wtedy jedyne co można sprawdzić, to zgodność surowych typów. Kompilator generuje ostrzeżenie, ale podczas wykonywania programu, nawet jeśli zostanie wykonane błędne rzutowanie, nie zostanie zgłoszony wyjątek. Oto fragment programu ilustrujący ten problem:
Para<String, String> ps = new Para<String, String>("ala", "ola");
Para<Integer, Integer> pi = new Para<Integer, Integer>(7, 13);
Para p = ps;  // zastosowanie surowego typu
pi = (Para<Integer, Integer>) p;   // Ostrzeżenie kompilatora: unchecked cast
System.out.println("pi = " + pi);   // pi jest parą napisów! pi = Para(ala, ola)
  • Obiekty typów uogólnionych nie mogą być wyjątkami. Dzieje się tak dlatego, że obsługa wyjątków wykonuje się podczas wykonywania programu, a dopasowywanie wyjątku do klauzuli catch odbywa się na podstawie typu wyjątku. W Javie nie można nawet zadeklarować uogólnionej klasy dziedziczącej po klasie Throwable ani po jej podklasach.

Podsumowanie

Nie ma wątpliwości, że współczesne języki obiektowe wymagają mechanizmów do definiowania w bezpieczny ze względu na typy sposób operacji na typach uogólnionych. Realizacja tego mechanizmu w Javie zaspokaja większość potrzeb programistów, choć mogłaby być lepsza, gdyby ten mechanizm został wbudowany w ten język od jego pierwszej wersji.

Samo zagadnienie definiowania typów uogólnionych (czy polimorficznych) jest niezwykle interesujące, stało się tematem wielu prac badawczych i pełne jego omówienie wykracza poza ramy niniejszego wykładu.

Warto na koniec krótko porównać podejście do typów uogólnionych w Javie (czy C#) z podejściem przyjętym w C++. Bez wątpienia rozwiązanie zastosowane w C++ jest znacznie bardziej elastyczne i pozwala za pomocą typów uogólnionych w bezpieczny (bo weryfikowany przez kompilator) sposób robić wszystko to, co jest dozwolone dla zastosowanych argumentów szablonu. Z drugiej jednak strony należy też zwrócić uwagę, że o ile w Javie i C# sprawdzanie poprawności typu uogólnionego odbywa się podczas kompilowania tego typu i jest tu jawnie podawana specyfikacja oczekiwań względem parametrów (na przykład poprzez zadanie ograniczeń górnych), o tyle w C++ praktycznie całe sprawdzanie odbywa się dopiero podczas kompilacji użycia szablonu (czyli zwykle długo po stworzeniu samego szablonu) i nie ma tam możliwości innego niż poprzez komentarz zapisania wymagań dotyczących parametrów.

Przypisy

  1. Ze względu na specyfikę Javy musimy ograniczyć się tu tylko do typów referencyjnych, także rozwiązanie przyjęte w Javie, które dalej omawiamy, ma tę niedogodność.
  2. Jeśli nie jesteś przekonany dlaczego kompilator tego nie jest w stanie przewidzieć, wyobraź sobie następującą sytuację: przed wstawieniem na stos pytamy użytkownika, czy wstawić osobę czy np. obiekt klasy Nietoperz i wstawiamy obiekt wybrany przez użytkownika. Teraz przy pobieraniu ze stosu nawet my (twórcy programu) nie wiemy, czy dostaniemy osobę czy nietoperza.
  3. A jeszcze inne podejście do tego problemu można znaleźć np. w realizacji funkcji uogólnionych w językach funkcyjnych.
  4. To ograniczenie można obejść przekazując jako parametr parę i podmieniając jej zawartość, ale to nie jest czytelne rozwiązanie.
  5. Nie musi to być bezpośrednia podklasa, a nawet może to być sam typ Object.
  6. W rzeczywistości sytuacja jest bardziej skomplikowana, kompilator Javy potrafi obsłużyć także takie metody, gdzie typ wyniku nie zależy od typu parametrów, jak na przykład tu
    <T> T dziwna() {
       return null;
     }
    Można podać ciekawe zastosowania tego mechanizmu. Składnia Javy pozwala także na podanie typu wyniku funkcji uogólnionej przy jej wywołaniu. Są to jednak tematy wykraczające poza ramy niniejszego wykładu.
  7. Oczywiście znacznie bardziej naturalną nazwą dla operacji porównywania byłoby "<", niestety składnia Javy – w przeciwieństwie do C++, C# czy Smalltalka – nie pozwala na przeciążanie nazw operatorów.
  8. Typy utożsamiamy tu ze zbiorami wartości należących do tych typów, zatem część wspólna kilku typów, to wartości należące do każdego z typów.
  9. Nie analizujemy tu zalet i wad obu tych notacji.