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

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

    Этот пост для тех, кто работает над очередным 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 вместо Predicate, но в ответ получит ошибку компиляции:

    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 или Predicate), то в случае, если аргумент - производитель (producer), нужно использовать ? extends T, а если аргумент - потребитель (consumer), нужно использовать ? super T.
    Производитель и потребитель, кто это такие? Очень просто: если метод читает данные из аргумента, то этот аргумент - производитель, а если метод передаёт данные в аргумент, то аргумент является потребителем. Важно заметить, что определяя производителя или потребителя, мы рассматриваем только данные типа T.

    В нашем примере Predicate - это потребитель (метод getAll(Predicate) передаёт в этот аргумент данные типа 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) имеет аргумент-производитель с параметризованным типом, который мы можем расширить. Вместо

    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
    • +33
    • 50,2k
    • 5
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 5
    • +2
      Когда начал писать на Java — и узнал что есть конструкции ? extends T и ? super T — сразу попытался представить ситуации, где они могут быть полезны — и как-то естественно пришёл к таким интерфейсам.

      Я бы сформулировал вместо PECS другой принцип (сокрытия реализации.) — «знай только необходимый минимум о типах передаваемых тебе параметров» — отсюда вытечет как использование extends так и super.
      • 0
        Не для всех это очевидно, к сожалению. :-)
        У разных людей по-разному развита склонность к абстрактному мышлению.
        И если человек специально не подумает об этом, то может и не прийти к аналогичным выводам.
        • 0
          Ага, вот пример, на который я нарвался в apache commons-configuration: issues.apache.org/jira/browse/CONFIGURATION-561.

          Товарищи удачно мигрировали на Java 1.5+ (до этого была совместимость 1.4+, где generics ещё не было).

          проблемный код
          В API
          public List getList(String key, List defaultValue)
          

          поменялся на
          public List<Object> getList(String key, List<Object> defaultValue)
          


          И код вида
          Configuration conf = ...; // some configuration
          List<String> defaults = ...; // some default value
          
          List<Object> value = conf.getList("id", defaults);
          

          Теперь просто не соберется.


          Сейчас это, конечно, исправлено.
      • +5
        Было бы неплохо в контексте статьи упомянуть такой термин, как "вариантность". Например, что параметр типа в конструкции <T> является инвариантным, в <? extends T>ковариантным, а в <? super T>контравариантным. А то много разработчиков слышали эти термины, но что именно они описывают — без понятия, хотя каждый знаком с их сутью. К тому же, во всяких блогах по ООП они, почему-то, в подавляющем большинстве объясняются на примерах из C#.
        • 0
          Вот тут Миша Ершов хорошо рассказал про совместимые API, в том числе, и про дженерификацию:

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.