To jest jeden z artykułów w ramach darmowego kursu programowania w Javie. Proszę zapoznaj się z pozostałymi częściami, mogą one być pomocne w zrozumieniu materiału z tego artykułu.

Czym są typy generyczne

W uproszczeniu można powiedzieć, że typy generyczne są “szablonami”. Dzięki typom generycznym możemy uniknąć niepotrzebnego rzutowania. Ponadto przy ich pomocy kompilator jest w stanie sprawdzić poprawność typów na etapie kompilacji, oznacza to więcej błędów wykrytych w jej trakcie1.

Poza tym dzięki typom generycznym możemy konstruować bardziej złożone klasy, które możemy używać w wielu kontekstach, łatwiej będzie Ci to zrozumieć na przykładzie.

Porównanie typów generycznych i standardowych

Znasz już zwykłe klasy i interfejsy, zostały one omówione we wcześniejszych artykułach. Klasy mają swoje atrybuty, których typy znasz pisząc program.

public class Apple {
}

public class AppleBox {
    private Apple apple;

    public AppleBox(Apple apple) {
        this.apple = apple;
    }

    public Apple getApple() {
        return apple;
    }
}

W przykładzie powyżej klasa AppleBox “wie” jakiego typu obiekt może przechowywać, jest to obiekt typu Apple. A co jeśli chcielibyśmy zrobić analogiczną klasę dla owoców innego rodzaju? Oczywiście możemy stworzyć podobne pudełko jak w przykładzie poniżej:

public class Orange {
}

public class OrangeBox {
    private Orange orange;

    public OrangeBox(Orange orange) {
        this.orange = orange;
    }

    public Orange getOrange() {
        return orange;
    }
}

Oba przykłady są jak najbardziej poprawne jednak występuje w nich duplikacja. Te same elementy, konstrukcje powielane są wielokrotnie. Duplikacja w kodzie generalnie jest złą praktyką, należy jej unikać. Możemy zatem stworzyć kolejną klasę:

public class FruitBox {
    private Object fruit;

    public FruitBox(Object fruit) {
        this.fruit = fruit;
    }

    public Object getFruit() {
        return fruit;
    }
}

public class Main {
    public static void main(String[] args) {
        FruitBox fruitBox = new FruitBox(new Orange());
        Orange fruit1 = (Orange) fruitBox.getFruit();
    }
}

Z racji tego, że atrybut fruit jest typu Object możemy do niego przypisać zarówno instancję klasy Orange jak i Apple. Pojawia się jednak pewien problem. Mianowicie jeśli chcemy pobrać atrybut fruit i przypisać go do zmiennej odpowiedniego typu musimy rzutować. Tego typu konstrukcja może powodować błędy podczas wykonania programu i warto jej unikać. Z pomocą przychodzą typy generyczne. Proszę spójrz na przykład poniżej.

public class BoxOnSteroids<T> {
    public T fruit;

    public BoxOnSteroids(T fruit) {
        this.fruit = fruit;
    }

    public T getFruit() {
        return fruit;
    }
}

public class Main {
    public static void main(String[] args) {
        BoxOnSteroids<Apple> appleBox = new BoxOnSteroids<Apple>(new Apple());
        BoxOnSteroids<Orange> orangeBox = new BoxOnSteroids<Orange>(new Orange());

        Orange fruit = orangeBox.getFruit();
    }
}

public class BoxOnSteroids<T> to nic innego jak pierwsza linijka definicji klasy. Nowa tutaj jest konstrukcja z nawiasami. Oznacza ona właśnie typ generyczny, który możemy parametryzować innym typem. Typ ten dostaje tymczasową nazwę, w tym przypadku T, której używamy dalej w ciele klasy.

W trakcie tworzenia instancji obiektu BoxOnSteroids podajemy informację o typie, który chcielibyśmy wstawić w miejsce T. W naszym przykładzie są to klasy Apple lub Orange. Dzięki takiej konstrukcji kompilator dokładnie wie jakiego typu obiekt zostanie zwrócony przez metodę getFruit w związku z tym rzutowanie nie jest konieczne2.

Pobierz opracowania zadań z rozmów kwalifikacyjnych

Przygotowałem rozwiązania kilku zadań z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 1000 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.

Klasy generyczne i parametryzowanie

Wspomniałem wcześniej, że klasy generyczne są szablonami dla nowych klas. W uproszczeniu można powiedzieć, że parametry klasy generycznej są elementami uzupełnianymi w szablonie.

Spróbuję opisać to bardziej obrazowo. Szablon to foremka do wycinania ciastek. Tej foremki możemy użyć do wycięcia wielu ciastek. Każde z nich możemy ozdobić w inny sposób. To w jaki sposób ciastko jest ozdobione jest parametrem tego szablonu:

public class CookieCutter<T> {
    private T glaze;
}

Definicja klasy generycznej

Klasę generyczną definiujemy w następujący sposób

class Name<T1, T2, ..., Tn> {
    /* body */
}

Zauważ, że w nawiasach <> możemy umieścić więcej niż jeden parametr. Chociaż zgodnie ze specyfikacją języka Java możesz użyć dowolnej nazwy która nadaje się na nazwę zmiennej istnieje konwencja nazewnicza sugerująca nazwy parametrów. Zwyczajowo do tego celu używa się wielkich liter T, K, U, V, E.

W miejsce parametrów możemy wstawić dowolny obiekt, nie może to jednak być typ prosty. Innymi słowy Integer jest w porządku, int powoduje błąd.

Instancja klasy generycznej

Skoro już wiemy jak zdefiniować klasę generyczną przydałoby się stworzyć jej instancję, żeby w końcu jej użyć :). Linijka poniżej tworzy instancję klasy generycznej BoxOnSteroids, która parametryzowana jest typem Orange.

BoxOnSteroids<Orange> orangeBox = new BoxOnSteroids<Orange>(new Orange());

Zauważ, że i tutaj występuje pewna duplikacja. Zarówno przy określaniu typu zmiennej jak i przy wywołaniu konstruktora powtarzamy klasę Orange. Nie jest to konieczne. Jeśli kompilator jest w stanie “wywnioskować” jaki typ powinien być użyty możemy go pominąć przy konstruktorze.

BoxOnSteroids<Orange> orangeBox = new BoxOnSteroids<>(new Orange());

Zagnieżdżone typy generyczne

Możesz też tworzyć instancje typów generycznych, które są bardziej skomplikowane. Przykład poniżej pokazuje klasę Pair, która parametryzowana jest dwoma innymi typami.

public class Pair<T, S> {
    private T first;
    private S second;

    public Pair(T first, S second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public S getSecond() {
        return second;
    }
}

Java pozwala na to aby tworząc instancję typu generycznego parametryzować go innym typem generycznym. Brzmi to skomplikowanie, mam nadzieję, że przykład pomoże Ci to zrozumieć:

Pair<BoxOnSteroids<Orange>, BoxOnSteroids<Apple>> pairOfBoxes =
        new Pair<>(
                new BoxOnSteroids<>(new Orange()),
                new BoxOnSteroids<>(new Apple())
        );

W przykładzie tym tworzony jest obiekt klasy Pair, który parametryzowany jest klasami BoxOnSteroids<Orange> i BoxOnSteroids<Apple>.

Typy generyczne nie rozwiązują wszystkich problemów

Typy generyczne zostały wprowadzone w wersji Javy 1.5. Nie były dostępne od początku jej istnienia. Zatem istnieją sytuacje, w których nawet ich stosowanie może prowadzić do wystąpienia błędów w trakcie wykonywania programu. Proszę spójrz na przykład poniżej:

BoxOnSteroids boxWithoutType = new BoxOnSteroids(new Apple());
BoxOnSteroids<Apple> boxWithApple = boxWithoutType;
BoxOnSteroids<Orange> boxWithOrange = boxWithoutType;
Apple apple = boxWithApple.getFruit();
Orange orange = boxWithOrange.getFruit(); // ClassCastException

W przykładzie tym tworzona jest instancja klasy generycznej BoxOnSteroids bez wyspecyfikowania klasy, która znajduje się “w środku”. Następnie tę instancję przypisujemy do zmiennych typu BoxOnSteroids<Apple> i BoxOnSteroids<Orange>. O ile w pierwszym przypadku typ owocu trzymanego w środku się zgadza to ostatnia linia nie jest poprawna – generuje błąd typu ClassCastException. Obiekt typu Apple jest rzutowany przez kompilator do typu Orange3.

Słowo kluczowe extends

To słowo kluczowe ma zastosowanie także w przypadku typów generycznych. Dzięki niemu możemy ograniczyć zestaw klas którymi możemy parametryzować nasz typ generyczny. Omówmy to na przykładzie:

public interface Figure {
    String getName();
}

public class Circle implements Figure {
    public String getName() {
        return "circle";
    }
}

public class BoxForFigures<T extends Figure> {
    private T element;

    public BoxForFigures(T element) {
        this.element = element;
    }

    public T getElement() {
        return element;
    }

    public String getElementName() {
        return element.getName();
    }
}

BoxForFigures<Circle> circleBox = new BoxForFigures<>(new Circle());
BoxForFigures<Apple> appleBox; // complilation error

Jak widzisz przykład definiuje prosty interfejs Figure i klasę Circle, która go implementuje. Następnie definiujemy klasę BoxForFigures, która jest generyczna i może być parametryzowana przez typy dziedziczące/implementujące Figure.

Dzięki takiemu zapisowi kompilator pozwoli nam stworzyć instancję BoxForFigures<Circle> circleBox jednak zacznie się buntować przy BoxForFigures<Apple> appleBox (Apple nie implementuje interfejsu Figure).

Kolejną zaletą używania tego słowa kluczowego jest możliwość wywoływania metod na obiekcie typu parametryzowanego. W przykładzie powyżej wiemy że T jest czymś co implementuje Figure więc musi mieć metody dostępne w tym interfejsie. Właśnie z tego powodu w metodzie getElementName możemy wywołać metodę getName z tego interfejsu.

Dziedziczenie typów generycznych

Tutaj należy się dodatkowe zdanie wyjaśnienia poparte prostym przykładem. Proszę spójrz na początek na klasy Rectangle i Square poniżej:

public class Rectangle implements Figure {
    public String getName() {
        return "rectangle";
    }
}

public class Square extends Rectangle {
    public String getName() {
        return "square";
    }
}

Jak wiesz każda klasa w języku Java dziedziczy po klasie Object (bezpośrednio, bądź pośrednio). W naszym przykładzie bezpośrednio po klasie Object dziedziczą klasy Rectangle, BoxForFigures<Rectangle> i BoxForFigures<Square>4. Natomiast Square dziedziczy po Rectangle.

Dziedziczenie typów generycznych.

Ma to swoje konsekwencje widoczne w przykładzie poniżej:

Rectangle rectangle = new Square();
BoxForFigures<Rectangle> rectangleBox = new BoxForFigures<Square>(new Square()); // compilation error

Dzięki takiemu schematowi dziedziczenia do referencji typu Rectangle możemy przypisać obiekt Square. Jednak próba przypisania obiektu BoxForFigures<Square> do referencji BoxForFigures<Rectangle> powoduje błąd kompilacji.

Jednak podobnie jak w przypadku zwykłych klas, klasy generyczne także mogą dziedziczyć po innych klasach. W szczególności mogą także dziedziczyć po klasach generycznych.

class StandardBox<T> {
    public T object;

    public StandardBox(T object) {
        this.object = object;
    }
}

public class FancyBox<T> extends StandardBox<T> {
    public FancyBox(T object) {
        super(object);
    }
    public void saySomethingFancy() {
        System.out.println("our " + object + " is cool!");
    }
}

public class Main {
    public static void main(String[] args) {
        FancyBox<String> box = new FancyBox<>("something");
        box.saySomethingFancy();
    }
}

W naszym przykładzie klasa FancyBox dziedziczy po StandardBox, widoczne jest to na diagramie poniżej.

Dziedziczenie typów generycznych

Metody z generycznymi argumentami – wildcard

FancyBox<?>

Pisząc metody, które jako argumenty przyjmują typy generyczne nie zawsze chcesz dokładnie specyfikować typ. W takim wypadku z pomocą przychodzi znak ?, który może akceptować różne typy.

private static void method1(FancyBox<?> box) {
    Object object = box.object;
    System.out.println(object);
}

private static void plainWildcard() {
    method1(new FancyBox<>(new Object()));
    method1(new FancyBox<>(new Square()));
    method1(new FancyBox<>(new Apple()));
}

Jak widzisz w przykładzie powyżej metoda method1 może akceptować różne klasę FancyBox parametryzowaną dowolnym typem.

Przypisanie wartości

Ma to jednak swoje konsekwencje. Klasa, która parametryzowana jest ? nie przyjmie żadnych obiektów poza null. Przykład poniżej zakończy się błędem kompilacji:

FancyBox<?> box = new FancyBox<>("object");
box.object = null;
// box.object = "xxx"; // compilation error

FancyBox<? extends Figure> “upper bound”

Znak ? może występować także w połączeniu ze słówkiem kluczowym extends. W takim przypadku możesz ograniczyć akceptowane typy “z góry”. W przykładzie poniżej metoda akceptuje jedynie instancje FancyBox, które parametryzowane są klasami dziedziczącymi po Figure.

private static void method2(FancyBox<? extends Figure> box) {
    Figure figure = box.object;
    System.out.println(figure);
}

private static void method3(FancyBox<Figure> box) {
    Figure figure = box.object;
    System.out.println(figure);
}

private static void upperBoundWildcard() {
    FancyBox<Figure> fancyFigureBox = new FancyBox<>(new Circle());
    FancyBox<Circle> fancyCircleBox = new FancyBox<>(new Circle());
    FancyBox<Square> fancySquareBox = new FancyBox<>(new Square());
    method2(fancyFigureBox);
    method2(fancyCircleBox);
    method2(fancySquareBox);
    // method3(fancySquareBox); // compilation error
}

W przykładzie tym możesz także zobaczyć, że typ FancyBox<Figure> jest bardziej restrykcyjny niż FancyBox<? extends Figure>. W konsekwencji próba wywołania method3 z argumentem innego typu niż FancyBox<Figure> skutkuje błędem kompilacji.

Przypisanie wartości

Podobnie jak w przypadku ? tutaj także są ograniczenia dotyczące przypisywania wartości. Spójrz jeszcze raz na przykład:

private static void method2(FancyBox<? extends Figure> box) {
    Figure figure = box.object;
    System.out.println(figure);
    // box.object = null;
    // box.object = new Square(); // compilation error
}

Skoro wewnątrz metody parametr box może mieć zarówno typ FancyBox<Square> jak i FancyBox<Circle> (jak i wiele innych) kompilator nie może pozwolić na wstawienie tam obiektu innego niż null. Nie może tego zrobić bo nie wie jakiego typu obiekty przechowywane są w box.

FancyBox<? super Rectangle> “lower bound”

Poza ograniczeniem “z góry” możesz także ograniczyć akceptowalne typy “z dołu”. W przykładzie poniżej metoda akceptuje wyłącznie argumenty typu FancyBox<Object>, FancyBox<Figure> i FancyBox<Rectangle>.

private static void method4(FancyBox<? super Rectangle> box) {
    box.object = new Square();
    // box.object = new Circle(); // compilation error
}

private static void lowerBoundWildcard() {
    FancyBox<Rectangle> fancyRectangleBox = new FancyBox<>(new Rectangle());
    FancyBox<Figure> fancyFiguraBox = new FancyBox<>(new Rectangle());
    FancyBox<Object> fancyObjectBox = new FancyBox<>(new Object());
    method4(fancyRectangleBox);
    method4(fancyFiguraBox);
    method4(fancyObjectBox);
    // FancyBox<Square> fancySquareBox = new FancyBox<>(new Square());
    // method4(fancySquareBox); // compilation error
}

Zauważ, ze w niektórych miejscach nie ma potrzeby podawania typu generycznego. Samo <> wystarczy, kompilator jest w stanie wywnioskować jakiego typu może się tam spodziewać. Typy generyczne są skomplikowane. Jeśli aktualnie masz mętlik w głowie nie przejmuj się, przeczytaj artykuł jeszcze raz, przejrzyj przykłady. Zrozumienie przyjdzie z doświadczeniem.

Przypisanie wartości

Wyżej wspomniałem, że przy ograniczeniu z dołu kompilator wie czego może się spodziewać. W przykładzie:

private static void method4(FancyBox<? super Rectangle> box) {
    box.object = null;
    box.object = new Rectangle();
    box.object = new Square();
    // box.object = new Object(); // compilation error
}

Parametr box może być typu FancyBox<Object>, FancyBox<Square> i FancyBox<Rectangle>. Zatem w tym przypadku do pola box.object można przypisać null i każdy obiekt, który dziedziczy po Rectangle.

Typy generyczne są skomplikowane

Nie zostały one dodane do Javy od samego początku. W związku z tym, że twórcy chcieli zachować kompatybilność wstecz5 istnieje wiele kruczków, które nie są trywialne. Pominąłem w artykule np. “type erasure” czy generyczne metody, które nie są istotne na początku. Jeśli jesteś nimi zainteresowany odsyłam do materiałów dodatkowych.

Materiały dodatkowe

Wszystkie przykłady użyte w tym artykule dostępne są na githubie. Poniżej zebrałem dla Ciebie zestaw dodatkowy materiałów, jeśli chciałbyś poszerzyć swoją wiedzę na temat typów generycznych w języku Java.

Podsumowanie

Nie jest to oczywiście kompletny artykuł dotyczący typów generycznych w Javie. Pominięte zostały aspekty wymazywania typów czy bardziej szczegółowe informacje dotyczące użycia ?. Jeśli któryś fragment jest dla Ciebie nie do końca zrozumiały daj znać, postaram się rozszerzyć artykuł o dodatkowe przykłady i opisy.

Na koniec mam do Ciebie prośbę. Proszę podziel się artykułem ze swoimi znajomymi, którzy mogą być zainteresowani tematem programowania. Zależy mi na dotarciu do jak największej liczby czytelników. Jeśli nie chcesz ominąć żadnego kolejnego artykułu polub nas na Facebooku :) Do następnego razu!

  1. Mała dygresja. Każdy błąd w kodzie kosztuje. Ktoś w końcu płaci za pracę testerów, programistów, administratorów. Im wcześniej wykryjemy błąd tym tańsze jest jego naprawienie. Poprawienie programu działającego na środowisku produkcyjnym może być bardzo drogie. Wykrywanie błędów w trakcie kompilacji, chociaż może być frustrujące dla programisty jest najtańszym rozwiązaniem :) 

  2. W praktyce rzutowanie tam występuje jednak jest wykonywane automatycznie przez kompilator generujący bytecode. 

  3. Tu właśnie objawia się to automatyczne rzutowanie generowane przez kompilator 

  4. W rzeczywistości, po skompilowaniu powstanie jeden plik class z klasą BoxForFigures

  5. Twórcom zależało na tym aby programy napisane w starej wersji Javy mogły być uruchamiane na najnowszych wersjach maszyny wirtualnej. 

Pobierz opracowania zadań z rozmów kwalifikacyjnych

Przygotowałem rozwiązania kilku zadań z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 1000 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.

Zostaw komentarz