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
nie można przypisać do
List<Long>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.