Semyon Kirekov
Semyon KirekovFullstack Software Engineer @ MTS Group

Typy generyczne w Javie - zaawansowane zastosowania

Poznaj kilka zaawansowanych przypadków pracy z typami generycznymi w Javie i rozwijaj swoje umiejętności.
9.06.20219 min
Typy generyczne w Javie - zaawansowane zastosowania

W artykule tym podzielę się radami na temat tego, jak najefektywniej zaimplementować typy generyczne w Javie. 

Nie używaj typów surowych

To brzmi jak coś oczywistego - typy surowe kłócą się z założeniami typów generycznych, ponieważ nie pozwalają kompilatorowi na wykrywanie błędów w typach. Nie jest to jednak jedyny problem. Załóżmy, że mamy taką klasę. 

class GenericContainer<T> {
    private final T value;
    ...
    public List<Integer> getNumbers() {
        return numbersList;
    }
}

Generyczny kontener


Załóżmy, że nie obchodzą nas typy generyczne - wszystko, czego potrzebujemy to przejście numbersList

public void traverseNumbersList(Container container) {
    for (int num : container.getNumbers()) {
        System.out.println(num);
    }
}

Przejście numbersList

Zaskakujące - kod się nie kompiluje.

error: incompatible types: Object cannot be converted to int
        for (int num : container.getNumbers()) {
                                           ^


Rzecz w tym, że użycie typu surowego usuwa nie tylko informacje o typie generycznym, ale nawet informacje predefiniowane. Tak więc List<Integer> staje się po prostu List.

Co można z tym zrobić? Odpowiedź jest prosta - jeśli nie zależy Ci na typie generycznym, użyj operatora wilidcard.

public void traverseNumbersList(Container<?> container) {
    for (int num : container.getNumbers()) {
        System.out.println(num);
    }
}

Przejście numbersList. Wersja poprawiona


Ten kod działa tak, jak powinien. 

Wejście oparte na wildcard jest lepsze

Główna różnica między tablicami a typami generycznymi polega na tym, że tablice są kowariantne, a typy generyczne nie. Oznacza to, że Number[] jest nadtypem dla Integer[].

Object[] jest typem nadrzędnym dla jakiejkolwiek tablicy (oprócz tych prymitywnych). Brzmi to logicznie, ale może doprowadzić do bugów w czasie uruchamiania. 

Number[] nums = new Long[3];
nums[0] = 1L;
nums[1] = 2L;
nums[2] = 3L;
Object[] objs = nums;
objs[2] = "ArrayStoreException happens here";

Kowariancja błędów z tablicami


Kod się kompiluje, ale rzuca wyjątek. Typy generyczne są tutaj po to, aby rozwiązać ten problem. 

List<Number> nums = new ArrayList<Number>();
List<Long> longs = new ArrayList<Long>();
nums = longs;   // compilation error

Niezmienność typów generycznych eliminuje możliwe błędy


List<Long>
nie można przypisać do List<Number>. Pomimo że pomaga nam to uniknąć ArrayStoreException, to jednak narzuca ograniczenia, które mogą sprawić, że API będzie niezbyt elastyczne. 

interface Employee {
    Money getSalary();
}

interface EmployeeService {
    Money calculateAvgSalary(Collection<Employee> employees);
}

Nieelastyczne API


Wszystko wygląda dobrze, prawda? Wstawiamy nawet Collection jako parametr wejściowy. To pozwala nam na przekazywanie List, Set, Queue itp. Nie zapominaj jednak, że Employee to tylko interfejs. A co by było, gdybyśmy pracowali z kolekcjami poszczególnych implementacji? Na przykład List<Manager> czy Set<Accountant>? Nie mogliśmy ich przekazać bezpośrednio - czyli wymagałoby to każdorazowego przenoszenia elementów do kolekcji Employee.

Możemy też użyć operatora wildcard. 

interface EmployeeService {
    Money calculateAvgSalary(Collection<? extends Employee> employees);
}

List<Manager> managers = ...;
Set<Accountant> accountants = ...;
Collection<SoftwareEngineer> engineers = ...;

// All these examples compile successfully
employeeService.calculateAvgSalary(managers);
employeeService.calculateAvgSalary(accountants);
employeeService.calculateAvgSalary(engineers);

Elastyczne API


Jak widać, właściwe użycie typów generycznych znacznie ułatwia życie. Spójrzmy na inny przykład. Załóżmy, że musimy zadeklarować API dla usługi sortującej. Spójrz na pierwszą próbę.

interface SortingService {
    <T> void sort(List<T> list, Comparator<T> comparator); 
}

Nasza pierwsza usługa sortująca


Mamy teraz inny problem. Musimy być pewni, że Comparator został stworzony dla typu T. Nie zawsze tak jednak jest. Moglibyśmy stworzyć coś uniwersalnego dla Employee, co w tym przypadku niestety nie zadziałałoby dla Accountant, czy Manager. A teraz ulepszymy trochę wygląd naszego API. 

interface SortingService {
    <T> void sort(List<T> list, Comparator<? super T> comparator); 
}

// universal comparator
Comparator<Employee> comparator = ...;

List<Manager> managers = ...;
List<Accountant> accountants = ...;
List<SoftwareEngineer> engineers = ...;

// All these examples compile successfullly
sortingService.sort(managers, comparator);
sortingService.sort(accountants, comparator);
sortingService.sort(engineers, comparator);

Lepsza usługa sortowania

Użycie ograniczeń wprowadza zamieszanie. Te wszystkie ? extends T i ? super T i sprawiają wrażenia zbyt skomplikowanych. Na szczęście mamy jedną prostą zasadę, która pomoże nam zidentyfikować poprawne użycie pewnych rzeczy - PECS (producer-extends, consumer-super). Oznacza to, że producent powinien być ? extends T, a konsument ? super T

Podziałamy teraz na konkretnych przykładach. Metoda MoneyService.calculateAvgSalary, którą opisaliśmy wcześniej, akceptuje producenta, ponieważ kolekcja “produkuje” elementy, z których korzystamy dla dalszych obliczeń.

Kolejny przykład pochodzi prosto ze standardowej biblioteki JDK. Mówię tutaj o Collection.addAll.

interface Collection<E> {
    boolean addAll(Collection<? extends E> c);
    ...
}

Collection.addAll

Definiowanie upper bound generic pozwala nam na powiązanie Collection<Employee> z Collection<Manager> lub jakąkolwiek inną klasą, która ma taki sam interfejs. 

A co z konsumentami? Compactor, którego użyliśmy w SortingService, jest tutaj idealnym przykładem. Interfejs ten ma jedną metodę, która akceptuje typ generyczny, który z kolei zwraca nam konkrety typ - typowy przykład konsumenta. Innymi są Predicate, Consumer, Comparable oraz pozostałe, które pochodzą z pakietu java.util. Większość tych interfejsów należy używać razem z ? super T.

Mamy tutaj również coś unikalnego, co jest zarówno producentem, jak i konsumentem - java.util.Function. Funkcja ta konwertuje wartość wejściową z jednego typu na drugi, a więc użycie commonFunction to Function<? super T, ? extends R>. Wygląda to strasznie, ale pomaga stworzyć solidniejszy kod. Wszystkie funkcje mapujące w interfejsie Stream stosują się do tej zasady. 

interface Stream<T> extends BaseStream<T, Stream<T>> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
    IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
    ...
}

Mapowanie w Stream 


Można zauważyć, że SortingService.sort akceptuje List<T> zamiast List<? extends T>. Dlaczego tak jest? Bo mamy do czynienia z producentem. Chodzi o to, że używanie wildcardów upper-bounded i lower-bounded ma sens, gdy definiujemy wstępnie zdefiniowany typ. Ponieważ SortingService.sort parametryzuje się samemu, nie ma sensu ograniczać List w ten sposób. Z drugiej strony, jeśli SortingService ma typ generyczny, to ? extends T będzie niegłupim pomysłem. 

interface SortingService<T> {
    void sort(List<? extends T> list, Comparator<? super T> comparator); 
}

Sparametryzowana usługa sortująca

Nie zwracaj kontenerów z ograniczeniami typu


Upper-bounded

Ci, którzy odkryli możliwości ograniczonych typów generycznych, pewnie pomyślą, że to jakiś cudowny środek. Ale może to niestety doprowadzić do czegoś takiego:

interface EmployeeRepository {
    List<? extends Employee> findEmployeesByNameLike(String nameLike);
}

Repozytorium Employee


Co tu jest nie tak? Po pierwsze List<? extends Employee> nie można przypisać do List<Employee> bez rzutowania. Upper-bounded również nakłada ograniczenia, które nie są tak oczywiste.

Przykład: jakie wartości możemy umieścić w kolekcji zwróconej przez EmployeeRepository.findEmployeesByNameLike(String)? Pewnie Ci się wydaje, że Accountant, Manager, SoftwareEngineer i tak dalej. Ale to nie tak.

List<? extends Employee> employees = employeeRepository.findEmployeesByNameLike(nameLike);

employees.add(new Accountant());        // compile error
employees.add(new SoftwareEngineer());  // compile error
employees.add(new Manager());           // compile error
employees.add(null);                    // passes successfully ?

Typu generyczne ograniczone od góry 


Powyższy fragment sprawia wrażenie nieintuicyjnego, ale w rzeczywistości wszystko działa dobrze. Zdekonstruujmy to. Musimy przede wszystkim określić, jakie kolekcje można przypisać do List<?extends Employee>.

List<? extends Employee> accountants = new ArrayList<Accountant>();
List<? extends Employee> managers    = new ArrayList<Manager>();
List<? extends Employee> engineers   = new ArrayList<SoftwareEngineer>();
                                       // ...any other type that extends from Employee 

Typy generyczne ograniczone od góry z kolekcjami


W zasadzie każdą listę dowolnego typu, która dziedziczy po Employee, można przypisać do List<?extends Employee>. To niestety sprawia, że dodawanie nowych elementów jest ciężkie. Kompilator nie może znać dokładnego typu listy - dlatego zabrania dodawania jakichkolwiek elementów w celu wyeliminowania potencjalnego zanieczyszczenia sterty. null to jednak przypadek szczególny. Wartość ta nie ma własnego typu i można ją przypisać do wszystkiego (z wyjątkiem typów prymitywnych). Jest to powód, dla którego null jest jedyną dozwoloną wartością.

A co z pobieraniem elementów z listy?

List<? extends Employee> employees = ...;

// passes successfully ?
for (Employee e : employees) {
    System.out.println(e);
}

Pobieranie elementów z listy ograniczonej od góry


Employee to typ nadrzędny dla każdego potencjalnego elementu, który może zawierać lista. Nie ma w tym nic złego.


Lower-bounded

A jaki element możemy dodać do List<? super Employee>? Logika mówi, że albo Object, albo Employee. I znowu zostajemy oszukani.

List<? super Employee> employees = ...;

employees.add(new Accountant());                       // passes successfully ?
employees.add(new Manager());                          // passes successfully ?
employees.add(new SoftwareEngineer());                 // passes successfully ?
employees.add(new Employee() { /*implementation*/ });  // passes successfully ?
employees.add(new Object());                           // compile error

Typy ogólne z ograniczeniami do dołu


I znowu, aby rozgryźć ten przypadek, dowiedzmy się, jakie kolekcje można przypisać do List<? super Employee>.

List<? super Employee> employees = new ArrayList<Employee>();
List<? super Employee> objects   = new ArrayList<Object>();

Typy generyczne z ograniczeniami do dołu i z kolekcjami


Kompilator wie, że lista może składać się z typów Object lub Employee - dlatego można spokojnie dodać Accountant, Manager, SoftwareEngineer, i Employee. Wszystkie implementują interfejs Employee i dziedziczą z klasy Object. Jednocześnie Object nie może zostać dodany, ponieważ nie implementuje Employee.

Wręcz przeciwnie, czytanie z List<? super Employee> nie jest takie proste.

List<? super Employee> employees = ...;

// compile error
for (Employee e : employees) {
    System.out.println(e);
}

Pobieranie elementów z listy z ograniczeniami do dołu


Kompilator nie jest pewny, czy zwracany element jest typu Employee. Być może jest to Object - dlatego kod się nie kompiluje.


Upper- i lower-bounded - podsumowanie

Możemy założyć, że upper-bounded sprawia, że kolekcja jest read-only, a lower-bounded sprawiają, że jest ona write-only. Czy oznacza to zatem, że możemy je wykorzystać jako typy zwracane do ograniczenia dostępu klienta do manipulacji danych? Nie polecam takiego podejścia. 

Kolekcje upper-bounded nie są całkowicie read-only, ponieważ nadal można do nich dodać wartość null. Kolekcje lower-bounded nie są całkowicie write-only, ponieważ nadal można odczytywać wartości jako Object. Uważam, że o wiele lepiej skorzystać ze specjalnych kontenerów, które zapewnią wymagany dostęp do instancji. Możesz zastosować standardowe narzędzia JDK, takie jak Collections.unmodifiableList lub użyć bibliotek, które to zrobią (na przykład Vavr).

Kolekcje upper- i lower-bounded znacznie lepiej działają jako parametry wejściowe. Nie należy ich mieszać z typami zwracanymi.

Rekurencyjne typy ogólne

Mówiliśmy już tutaj o rekurencyjnych typach ogólnych - to interfejs Stream. Spójrzmy na niego ponownie. 

interface Stream<T> extends BaseStream<T, Stream<T>> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
    IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);
    ...
}

Mapowanie Stream


Jak widać, Stream rozszerza BaseStream, który jest sparametryzowany za pomocą samego Stream. Jaki jest tego powód? Przyjrzyjmy się dokładniej BaseStream.

public interface BaseStream<T, S extends BaseStream<T, S>> extends AutoCloseable {  
    S sequential();
    S parallel();
    S unordered();
    S onClose(Runnable closeHandler);
    Iterator<T> iterator();
    Spliterator<T> spliterator();
    boolean isParallel();
    void close();
}

BaseStream


BaseStream jest typowym przykładem płynnego API, ale zamiast zwracać sam typ, metody zwracają S extends BaseStream<T, S>. A teraz wyobraźmy sobie, że BaseStream został zaprojektowany bez tej funkcji.

public interface BaseStream<T> extends AutoCloseable {  
    BaseStream<T> sequential();
    BaseStream<T> parallel();
    BaseStream<T> unordered();
    BaseStream<T> onClose(Runnable closeHandler);
    Iterator<T> iterator();
    Spliterator<T> spliterator();
    boolean isParallel();
    void close();
}

BaseStream bez rekurencji


Jak wpłynie to na całe Stream API?

List<Employee> employees = ...;
employees.stream()
         .map(Employee::getSalary)
         .parallel()
         .reduce(0L, (acc, next) -> acc + next);     // compile error ⛔: cannot find symbol

Stream API bez rekurencji


Metoda reduce należy do interfejsu Stream, ale nie do interfejsu BaseStream - dlatego równolegle zwraca BaseStream, a więc nie znajdziemy tutaj reduce. Może łatwiej to będzie zrozumieć na poniższym schemacie.


Rekurencyjne typy ogólne przydają się w takiej sytuacji.


Rekurencyjne Stream API


Takie podejście pozwala nam segregować interfejsy, co prowadzi do łatwiejszego utrzymania i czytelności.

Podsumowanie

Mam nadzieję, że ten artykuł się Wam przyda. Jeśli masz jakieś pytania, albo sugestie, to zostaw komentarz. 

Źródła


Oryginał tekstu w języku angielskim możesz przeczytać tutaj

<p>Loading...</p>