Теория и практика Java: Загадки родовых типов (generics)

Определение и устранение некоторых пробелов в изучении использования родовых типов (generics)

Родовые типы, добавленные в JDK 5.0, являются значительным улучшением независимости от типа в языке Java. Однако, для новичков некоторые аспекты родовых типов могут показаться странными или даже совершенно ненормальными. В статье этого месяца "Теория и практика Java" Брайан Гец исследует ловушки, в которые могут попасть новички при изучении родовых типов.

Брайан Гец (Brian Goetz), Главный консультант, Quiotix

Брайан Гец (Brian Goetz) является профессиональным разработчиком программного обеспечения на протяжении 17 лет. Он является главным консультантом в Quiotix, фирме, занимающейся разработкой программного обеспечения и консультациями и расположенной в Los Altos, California, а также работает в нескольких JCP Expert Groups. Вы можете найти его опубликованные и готовящиеся к публикации статьи в популярных отраслевых изданиях.



25.01.2005

Параметризованные типы (или родовые типы) имеют мнимое сходство с шаблонами C++ как по синтаксису, так и по ожидаемому месту их применения (например, в качестве контейнерных классов). Но это сходство только поверхностное – родовые типы в языке программирования Java почти полностью реализуются в компиляторе, который выполняет проверку типов и выявление типа (type inference) и, затем, генерирует обычные не параметризованные байткоды. Такая техника реализации, называемая стиранием (когда компилятор использует информацию о родовом типе для контроля типов и удаляет ее перед генерированием байткода), имеет неожиданные, а иногда и непонятные последствия. В то время как родовые типы являются большим шагом на пути к безопасности Java-классов, изучение их использования почти наверняка будет вызывать некоторую озадаченность (а иногда и мучения).

Примечание: В этой статье предполагается, что вы знакомы с основами родовых типов JDK 5.0.

Родовые типы не ковариантны

Вы, возможно, найдете полезным представлять коллекции в качестве абстракции массивов, хотя массивы имеют некоторые специализированные свойства, которых нет у коллекций. Массивы в языке программирования Java являются ковариантными – это означает, что если Integer расширяет Number (как и есть на самом деле), то не только Integer является Number, но и Integer[] тоже является Number[], и вы можете передавать или присваивать Integer[] при вызове Number[]. (Более формально, если Number является супертипом Integer, то Number[] является супертипом Integer[].) Вы можете подумать, что это верно и в отношении родовых типов – то есть List<Number> является супертипом List<Integer>, и что можно передавать List<Integer>, когда нужен List<Number>. К сожалению, это не так.

На самом деле существует веская причина, чтобы все работало именно так: описанная ситуация привела бы к нарушению независимости родовых типов, которую мы стремимся обеспечить. Представьте, например, что вы могли бы присвоить List<Integer> типу List<Number>. Тогда в следующем фрагменте кода вы смогли бы добавить в List<Integer> элемент, не являющийся Integer:

List<Integer> li = new ArrayList<Integer>();
List<Number> ln = li; // не верно 
ln.add(new Float(3.1415));

Поскольку ln имеет тип List<Number>, добавление Float к нему выглядит совершенно легальным. Но если ln присвоить li, то это нарушило бы независимость от типа, который явно указан при определении li (что li - это список целых чисел), вот почему родовые (generic) типы не могут быть ковариантными.

Другие проблемы ковариантности

Еще одним следствием того, что массивы являются ковариантными, а родовые типы нет, является невозможность создать экземпляр массива родового типа (new List<String>[3] записать нельзя), если типом аргумента не является групповой символ (new List<?>[3] записать можно). Давайте посмотрим, что могло бы произойти, если бы можно было объявить массив родовых типов:

List<String'[] lsa = new List<String>[10]; // не верно
Object[] oa = lsa;  // OK поскольку List<String> является подтипом Object
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[0] = li; 
Strings = lsa[0].get(0);

Последняя строка вызовет исключительную ситуацию ClassCastException, поскольку вы попытались вставить List<Integer> в List<String>. Поскольку ковариантность массива могла бы позволить вам нарушить независимость родового типа, создание экземпляра массивов родовых типов (за исключением типов, аргументы которых являются неограниченными групповыми символами) была запрещена.


Задержки создания

Из-за техники стирания, используемой компилятором, List<Integer> и List<String> представляют один и тот же класс, и при компиляции List<V> генерируется только один класс (в отличие от С++). В результате компилятор не знает, какой тип представлен V при компиляции класса List<V>, и поэтому вы не можете делать некоторые вещи с параметром типа (V в List<V>) в определении класса List<V>, которые можно было бы сделать, зная тип представленного класса.

Поскольку во время исполнения нельзя отличить List<String> от List<Integer> (во время исполнения оба они - просто Lists), создание переменных, тип которых указан родовым типом, является проблематичным. Такое отсутствие информации о типе во время исполнения выявляет проблему для родовых контейнерных классов и для родовых классов, которые хотят создать свои защитные копии.

Рассмотрим родовой класс Foo:

class Foo<T> { 
  public void doSomething(T param) { ... }
}

Предположим, что метод doSomething() желает создать защитную копию аргумента param. Не так уж и много вариантов. Реализовать doSomething() можно, например, следующим образом:

public void doSomething(T param) { 
  T copy = new T(param);  // не верно
}

Но вы не можете использовать тип параметра для доступа к конструктору, поскольку во время компиляции вы не знаете тип конструируемого класса и, следовательно, какие конструкторы доступны. При использовании родовых типов никогда нельзя сказать, например, "Т должен иметь конструктор копирования" (или даже конструктор без аргументов), поэтому доступ к конструкторам классов, представленных параметрами родовых типов, отсутствует.

Как насчет clone()? Скажем Foo определен как T extends Cloneable:

class Foo<T extends Cloneable> { 
  public void doSomething(T param) {
    T copy = (T) param.clone();  // не верно 
  }
}

К сожалению, вы не можете вызывать param.clone(). Почему? Потому что clone() имеет спецификацию доступа protected в Object, и вызывать clone() вы должны через ссылку на класс, который переопределил clone() как public. Но неизвестно, переопределил ли Т clone() как public, поэтому клонирование тоже не годится.

Создание ссылок с групповыми символами

Итак, вы не можете копировать ссылку на тип, чей класс полностью неизвестен во время компиляции. Как насчет типов с групповыми символами? Предположим вы хотите сделать копию параметра с типом Set<?>. Вы знаете, что Set имеет конструктор копирования. И вы также слышали, что лучше использовать Set<?> вместо простого типа Set, когда вам неизвестен тип содержимого набора, поскольку такой подход позволяет получать меньше не отмеченных предупреждений о преобразовании. Попробуем следующее:

class Foo {
  public void doSomething(Set<?> set) {
    Set<?> copy = new HashSet<?>(set);  // не верно
  }
}

К сожалению, вы не можете вызвать родовой конструктор с типом аргумента, указанным групповым символом, даже если знаете, что такой конструктор существует. Однако вы можете сделать так:

class Foo {
  public void doSomething(Set<?> set) {
    Set<?> copy = new HashSet<Object>(set);  
  }
}

Эта структура не самая очевидная, но она является независимой от типа и сделает именно то, что по вашему мнению и должен выполнять HashSet<?>(set).

Создание массивов

Как реализовать ArrayList<V>? Предположим, что класс ArrayList управляет массивом элементов с типом V, поэтому можно ожидать от конструктора для ArrayList<V> создания массива элементов с типом V:

class ArrayList<V> {
  private V[] backingArray;
  public ArrayList() {
    backingArray = new V[DEFAULT_SIZE]; // не верно
  }
}

Но этот код не работает - вы не можете создать экземпляр массива с типом, представленным параметром типа. Компилятор не знает о том, какой тип в действительности представляет V, поэтому он не может создать экземпляр массива элементов с типом V.

Классы Collections используют некрасивый трюк для решения этой проблемы, который генерирует не отмеченное предупреждение о преобразовании при компиляции классов Collections. Конструктор для реализации ArrayList выглядит так:

class ArrayList<V> {
  private V[] backingArray;
  public ArrayList() {
    backingArray = (V[]) new Object[DEFAULT_SIZE]; 
  }
}

Почему этот код не генерирует ArrayStoreException при доступе к backingArray? В конце концов, вы не можете присваивать массив Object массиву String. Объясню. Поскольку родовые типы реализуются с использованием техники стирания, тип backingArray фактически является типом Object[], т.к. Object затирает V. Это означает, что в любом случае класс ожидает, что элементами backingArray являются Object, но компилятор выполняет дополнительную проверку типов для гарантии того, что массив содержит только объекты с типом V. Поэтому этот подход будет работать, хотя он неуклюж и не является объектом для подражания (даже авторы generified Collections framework это подтвердят – см. раздел "Ресурсы").

Альтернативным подходом могло бы быть объявление backingArray как массива Object и приведение его в тип V[] везде, где он используется. Вы все еще получали бы не отмеченные предупреждения о преобразовании (как и в предыдущем случае), но это сделало бы некоторые не сформулированные допущения (например, что backingArray не должен покидать реализации ArrayList) более ясными.

Путь не окончен

Наилучшим подходом могла бы быть передача литерала класса (Foo.class) в конструктор, для того чтобы во время исполнения можно было знать значение T. Причина, по которой этот подход не был принят, – обратная совместимость. Классы коллекций родовых типов не были бы совместимы с предыдущими версиями Collections framework.

Вот как мог бы выглядеть ArrayList при использовании этого метода:

public class ArrayList<V> implements List<V> {
  private V[] backingArray;
  private Class<V> elementType;

  public ArrayList(Class<V> elementType) {
    this.elementType = elementType;
    backingArray = (V[]) Array.newInstance(elementType, DEFAULT_LENGTH);
  }
}

Но постойте-ка! Опять это ужасное непроверенное приведение типа при вызове Array.newInstance(). Почему? Опять же – из-за обратной совместимости. Вот сигнатура Array.newInstance()

public static Object newInstance(Class<?> componentType, int length)

вместо независимой от типа

public static<T> T[] newInstance(Class<T> componentType, int length)

Почему Array был обобщен таким способом? Опять разочаровывающий ответ – сохранение обратной совместимости. Для создания массива элементов примитивного типа, например int[], вы вызываете Array.newInstance() с полем TYPE из соответствующего wrapper-класса (в случае с int в качестве литерала класса передается Integer.TYPE). Обобщение Array.newInstance() с параметром Class<T> вместо Class<?> могло бы быть более независимым от типа для ссылочных типов, но сделало бы невозможным использование Array.newInstance() для создания экземпляра примитивного массива. Возможно, в будущем будет предоставлена альтернативная версия newInstance() для ссылочных типов, и вы сможете использовать его в обоих случаях.

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


Родовые типы и существующие классы

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

Еще одним методом, который, возможно, переписывался бы по-другому и не из-за обратной совместимости, является Collections.toArray(Object[]). Передаваемый в toArray() массив служит двум целям. Если коллекция достаточно маленькая, ее содержимое просто копируется в этот массив. В противном случае будет создан (с использованием отображения) новый массив такого же типа для хранения результата. Если бы Collections framework был переписан с нуля, аргументом Collections.toArray(), возможно, был бы не массив, а литерал класса:

interface Collection<E> { 
  public T[] toArray(Class<T super E> elementClass);
}

Поскольку Collections framework считается примером хорошего дизайна класса, стоит упомянуть области, в которых дизайн был ограничен обратной совместимостью, чтобы его аспекты не копировались слепо.

Одним из элементов обобщенного Collections API, который сразу же сбивает с толку, является сигнатура containsAll(), removeAll() и retainAll(). Можно было бы ожидать, что сигнатура для remove() и removeAll() должна быть такой:

interface Collection<E> { 
  public boolean remove(E e);  // на самом деле не так
  public void removeAll(Collection<? extends E> c);  // на самом деле не так
}

Но фактически:

interface Collection<E> { 
  public boolean remove(Object o);  
  public void removeAll(Collection<?> c);
}

Почему так? Снова ответ лежит в области обратной совместимости. Контракт интерфейса для x.remove(o) означает: "если o содержится в x, удалить его; в противном случае ничего не делать." Если x является родовой коллекцией, o не должен быть совместимым по типу с типом параметра x. Если бы removeAll() был преобразован для вызова только с совместимыми по типу аргументами (Collection<? extends E>), то определенная последовательность кода, которая была легальной до преобразования, стала бы не корректной, например:

// a collection of Integers
Collection c = new HashSet();
// a collection of Objects
Collection r = new HashSet();
c.removeAll(r);

Если бы вышеприведенный фрагмент был пееделан для работы с родовыми типами очевидным способом (устанавливая тип с - Collection<Integer> и r - Collection<Object>), то этот код не прошел бы компиляцию в случае, если сигнатура removeAll() требовала бы наличия аргумента типа Collection<? extends E> вместо отсутствия типа. Одной из ключевых целей преобразования библиотек классов для работы с родовыми типами было не нарушить и не изменить семантику существующего кода, поэтому remove(), removeAll(), retainAll() и containsAll() должны были быть спроектированы с более слабым ограничением по типу, чем могло бы быть при проектировании для родовых типов с нуля.

Спроектированные до родовых классов существующие классы могут иметь семантику, сопротивляющуюся "очевидному" подходу при преобразовании для работы с родовыми типами. В этих случаях вы, вероятно, будете идти на компромисс, аналогичный описанному выше, но при проектировании новых родовых классов с нуля важно понимать моменты, которые являются результатом обратной совместимости библиотек классов Java, для того чтобы они не были неудачно повторены.


Причастность механизма стирания

Поскольку родовые типы реализуются практически полностью в компиляторе Java, а не в библиотеке время исполнения, практически вся информация о родовых типах "стирается" при генерации байткода. Другими словами, компилятор генерирует почти такой же код, который вы написали бы вручную без использования родовых типов и приведений типов, после проверки вашей программы на независимость от типа. В отличие от С++ List<Integer> и List<String> являются одним и тем же классом (хотя и имеют различные типы, являющиеся подтипами List<?> - отличие более важное в JDK 5.0, чем в предыдущих версиях языка).

Одним из побочных эффектов механизма стирания является неспособность класса реализовать оба интерфейса Comparable<String> и Comparable<Number>, поскольку оба они на самом деле являются одним и тем же интерфейсом, указывая на один и тот же метод compareTo(). Может показаться более благоразумным объявить класс DecimalString, совместимый как со Strings, так и с Numbers, но с точки зрения компилятора Java вы пытались бы объявить один и тот же метод дважды:

public class DecimalString implements Comparable<Number>, Comparable<String> { ... } // нет

Еще одним следствием механизма стирания является бессмысленность использования приведений типов и instanceof с параметрами родовых типов. В следующем фрагменте кода независимость от типа совершенно не улучшается:

public <T> T naiveCast(T t, Object o) { return (T) o; }

Компилятор просто выдаст не отмеченное предупреждение о преобразовании, поскольку не знает о том, является приведение типов безопасным или нет. Метод naiveCast() фактически не выполняет какого-либо приведения типов. T просто замещается его "стирателем" (Object), а переданный объект будет приведен в Object - не то что задумывалось.

Механизм стирания также ответственен за описанные выше проблемы создания – вы не можете создать объект родового типа, поскольку компилятор не знает, какой конструктор вызвать. Если ваш родовой класс требует создания объектов, чей тип указан параметрами родового типа, его конструкторы должны принимать литерал класса (Foo.class) и сохранять его, для того чтобы экземпляры могли быть созданы отображением.


Заключение

Родовые типы являются большим шагом вперед в независимости от типа в языке программирования Java, но дизайн функциональных возможностей родовых типов и преобразование библиотеки классов для работы с родовыми типами не прошли без компромиссов. Расширение набора инструкций виртуальной машины для поддержки родовых типов было признано неприемлемым, поскольку это могло значительно усложнить обновление JVM для производителей виртуальных машин Java. Таким образом, был принят механизм стирания, который может быть реализован полностью в компиляторе. Аналогично, при обобщении библиотек классов Java желание сохранить обратную совместимость наложило много ограничений по сравнению с тем, как они могли бы быть обобщены, что привело к некоторым непонятным и запутанным конструкциям, подобным Array.newInstance(). Это не проблемы родовых типов, а проблемы эволюции языка и сохранения совместимости. Но они могут сделать родовые типы немного более непонятными и непростыми для изучения и использования.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=96663
ArticleTitle=Теория и практика Java: Загадки родовых типов (generics)
publish-date=01252005