Diversity w polskim IT
Michał Karmelita
Asseco Poland S.A.
Michał KarmelitaJava Developer @ Asseco Poland S.A.

Poznaj podstawy Javy - typy generyczne

Poznaj typy generyczne, czyli elementy pozwalające pisać kod programu w Javie bez wskazywania konkretnych typów danych, na których ten kod będzie operował.
18.05.20215 min
Poznaj podstawy Javy - typy generyczne

Tematem dzisiejszego wpisu stanie się jeden z bardzo użytecznych elementów języka Java, pozwalający pisać kod programu bez wskazywania konkretnych typów danych, na których ten kod będzie operował.

Podejście takie pozwala uniknąć redundancji; kod pisany jest tylko jeden raz, a następnie wykorzystany może zostać dla różnych typów danych. Paradygmat tego rodzaju (określany jako programowanie uogólnione) implementowany jest przez wiele języków programowania. Jego implementacja w Javie polega na stosowaniu tak zwanych typów generycznych. Aby wyjaśnić istotę tego zagadnienia oraz korzyści z jego wykorzystania zacznijmy od przykładu.

Wyobraźmy sobie na początek prosty kod reprezentujący czytnik książek, który wyglądać mógłby następująco:

public class BookReader {

    private Book book;

    void add(Book book) {
        this.book = book;
    }
 
    Book read() {
        return this.book;
    }
}


Jeżeli zamiast książki chcielibyśmy przeczytać czasopismo, to konieczne byłoby stworzenie „czytnika” o praktycznie identycznej strukturze, różniącego się jedynie typem danych:

public class MagazineReader {
 
    private Magazine magazine;
 
    void add(Magazine magazine) {
        this.magazine = magazine;
    }
 
    Magazine read() {
        return this.magazine;
    }
}


Powyższe dwa przykłady są oczywiście bardzo uproszczone, mimo tego każda klasa reprezentująca obiekt, który można odczytać, wymaga stworzenia własnej klasy czytnika. Abstrahuję tutaj od możliwości korzystania np. z mechanizmu dziedziczenia lub z interfejsów, ponieważ w realnych projektach nie zawsze będzie to możliwe (wyobraźmy sobie na przykład, że potrzebujemy jednej klasy, która operować będzie zarówno na typach danych dostarczanych przez Javę lub zewnętrzne biblioteki, jak i na typach utworzonych przez nas – stworzenie sensownej hierarchii między nimi niekoniecznie będzie możliwe).

Wracając do opisanego wcześniej problemu, został on rozwiązany w Javie za pomocą tzw. typów generycznych. Dzięki nim klasy z dwóch powyższych przykładów zastąpione mogą zostać jedną klasą, reprezentującą uniwersalny czytnik, która wyglądałaby następująco:

public class EReader<T> {
    private T t;
 
    void add(T t) {
        this.t = t;
    }
 
    T read() {
        return this.t;
    }
}


Jak widzimy, aby utworzyć generyczną wersję czytnika, do deklaracji klasy dodajemy literę T w ostrych nawiasach. Litera ta używana jest w dalszej części kodu zamiast wskazywania określonego typu danych. W powyższym przykładzie reprezentować może ona dowolny typ obiektowy.

Zastosowanie konkretnie litery „T” nie jest koniecznością narzuconą przez Javę, wynika jednak z pewnej konwencji, która jest powszechnie stosowana i zrozumiała przez programistów tego języka. Jej wykorzystanie jest zatem bardzo rekomendowane. Zgodnie z tą konwencją do tworzenia typów generycznych wykorzystuję się litery:

  • T (ang. Type), kiedy litera reprezentować ma typ,
  • E (ang. Element), kiedy chodzi o element (np. jakieś kolekcji),
  • K, V (ang. Key, Value), kiedy korzystać będziemy z kluczy i wartości,
  • S, U – kiedy wykorzystać chcemy równocześnie więcej niż jeden typ danych.


Dzięki zastosowaniu generycznej wersji czytnika, zamiast tworzyć obiekty na podstawie odrębnych klas, możemy wielokrotnie wykorzystać ten sam kod:

EReader<Book> bookReader = new EReader<>();
EReader<Magazine> magazineEReader = new EReader<>();


Warto zwrócić uwagę, że tworząc generyczny obiekt, musimy wskazać tylko raz, jakim typem będzie on parametryzowany (fragment: Ereader<Book>), natomiast w kolejny nawias ostry może pozostać już pusty (tzw. diamond operator). Taka konstrukcja dozwolona jest od Javy 7. We wcześniejszych wersjach tego języka konieczne byłoby zapisanie:

EReader<Book> bookReader = new EReader<Book>();
EReader<Magazine> magazineEReader = new EReader<Magazine>();


A zatem dwukrotne wskazanie, jakim typem parametryzujemy obiekt tworzony na podstawie klasy generycznej.

Stworzony przez nas przykład klasy generycznej pozwala na sparametryzowanie jej dowolnym typem obiektowym. W praktyce jednak, rzadko tworzyć będziemy kod, co do którego chcielibyśmy dopuścić możliwość tak szerokiego wykorzystania. Na szczęście Java pozwala nam na zawężenie zakresu klas, które stanowić będą prawidłowe typy dla naszej klasy generycznej. Wyobraźmy sobie na przykład, że chcemy, by klasa EReader parametryzowana mogła być tylko przez książki i magazyny. W tym celu stworzyć możemy abstrakcyjną klasę – nazwijmy ją WrittenForm – i następnie ustanowić relację dziedziczenia między tą abstrakcyjną klasą a stworzonymi wcześniej klasami Book oraz Magazine (tak, że dwie ostatnie dziedziczyć będą po WrittenForm). Następnie zmienić możemy deklarację klasy EReader w następujący sposób:

public class EReader<T extends WrittenForm> 


Dzięki takiemu zabiegowi, podczas tworzenia obiektów EReader jako parametr wstawione mogą zostać jedynie obiekty klasy Book lub Magazine oraz ewentualnie innych klas dziedziczących po WrittenForm. Co więcej, warunek podany przy definiowaniu klasy generycznej można rozszerzyć. Jeżeli np. zależałoby nam, żeby EReader parametryzowany mógł być wyłączenie przy pomocy klas, które dziedziczą po WrittenForm, a równocześnie rozszerzają interfejs Readable, to dopuszczalne będzie zapisanie:

public class EReader<T extends WrittenForm & Readable>


Kolejnym zagadnieniem związanym z typami generycznymi jest możliwość korzystania z tzw. wildcards, które w Javie oznaczane są znakiem zapytania. Wyobraźmy sobie prostą klasę Book zawierającą metodę read():

public class Book  {
    void read() {
    }
}


Po klasie tej niech dziedziczy klasa Novel:

public class Novel extends Book{
}


W celu „przeczytania” listy książek stworzyć moglibyśmy metodę wyglądającą następująco:

void readAll(List<Book> bookList) {
    bookList.forEach(Book::read);
}


Na pierwszy rzut oka wydawać by się mogło, że metoda ta wykorzystana może zostać także do „przeczytania” listy powieści (Novel), jednak w rzeczywistości taka lista nie mogłaby zostać przekazana jako jej argument. Dzieje się tak dlatego, że chociaż obiekt Book jest typem nadrzędnym dla obiektu Novel, to już List<Book>nie jest typem nadrzędnym wobec List<Novel>Innymi słowy – to, że między danymi typami zachodzi relacja dziedziczenia, nie oznacza, że taka sama relacja zachodzi między kolekcjami tych obiektów.

Powyższy problem rozwiązać można właśnie za pomocą tzw. wildcards, tj. definiując problematyczną metodę w następujący sposób:

void readAll(List<? extends Book> bookList) {
    bookList.forEach(Book::read);
}


Dzięki takiemu zapisowi jako argument metody readAllprzekazane będą mogły zostać również listy wszystkich obiektów dziedziczących po Book.

Java pozwala również na przekazanie jako argumentu listy obiektów klas nadrzędnych względem Book. W takim przypadku słowo extends zastąpić należy słowem super.

Podsumowując, typy generyczne są kolejnym przydatnym elementem Javy, pozwalającym pisać bardziej zwięzły kod. Warto rozważyć ich wykorzystanie zawsze, gdy stworzyć musimy kod o analogicznej strukturze dla więcej niż jednego typu danych.

<p>Loading...</p>