PO Typy uogólnione: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Janusz (dyskusja | edycje)
Nie podano opisu zmian
Janusz (dyskusja | edycje)
Nie podano opisu zmian
Linia 318: Linia 318:
Następnie żmudnie sprawdzamy, czy oba składniki obu par są sobie równe. To sprawdzenie jest utrudnione przez fakt, że elementy pary mogą być puste (null), a wtedy nie można wywołać od nich metody ''equals''.
Następnie żmudnie sprawdzamy, czy oba składniki obu par są sobie równe. To sprawdzenie jest utrudnione przez fakt, że elementy pary mogą być puste (null), a wtedy nie można wywołać od nich metody ''equals''.


Ja zobaczymy w dalszej części wykładu samo przedefiniowanie metody ''equals'' zwykle nie wystarcza, należy również przedefiniowac 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();
  }





Wersja z 19:21, 6 sie 2006

<<< Powrót

(moduł w trakcie pisania)

(wymagania wstępne:

reguła podstawiania (dziedziczenie)
typy referencyjne
rzutowanie typów 
autoboxing
akcesor
przeciążanie nazw
instanceof,getClass

)

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 obiektów przechowywanych lub przetwarzanych przez klasy.

Na pierwszy rzut oka może się wydawać, że sam fakt dysponowania mechanizmem dziedziczenia i regułą podstawiania pozwala na abstrahowanie do 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 zedeklarowanych atrybutach wartości dowolnego typu referencyjnego <ref name="PO_slaba_Java"> 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ść.</ref> 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 pamietał 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.
   // Ponadto deklaracja final daje tu zwiększone bezpieczeństwo.
   
   /** Creates a new instance of EltStosu */
   public EltStosu(Object elt, EltStosu nast) {
       this.elt = elt;
       this.nast = nast;
   }
}
class PustyStos extends Exception{}

Na razie wszystko wyglada dobrze, ale spróbujmy skorzystać z naszej impelmentacji 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 ostatni wiersz się nie skompiluje. 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.<ref name="PO_kompilator_nie_wie">Jeśli nie jesteś przekonany dlaczego kompilator tego nie jest w stanie przewidzieć wyobraź sobie następującę: 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.</ref>

Jak można temu zaradzić? Jedynym rozwiązaniem dostępnym w wczesniejszych 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 opisujących 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.<ref name="PO_typy_funkcyjne"> A jeszcze inne podejście do tego problemu można znaleźć np. w realizacji funkcji uogólnionych w językach funkcyjnych.</ref> 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 (np. umożliwia makroprogramowanie). 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 o ideę 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, jest ma szereg irytujących ograniczeń, których przyczyny nie można zrozumieć bez odwołania się do zastosowanego mechanizmu wymazywania typów. Wreszcie 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 miec dwa atrybuty przechowujące elementy pary, metody pozwalajace co najmniej na odczytywanie elementów pary (u nas będzie także możliwe podmienianie elementów pary) i konstruktor pozwalający an 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 jako wartości typu Object). Dlatego zdefiniujemy parę jako typ uogólniony:

public class Para<T1, T2> {
   T1 pierwszy;
   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 wylicza się po nazwie klasy, oddzielając je od siebie przecinkami. Cała lista parametrów jest otoczona nawiasami kątowymi (na wzór 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. To że 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 Para<T> {

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

Para< Osoba, String >

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

Para< Osoba, Osoba >

Zanim dokładniej przyjrzymy się dalszym 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 podac typ Integer). W naszych przykładach często będziemy korzystać z autoamtycznego konwertowania typów prostych na referencyjne i w drugą stronę (ang. autoboxing), dzięki czemu to ograniczenie nie będzie specjalnie kłopotliwe.
  • Ze wzgledu na semantykę Javy, obiekty klasy Para przechowują jedynie referencje do oryginalnych obiektów (a nie ich kopii). Zatem obiekt umieszczany w parze nie jest kopiowany.
  • W Javie dla prostych akcesorów zwykle stosuje się konwencję nazewniczą plegają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 przez nas pojęcie pary jest użyteczne w wielu sytuacjach, na przykład wtedy, gdy tworzymy metode dającą jako wynik dwie wartości - ponieważ metoda może dać jako wynik tylko jedną wartość, nasze wyniki musimy przed przekazaniem na zewnątrz zapakować jako parę.

Pary z różnymi typami paarmetró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ę

tak więc jak widać typem nie jest sam szablon Para, lecz dopiero jego ukonkretnienie poprzez podanie parametrów.

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 opakowaniu 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 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łędna 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 widac 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 (czyli, innymi słowy, dowolnym typem referencyjnym). (Zobaczymy w dalszej części tego wykładu bardzo ciekawy mechanizm pozwalający na doprecyzowanie jakich typów parametrów oczekuje klasa uogólniona.) Oznacza to, że możemy wykonywać na obiektach 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 uruchamiją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 klasy i adres obiektu w maszynie wirtualnej. Dzieje się tak dlatego, że nie przedefiniowaliśmy metody toString odziedziczonej po klasie Object. Możemy to łatwo 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.

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

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

public boolean equals(Object o) {
  if (!(o instanceof Para)) return false;
  Para p = (Para) o;
  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 arguentu 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ę, musimy 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.

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

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

if (o == this) return true;

u nas 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 (znów bez parametrów). 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 równe. To sprawdzenie jest utrudnione przez fakt, że elementy pary mogą być puste (null), a wtedy nie można wywołać od nich metody equals.

Ja zobaczymy w dalszej części wykładu samo przedefiniowanie metody equals zwykle nie wystarcza, należy również przedefiniowac 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();
 }


Metody parametryzowane typami

Ograniczenia na parametry typów uogólnionych

Ograniczenia typów uogólnionych w Javie

<references/>