Доброго времени суток!
Этот пост для тех, кто работает над очередным API на языке Java, либо пытается усовершенствовать уже существующий. Здесь будет дан простой совет, как с помощью конструкций
Перейти сразу к сути
Предположим, у вас есть интерфейс некого хранилища объектов, параметризованный, допустим, двумя типами: тип ключа (
Интерфейс выглядит вполне адекватно и логично, пользователь без проблем может написать простой код для работы с хранилищем:
Однако, в чуть менее тривиальных случаях клиент вашего API столкнётся с неприятными ограничениями.
Использование
Возьмём последний метод, который читает значения, удовлетворяющие предикату. Что с ним может быть не так? Берём, да и пишем:
Но дело в том, что у нашего клиента уже есть предикат для выбора автомобилей. Только он параметризован не классом
Компилятор говорит нам, что вызов метода невалиден, поскольку
А ведь всё решается так просто! Нам нужно лишь слегка изменить сигнатуру метода:
Запись
Мы обобщим данный приём чуть ниже, и запомнить его будет совсем просто.
Использование
С передаваемыми коллекциями та же история, только в обратную сторону. Здесь, в большинстве случаев, имеет смысл использовать
Чтобы снять это бессмысленное ограничение, мы, как и в предыдущем примере, расширяем сигнатуру нашего интерфейсного метода, используя wildcard
Запись
Настало время вывести общий принцип, благодаря которому мы всегда будем писать интерфейсы, абсолютно безопасные с точки зрения типов, но при этом не имеющие бессмысленных и создающих неудобства ограничений.
Этот принцип Joshua Bloch называет PECS (Producer Extends Consumer Super), а авторы книги Java Generics and Collections (Maurice Naftalin, Philip Wadler) — Get and Put Principle. Но давайте остановимся на PECS, запомнить проще. Этот принцип гласит:
Производитель и потребитель, кто это такие? Очень просто: если метод читает данные из аргумента, то этот аргумент — производитель, а если метод передаёт данные в аргумент, то аргумент является потребителем. Важно заметить, что определяя производителя или потребителя, мы рассматриваем только данные типа T.
В нашем примере
В случае, если аргумент является и потребителем, и производителем одновременно — например, если метод одновременно и читает из коллекции, и пишет в неё (плохой стиль, но всякое бывает) — тогда его нужно оставить как есть.
С возвращаемыми значениями тоже ничего делать не нужно — никакого удобства использование wildcard-ов в этом случае пользователю не принесёт, а лишь вынудит его использовать wildcard-ы в собственном коде.
Вооружившись PECS-принципом, мы можем теперь пройтись по всем методам нашего
делаем
и радуемся новому, более удобному API! (Заметьте, возвращаемое значение мы не трогаем!)
Производителями могут быть не только коллекции. Самый очевидный пример производителя — это фабрика:
Хорошим примером аргумента, являющегося и производителем, и потребителем, будет аргумент вот такого типа:
Коллекция может быть потребителем в случае, если это ouput-коллекция, в которую метод складывает результат своей работы (хотя такой стиль в Java редко используется и считается плохим тоном).
В этой статье мы познакомились с принципом PECS (Producer Extends Consumer Super) и научились его применять при разработке API на Java. Как показывает практика, даже в самых продвинутых программистских конторах об этом принципе некоторые разработчики не знают, и в результате проектируют не совсем удобное API. Но, к счастью, исправляются подобные ошибки очень легко, а запомнив мнемонику PECS однажды, вы уже просто не сможете не пользоваться ей в дальнейшем.
Этот пост для тех, кто работает над очередным 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 однажды, вы уже просто не сможете не пользоваться ей в дальнейшем.