Pull to refresh

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

Reading time6 min
Views152K
Доброго времени суток!

Этот пост для тех, кто работает над очередным 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, а значит, всё, что можно сделать с Vehicle, можно сделать и с Car! Так что мы вполне могли бы использовать предикат по 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
Tags:
Hubs:
Total votes 39: ↑36 and ↓3+33
Comments7

Articles