Продолжается подписка на наши издания! Вы не забыли подписаться?

Теория и практика Java. Эксперименты с generic-методами

Автор: Брайен Гетц (brian.goetz@sun.com)
главный инженер, Sun Microsystems

Перевод: М. Орельская
Источник: http://www.ibm.com/developerworks
Опубликовано: 28.04.2009

Понимание wildcard capture

Одним из наиболее сложных аспектов generic-типов (обобщенных типов) в языке Java являются wildcards (подстановочные символы, в данном случае – «?»), и особенно – толкование и разбор запутанных сообщений об ошибках, происходящих при wildcard capture (подстановке вычисляемого компилятором типа вместо wildcard). В данной части «Теория и практика Java» (Java theory and practice) старейший Java-разработчик Брайен Гетц расшифровывает некоторые из наиболее загадочно выглядящих сообщений об ошибках, выдаваемых компилятором «javac», и предлагает решения и варианты обхода, которые помогут упростить использование generic-типов.

Дискуссия по поводу generic-типов не прекращается с тех самых пор, как они были добавлены в язык Java в JDK 5. Одни считают, что эти типы упрощают программирование за счет расширения системы типов и, следовательно, повышают способность компилятора проверять безопасность типов. Другие говорят, что введение generic-типов только увеличивает путаницу. Все мы при их использовании сталкивались с некоторыми моментами, заставляющими задуматься, но именно wildcards представляют собой самую сложную часть generic-типов.

Основы работы с wildcards

Generic-и являются средством выражения ограничений поведения классов или методов для исходно неизвестных типов. Например: «какой бы из типов ни использовался в этом методе для параметров x или y, эти параметры должны быть одинакового типа», «вы должны обеспечить параметр одинакового типа для обоих этих методов», или «возвращаемое значение foo() имеет тот же тип, что и параметр bar()».

Wildcards – специальные вопросительные знаки, отмечающие место, где должно оказаться название параметра типа. Они являются средством выражения ограничений в условиях неизвестности какого-нибудь типа. Первоначально эти знаки не являлись частью структуры generic-ов, унаследованной от проекта Generic Java (GJ), а были добавлены в процессе разработки дизайна за те более чем пять лет, что прошли между созданием JSR 14 и его последним выпуском.

Wildcards играют важную роль в системе типов. Они позволяют задать границы для семейства типов, определенных каким-нибудь generic-классом. Для generic-класса ArrayList тип ArrayList<?> является супер-типом ArrayList<T> с параметром любого типа T (так же, как обычный тип (raw type) ArrayList и корневой тип (root type) Object, но эти супертипы менее удобны при выполнении вывода типа).

Тип List<?> отличается как от raw-типа List, так и от конкретного типа List<Object>. Сказать, что переменная x имеет тип List<?>, означает, что существует некоторый тип T, для которого x имеет тип List<T>, и что при этом x – однородная переменная, хотя неизвестно, какой конкретно тип имеют здесь ее элементы. Это не значит, что содержимое тут может быть любым – имеется в виду, что мы не знаем заранее, какие ограничения типа использованы, но знаем, что ограничение есть. С другой стороны, raw-тип List является гетерогенным; мы не можем наложить какие-либо ограничения на его элементы, а конкретный тип List<Object> предполагает, что мы ясно представляем себе, что в него могут быть переданы любые объекты. (Конечно, система generic-типов не содержит концепции «содержимое списка», но generic-и проще всего понять на примере типов, описывающих такие коллекции как List).

Wildcards удобно использовать в системе типов, в частности, потому, что generic-типы не являются ковариантными. Массивы же ковариантны. Поскольку Integer является подтипом Number, то массив типа Integer[] – подтип от Number[], и, следовательно, значение переменной типа Integer[]можно передать туда, где требуется Number[]. С другой стороны, generic-типы – не ковариантны; List<Integer> не является подтипом от List<Number>, попытка передать List<Integer> туда, где необходим List<Number>, вызовет ошибку несоответствия типов. Это не случайность, но и не обязательно ошибка, как кажется многим. На самом деле большая часть проблем вызвана разницей между принципами работы generic-типов и обычных массивов.

У меня появился wildcard. Что теперь?

Листинг 1 содержит пример простого типа-контейнера Box, который поддерживает операции put и get. Box параметризован параметром T, описывающим тип содержимого контейнера. Например, Box<String> может включать в себя только элементы типа String.

Листинг 1. Простой generic-тип Box

public interface Box<T> {
    public T get();
    public void put(T element);
}

Одно из преимуществ wildcards состоит в том, что они дают возможность написать код, который может оперировать переменными generic-типов без знания их точных границ. Допустим, у вас есть переменная типа Box<?> – такая, как параметр box в методе unbox() в листинге 2. Что же может делать unbox(), обрабатывая параметр box?

Листинг 2. Метод Unbox с wildcard-параметром

public void unbox(Box<?> box) 
{
  System.out.println(box.get());
}

Как видим, он может делать довольно много. Может вызвать метод get(), а может – любой из методов, унаследованных от Object (например, hashCode()). Единственное, что он не может сделать – вызвать метод put(), потому что без знания типа параметра T для данного экземпляра класса Box компилятор не в состоянии проверить эту операцию на безопасность. Поскольку box является экземпляром Box<?>, а не raw-класса Box, компилятор знает о существовании некоего T, который работает как параметр типа для box. Но что это за T – неизвестно, поэтому вызвать put() нельзя. Ведь нет возможности убедиться, что такой вызов не нарушит ограничения безопасности типа для Box (вообще же, вызвать put() можно только в одном случае: если передать туда null; мы можем не знать, какой тип представлен T, но зато знаем, что литерал null является допустимым значением для любого ссылочного типа).

Что знает unbox() о типе возвращаемого значения у метода box.get()? Он знает, что это тип T для какого-то неизвестного T. Поэтому лучшее, что он может сделать – принять решение, согласно которому тип возвращаемого значения от get() является замещением неизвестного ранее типа T. В случае неограниченного wildcard этот тип представлен типом Object. Таким образом, выражение box.get() в листинге 2 возвращает значение типа Object.

Wildcard capture

Листинг 3 содержит код, который похож на работающий, но только похож. В данном случае берется экземпляр generic-типа Box, из него получается значение, и производится попытка установить это значение обратно в тот же Box.

Листинг 3. Взятое из box значение не может быть помещено обратно

public void rebox(Box<?> box) {
    box.put(box.get());
}

Rebox.java:8: put(capture#337 of ?) in Box<capture#337 of ?> cannot be applied
   to (java.lang.Object)
    box.put(box.get());
       ^
1 error

Код похож на работающий, потому что полученное значение обладает правильным типом для того, чтобы быть помещенным обратно. Но вместо этого компилятор выдает – весьма сбивая с толку – сообщение о несовместимости «capture#337 of ?" с Object.

И что же это «capture#337 of ?» может значить? Когда компилятор встречает переменную с wildcard-знаком в ее типе – например, параметр box в rebox(), он знает, что здесь должно было быть некое T, для которого box есть Box<T>. Ему неизвестно, что за тип представляет T, но он может создать заглушку для этого типа, которая будет ссылаться на тип, представляемый T. Такое место вставки называется capture данного конкретного wildcard-знака. В рассматриваемом нами случае компилятор назначил имя "capture#337 of ?" wildcard-знаку в типе box. Каждое вхождение wildcard-знака в каждом объявлении переменной дает разную capture, поэтому в generic-декларации foo(Pair<?,?> x, Pair<?,?> y) компилятор назначил бы разные имена каждому из четырех wildcard-знаков, поскольку здесь отсутствует связь между какими-либо параметрами типа.

Это сообщение об ошибке уведомляет нас о том, что мы не можем вызвать put(), поскольку такой вызов нельзя проверить на предмет совместимости типа реального параметра в put() с типом его формального параметра; ведь тип формального параметра неизвестен. В данном случае вопросительный знак ? в декларации означает «? extends Object», поэтому компилятор уже решил, что типом box.get() является Object, а не "capture#337 of ?", и что невозможно статически проверить, является ли Object допустимым значением для типа, определенного «capture#337 of ?».

Capture helpers

Несмотря на то, что компилятор отбрасывает здесь какую-то полезную информацию, существует способ заставить его данную информацию восстановить. Для этого нужно дать неизвестному wildcard-типу имя. В Листинге 4 показана реализация rebox(), включающая вспомогательный generic-метод, который это и делает:

Листинг 4. Использование хелпера

public void rebox(Box<?> box) {
    reboxHelper(box);
}

private<V> void reboxHelper(Box<V> box) {
    box.put(box.get());
}

Вспомогательный метод reboxHelper() является generic-методом. Generic-методы вводят дополнительные параметры типов (помещаемые в угловые скобки перед типом возвращаемого значения), которые обычно используются для формулирования ограничений типов между параметрами и/или возвращаемым значением метода. Однако в случае reboxHelper() generic-метод не задействует параметр типа для определения ограничения типа, а позволяет компилятору – через вывод типа – дать имя параметру типа переменной box.

Использование хелпера позволяет обойти ограничения компилятора при использовании wildcards. Когда rebox() вызывает reboxHelper(), ему известно, что это действие безопасно, потому что его собственный параметр box должен быть Box<T> для некоторого неизвестного T. Поскольку параметр типа V является встроенным в сигнатуру метода, не связан с другими параметрами типа, и может также быть параметром любого неизвестного типа, то Box<T> для некоторого неизвестного T может таким же образом представлять собой Box<V> для некоторого неизвестного V. (Данный принцип схож с принципом альфа-редукции в лямбда-исчислении, где позволяется переименовывать связанные переменные). Теперь выражение box.get() в reboxHelper() имеет не тип Object, а тип V, и можно передать параметр V в Box<V>.put().

Можно было объявить и rebox() как generic-метод, как мы объявили reboxHelper(), но в разработке API это не считается хорошим стилем. Основной принцип разработки здесь – не давать никакого имени, если на это имя никогда не будет ссылок. В контексте generic-методов это означает, что если параметр типа показывается в сигнатуре метода только один раз, то он, скорее, должен быть wildcard, нежели именованным параметром типа. Вообще же, API с wildcards проще, чем API с generic-методами; разрастание имен типов в более сложном объявлении методов может сделать это объявление менее читаемым. Поскольку всегда существует возможность при необходимости восстановить имя с помощью private capture-хелпера, вышеописанный подход дает способ сохранять API чистыми, не удаляя при этом нужную информацию.

Вывод типа

Прием с capture-хелпером основан на выводе типов (type inference) и преобразовании при фиксации (capture conversion). Java-компилятор не выполняет вывода типов во многих местах, однако для параметров типа в generic-методах он это делает (другие языки гораздо больше полагаются на вывод типов, поэтому в будущем, возможно, разработчики расширят функции вывода типов в Java). При необходимости можно указать значение параметра типа, но только в том случае, если существует возможность именовать этот тип – а capture-типы нельзя именовать. Поэтому данный прием сработает лишь в том случае, если компилятор выводит тип сам. Именно преобразование позволяет компилятору создать заглушку названия типа для wildcard так, чтобы при выводе типов был выведен именно этот тип.

Компилятор будет стараться вывести наиболее подходящий тип для имеющихся параметров типа при разрешении вызова generic-метода. К примеру, для такого generic-метода:

public static<T> T identity(T arg) { return arg };

и такого вызова:

Integer i = 3;
System.out.println(identity(i));

компилятор мог бы решить, что T имеет тип Integer, Number, Serializable или Object, но выбирает Integer, поскольку он является наиболее точно удовлетворяющим ограничениям.

Вывод типа можно использовать для уменьшения избыточности при создании generic-экземпляров. Например, использование класса Box, создающего Box<String>, требует указания параметра типа String дважды:

Box<String> box = new BoxImpl<String>();

Такое нарушение «принципа DRY» (Don't Repeat Yourself – «Не повторяйся!») может раздражать, даже когда IDE готовы проделать за вас некоторую часть работы. Однако если реализация класса BoxImpl поддерживает generic-метод фабрики класса, как показано в Листинге 5 (это, в любом случае, хорошая идея), есть возможность сократить подобную избыточность в клиентском коде:

Листинг 5. Generic-метод фабрики класса, позволяющий избежать чрезмерных описаний в параметрах типа.

public class BoxImpl<T> implements Box<T> 
{
  public static<V> Box<V> make() 
  {
    return new BoxImpl<V>();
  }

    ...
}

Если Box создается с использованием фабрики BoxImpl.make(), параметр типа достаточно указать всего один раз:

Box<String> myBox = BoxImpl.make();

Generic-метод make() возвращает Box<V> для некоторого типа V, и возвращаемое значение используется в контексте, которому требуется Box<String>. Компилятор выбирает тип String как наиболее определенный из тех, что V мог бы принять, учитывая ограничения типа, и таким образом распознает здесь V как String. При этом указать значение V можно и вручную:

Box<String> myBox = BoxImpl.<String>make();

Помимо уменьшения количества нажатий на клавиши, продемонстрированная здесь техника использования фабричного метода имеет и другие преимущества по сравнению с использованием конструкторов: фабрикам классов можно давать более подробные имена, фабрики классов способны возвращать подтипы именованных типов возвращаемых значений, и они не обязательно должны создавать новый экземпляр объекта при каждом вызове, поскольку позволяют совместное использование неизменяемых экземпляров. (Чтобы узнать больше о преимуществах статических фабрик классов, обратитесь к статье «Effective Java, Item #1», см. раздел «Ресурсы»).

Заключение

Несомненно, wildcards могут вызывать затруднения. Одни из самых запутанных сообщений об ошибках, выдаваемых компилятором Java, связаны как раз с применением wildcards. Некоторые из наиболее сложных разделов спецификации языка Java также посвящены wildcards. Тем не менее, при правильном использовании wildcards чрезвычайно удобны. В данной статье описано два приема – использование capture-хелперов и generic-фабрик, преимущество которых заключается в использовании generic-методов и вывода типов. При надлежащем применении они помогают устранить значительную часть сложностей.

Ресурсы


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

Copyright © 1994-2016 ООО "К-Пресс"