Статьи

Подтипирование в дженериках Java

Универсальные типы представляют новый спектр безопасности типов в Java-программе. При одинаковом типе универсальные типы могут быть весьма выразительными, особенно при использовании подстановочных знаков . В этой статье я хочу объяснить, как работает подтип с обобщениями Java.

Общие соображения по подтипу родового типа

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

1
2
3
4
5
// assuming that such subtyping was possible
ArrayList<Number> list = new ArrayList<Integer>();
// the next line would cause a ClassCastException
// because Double is no subtype of Integer
list.add(new Double(.1d))

Прежде чем обсуждать это более подробно, давайте сначала немного подумаем о типах в целом: типы вносят избыточность в вашу программу. Когда вы определяете переменную типа Number, вы должны убедиться, что эта переменная ссылается только на объекты, которые знают, как обрабатывать любой метод, определенный Number, такой как Number.doubleValue. Тем самым вы можете безопасно вызывать doubleValue для любого объекта, который в данный момент представлен вашей переменной, и вам больше не нужно отслеживать фактический тип объекта, на который ссылается переменная. (Пока ссылка не является нулевой. Ссылка на ноль на самом деле является одним из немногих исключений строгой безопасности типов Java. Конечно, нулевой «объект» не знает, как обрабатывать вызовы какого-либо метода.) Однако, если вы попытались назначив объект типа String этой переменной с типом Number, компилятор Java распознает, что этот объект фактически не понимает методы, требуемые Number, и выдаст ошибку, поскольку в противном случае он не мог бы гарантировать, что возможный будущий вызов, например, doubleValue будет понятно. Однако, если бы у нас не было типов в Java, программа не изменила бы свою функциональность просто так. До тех пор, пока мы никогда не выполняем ошибочный вызов метода, Java-программа без типов будет эквивалентна. В этом свете типы просто мешают нам, разработчикам, делать что-то глупое, лишая нас немного свободы. Кроме того, типы являются хорошим способом неявного документирования вашей программы. (Другие языки программирования, такие как Smalltalk , не знают типов, и, кроме того, что большую часть времени они раздражают, это также может иметь свои преимущества.)

С этим, давайте вернемся к дженерикам. Определяя универсальные типы, вы позволяете пользователям вашего универсального класса или интерфейса добавлять некоторую безопасность типов в свой код, потому что они могут ограничить себя только тем, чтобы использовать ваш класс или интерфейс определенным образом. Когда вы, например, определяете список, содержащий только числа, определяя List <Number>, вы советуете компилятору Java выдавать ошибку всякий раз, когда вы, например, пытаетесь добавить объект типа String в этот список. До Java-дженериков вы просто должны были верить, что список содержит только числа. Это может быть особенно болезненным, когда вы передаете ссылки своих коллекций на методы, определенные в стороннем коде, или получаете коллекции из этого кода. С помощью дженериков вы могли бы гарантировать, что все элементы в вашем Списке имели определенный супертип даже во время компиляции.

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

1
class MyList<T> extends ArrayList<T> { }

вы не знаете тип T в MyList, и вы должны ожидать, что этот тип может быть таким же простым, как Object. Вот почему вы можете ограничить свой универсальный тип, чтобы он требовал некоторого минимального типа:

1
2
3
4
5
6
7
8
9
class MyList<T extends Number> extends ArrayList<T> {
  double sum() {
  double sum = .0d;
    for(Number val : this) {
      sum += val.doubleValue();
    }
  return sum;
  }
}

Это позволяет предположить, что любой объект в MyList является подтипом Number. Таким образом, вы получаете некоторую безопасность типов в вашем родовом классе.

Wildcards

Подстановочные знаки являются эквивалентом Java любого типа . Следовательно, вам не разрешается использовать подстановочные знаки при создании экземпляра типа, то есть при определении того, какой конкретный тип должен представлять какой-либо экземпляр универсального класса. Например, создание экземпляра типа происходит при создании экземпляра объекта как нового ArrayList <Number>, где вы, помимо прочего, неявно вызываете конструктор типа ArrayList, который содержится в определении его класса

1
class ArrayList<T> implements List<T> { ... }

с ArrayList <T>, являющимся тривиальным конструктором типов с одним единственным аргументом. Таким образом, ни в определении конструктора типа ArrayList (ArrayList <T>), ни в вызове этого конструктора (new ArrayList <Number>) вам не разрешено использовать подстановочный знак. Однако когда вы ссылаетесь только на тип без создания экземпляра нового объекта, вы можете использовать подстановочные знаки, например, в локальных переменных. Поэтому допускается следующее определение:

1
ArrayList<?> list;

Определяя эту переменную, вы создаете заполнитель для ArrayList любого универсального типа. Однако, с этим небольшим ограничением универсального типа, вы не можете добавлять объекты в список через его ссылку на эту переменную. Это потому, что вы сделали такое общее предположение об универсальном типе, представленном списком переменных, что было бы небезопасно добавлять объект, например, типа String, потому что список за списком может потребовать объекты любого другого подтипа некоторого типа. В общем, этот обязательный тип неизвестен, и не существует объекта, который является подтипом любого типа и может быть безопасно добавлен. (Исключением является нулевая ссылка, которая отменяет проверку типов. Однако вы никогда не должны добавлять ноль в коллекции.) В то же время все объекты, которые вы получаете из списка, будут иметь тип Object, потому что это единственное безопасное предположение о общий супертип всех возможных списков, представленных этой переменной. По этой причине вы можете сформировать более сложные символы подстановки, используя ключевые слова extends и super:

1
ArrayList<?> list = new ArrayList<List<?>>();

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

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

1
List<? extends Number> list = new ArrayList<Integer>();

потому что необработанный тип ArrayList является подтипом List и потому что универсальный тип Integer является подтипом? расширяет номер.

Наконец, имейте в виду, что подстановочный знак List <?> Является ярлыком для List <? расширяет Object>, так как это часто используемое определение типа. Если конструктор универсального типа, тем не менее, применяет другую нижнюю границу типа, как, например, в

1
class GenericClass<T extends Number> { }

переменная GenericClass <?> вместо этого будет ярлыком GenericClass <? расширяет номер>.

Принцип «получи и положи»

Это наблюдение приводит нас к принципу « получи и положи» . Этот принцип лучше всего объяснить другим известным примером:

1
2
3
4
5
class CopyClass {
  <T> void copy(List<T> from, List<T> to) {
    for(T item : from) to.add(item);
  }
}

Это определение метода не очень гибкое. Если у вас был какой-то список List <Integer>, вы не могли бы скопировать его содержимое в какой-либо List <Number> или даже в List <Object>. Следовательно, принцип get-and-put гласит, что вы всегда должны использовать подстановочные знаки с нижним ограничением (? Extends), когда вы только читаете объекты из универсального экземпляра (через возвращаемый аргумент), и всегда использовать подстановочные знаки с верхним ограничением (? Super), когда вы предоставляете аргументы только для методов общего экземпляра. Следовательно, лучшая реализация MyAddRemoveList будет выглядеть так:

1
2
3
4
5
class CopyClass {
  <T> void copy(List<? extends T> from, List<? super T> to) {
    for(T item : from) to.add(item);
  }
}

Поскольку вы читаете только из одного списка и пишете в другой, к сожалению, это легко забыть, и вы даже можете найти классы в базовом API Java, которые не применяют принцип get-and-put. (Обратите внимание, что вышеупомянутый метод также описывает конструктор универсального типа.)

Обратите внимание, что типы List <? расширяет T> и List <? super T> менее специфичны, чем требование List <T>. Также обратите внимание, что этот тип подтипирования уже неявный для неуниверсальных типов. Если вы определяете метод, который запрашивает параметр метода типа Number, вы можете автоматически получать экземпляры любого подтипа, например, Integer. Тем не менее, всегда безопасно читать этот объект типа Integer, даже если вы ожидаете супертипа Number. И поскольку невозможно выполнить обратную запись в эту ссылку, т. Е. Вы не можете перезаписать объект Integer, например, экземпляром Double, язык Java не требует, чтобы вы отказались от намерения писать, объявив сигнатуру метода, например void someMethod (<? расширяет номер> номер). Точно так же, когда вы пообещали вернуть Integer из метода, но вызывающему объекту требуется только объект с типом Number, вы все равно можете вернуть ( записать ) любой подтип из вашего метода. Точно так же, поскольку вы не можете прочитать значение из гипотетической возвращаемой переменной, вам не нужно отказываться от этих гипотетических прав на чтение подстановочным знаком при объявлении возвращаемого типа в сигнатуре вашего метода.

Ссылка: Подтип в дженериках Java от нашего партнера JCG Рафаэля Винтерхальтера в блоге My daily Java .