Использование generic wildcards для повышения удобства Java API

в 4:47, , рубрики: Без рубрики

Доброго времени суток!

Этот пост для тех, кто работает над очередным API на языке Java, либо пытается усовершенствовать уже существующий. Здесь будет дан простой совет, как с помощью конструкций ? extends T и ? super T можно значительно повысить удобство вашего интерфейса.

Перейти сразу к сути

Исходный API

Предположим, у вас есть интерфейс некого хранилища объектов, параметризованный, допустим, двумя типами: тип ключа (K) и тип значения (V). Интерфейс определяет набор методов для работы с данными в хранилище:

public interface MyObjectStore<K, V> {
	/**
	 * Кладёт значение в хранилище по заданному ключу.
	 * 
	 * @param key Ключ.
	 * @param value Значение.
	 */
	void put(K key, V value);

	/**
	 * Читает значение из хранилища по заданному ключу.
	 * 
	 * @param key Ключ.
	 * @return Значение либо null.
	 */
	@Nullable V get(K key);

	/**
	 * Кладёт все пары ключ-значение в хранилище.
	 * 
	 * @param entries Набор пар ключ-значение.
	 */
	void putAll(Map<K, V> entries);

	/**
	 * Читает все значения из хранилища по заданным
	 * ключам.
	 * 
	 * @param keys Набор ключей.
	 * @return Пары ключ-значение.
	 */
	Map<K, V> getAll(Collection<K> keys);

	/**
	 * Читает из хранилища все значения, удовлетворяющие
	 * заданному условию (предикату).
	 * 
	 * @param p Предикат для проверки значений.
	 * @return Значения, удовлетворяющие предикату.
	 */
	Collection<V> getAll(Predicate<V> p);

        ... // и так далее
}

Определение Predicate

interface Predicate<E> {
	/**
	 * Возвращает true, если значение удовлетворяет
	 * условию, false в противном случае.
	 *
	 * @param exp Выражение для проверки.
	 * @return true, если удовлетворяет; false, если нет.
	 */
	boolean apply(E exp);
}

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

MyObjectStore<Long, Car> carsStore = ...;

carsStore.put(20334L, new Car("BMW", "X5", 2013));

Car c = carsStore.get(222L);

...

Однако, в чуть менее тривиальных случаях клиент вашего API столкнётся с неприятными ограничениями.

Использование ? super T

Возьмём последний метод, который читает значения, удовлетворяющие предикату. Что с ним может быть не так? Берём, да и пишем:

Collection<Car> cars = carsStore.getAll(new Predicate<Car>() {
	@Override public boolean apply(Car exp) {
		... // Здесь наша логика по выбору автомобиля.
	}
});

Но дело в том, что у нашего клиента уже есть предикат для выбора автомобилей. Только он параметризован не классом Car, а классом Vehicle, от которого Car унаследован. Он может попытаться запихать Predicate<Vehicle> вместо Predicate<Car>, но в ответ получит ошибку компиляции:

no suitable method found for getAll(Predicate<Vehicle>)

Компилятор говорит нам, что вызов метода невалиден, поскольку Vehicle — это не Car. Но ведь он является родительским типом Car, а значит, всё, что можно сделать с Car, можно сделать и с Vehicle! Так что мы вполне могли бы использовать предикат по Vehicle для выбора значений типа Car. Просто мы не сказали компилятору об этом, и, тем самым, заставляем пользователя городить конструкции вроде:

final Predicate<Vehicle> vp = mgr.getVehiclePredicate();

Collection<Car> cars = carsStore.getAll(new Predicate<Car>() {
	@Override public boolean apply(Car exp) {
		return vp.apply(exp);
	}
});

А ведь всё решается так просто! Нам нужно лишь слегка изменить сигнатуру метода:

Collection<V> getAll(Predicate<? super V> p);

Запись Predicate<? super V> означает «предикат от V или любого супертипа V (вплоть до Object)». Данное изменение никак не ломает компиляцию существующего кода, зато устраняет абсолютно бессмысленные ограничения на параметр предиката. Клиент теперь может использовать свой предикат для Vehicle совершенно свободно:

MyObjectStore<Long, Car> carsStore = ...;

Predicate<Vehicle> vp = mgr.getVehiclePredicate();

Collection<Car> cars = carsStore.getAll(vp);

Мы обобщим данный приём чуть ниже, и запомнить его будет совсем просто.

Использование ? extends T

С передаваемыми коллекциями та же история, только в обратную сторону. Здесь, в большинстве случаев, имеет смысл использовать ? extends T для типа элементов коллекции. Посудите сами: имея ссылку на MyObjectStore<Long, Vehicle>, пользователь вполне вправе положить в хранилище набор объектов Map<Long, Car> (ведь Car — это подтип Vehicle), но текущая сигнатура метода не позволяет ему это сделать:

MyObjectStore<Long, Vehicle> carsStore = ...;

Map<Long, Car> cars = new HashMap<Long, Car>(2);

cars.put(1L, new Car("Audi", "A6", 2011));
cars.put(2L, new Car("Honda", "Civic", 2012));

carsStore.putAll(cars); // Ошибка компиляции.

Чтобы снять это бессмысленное ограничение, мы, как и в предыдущем примере, расширяем сигнатуру нашего интерфейсного метода, используя wildcard ? extends T для типа элемента коллекции:

void putAll(Map<? extends K, ? extends V> entries);

Запись Map<? extends K, ? extends V> буквально означает «мапка с ключами типа K или любого из подтипов K и со значениями типа V или любого из подтипов V».

Принцип PECS — Producer Extends Consumer Super

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

Этот принцип Joshua Bloch называет PECS (Producer Extends Consumer Super), а авторы книги Java Generics and Collections (Maurice Naftalin, Philip Wadler) — Get and Put Principle. Но давайте остановимся на PECS, запомнить проще. Этот принцип гласит:

Если метод имеет аргументы с параметризованным типом (например, Collection<T> или Predicate<T>), то в случае, если аргумент — производитель (producer), нужно использовать ? extends T, а если аргумент — потребитель (consumer), нужно использовать ? super T.

Производитель и потребитель, кто это такие? Очень просто: если метод читает данные из аргумента, то этот аргумент — производитель, а если метод передаёт данные в аргумент, то аргумент является потребителем. Важно заметить, что определяя производителя или потребителя, мы рассматриваем только данные типа T.

В нашем примере Predicate<T> — это потребитель (метод getAll(Predicate<T>) передаёт в этот аргумент данные типа T), а Map<K, V>производитель (метод putAll(Map<K, V>) читает данные типа T — в данном случае под T подразумевается K и V — из этого аргумента).

В случае, если аргумент является и потребителем, и производителем одновременно — например, если метод одновременно и читает из коллекции, и пишет в неё (плохой стиль, но всякое бывает) — тогда его нужно оставить как есть.

С возвращаемыми значениями тоже ничего делать не нужно — никакого удобства использование wildcard-ов в этом случае пользователю не принесёт, а лишь вынудит его использовать wildcard-ы в собственном коде.

Вооружившись PECS-принципом, мы можем теперь пройтись по всем методам нашего MyObjectStore интерфейса и сделать улучшения там, где это требуется. Методы put(K, V) и get(K) улучшений не требуют (т.к. они не имеют аргументов с параметризованным типом); методы putAll(Map<? extends K, ? extends V>) и getAll(Predicate<? super V>) мы уже и так улучшили, дальше некуда; а вот метод getAll(Collection<K>) имеет аргумент-производитель с параметризованным типом, который мы можем расширить. Вместо

Map<K, V> getAll(Collection<K> keys);

делаем

Map<K, V> getAll(Collection<? extends K> keys);

и радуемся новому, более удобному API! (Заметьте, возвращаемое значение мы не трогаем!)

Другие примеры потребителя и производителя

Производителями могут быть не только коллекции. Самый очевидный пример производителя — это фабрика:

interface Factory<T> {
	/**
	 * Создаёт новый экземпляр объекта заданного типа.
	 * 
	 * @param args Аргументы.
	 * @return Новый объект.
	 */
	T create(Object... args);
}

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

interface Cloner<T> {
	/**
	 * Клонирует объект.
	 *
	 * @param obj Исходный объект.
	 * @return Копия.
	 */
	T clone(T obj);
}

Коллекция может быть потребителем в случае, если это ouput-коллекция, в которую метод складывает результат своей работы (хотя такой стиль в Java редко используется и считается плохим тоном).

Заключение

В этой статье мы познакомились с принципом PECS (Producer Extends Consumer Super) и научились его применять при разработке API на Java. Как показывает практика, даже в самых продвинутых программистских конторах об этом принципе некоторые разработчики не знают, и в результате проектируют не совсем удобное API. Но, к счастью, исправляются подобные ошибки очень легко, а запомнив мнемонику PECS однажды, вы уже просто не сможете не пользоваться ей в дальнейшем.

Литература

  1. Joshua Bloch — Effective Java (2nd Edition)
  2. Maurice Naftalin, Philip Wadler — Java Generics and Collections

Автор: weekens

Источник


  1. Viktor:

    что такое mgr.getVehiclePredicate() ? когда не понимаешь в теме, и пытаешь понять – а тут ещё непонятные сущности – только ещё больше запутываешся. Такое ошушение, что статьи пишутся для тех кто уже понимает, и типа – “да да да, так оно и есть…”

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js