Обобщения в Java (Java Generics)
от aNNiMON
Дженерики появились в Java 1.5 и призваны обезопасить код от неправильной типизации. Параметризируя класс, интерфейс или метод, можно добиться гибкости в переиспользовании алгоритмов, строгой проверки типов и упростить написание кода.
Без использования дженериков в код может пробраться ошибка типов:
- List list = new ArrayList();
- list.add("1");
- list.add("2");
- list.add(3);
- String v1 = (String) list.get(0);
- String v2 = (String) list.get(1);
- String v3 = (String) list.get(2);
Здесь мы случайно добавили в список число 3, а затем берём из списка строки. Код скомпилируется, но вот при запуске будет выдан ClassCastException на последней строке.
Перепишем с использованием дженериков:
- List<String> list = new ArrayList<>();
- list.add("1");
- list.add("2");
- list.add(3);
- String v1 = list.get(0);
- String v2 = list.get(1);
- String v3 = list.get(2);
Теперь компилятор указывает на нашу ошибку в list.add(3). К тому же код стал меньше за счёт отсутствия приведения типов.
Готовим дженерики
Для наглядности опишем гараж в терминах ООП. У нас будет некоторое транспортное средство:
- public abstract class Vehicle {
- protected final String name;
- public Vehicle(String name) {
- this.name = name;
- }
- public String getName() {
- return name;
- }
- }
Поскольку "транспортное средство" это слишком абстрактное понятие (потому мы и пометили класс как abstract), создадим ещё класс для автомобиля и мотоцикла.
- public class Car extends Vehicle {
- public Car(String name) {
- super(name);
- }
- }
- public class Motorcycle extends Vehicle {
- public Motorcycle(String name) {
- super(name);
- }
- }
Наконец, сам гараж. В гараже мы можем хранить автомобиль, мотоцикл, даже велосипед, так что общим типом для них будет "абстрактное транспортное средство".
- public class Garage {
- private Vehicle vehicle;
- public Vehicle get() {
- return vehicle;
- }
- public void set(Vehicle vehicle) {
- this.vehicle = vehicle;
- }
- }
Сейчас у нас нет дженериков, использование будет таким:
- Garage garage = new Garage();
- garage.set(new Car("Aston Martin"));
- Car car = (Car) garage.get();
- System.out.println(car.getName()); // Aston Martin
- Garage garage2 = new Garage();
- garage2.set(new Motorcycle("Honda CBR500R"));
- Motorcycle motorcycle = (Motorcycle) garage.get();
- System.out.println(motorcycle.getName());
Небольшая невнимательность, и вот вместо мотоцикла мы вывозим кучу металлолома марки "ClassCastException".
Параметризация класса
Для параметризации класса или интерфейса, необходимо добавить <T> после имени класса. Вместо T можно использовать что-то другое, например V, VEHICLE, но обычно используют T, как сокрашение от Type. Это T можно будет подставлять в своём классе как тип объекта.
Перепишем класс Garage.
- public class Garage<T> {
- private T vehicle;
- public T get() {
- return vehicle;
- }
- public void set(T vehicle) {
- this.vehicle = vehicle;
- }
- }
И предыдущий пример использования:
- Garage<Car> garage = new Garage<>();
- garage.set(new Car("Aston Martin"));
- Car car = garage.get();
- System.out.println(car.getName()); // Aston Martin
- Garage<Motorcycle> garage2 = new Garage<>();
- garage2.set(new Motorcycle("Honda CBR500R"));
- Motorcycle motorcycle = garage.get();
- System.out.println(motorcycle.getName());
Теперь на ошибку в строке Motorcycle motorcycle = garage.get(); нам укажет компилятор. Но, погодите-ка:
- class Jupiter {
- }
- Garage<Jupiter> garage = new Garage<>();
- garage.set(new Jupiter());
- Jupiter jupiter = garage.get();
Какого чёрта в нашем гараже делает Юпитер? И почему это компилируется и работает? Ответ прост: мы не указали верхнюю границу типов.
Верхняя граница типов
Когда мы переписывали класс Garage с обычной версии на версию с дженериками, мы забыли о том, что изначально в гараже были объекты типа Vehicle. Нужно это исправить и указать, что T должно быть не любым объектом, а подклассом Vehicle.
Для это служит такая конструкция: <T extends SomeClass>
- public class Garage<T extends Vehicle> {
- private T vehicle;
- public T get() {
- return vehicle;
- }
- public void set(T vehicle) {
- this.vehicle = vehicle;
- }
- }
Теперь мы не сможем хранить в гараже что попало:
- Garage<Jupiter> garage = new Garage<>();
- // Error: type argument Jupiter is not within bounds of type-variable T
Кстати, некоторые ошибочно думают, что раз тип верхней границы указан для класса, то можно в поле использовать его, а не T:
- public class Garage<T extends Vehicle> {
- private Vehicle vehicle;
- public T get() {
- return vehicle;
- }
- public void set(T vehicle) {
- this.vehicle = vehicle;
- }
- }
Это неправильно. T должно присутствовать и в типе поля, иначе метод get не сможет вернуть правильный тип. Пример:
- Garage<Car> garage = new Garage<>();
- public class Garage</*T*/Car extends Vehicle> {
- private Vehicle vehicle;
- public /*T*/Car get() {
- return vehicle;
- }
- public void set(/*T*/Car vehicle) {
- this.vehicle = vehicle;
- }
- }
В методе get ожидается возвращение типа Car, а возвращается Vehicle. Конечно, можно написать return (T) vehicle;, но это небезопасно. В нормальной версии класса Garage T и так подразумевается как Vehicle, все его методы доступны, так что нет смысла заменять T на тип верхней границы.
Пример с множественной параметризацией
Для примера сделаем трёхместный гараж.
- public class TripleGarage<T extends Vehicle, U extends Vehicle, V extends Vehicle> {
- private T vehicle1;
- private U vehicle2;
- private V vehicle3;
- ...
- }
Теперь в этот гараж можно поставить три любые транспортные средства.
- public class Truck extends Car {
- public Truck(String name) {
- super(name);
- }
- }
- Car CAR = new Car("Aston Martin");
- Motorcycle MOTORCYCLE = new Motorcycle("Honda CBR500R");
- Truck TRUCK = new Truck("Road Kill");
- TripleGarage<Car, Motorcycle, Truck> garage1 = new TripleGarage<>();
- garage1.set1(CAR);
- garage1.set2(MOTORCYCLE);
- garage1.set3(TRUCK);
- TripleGarage<Car, Motorcycle, Truck> garage2 = new TripleGarage<>();
- garage2.set1(TRUCK);
- garage2.set2(MOTORCYCLE);
- garage2.set3(TRUCK);
- Car car1 = garage2.get1();
- // Truck truck1 = garage2.get1();
- TripleGarage<Car, Car, Car> garage3 = new TripleGarage<>();
- garage3.set1(CAR);
- garage3.set2(CAR);
- garage3.set3(CAR);
Во втором случае, хоть мы и указали, что на первом месте должен быть Car, поставить Truck мы всё-таки можем, ведь он наследуется от Car. Но вот при получении объекта, Truck мы явно уже не получим, разве что явным приведением типа: Truck truck = (Truck) garage2.get1();
Дженерики и массивы
Теперь сделаем гараж, в который можно ставить множество транспортных средств одного типа. На ум, конечно же, приходит массив.
- public class FixedSizedGarage<T extends Vehicle> {
- private final T[] vehicles;
- @SuppressWarnings("unchecked")
- public FixedSizedGarage(int size) {
- // vehicles = new T[size];
- vehicles = (T[]) new Vehicle[size];
- }
- public T get(int index) {
- return vehicles[index];
- }
- public void set(int index, T vehicle) {
- this.vehicles[index] = vehicle;
- }
- }
Но с массивом и дженериками есть ограничение - нельзя создать массив типа T. Зато можно привести любой другой массив к типу (T[]), но это небезопасно. Тем не менее, в данном случае всё будет работать.
- FixedSizeGarage<Car> garage = new FixedSizeGarage<>(2);
- garage.set(0, new Car("Aston Martin"));
- garage.set(1, new Car("Audi"));
- Car car1 = garage1.get(0);
- Car car2 = garage1.get(1);
Дженерики и списки
Вместо массива можно использовать список. Поскольку интерфейс List параметризован, мы можем указать ему тип T и использовать в своей реализации гаража с переменным размером.
- public class DynamicSizedGarage<T extends Vehicle> {
- private final List<T> vehicles;
- public DynamicSizedGarage() {
- vehicles = new ArrayList<>();
- }
- public void add(T t) {
- vehicles.add(t);
- }
- public T get(int index) {
- return vehicles.get(index);
- }
- }
- DynamicSizedGarage<Motorcycle> garage = new DynamicSizedGarage<>();
- garage.add(new Motorcycle("Honda CBR500R"));
- garage.add(new Motorcycle("Harley-Davidson"));
- Motorcycle motorcycle1 = garage.get(0);
- Motorcycle motorcycle2 = garage.get(1);
Поскольку гараж у нас расширяемый, мы можем добавить больше функционала. Например, возможность добавить сразу несколько транспортных средств:
- public void addAll(List<T> list) {
- vehicles.addAll(list);
- }
- List<Car> cars = new ArrayList<>();
- cars.add(new Car("Toyota"));
- cars.add(new Car("Jaguar"));
- cars.add(new Car("BMW"));
- DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
- garage.add(new Car("Aston Martin"));
- garage.addAll(cars);
Возможность посмотреть, что находится в гараже:
- public void forEach(Consumer<T> consumer) {
- for (T vehicle : vehicles) {
- consumer.accept(vehicle);
- }
- }
- DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
- garage.add(new Car("Toyota"));
- garage.add(new Car("Jaguar"));
- garage.add(new Car("BMW"));
- garage.forEach(car -> System.out.println(car.getName()));
Вроде бы всё хорошо, но в последних двух методах есть ограничения. Например, мы не можем добавить в гараж список грузовиков:
- List<Truck> trucks = new ArrayList<>();
- trucks.add(new Truck("Hell yeah"));
- trucks.add(new Truck("Terminator"));
- DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
- garage.add(new Car("Aston Martin"));
- // Error: incompatible types: List<Truck>
- // cannot be converted to List<Car>
- garage.addAll(trucks);
Причиной этому сигнатура метода void addAll(List<T> c). Если Garage параметризован по Car, то отныне везде, где используется T, ожидается Car и только он. Нам же нужно сделать так, чтобы в гараж можно было добавлять все подклассы заданного типа Car.
Wildcards
Wildcards обозначаются знаком вопроса <?> (ещё он зовётся джокером). Их можно ограничивать верхней и нижней границей, что существенно увеличивает мощь дженериков.
Верхняя граница подстановки
Мы уже рассматривали ограничение с верхней границей, но тогда оно использовалось для типов <T extends SomeClass>. Теперь используются wildcards, а выглядит это так: <? extends SomeClass>.
Чтобы исправить проблему с добавлением нескольких грузовиков в гараж с автомобилями, нужно использовать верхнюю границу подстановки.
Вместо
void addAll(List<T> list)
метод должен быть объявлен как
void addAll(List<? extends T> list)
Нижняя граница подстановки
Ещё одна проблема кроется в методе forEach. Там мы не можем использовать Consumer<Vehicle> или Consumer<Object>:
- Consumer<Object> objectConsumer = object -> System.out.println(object.hashCode());
- Consumer<Vehicle> vehicleConsumer = vehicle -> System.out.println(vehicle.getName());
- DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
- garage.add(new Car("Toyota"));
- garage.add(new Car("Jaguar"));
- garage.add(new Car("BMW"));
- // Error: incompatible types: Consumer<Vehicle>
- // cannot be converted to Consumer<Car>
- garage.forEach(vehicleConsumer);
- // Error: incompatible types: Consumer<Object>
- // cannot be converted to Consumer<Car>
- // garage.forEach(objectConsumer);
На этот раз нам нужно ограничение по нижней границе, чтобы Consumer мог использовать иерархию классов, начиная от Car, а именно Car, Vehicle, Object.
Нижняя граница подстановки задаётся так: <? super SomeClass>.
Теперь, объявив метод forEach вместо
void forEach(Consumer<T> consumer)
как
void forEach(Consumer<? super T> consumer)
приведённый выше пример будет работать.
PECS
Давайте добавим ещё два метода. Первый будет заменять сразу несколько транспортных средств в гараже, а второй возвращать список транспортных средств, удовлетворяющих некоему критерию.
- public void replaceWith(List<T> list) {
- final int listSize = list.size();
- final int size = vehicles.size();
- vehicles.subList(0, Math.min(listSize, size)).clear();
- vehicles.addAll(0, list);
- }
- public List<T> filter(Predicate<T> predicate) {
- List<T> result = new ArrayList<>();
- for (T vehicle : vehicles) {
- if (predicate.test(vehicle)) {
- result.add(vehicle);
- }
- }
- return result;
- }
Как определить, какое ограничение использовать для дженериков?
Существует правило 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).
Ещё больше примеров для самопроверки.
- public void fill(Supplier<T> supplier, int count) {
- for (int i = 0; i < count; i++) {
- vehicles.add(supplier.get());
- }
- }
- public void merge(DynamicSizedGarage<T> garage) {
- vehicles.addAll(garage.vehicles);
- }
- public void sort(Comparator<T> comparator) {
- vehicles.sort(comparator);
- }
Параметризация методов
Параметризовать можно не только классы и интерфейсы, но также методы и конструкторы:
- public <T> void method1(T element) {
- // ..
- }
- public <T extends Vehicle> void method2(T vehicle) {
- System.out.println(vehicle.getName();
- }
- public <T, U, V> U method3(T t, U u, V v) {
- // ..
- return u;
- }
Давайте реализуем метод, который трансформирует всё, что находится в гараже в нечто иное.
- public <U> List<U> map(Function<T, U> function) {
- List<U> result = new ArrayList<>();
- for (T vehicle : vehicles) {
- result.add(function.apply(vehicle));
- }
- return result;
- }
- DynamicSizedGarage<Car> garage = new DynamicSizedGarage<>();
- garage.add(new Car("BMW"));
- garage.add(new Car("Jaguar"));
- garage.add(new Car("Aston Martin"));
- List<String> names = garage.map(Vehicle::getName);
- names.forEach(System.out::println); // "BMW", "Jaguar", "Aston Martin"
Здесь мы указали новый дженерик параметр U и используем его при параметризации интерфейса Function<T, U>.
И ещё раз, используя правило PECS, попробуйте правильно записать сигнатуру метода с использованием ограничений.
Подсказка
Ответ
Наконец, сложный пример. Допустим, у нас есть список и двойной предикат. Наша задача попарно перебрать все транспортные средства в гараже и в списке и, если оба транспортных средства удовлетворяют критерию, добавить элемент из списка в гараж.
- public <U extends T> void addIf(
- List<? extends U> list, BiPredicate<? super T, ? super U> predicate) {
- List<U> candidatesToAdd = new ArrayList<>();
- Iterator<? extends U> it1 = list.iterator();
- Iterator<? extends T> it2 = vehicles.iterator();
- while (it1.hasNext() && it2.hasNext()) {
- U u = it1.next();
- T t = it2.next();
- if (predicate.test(t, u)) {
- candidatesToAdd.add(u);
- }
- }
- addAll(candidatesToAdd);
- }
Что здесь происходит с дженериками, предлагаю разобраться самостоятельно.
*вариантность
Не лишним будет упомянуть, что ограничение <? extends T> считается ковариантным, <? super T> контравариантным, а <T> инвариантным подставляемому типу.
Неизвестный тип
Последнее, что необходимо рассмотреть, это случай, когда тип абсолютно неизвестен или он нас не волнует, а нам нужно его правильно обработать не потеряв типизацию.
Допустим, мы хотим логировать содержимое переданного списка. Первое, что приходит на ум - параметризовать типом Object.
- public static void print(List<Object> list) {
- for (Object object : list) {
- log(object);
- }
- }
- public static void main(String[] args) {
- List<Number> numbers = Arrays.asList(0, 0.5, 12.3, 30, 450);
- // Error: incompatible types: List<Number>
- // cannot be converted to List<Object>
- // Logger.print(numbers);
- }
Но на деле выходит ошибка несовместимости типов и она оправдана. Чтобы заставить метод работать не опуская параметризацию, нужно указать самый общий тип параметризации - неизвестность. Обозначается неизвестный тип джокером <?> без каких-либо extends или super.
- public static void print(List<?> list) {
- for (Object object : list) {
- log(object);
- }
- }
- public static void main(String[] args) {
- List<Number> numbers = Arrays.asList(0, 0.5, 12.3, 30, 450);
- Logger.print(numbers);
- List<Object> objects = Arrays.asList("string", new Object[5], null, 15);
- Logger.print(objects);
- }
Теперь метод работает с любыми списками.
На этом всё, спасибо за внимание.
Исходный код всех примеров здесь: GitHub