Обобщения в Java (Java Generics)

от
Java    generics, обобщения, дженерики, wildcards, super, extends


Дженерики появились в Java 1.5 и призваны обезопасить код от неправильной типизации. Параметризируя класс, интерфейс или метод, можно добиться гибкости в переиспользовании алгоритмов, строгой проверки типов и упростить написание кода.

Без использования дженериков в код может пробраться ошибка типов:
  1. List list = new ArrayList();
  2. list.add("1");
  3. list.add("2");
  4. list.add(3);
  5.  
  6. String v1 = (String) list.get(0);
  7. String v2 = (String) list.get(1);
  8. String v3 = (String) list.get(2);

Здесь мы случайно добавили в список число 3, а затем берём из списка строки. Код скомпилируется, но вот при запуске будет выдан ClassCastException на последней строке.

Перепишем с использованием дженериков:
  1. List<String> list = new ArrayList<>();
  2. list.add("1");
  3. list.add("2");
  4. list.add(3);
  5.  
  6. String v1 = list.get(0);
  7. String v2 = list.get(1);
  8. String v3 = list.get(2);

Теперь компилятор указывает на нашу ошибку в list.add(3). К тому же код стал меньше за счёт отсутствия приведения типов.


Готовим дженерики
Для наглядности опишем гараж в терминах ООП. У нас будет некоторое транспортное средство:
  1. public abstract class Vehicle {
  2.  
  3.     protected final String name;
  4.  
  5.     public Vehicle(String name) {
  6.         this.name = name;
  7.     }
  8.  
  9.     public String getName() {
  10.         return name;
  11.     }
  12. }

Поскольку "транспортное средство" это слишком абстрактное понятие (потому мы и пометили класс как abstract), создадим ещё класс для автомобиля и мотоцикла.
  1. public class Car extends Vehicle {
  2.  
  3.     public Car(String name) {
  4.         super(name);
  5.     }
  6. }
  7.  
  8. public class Motorcycle extends Vehicle {
  9.  
  10.     public Motorcycle(String name) {
  11.         super(name);
  12.     }
  13. }

Наконец, сам гараж. В гараже мы можем хранить автомобиль, мотоцикл, даже велосипед, так что общим типом для них будет "абстрактное транспортное средство".
  1. public class Garage {
  2.  
  3.     private Vehicle vehicle;
  4.  
  5.     public Vehicle get() {
  6.         return vehicle;
  7.     }
  8.  
  9.     public void set(Vehicle vehicle) {
  10.         this.vehicle = vehicle;
  11.     }
  12. }

Сейчас у нас нет дженериков, использование будет таким:
  1. Garage garage = new Garage();
  2. garage.set(new Car("Aston Martin"));
  3. Car car = (Car) garage.get();
  4. System.out.println(car.getName()); // Aston Martin
  5.  
  6. Garage garage2 = new Garage();
  7. garage2.set(new Motorcycle("Honda CBR500R"));
  8. Motorcycle motorcycle = (Motorcycle) garage.get();
  9. System.out.println(motorcycle.getName());

ClassCastException

Небольшая невнимательность, и вот вместо мотоцикла мы вывозим кучу металлолома марки "ClassCastException".


Параметризация класса
Для параметризации класса или интерфейса, необходимо добавить <T> после имени класса. Вместо T можно использовать что-то другое, например V, VEHICLE, но обычно используют T, как сокрашение от Type. Это T можно будет подставлять в своём классе как тип объекта.

Перепишем класс Garage.
  1. public class Garage<T> {
  2.  
  3.     private T vehicle;
  4.  
  5.     public T get() {
  6.         return vehicle;
  7.     }
  8.  
  9.     public void set(T vehicle) {
  10.         this.vehicle = vehicle;
  11.     }
  12. }

И предыдущий пример использования:
  1. Garage<Car> garage = new Garage<>();
  2. garage.set(new Car("Aston Martin"));
  3. Car car = garage.get();
  4. System.out.println(car.getName()); // Aston Martin
  5.  
  6. Garage<Motorcycle> garage2 = new Garage<>();
  7. garage2.set(new Motorcycle("Honda CBR500R"));
  8. Motorcycle motorcycle = garage.get();
  9. System.out.println(motorcycle.getName());

Теперь на ошибку в строке Motorcycle motorcycle = garage.get(); нам укажет компилятор. Но, погодите-ка:
  1. class Jupiter {
  2. }
  3.  
  4. Garage<Jupiter> garage = new Garage<>();
  5. garage.set(new Jupiter());
  6. Jupiter jupiter = garage.get();

Юпитер

Какого чёрта в нашем гараже делает Юпитер? И почему это компилируется и работает? Ответ прост: мы не указали верхнюю границу типов.


Верхняя граница типов
Когда мы переписывали класс Garage с обычной версии на версию с дженериками, мы забыли о том, что изначально в гараже были объекты типа Vehicle. Нужно это исправить и указать, что T должно быть не любым объектом, а подклассом Vehicle.

Для это служит такая конструкция: <T extends SomeClass>
  1. public class Garage<T extends Vehicle> {
  2.  
  3.     private T vehicle;
  4.  
  5.     public T get() {
  6.         return vehicle;
  7.     }
  8.  
  9.     public void set(T vehicle) {
  10.         this.vehicle = vehicle;
  11.     }
  12. }

Теперь мы не сможем хранить в гараже что попало:
  1. Garage<Jupiter> garage = new Garage<>();
  2. // Error: type argument Jupiter is not within bounds of type-variable T

Кстати, некоторые ошибочно думают, что раз тип верхней границы указан для класса, то можно в поле использовать его, а не T:
  1. public class Garage<T extends Vehicle> {
  2.  
  3.     private Vehicle vehicle;
  4.  
  5.     public T get() {
  6.         return vehicle;
  7.     }
  8.  
  9.     public void set(T vehicle) {
  10.         this.vehicle = vehicle;
  11.     }
  12. }

Это неправильно. T должно присутствовать и в типе поля, иначе метод get не сможет вернуть правильный тип. Пример:
  1. Garage<Car> garage = new Garage<>();
  2.  
  3. public class Garage</*T*/Car extends Vehicle> {
  4.  
  5.     private Vehicle vehicle;
  6.  
  7.     public /*T*/Car get() {
  8.         return vehicle;
  9.     }
  10.  
  11.     public void set(/*T*/Car vehicle) {
  12.         this.vehicle = vehicle;
  13.     }
  14. }

В методе get ожидается возвращение типа Car, а возвращается Vehicle. Конечно, можно написать return (T) vehicle;, но это небезопасно. В нормальной версии класса Garage T и так подразумевается как Vehicle, все его методы доступны, так что нет смысла заменять T на тип верхней границы.


Пример с множественной параметризацией
Для примера сделаем трёхместный гараж.
  1. public class TripleGarage<T extends Vehicle, U extends Vehicle, V extends Vehicle> {
  2.  
  3.     private T vehicle1;
  4.     private U vehicle2;
  5.     private V vehicle3;
  6.  
  7.     ...
  8. }

Теперь в этот гараж можно поставить три любые транспортные средства.
  1. public class Truck extends Car {
  2.  
  3.     public Truck(String name) {
  4.         super(name);
  5.     }
  6. }
  7.  
  8. Car CAR = new Car("Aston Martin");
  9. Motorcycle MOTORCYCLE = new Motorcycle("Honda CBR500R");
  10. Truck TRUCK = new Truck("Road Kill");
  11.  
  12. TripleGarage<Car, Motorcycle, Truck> garage1 = new TripleGarage<>();
  13. garage1.set1(CAR);
  14. garage1.set2(MOTORCYCLE);
  15. garage1.set3(TRUCK);
  16.  
  17. TripleGarage<Car, Motorcycle, Truck> garage2 = new TripleGarage<>();
  18. garage2.set1(TRUCK);
  19. garage2.set2(MOTORCYCLE);
  20. garage2.set3(TRUCK);
  21. Car car1 = garage2.get1();
  22. // Truck truck1 = garage2.get1();
  23.  
  24. TripleGarage<Car, Car, Car> garage3 = new TripleGarage<>();
  25. garage3.set1(CAR);
  26. garage3.set2(CAR);
  27. garage3.set3(CAR);

Во втором случае, хоть мы и указали, что на первом месте должен быть Car, поставить Truck мы всё-таки можем, ведь он наследуется от Car. Но вот при получении объекта, Truck мы явно уже не получим, разве что явным приведением типа: Truck truck = (Truck) garage2.get1();


Дженерики и массивы
Теперь сделаем гараж, в который можно ставить множество транспортных средств одного типа. На ум, конечно же, приходит массив.
  1. public class FixedSizedGarage<T extends Vehicle> {
  2.  
  3.     private final T[] vehicles;
  4.  
  5.     @SuppressWarnings("unchecked")
  6.     public FixedSizedGarage(int size) {
  7.         // vehicles = new T[size];
  8.         vehicles = (T[]) new Vehicle[size];
  9.     }
  10.  
  11.     public T get(int index) {
  12.         return vehicles[index];
  13.     }
  14.  
  15.     public void set(int index, T vehicle) {
  16.         this.vehicles[index] = vehicle;
  17.     }
  18. }

Но с массивом и дженериками есть ограничение - нельзя создать массив типа T. Зато можно привести любой другой массив к типу (T[]), но это небезопасно. Тем не менее, в данном случае всё будет работать.
  1. FixedSizeGarage<Car> garage = new FixedSizeGarage<>(2);
  2. garage.set(0, new Car("Aston Martin"));
  3. garage.set(1, new Car("Audi"));
  4. Car car1 = garage1.get(0);
  5. Car car2 = garage1.get(1);


Дженерики и списки
Вместо массива можно использовать список. Поскольку интерфейс List параметризован, мы можем указать ему тип T и использовать в своей реализации гаража с переменным размером.
  1. public class DynamicSizedGarage<T extends Vehicle> {
  2.  
  3.     private final List<T> vehicles;
  4.  
  5.     public DynamicSizedGarage() {
  6.         vehicles = new ArrayList<>();
  7.     }
  8.  
  9.     public void add(T t) {
  10.         vehicles.add(t);
  11.     }
  12.  
  13.     public T get(int index) {
  14.         return vehicles.get(index);
  15.     }
  16. }
  17.  
  18. DynamicSizedGarage<Motorcycle> garage = new DynamicSizedGarage<>();
  19. garage.add(new Motorcycle("Honda CBR500R"));
  20. garage.add(new Motorcycle("Harley-Davidson"));
  21. Motorcycle motorcycle1 = garage.get(0);
  22. Motorcycle motorcycle2 = garage.get(1);

Поскольку гараж у нас расширяемый, мы можем добавить больше функционала. Например, возможность добавить сразу несколько транспортных средств:
  1. public void addAll(List<T> list) {
  2.     vehicles.addAll(list);
  3. }
  4.  
  5. List<Car> cars = new ArrayList<>();
  6. cars.add(new Car("Toyota"));
  7. cars.add(new Car("Jaguar"));
  8. cars.add(new Car("BMW"));
  9.  
  10. DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
  11. garage.add(new Car("Aston Martin"));
  12. garage.addAll(cars);

Возможность посмотреть, что находится в гараже:
  1. public void forEach(Consumer<T> consumer) {
  2.     for (T vehicle : vehicles) {
  3.         consumer.accept(vehicle);
  4.     }
  5. }
  6.  
  7. DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
  8. garage.add(new Car("Toyota"));
  9. garage.add(new Car("Jaguar"));
  10. garage.add(new Car("BMW"));
  11. garage.forEach(car -> System.out.println(car.getName()));

Вроде бы всё хорошо, но в последних двух методах есть ограничения. Например, мы не можем добавить в гараж список грузовиков:
  1. List<Truck> trucks = new ArrayList<>();
  2. trucks.add(new Truck("Hell yeah"));
  3. trucks.add(new Truck("Terminator"));
  4.  
  5. DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
  6. garage.add(new Car("Aston Martin"));
  7. // Error: incompatible types: List<Truck>
  8. //        cannot be converted to List<Car>
  9. garage.addAll(trucks);

Причиной этому сигнатура метода void addAll(List<T> c). Если Garage параметризован по Car, то отныне везде, где используется T, ожидается Car и только он. Нам же нужно сделать так, чтобы в гараж можно было добавлять все подклассы заданного типа Car.


Wildcards
joker
Wildcards обозначаются знаком вопроса <?> (ещё он зовётся джокером). Их можно ограничивать верхней и нижней границей, что существенно увеличивает мощь дженериков.

Верхняя граница подстановки
Мы уже рассматривали ограничение с верхней границей, но тогда оно использовалось для типов <T extends SomeClass>. Теперь используются wildcards, а выглядит это так: <? extends SomeClass>.

Чтобы исправить проблему с добавлением нескольких грузовиков в гараж с автомобилями, нужно использовать верхнюю границу подстановки.

Вместо
void addAll(List<T> list)
метод должен быть объявлен как
void addAll(List<? extends T> list)


Нижняя граница подстановки
Ещё одна проблема кроется в методе forEach. Там мы не можем использовать Consumer<Vehicle> или Consumer<Object>:
  1. Consumer<Object> objectConsumer = object -> System.out.println(object.hashCode());
  2. Consumer<Vehicle> vehicleConsumer = vehicle -> System.out.println(vehicle.getName());
  3.  
  4. DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
  5. garage.add(new Car("Toyota"));
  6. garage.add(new Car("Jaguar"));
  7. garage.add(new Car("BMW"));
  8. // Error: incompatible types: Consumer<Vehicle>
  9. //        cannot be converted to Consumer<Car>
  10. garage.forEach(vehicleConsumer);
  11. // Error: incompatible types: Consumer<Object>
  12. //        cannot be converted to Consumer<Car>
  13. // garage.forEach(objectConsumer);

На этот раз нам нужно ограничение по нижней границе, чтобы Consumer мог использовать иерархию классов, начиная от Car, а именно Car, Vehicle, Object.

Нижняя граница подстановки задаётся так: <? super SomeClass>.

Теперь, объявив метод forEach вместо
void forEach(Consumer<T> consumer)
как
void forEach(Consumer<? super T> consumer)
приведённый выше пример будет работать.


PECS
Давайте добавим ещё два метода. Первый будет заменять сразу несколько транспортных средств в гараже, а второй возвращать список транспортных средств, удовлетворяющих некоему критерию.
  1. public void replaceWith(List<T> list) {
  2.     final int listSize = list.size();
  3.     final int size = vehicles.size();
  4.     vehicles.subList(0, Math.min(listSize, size)).clear();
  5.     vehicles.addAll(0, list);
  6. }
  7.  
  8. public List<T> filter(Predicate<T> predicate) {
  9.     List<T> result = new ArrayList<>();
  10.     for (T vehicle : vehicles) {
  11.         if (predicate.test(vehicle)) {
  12.             result.add(vehicle);
  13.         }
  14.     }
  15.     return result;
  16. }

Как определить, какое ограничение использовать для дженериков?
Существует правило PECS - Producer Extends Consumer Super.
Если аргумент используется как поставщик, то есть нужно что-то взять из объекта, то используется <? extends SomeClass>.
Если аргумент используется как потребитель, то есть нужно что-то положить в объект, то используется <? super SomeClass>.
Если аргумент используется как для чтения, так и для записи, то ограничения не накладывается и нужно использовать <SomeClass> или просто <T>.

В методе replaceWith(List<T> list) мы используем list в качестве поставщика, получая от него элементы, которые потом будут добавлены в гараж. В таком случае нужно использовать replaceWith(List<? extends T> list).

В методе filter(Predicate<T> predicate) мы используем predicate в качестве потребителя, передавая ему элементы для того, чтобы убедиться, что транспортное средство удовлетворяет критерию. В этом случае используем filter(Predicate<? super T> predicate).

Ещё больше примеров для самопроверки.

  1. public void fill(Supplier<T> supplier, int count) {
  2.     for (int i = 0; i < count; i++) {
  3.         vehicles.add(supplier.get());
  4.     }
  5. }
Supplier<? super T> или Supplier<? extends T>?

  1. public void merge(DynamicSizedGarage<T> garage) {
  2.     vehicles.addAll(garage.vehicles);
  3. }
DynamicSizedGarage<? super T> или DynamicSizedGarage<? extends T>?

  1. public void sort(Comparator<T> comparator) {
  2.     vehicles.sort(comparator);
  3. }
Comparator<? super T> или Comparator<? extends T>?


Параметризация методов
Параметризовать можно не только классы и интерфейсы, но также методы и конструкторы:
  1. public <T> void method1(T element) {
  2.     // ..
  3. }
  4. public <T extends Vehicle> void method2(T vehicle) {
  5.     System.out.println(vehicle.getName();
  6. }
  7. public <T, U, V> U method3(T t, U u, V v) {
  8.     // ..
  9.     return u;
  10. }

Давайте реализуем метод, который трансформирует всё, что находится в гараже в нечто иное.
  1. public <U> List<U> map(Function<T, U> function) {
  2.     List<U> result = new ArrayList<>();
  3.     for (T vehicle : vehicles) {
  4.         result.add(function.apply(vehicle));
  5.     }
  6.     return result;
  7. }
  8.  
  9. DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
  10. garage.add(new Car("BMW"));
  11. garage.add(new Car("Jaguar"));
  12. garage.add(new Car("Aston Martin"));
  13.  
  14. List<String> names = garage.map(Vehicle::getName);
  15. names.forEach(System.out::println); // "BMW", "Jaguar", "Aston Martin"

Здесь мы указали новый дженерик параметр U и используем его при параметризации интерфейса Function<T, U>.

И ещё раз, используя правило PECS, попробуйте правильно записать сигнатуру метода с использованием ограничений.

Подсказка
Ответ

Наконец, сложный пример. Допустим, у нас есть список и двойной предикат. Наша задача попарно перебрать все транспортные средства в гараже и в списке и, если оба транспортных средства удовлетворяют критерию, добавить элемент из списка в гараж.
  1. public <U extends T> void addIf(
  2.         List<? extends U> list, BiPredicate<? super T, ? super U> predicate) {
  3.     List<U> candidatesToAdd = new ArrayList<>();
  4.     Iterator<? extends U> it1 = list.iterator();
  5.     Iterator<? extends T> it2 = vehicles.iterator();
  6.     while (it1.hasNext() && it2.hasNext()) {
  7.         U u = it1.next();
  8.         T t = it2.next();
  9.         if (predicate.test(t, u)) {
  10.             candidatesToAdd.add(u);
  11.         }
  12.     }
  13.     addAll(candidatesToAdd);
  14. }

Что здесь происходит с дженериками, предлагаю разобраться самостоятельно.


*вариантность
Не лишним будет упомянуть, что ограничение <? extends T> считается ковариантным, <? super T> контравариантным, а <T> инвариантным подставляемому типу.


Неизвестный тип
Последнее, что необходимо рассмотреть, это случай, когда тип абсолютно неизвестен или он нас не волнует, а нам нужно его правильно обработать не потеряв типизацию.

Допустим, мы хотим логировать содержимое переданного списка. Первое, что приходит на ум - параметризовать типом Object.
  1. public static void print(List<Object> list) {
  2.     for (Object object : list) {
  3.         log(object);
  4.     }
  5. }
  6.  
  7. public static void main(String[] args) {
  8.     List<Number> numbers = Arrays.asList(0, 0.5, 12.3, 30, 450);
  9.  
  10.     // Error: incompatible types: List<Number>
  11.     //        cannot be converted to List<Object>
  12.     // Logger.print(numbers);
  13. }

Но на деле выходит ошибка несовместимости типов и она оправдана. Чтобы заставить метод работать не опуская параметризацию, нужно указать самый общий тип параметризации - неизвестность. Обозначается неизвестный тип джокером <?> без каких-либо extends или super.
  1. public static void print(List<?> list) {
  2.     for (Object object : list) {
  3.         log(object);
  4.     }
  5. }
  6.  
  7. public static void main(String[] args) {
  8.     List<Number> numbers = Arrays.asList(0, 0.5, 12.3, 30, 450);
  9.     Logger.print(numbers);
  10.  
  11.     List<Object> objects = Arrays.asList("string", new Object[5], null, 15);
  12.     Logger.print(objects);
  13. }

Теперь метод работает с любыми списками.



На этом всё, спасибо за внимание.
Исходный код всех примеров здесь: GitHub
  • +9
  • views 18560