PO Typy uogólnione
(moduł w trakcie pisania)
(wymagania wstępne:
reguła podstawiania (dziedziczenie) typy referencyjne rzutowanie typów autoboxing akcesor przeciążanie nazw
)
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++).
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ę.
Metody parametryzowane typami
Ograniczenia typów uogólnionych w Javie
<references/>