Jak z jednej klasy zrobić wiele typów?

Moim mikołajkowym prezentem dla wszystkich (zarówno tych grzecznych jak i niegrzecznych) będzie kolejny artykuł o Javie. Wydaje mi się, że cykl artykułów o średnio-zaawansowanej Javie najszybciej się rozwija. Ale w najbliższym czasie wrócę do innych tematów.

Dziś taki trochę przewrotny tytuł. Ale zanim przejdę do tematu interfejsów funkcyjnych i kolekcji Javowych muszę opowiedzieć kilka słów na temat typów generycznych. Jest to funkcjonalność podobna do szablonów z C++ – pozwala otrzymać wiele typów z jednej klasy. Jednak w przypadku Javy nie jest to aż tak rozbudowane jak w C++. Ale nie będę się skupiał na porównywaniu tych dwóch języków, bez obaw.

Stos

Stos to cała filozofia przechowywania elementów. Doskonałym przykładem jest gra w makao – czy też dowolna inna gra karciana. Gdy masz kupkę (stosik) kart, to najczęściej masz dostęp do tej karty, która leży na samym szczycie stosu. Żeby zobaczyć, jaka jest druga karta, najpierw musisz zdjąć pierwszą, żeby można było wziąć tą drugą. Na pierwszy rzut oka nie wiesz, ile kart jest w stosiku, ale bez wątpienia jesteś w stanie stwierdzić, czy stos jest pusty, czy nie. Stos w informatyce różni się od tego w kartach tym, że karty możemy odkładać tylko na szczyt stosu – nie możemy dokładać kart na spód stosu. Podsumowując to, co napisałem możemy zdefiniować 3 operacje, które na stosie możemy wykonać:

  • zdejmij element ze szczytu stosu
  • odłóż element na szczyt stosu
  • sprawdź czy stos jest pusty

Czasami jeszcze dodaje się czwartą operację:

  • podejrzyj jaki element jest na szczycie stosu

Informatycy nazywają stos kolejką typu LIFOLast-In-First-Out. Co też opisuje filozofię przechowywania danych. Jako pierwsze ze stosu zostaną zdjęte elementy, które trafiły tam jako ostatnie. Cytat z Biblii aż sam się nasuwa.

Oczywiście, teraz mógłbym zacząć opisywać możliwe zastosowania stosu. Ale daruję Ci tę dygresję, która potencjalnie by była zdecydowanie za długa. Jako appetizer powiem, że stos możemy użyć do obliczenia wartości wyrażenia arytmetycznego zapisanego w Odwrotnej Notacji Polskiej. Jest to zagadnienie, które pojawia się już na pierwszym roku studiowania informatyki.

Podsumowując powyższe punkty, możemy stworzyć taki interfejs:

public interface IntStack {
    /**
     * Pushes element onto stack's top
     * @param element
     */
    void push(int element);

    /**
     * Gets and removes element from stack's top
     * @return value from the stack's top
     */
    int pop();

    /**
     * Only returns element from stack's top
     * @return value from the stack's top
     */
    int peek();

    /**
     * Checks if stack is empty
     * @return true if stack is empty, false otherwise
     */
    boolean isEmpty();
}

Sama struktura stosu jest bardzo prosta. W podstawowej wersji, możemy założyć, że stos ma ustalony rozmiar – dzięki czemu najprostszy stos możemy zaimplementować wykorzystując tablice. Rozważmy taką implementację dla liczb całkowitych.

public class SimpleIntStack implements IntStack {
    int array[];
    int size;
    int top;

    public SimpleIntStack(int size) {
        array = new int[size];
        top = 0;
        this.size = size;
    }

    @Override
    public void push(int element) {
        array[top] = element;
        top++;
    }

    @Override
    public int pop() {
        top--;
        return array[top];
    }

    @Override
    public int peek() {
        return array[top-1];
    }

    @Override
    public boolean isEmpty() {
        return top == 0;
    }
}

Taka implementacja nie jest idiotoodporna, ale nie komplikujmy za bardzo, żeby nie zgubić głównej idei.

Masz więc stos dla liczb całkowitych. Ale okazuje się, że zaraz będziesz potrzebował stosu dla obiektów typu String. Co zrobisz w takiej sytuacji? Pierwsze co się może nasuwać niezbyt doświadczonemu programiście, to stworzenie interfejsu:

public interface StringStack {
    void push(String element);
    String pop();
    String peek();
    boolean isEmpty();
}

I następnie zaimplementowanie go:

public class SimpleStringStack implements StringStack {
    String array[];
    int size;
    int top;

    public SimpleStringStack(int size) {
        array = new String[size];
        this.size = size;
        top = 0;
    }

    @Override
    public void push(String element) {
        array[top] = element;
        top++;
    }

    @Override
    public String pop() {
        top--;
        return array[top];
    }

    @Override
    public String peek() {
        return array[top-1];
    }

    @Override
    public boolean isEmpty() {
        return top == 0;
    }
}

Klasa Object

Czy zauważyłeś bezsens takiego postępowania? Kod obu klas różni się tylko typem – cała reszta jest zupełnie taka sama. No to nasuwa się kolejne możliwe rozwiązanie. Stwórzmy ogólny stos dla typu Object. Wszak każda klasa dziedziczy po klasie Object. Jeżeli chodzi o typy prymitywne, to każdy z nich ma swoją klasę kopertową (o których pisałem jakiś czas temu, sprawdź tutaj), więc też nie powinno być problemu. No to zróbmy tak. Najpierw interfejs:

public interface Stack {
    void push(Object element);
    Object pop();
    Object peek();
    boolean isEmpty();
}

I teraz jego implementacja:

public class SimpleStack implements Stack {
    Object array[];
    int size;
    int top;

    public SimpleStack(int size) {
        array = new Object[size];
        this.size = size;
        top = 0;
    }

    @Override
    public void push(Object element) {
        array[top] = element;
        top++;
    }

    @Override
    public Object pop() {
        top--;
        return array[top];
    }

    @Override
    public Object peek() {
        return array[top-1];
    }

    @Override
    public boolean isEmpty() {
        return top == 0;
    }
}

Jaka jest wada takiego rozwiązania? Rozważmy prosty program:

public class Main {
    public static void main(String[] args) {
        Stack intStack = new SimpleStack(10);
        Stack stringStack = new SimpleStack(10);

        intStack.push(10);
        stringStack.push("doktor ziel");
    }
}

Ten program wygląda w porządku, prawda? Mam dwa stosy, na każdy z nich odkładam obiekt danego typu, wszystko gra. Ale możemy pójść dalej:

public class Main {
    public static void main(String[] args) {
        Stack intStack = new SimpleStack(10);
        Stack stringStack = new SimpleStack(10);

        intStack.push(10);
        stringStack.push("doktor ziel");
        
        intStack.push("zielony");
        stringStack.push(123);
    }
}

Kompilator nie pilnuje typu. Czyli na każdy ze stosów możemy odłożyć cokolwiek. A to może doprowadzić to trudnych do zdiagnozowania problemów w czasie działania aplikacji. To oznacza, że bardzo duża odpowiedzialność spoczywa na programiście. On musi uważać co odkłada na stos!

Pójdźmy dalej i spróbujmy zdjąć elementy ze stosów:

public class Main {
    public static void main(String[] args) {
        Stack intStack = new SimpleStack(10);
        Stack stringStack = new SimpleStack(10);

        intStack.push(10);
        stringStack.push("doktor ziel");

        intStack.push("zielony");
        stringStack.push(123);
        
        int number = intStack.pop();
        String string = stringStack.pop();
    }
}

Powyższy program się nie skompiluje. Metoda pop() zwraca obiekt klasy Object. Czyli musimy dodać rzutowanie:

public class Main {
    public static void main(String[] args) {
        Stack intStack = new SimpleStack(10);
        Stack stringStack = new SimpleStack(10);

        intStack.push(10);
        stringStack.push("doktor ziel");

        intStack.push("zielony");
        stringStack.push(123);

        int number = (int) intStack.pop();
        String string = (String) stringStack.pop();
    }
}

Dodaliśmy operatory rzutowania, program stał się odrobinę brzydszy (moim zdaniem) ale tak naprawdę nic nie zyskaliśmy. Wciąż nie mamy żadnej kontroli typów. Przy uruchomieniu powyższego programu dostaniem komunikat o błędzie: class java .lang.String cannot be cast to class java .lang.Integer. Czyli o błędzie dowiemy się w czasie działania programu, a chcielibyśmy w czasie kompilacji…

Typy generyczne

Do takiego programowania byliśmy zmuszeni do roku 2004, kiedy wydano piątą wersję Javy, w której dodano nową funkcjonalność. Niestety, przez dążenie do wstecznej kompatybilności, typom generycznym sporo brakuje do szablonów z C++.

Typ generyczny to taka klasa, w której jeden (bądź więcej) typ jest opisywany przez parametr klasy. W skrócie możemy stworzyć interfejs, w którym typ zwracany przez metody pop() i peek() oraz typ argumentu metody push() jest parametrem klasy. Czyli dostaniemy coś w stylu:

public interface GenericStack {
    void push(T element);
    T pop();
    T peek();
    boolean isEmpty();
}

Ale kompilator zaprotestuje, bo będzie oczekiwał, że T to nazwa klasy. Musimy go poinstruować, że T jest parametrem. Używamy do tego nawiasów trójkątnych:

public interface GenericStack<T> {
    void push(T element);
    T pop();
    T peek();
    boolean isEmpty();
}

Niestety zaimplementowanie takiego interfejsu, w sposób analogiczny do poprzednich interfejsów, nie jest trywialne. Dlatego, że Java nie pozwala na stworzenie tablicy elementów typu opisywanego przez parametr typu generycznego. Nie możemy więc zaimplementować tego interfejsu w następujący sposób:

public class SimpleGenericStack<T> implements GenericStack<T> {
    T array[];
    int size;
    int top;

    public SimpleGenericStack(int size) {
        array = new T[size];
        this.size = size;
        top = 0;
    }

    @Override
    public void push(T element) {
        array[top] = element;
        top++;
    }

    @Override
    public T pop() {
        top--;
        return array[top];
    }

    @Override
    public T peek() {
        return array[top-1];
    }

    @Override
    public boolean isEmpty() {
        return top == 0;
    }
}

Kompilatorowi nie spodoba się linia 7. Musimy zastosować trick:

public SimpleGenericStack(int size) {
    List<T> temp = new ArrayList<>();
    for (int i = 0; i < size; i++) {
        temp.add(null);
    }
    array = (T[]) temp.toArray();
    this.size = size;
    top = 0;
}

I teraz możemy przepisać program, który omawialiśmy wcześniej, tak aby korzystał ze zdefiniowanych przed chwilą typów generycznych:

public class Main {
    public static void main(String[] args) {
        GenericStack<Integer> intStack = new SimpleGenericStack<Integer>(10);
        GenericStack<String> stringStack = new SimpleGenericStack<String>(10);

        intStack.push(10);
        stringStack.push("doktor ziel");

        intStack.push("zielony");
        stringStack.push(123);

        int number = (int) intStack.pop();
        System.out.println(number);

        String string = (String) stringStack.pop();
        System.out.println(string);
    }
}

Problematyczne są linijki 9 i 10. Teraz kompilator kontroluje typ elementów wkładanych na stos i sygnalizuje, że te dwie linijki nie są prawidłowe. Dodatkowo w linijce 12 rzutowanie na int nie jest potrzebne. Kompilator już wie, jaki typ jest przechowywany na stosie, więc nie musimy mu dawać wskazówek. Podobnie w linijce 15 z rzutowaniem na String. Nasz program upraszcza się do:

public class Main {
    public static void main(String[] args) {
        GenericStack<Integer> intStack = new SimpleGenericStack<Integer>(10);
        GenericStack<String> stringStack = new SimpleGenericStack<String>(10);

        intStack.push(10);
        stringStack.push("doktor ziel");

        int number = intStack.pop();
        System.out.println(number);

        String string = stringStack.pop();
        System.out.println(string);
    }
}

Dodatkowo, od czasów siódmej wersji Javy, wywołując konstruktor klasy generycznej (linijki 3 i 4) nie musimy specyfikować, dla jakiego typu parametrycznego tworzymy obiekt. Kompilator sam się domyśli na podstawie typu zmiennej. Tak więc przy wywołaniu konstruktora możemy wstawić pusty nawias trójkątny (nazywany operatorem diamentu):

public class Main {
    public static void main(String[] args) {
        GenericStack<Integer> intStack = new SimpleGenericStack<>(10);
        GenericStack<String> stringStack = new SimpleGenericStack<>(10);

        intStack.push(10);
        stringStack.push("doktor ziel");

        int number = intStack.pop();
        System.out.println(number);

        String string = stringStack.pop();
        System.out.println(string);
    }
}

Typy generyczne w runtime

Zasadniczo w tym miejscu można byłoby skończyć ten wpis. Zrobiliśmy krótki wstęp do typów generycznych. Niedługo będę kontynuował temat, bo to jest naprawdę bardzo interesujące. Co więcej, w zaawansowanych programach napisanych w Javie, typy generyczne są bardzo często używane.

Na sam koniec, chciałbym opowiedzieć, jak typy generyczne są traktowane w czasie działania aplikacji, czyli w tak zwanym runtime. Okazuje się, że klasa generyczna w pamięci programu jest traktowana jako klasa, w której wszystkie typy parametryczne są równe klasie Object. Czyli nasz SimpleGenericStack niewiele się różni od SimpleStack. Tablica array, w obu przypadkach jest tablicą obiektów klasy Object. W przypadku generycznym kompilator tylko w czasie kompilacji sprawdza typy i dodaje automatyczne rzutowania. Takie usunięcie informacji o typie w angielskojęzycznej literaturze określa się jako type erasure. Nie znam polskiego odpowiednika tej nazwy.

Tak jak wspomniałem, takie zachownie wynikło z chęci zachowania wstecznej kompatybilności. Ponieważ przed piątą wersją Javy, w bibliotece standardowej można było znaleźć klasy implementujące różne kolekcje i one wszystkie działały w oparciu o przechowywaniu obiektów klasy Object. Piąte wydanie Javy dodało generykę, ale nikt nie chciał na nowo przepisywać całej biblioteki standardowej Javy. Takie lenistwo twórców Javy można zaobserwować w jeszcze kilku innych sytuacjach. Ale o tym napiszę kiedy indziej.

Podsumowanie

Napomknąłem o Javowych kolekcjach. Do tego tematu wrócę już wkrótce. Dzisiejszy wpis jest wstępem do cyklu artykułów o kolekcjach. Jako, że kolekcje są zaimplementowane jako typy generyczne, wydaje mi się, że taki wstęp może okazać się przydatnym. Ten wpis nie wyczerpuje tematu generyków w Javie. Nie wspomniałem w ogóle o metodach generycznych, ani o klasach generycznych i dziedziczeniu. Mam nadzieję, że ten wpis zachęci Cię do lektury moich następnych wpisów.

Kody źródłowe, których użyłem w artykule możesz znaleźć na githubie.

Dziękuję, że doczytałeś do tego miejsca. Mam nadzieję, że Ci się spodobało i wrócisz po więcej. A kolejne artykuły już wkrótce!

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Scroll to top