Практика применения Wildcards в Java: от простых Generic типов до подстановочных символов

0

Подстановочные символы Wildcards сегодня используются в большей степени для разработки библиотек и иногда в создании бизнес-приложений. Действительно мощный инструмент зачастую вызывает затруднение даже у senior программистов. Эксперт в области тестирования ПО, тренер Luxoft Training Денис Цыганов рассказал, в чем суть использования Wildcards и Generic в Java.  

В чем суть

После  появления Collection API в близком к современному виде разработчики Sun Microsystems (в  дальнейшем и Oracle) искали решение для упрощения проверки безопасности по типам (typesafe) и ситуаций «загрязнения кучи» (heap pollution). Так появились Generic (обобщения), сделавшие возможным обнаружение несоответствия типов на этапе компиляции. До реализации этого механизма разработчики «ловили» указанные ошибки при непосредственном запуске приложения. В итоге, проблемы иногда могли впервые проявляться спустя годы использования продукта. 

Разработчики Java внедряли Generics в существующую экосистему. Это заставило Oracle обеспечить обратную совместимость. Был выбран такой вариант реализации «дженериков», при  котором все проверки выполнялись на этапе компиляции, а после происходило стирание типов (type erasure). То есть исполняемый код (скомпилированный с type erasure) ничем не отличается от скомпилированного кода без Generics. Следовательно, существующий не требовал изменения или перекомпиляции. 

Как следствие такого решения, generic типы — инвариантны.  Даже если generic типы находятся в отношении наследования, производные от этих типов в отношении наследования не находятся.

К примеру, следующая конструкция вызовет ошибку компиляции:

ArrayList<Number> list = new ArrayList<Integer>();

Т.к. ArrayList<Number> не является суперклассом для ArrayList<Integer>.

Это не всегда удобно, так как разработчикам часто хочется иметь метод с параметром, содержащим generic тип, который является более общим по отношения к используемым generic типам. Ожидая, что метод “охватит” эти типы.  Например:

void doSomethingWithList(List<Number> list) { … }

На первый взгляд, логично, что такой метод мог бы сортировать любые списки содержащие любых потомков Numbers, типа 

List<Integer> или List<BigDecimal>

Для начала попробуем обойтись без Wildcards. В решении данного вопроса разработчику не поможет стандартный для подобных случаев механизм перегрузки методов. Следуя этому подходу можно написать несколько методов:

void doSomethingWithList(List<Number> list) { … }

void doSomethingWithList(List<Integer> list) { … }

void doSomethingWithList(List<BigDecimal> list) { … }

К сожалению, такой код не скомпилируется благодаря тому же стиранию типов. Т.к. после все три метода имеют одинаковую сигнатуру:

void doSomethingWithList(List list) { … }

Мы можем поиграть с названиями методов, но их тело, скорее всего, будет выглядеть абсолютно одинаково (или очень похоже), а это нарушает один из главных принципов разработки — DRY (Don’t Repeat Yourself).

Эту проблему можно решить с помощью  Wildcards. Символ «?» может быть использован вместо Generic типа, обозначая любой тип. Также можно задать границы для семейства типов, определенных обобщенным классом, делая api понятным и изящным. Wildcards способен ограничивать тип вниз (extends) или  вверх (super). 

Важно понимать, что Wildcard тип может иметь только ссылка, но не сам объект. То есть он может быть задан для локальной переменной, поля класса, параметра метода или типа возвращаемого значения, но не может быть использован в операторе new

new ArrayList<?>(); // не скомпилируется

Примеры объявления переменных c Wildcards: 

List<?> list; 

List<? extends Number> listE;

List<? super Number> listS;

Первая и вторая ссылки — ковариантны. Третья — контравариантна. 

Что это означает?

Ковариантность — перенос отношения наследования с исходного типа на производный (обобщенный) тип. Т.е. List<? extends Number> является суперклассом для List<Number>, List<Integer>, List<BigDecmal> и даже для List<? extends Integer> и ему подобных.

Такая ковариантность на практике делает невозможным добавление элементов в список адресуемый через Wildcard тип. И действительно, какого же типа может быть добавляемый элемент? Допустим, у нас есть переменная типа List<? extends Number>. При попытке добавления объекта любого типа, нас ждет неудача, т.к. за этой ссылкой может находиться объект типа List<Integer>, List<BigDecimal> или даже List<Number>. В первый невозможно положить BigDecimal, а во второй Integer. И оба не примут ссылку типа Number. А т.к. настоящий generic тип объекта по такой ссылке компилятор определить не может, это означает, что нет таких объектов, которые можно записать в любой из них. Единственное исключение — null (может быть добавлен, т.к. может выступать как любой тип). 

Контравариантность — обращение отношения наследования для производных типов. Т.е. List<? super Number> не является суперклассом для List<Integer>, зато является для List<Number>, List<Object>, List<? super Object>.

В такой список, очевидным образом, можно добавить только объекты типа ограничителя. Т.е. List<? super Number> примет Number, но не примет Object по вышеуказанной причине. Если задуматься, то список также примет любые типы, расширяющие Number, т.к. любой из этих типов совместим с Number (помните, мы говорим о типе объекта, а не о Generic типе).

Примечание 1: обозначенные ограничения относятся только к Generic типу. Так, например, следующий код скомпилируется без проблем: 

List<String> list = new ArrayList<String>();

Для краткости можно опускать generic тип при создании объекта: 

List<String> list = new ArrayList<>();

Эта возможность появилась в версии 1.7 и называется Diamonds (угловые скобки напоминают бриллиант).

Примечание 2: Массивы в отличии от generics ковариантны. Иными словами, если  Объекты находятся в отношении наследования, то и их производные (массивы), находятся в том же отношении. Т.е. следующий код корректен: 

Object[] array = new Integer[5];

Практика применения

Чтобы понять суть Generic разберем пример программирования до и после их появления. Мир без «генериков» выглядит так:

        /*Pre-Generics world*/

  // Список объектов без ограничения по типу

  //теперь он называется raw type List

        List listOfStrings = new ArrayList();

        //В него можно добавить элементы любого объектного типа

        listOfStrings.add(«String»);

        listOfStrings.add(10);

Для извлечения элемента нужно знать точный тип (для применения  оператора Cast).

        String string = (String) listOfStrings.get(0);

        //есть шанс получить ClassCastException, если мы не угадали с типом

        String secondString = (String) listOfStrings.get(1);

Эта ошибка проявится  в среде выполнения (Runtime) только во время выполнения конкретной строки кода (если соответствующая ветка исполняется не часто, велик шанс эту ошибку не обнаружить на этапе разработки).

С появлением «генериков» код выглядит иначе. Ошибки проявляются намного раньше — во  время разработки, а стоимость их исправления значительно ниже. Кроме того, при извлечении элемента из списка нет необходимости приводить тип, а также знать (читай — искать по коду) точный тип — он будет предложен IDE или упомянут компилятором.

        /* Generics world */

Список элементов определенного типа — String в примере ниже.

        List<String> trueListOfStrings = new ArrayList<>();

        //it accepts String parameter only

        trueListOfStrings.add(«String»);

В случае использования несовместимого  типа — получим ошибку компиляции.

        //trueListOfStrings.add(10);

        String alwaysCorrectType = trueListOfStrings.get(0);

//Возможность получить ClassCastException пропадает вместе с Cast операцией.

    }

}

Теперь рассмотрим задачу отбора животных из существующего списка по определенному условию (conditional copy). В этом примере мы уже будем применять Wildcards. 

Заранее обозначим требования к нашему API: 

  1. Метод должен копировать некоторые объекты из исходного списка в результирующий; 
  2. Оба списка уже существуют и принимаются в параметрах; 
  3. Для принятия решения о копировании объекта необходимо вызывать методы самого объекта для получения необходимой информации (доступность методов объекта). 
  4. Метод должен давать возможность копирования в списки обобщенные любым супертипом (гибкость). 

Для начала определим типы животных:

class Animal {

        boolean isNeeded() {

boolean result = false; 

            //имитация некоторой логики

            return result;

        }

}

class Cat extends Animal {

//переопределяем логику isNeeded() для класса Cat

}

Подготовим несколько списков для тестирования:

    List<Object> objects = new ArrayList<>();

    List<Animal> animals = new ArrayList<>();

    List<Cat> cats = new ArrayList<>();

Теперь попробуем решить задачу «в лоб»:

<T> void conditionalCopy(List<T> dst, List<T> src) {

    /*  при такой сигнатуре метода нет возможности вызвать методы класса Animal, т.к. тип T в рамках метода не ограничен и может представлять любой тип */

        dst.addAll(src);

}

Итак, у нас проблема с реализацией требования 3. 

Попробуем исправить:

<T extends Animal> void conditionalCopy(List<T> dst, List<T> src) {

List<T> list = src.stream()

.filter(Animal::isNeeded)

.collect(Collectors.toList());

     dst.addAll(list);

}

Проблема решена, пробуем копировать:

conditionalCopy(animals, cats); //1

conditionalCopy(animals, animals); //2

conditionalCopy(objects, cats); //3

Строки 1 и 3 вызывают проблему компиляции. Мы помним, что «дженерики» и поэтому требование 4 не реализовано. Давайте решать эту проблему:

<T extends Animal> void conditionalCopy(List<T> dst, List<? extends T> src) {

List<T> list = src.stream()

.filter(Animal::isNeeded)

.collect(Collectors.toList());

dst.addAll(list);

}

Снова проверяем:

conditionalCopy(animals, cats); //1

conditionalCopy(animals, animals); //2

conditionalCopy(objects, cats); //3

На этот раз проблема компиляции только в одной строке (3). Т ограничен типом Animal, в результате копировать в List<Object> невозможно. 

Самое время применить контравариантность:

<T extends Animal> void conditionalCopy(List<? super T> dst, List<? extends T> src) {

List<T> list = src.stream()

.filter(Animal::isNeeded)

.collect(Collectors.toList());

dst.addAll(list);

}

Последняя проверка подтверждает правильность решения. Все строки компилируются и отлично работают.  

Теперь реализованы все требования. 

Кстати, наш API не позволяет некорректные варианты копирования вроде таких:

conditionalCopy(cats, animals); //4

conditionalCopy(cats, objects); //5

Обе строки (4) и (5) вызывают ошибку компиляции.

Несмотря на кажущуюся простоту Wildcards стали камнем преткновения для многих программистов. Я думаю, что  использование символа «?»  в каком-то смысле запутывает разработчика, создавая ощущение, что объект готов работать с любым типом в любой момент. Однако это не так. «Любой» — это не «каждый» и, тем более, не «все сразу».  В данном контексте правильно было бы сказать «какой-то конкретный, из допустимого множества типов». Для их правильного определения необходима практика. Однако для полного понимания разработчикам необходимо узнать основные механизмы стирания типов, правильно использовать ограничения extends и super, принцип PECS, raw type, проблему heap pollution,  и механизм Generic в целом.

Digital Report
Share.

About Author

Digital-Report.ru — информационно-аналитический портал, который отслеживает изменения цифровой экономики. Мы описываем все технологические тренды, делаем обзоры устройств и технологических событий, которые влияют на жизнь людей.

Comments are closed.