Java Generics — это одно из самых значительных изменений за всю историю языка Java. «Дженерики», доступные с Java 5, сделали использование Java Collection Framework проще, удобнее и безопаснее. Ошибки, связанные с некорректным использованием типов, теперь обнаруживаются на этапе компиляции. Да и сам язык Java стал еще безопаснее. Несмотря на кажущуюся простоту обобщенных типов, многие разработчики сталкиваются с трудностями при их использовании. В этом посте я расскажу об особенностях работы с Java Generics, чтобы этих трудностей у вас было поменьше. Пригодится, если вы не гуру в дженериках, и поможет избежать много трудностей при погружении в тему.



Работа с коллекциями


Предположим, банку нужно подсчитать сумму сбережений на счетах клиентов. До появления «дженериков» метод вычисления суммы выглядел так:

public long getSum(List accounts) {
   long sum = 0;

   for (int i = 0, n = accounts.size(); i < n; i++) {
       Object account = accounts.get(i);
       if (account instanceof Account) {
           sum += ((Account) account).getAmount();
       }
   }

   return sum;
}

Мы итерировались, пробегались по списку аккаунтов и проверяли, действительно ли элемент из этого списка является экземпляром класса Account — то есть счетом пользователя. Выполняли приведение типа нашего объекта класса Account и метод getAmount, который возвращал сумму на этом счете. Дальше все это суммировали и возвращали итоговую сумму. Требовалось выполнить два действия:
if (account instanceof Account) { // (1)

sum += ((Account) account).getAmount(); // (2)

Если не сделать проверку (instanceof) на принадлежность к классу Account, то на втором этапе возможен ClassCastException – то есть аварийное завершение программы. Поэтому такая проверка была обязательной.

С появлением Generics необходимость в проверке и приведении типа отпала:
public long getSum2(List<Account> accounts) {
   long sum = 0;

   for (Account account : accounts) {
       sum += account.getAmount();
   }

   return sum;
}

Теперь метод
getSum2(List<Account> accounts)
принимает в качестве аргументов только список объектов класса Account. Это ограничение указано в самом методе, в его сигнатуре, программист просто не может передать никакой другой список — только список клиентских счетов.

Нам не нужно выполнять проверку типа элементов из этого списка: она подразумевается описанием типа у параметра метода
List<Account> accounts
(можно прочитать как список объектов класса Account). И компилятор выдаст ошибку, если что-то пойдет не так — то есть если кто-то попробует передать в этот метод список объектов, отличных от класса Account.

Во второй строчке проверки необходимость тоже отпадала. Если потребуется, приведение типов (casting) будет сделано на этапе компиляции.

Принцип подстановки


Принцип подстановки Барбары Лисков – специфичное определение подтипа в объектно-ориентированном программировании. Идея Лисков о «подтипе» дает определение понятия замещения: если S является подтипом T, тогда объекты типа T в программе могут быть замещены объектами типа S без каких-либо изменений желательных свойств этой программы.

Тип
Подтип
Number
Integer
List<E>
ArrayList<E>
Collection<E>
List<E>
Iterable<E>
Collection<E>

Примеры отношения тип/подтип

Вот пример использования принципа подстановки в Java:
Number n = Integer.valueOf(42);
List<Number> aList = new ArrayList<>();
Collection<Number> aCollection = aList;
Iterable<Number> iterable = aCollection;

Integer является подтипом Number, следовательно, переменной n типа Number можно присвоить значение, которое возвращает метод Integer.valueOf(42).

Ковариантность, контравариантность и инвариантность


Сначала немного теории. Ковариантность — это сохранение иерархии наследования исходных типов в производных типах в том же порядке. Например, если Кошка — это подтип Животные, то Множество<Кошки> — это подтип Множество<Животные>. Следовательно, с учетом принципа подстановки можно выполнить такое присваивание:

Множество<Животные>  = Множество<Кошки>

Контравариантность — это обращение иерархии исходных типов на противоположную в производных типах. Например, если Кошка — это подтип Животные, то Множество<Животные> — это подтип Множество<Кошки>. Следовательно,  с учетом принципа подстановки можно выполнить такое присваивание:

Множество<Кошки> = Множество<Животные>

Инвариантность — отсутствие наследования между производными типами. Если Кошка — это подтип Животные, то Множество<Кошки> не является подтипом Множество<Животные> и Множество<Животные> не является подтипом Множество<Кошки>.

Массивы в Java ковариантны. Тип S[] является подтипом T[], если S — подтип T. Пример присваивания:
String[] strings = new String[] {"a", "b", "c"};
Object[] arr = strings;

Мы присвоили ссылку на массив строк переменной arr, тип которой – «массив объектов». Если бы массивы не были ковариантными, нам бы это сделать не удалось. Java позволяет это сделать, программа скомпилируется и выполнится без ошибок.

arr[0] = 42; // ArrayStoreException. Проблема обнаружилась на этапе выполнения программы

Но если мы попытаемся изменить содержимое массива через переменную arr и запишем туда число 42, то получим ArrayStoreException на этапе выполнения программы, поскольку 42 является не строкой, а числом. В этом недостаток ковариантности массивов Java: мы не можем выполнить проверки на этапе компиляции, и что-то может сломаться уже в рантайме.

«Дженерики» инвариантны. Приведем пример:
List<Integer> ints = Arrays.asList(1,2,3);
List<Number> nums = ints; // compile-time error. Проблема обнаружилась на этапе компиляции
nums.set(2, 3.14);
assert ints.toString().equals("[1, 2, 3.14]");

Если взять список целых чисел, то он не будет являться ни подтипом типа Number, ни каким-либо другим подтипом. Он является только подтипом самого себя. То есть List <Integer> — это List<Integer> и ничего больше. Компилятор позаботится о том, чтобы переменная ints, объявленная как список объектов класса Integer, содержала только объекты класса Integer и ничего кроме них. На этапе компиляции производится проверка, и у нас в рантайме уже ничего не упадет.

Wildcards


Всегда ли Generics инварианты? Нет. Приведу примеры:
List<Integer> ints = new ArrayList<Integer>();
List<? extends Number> nums = ints;

Это ковариантность. List<Integer> — подтип List<? extends Number>

List<Number> nums = new ArrayList<Number>();
List<? super Integer> ints = nums;

Это контравариантность. List<Number> является подтипом List<? super Integer>.

Запись вида "? extends ..." или "? super ..." — называется wildcard или символом подстановки, с верхней границей (extends) или с нижней границей (super). List<? extends Number> может содержать объекты, класс которых является Number или наследуется от Number. List<? super Number> может содержать объекты, класс которых Number или  у которых Number является наследником (супертип от Number).


extends B — символ подстановки с указанием верхней границы
super B — символ подстановки с указанием нижней границы
где B — представляет собой границу

Запись вида T2 <= T1 означает, что набор типов описываемых T2 является подмножеством набора типов описываемых T1

т.е.
Number <=? extends Object
? extends Number <=? extends Object
и
? super Object <=? super Number


Более математическая интерпретация темы

Пара задачек для проверки знаний:

1. Почему в примере ниже compile-time error? Какое значение можно добавить в список nums?
List<Integer> ints = new ArrayList<Integer>();
ints.add(1);
ints.add(2);
List<? extends Number> nums = ints;
nums.add(3.14); // compile-time error

Ответ
Если контейнер объявлен с wildcard ? extends, то можно только читать значения. В список нельзя ничего добавить, кроме null. Для того чтобы добавить объект в список нам нужен другой тип wildcard — ? super


2. Почему нельзя получить элемент из списка ниже?
public static <T> T getFirst(List<? super T> list) {
   return list.get(0); // compile-time error
}

Ответ
Нельзя прочитать элемент из контейнера с wildcard ? super, кроме объекта класса Object

public static <T> Object getFirst(List<? super T> list) {
   return list.get(0);
}



The Get and Put Principle или PECS (Producer Extends Consumer Super)


Особенность wildcard с верхней и нижней границей дает дополнительные фичи, связанные с безопасным использованием типов. Из одного типа переменных можно только читать, в другой — только вписывать (исключением является возможность записать null для extends и прочитать Object для super). Чтобы было легче запомнить, когда какой wildcard использовать, существует принцип PECS — Producer Extends Consumer Super.

  • Если мы объявили wildcard с extends, то это producer. Он только «продюсирует», предоставляет элемент из контейнера, а сам ничего не принимает.
  • Если же мы объявили wildcard с super — то это consumer. Он только принимает, а предоставить ничего не может.

Рассмотрим использование Wildcard и принципа PECS на примере метода copy в классе java.util.Collections.

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
…
}

Метод осуществляет копирование элементов из исходного списка src в список dest. src — объявлен с wildcard ? extends и является продюсером, а dest — объявлен с wildcard ? super и является потребителем. Учитывая ковариантность и контравариантность wildcard, можно скопировать элементы из списка ints в список nums:
List<Number> nums = Arrays.<Number>asList(4.1F, 0.2F);
List<Integer> ints = Arrays.asList(1,2);
Collections.copy(nums, ints);


Если же мы по ошибке перепутаем параметры метода copy и попытаемся выполнить копирование из списка nums в список ints, то компилятор не позволит нам это сделать:
Collections.copy(ints, nums); // Compile-time error


<?> и Raw типы


Ниже приведен wildcard с неограниченным символом подстановки. Мы просто ставим <?>, без ключевых слов super или extends:
static void printCollection(Collection<?> c) {
   // a wildcard collection
   for (Object o : c) {
       System.out.println(o);
   }
}


На самом деле такой «неограниченный» wildcard все-таки ограничен, сверху. Collection<?> — это тоже символ подстановки, как и "? extends Object". Запись вида Collection<?> равносильна Collection<? extends Object> , а значит — коллекция может содержать объекты любого класса, так как все классы в Java наследуются от Object – поэтому подстановка называется неограниченной.

Если мы опустим указание типа, например, как здесь:
ArrayList arrayList = new ArrayList();

то, говорят, что ArrayList — это Raw тип параметризованного ArrayList<T>. Используя Raw типы, мы возвращаемся в эру до дженериков и сознательно отказываемся от всех фич, присущих параметризованным типам.

Если мы попытаемся вызвать параметризованный метода у Raw типа, то компилятор выдаст нам предупреждение «Unchecked call». Если мы попытаемся выполнить присваивание ссылки на параметризованный тип Raw типу, то компилятор выдаст предупреждение «Unchecked assignment». Игнорирование этих предупреждений, как мы увидим позже, может привести к ошибкам во время выполнения нашего приложения.
ArrayList<String> strings = new ArrayList<>();
ArrayList arrayList = new ArrayList();
arrayList = strings; // Ok
strings = arrayList; // Unchecked assignment
arrayList.add(1); //unchecked call


Wildcard Capture


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

public static void reverse(List<?> list);

// Ошибка!
public static void reverse(List<?> list) {
  List<Object> tmp = new ArrayList<Object>(list);
  for (int i = 0; i < list.size(); i++) {
    list.set(i, tmp.get(list.size()-i-1)); // compile-time error
  }
}

Ошибка компиляции возникла, потому что в методе reverse в качестве аргумента принимается список с неограниченным символом подстановки <?> .
<?> означает то же что и <? extends Object>. Следовательно, согласно принципу PECS, list – это producer. А producer только продюсирует элементы. А мы в цикле for вызываем метод set(), т.е. пытаемся записать в list. И поэтому упираемся в защиту Java, что не позволяет установить какое-то значение по индексу.

Что делать? Нам поможет паттерн Wildcard Capture. Здесь мы создаем обобщенный метод rev. Он объявлен с переменной типа T. Этот метод принимает список типов T, и мы можем сделать сет.
public static void reverse(List<?> list) { 
  rev(list); 
}

private static <T> void rev(List<T> list) {
  List<T> tmp = new ArrayList<T>(list);
  for (int i = 0; i < list.size(); i++) {
    list.set(i, tmp.get(list.size()-i-1));
  }
}

Теперь у нас все скомпилируется. Здесь произошел захват символа подстановки (wildcard capture). При вызове метода reverse(List<?> list) в качестве аргумента передается список каких-то объектов (например, строк или целых чисел). Если мы можем захватить тип этих объектов и присвоить его переменной типа X, то можем заключить, что T является X.

Более подробно о Wildcard Capture можно прочитать здесь и здесь.

Вывод


Если необходимо читать из контейнера, то используйте wildcard с верхней границей "? extends". Если необходимо писать в контейнер, то используйте wildcard с нижней границей "? super". Не используйте wildcard, если нужно производить и запись, и чтение.

Не используйте Raw типы! Если аргумент типа не определен, то используйте wildcard <?>.

Переменные типа


Когда мы записываем при объявлении класса или метода идентификатор в угловых скобках, например <T> или <E>, то создаем переменную типа. Переменная типа — это неквалифицированный идентификатор, который можно использовать в качестве типа в теле класса или метода. Переменная типа может быть ограничена сверху.
public static <T extends Comparable<T>> T max(Collection<T> coll) {
  T candidate = coll.iterator().next();
  for (T elt : coll) {
    if (candidate.compareTo(elt) < 0) candidate = elt;
  }
  return candidate;
}

В этом примере выражение T extends Comparable<T> определяет T (переменную типа), ограниченную сверху типом Comparable<T>. В отличие от wildcard, переменные типа могут быть ограничены только сверху (только extends). Нельзя записать super. Кроме того, в этом примере T зависит от самого себя, это называется recursive bound — рекурсивная граница.

Вот еще пример из класса Enum:
public abstract class Enum<E extends Enum<E>>implements Comparable<E>, Serializable

Здесь класс Enum параметризован типом E, который является подтипом от Enum<E>.

Multiple bounds (множественные ограничения)


Multiple Bounds – множественные ограничения. Записывается через символ "&", то есть мы говорим, что тип, представленный переменной типа T, должен быть ограничен сверху классом Object и интерфейсом Comparable.

<T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)

Запись Object & Comparable<? super T&gt образует тип пересечения Multiple Bounds. Первое ограничение — в данном случае Object — используется для erasure, процесса затирания типов. Его выполняет компилятор на этапе компиляции.

Вывод


Переменная типа может быть ограничена только сверху одним или несколькими типами. В случае множественного ограничения левая граница (первое ограничение) используется в процессе затирания (Type Erasure).

Type Erasure


Type Erasure представляет собой отображение типов (возможно, включая параметризованные типы и переменные типа) на типы, которые никогда не являются параметризованными типами или переменными типами. Мы записываем затирание типа T как |T|.

Отображение затирания определяется следующим образом:
  • Затиранием параметризованного типа G<T1,...,Tn> является |G|
  • Затиранием вложенного типа T.C является |T|.C
  • Затиранием типа массива T[] является |T|[]
  • Затиранием переменной типа является затирание ее левой границы
  • Затиранием любого иного типа является сам этот тип


В процессе выполнения Type Erasure (затирания типов) компилятор производит следующие действия:
  • добавляет приведение типов для обеспечения type safety, если это необходимо
  • генерирует Bridge методы для сохранения полиморфизма


T (Тип)
|T| (Затирание типа)
List< Integer>, List< String>, List< List< String>>
List
List< Integer>[]
List[]
List
List
int
int
Integer
Integer
<T extends Comparable<T>>
Comparable
<T extends Object & Comparable<? super T>>
Object
LinkedCollection<E>.Node
LinkedCollection.Node

Эта таблица показывает, во что превращаются разные типы в процессе затирания, Type Erasure.

На скриншоте ниже два примера программы:


Разница между ними в том, что слева происходит compile-time error, а справа все компилируется без ошибок. Почему?

Ответ
В Java два разных метода не могут иметь одну и ту же сигнатуру. В процессе Type Erasure компилятор добавит bridge-метод public int compareTo(Object o). Но в классе уже содержится метод с такой сигнатурой, что и вызовет ошибку во время компиляции.

Скомпилируем класс Name, удалив метод compareTo(Object o), и посмотрим на получившийся байткод с помощью javap:
# javap Name.class 
Compiled from "Name.java"
public class ru.sberbank.training.generics.Name implements java.lang.Comparable<ru.sberbank.training.generics.Name> {
  public ru.sberbank.training.generics.Name(java.lang.String);
  public java.lang.String toString();
  public int compareTo(ru.sberbank.training.generics.Name);
  public int compareTo(java.lang.Object);
}

Видим, что класс содержит метод int compareTo(java.lang.Object) , хотя мы его удалили из исходного кода. Это и есть bridge метод, который добавил компилятор.


Reifiable типы


В Java мы говорим, что тип является reifiable, если информация о нем полностью доступна во время выполнения программы. В reifiable типы входят:
  • Примитивные типы (int, long, boolean)
  • Непараметризованные (необобщенные) типы (String, Integer)
  • Параметризованные типы, параметры которых представлены в виде unbounded wildcard (неограниченных символов подстановки) (List<?>, Collection<?>)
  • Raw (несформированные) типы (List, ArrayList)
  • Массивы, компоненты которых — Reifiable типы (int[], Number[], List<?>[], List[)


Почему информация об одних типах доступна, а о других нет? Дело в том, что из-за процесса затирания типов компилятором информация о некоторых типах может быть потеряна. Если она потерялась, то такой тип будет уже не reifiable. То есть она во время выполнения недоступна. Если доступна – соответственно, reifiable.

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

Какие типы не являются reifiable:
  • Переменная типа (T)
  • Параметризованный тип с указанным типом параметра (List<Number> ArrayList<String>, List<List<String>>)
  • Параметризованный тип с указанной верхней или нижней границей (List<? extends Number>, Comparable<? super String>). Но здесь стоит оговориться: List<? extends Object>не reifiable, а List<?> — reifiable


И еще одна задачка. Почему в примере ниже нельзя создать параметризованный Exception?

class MyException<T> extends Exception { 
   T t;
}

Ответ
Каждое catch выражение в try-catch проверяет тип полученного исключения во время выполнения программы (что равносильно instanceof),  соответственно, тип должен быть Reifiable. Поэтому Throwable и его подтипы не могут быть параметризованы.

class MyException<T> extends Exception {// Generic class may not extend ‘java.lang.Throwable’
   T t;
}



Unchecked Warnings


Компиляция нашего приложения может выдать так называемый Unchecked Warning — предупреждение о том, что компилятор не смог корректно определить уровень безопасности использования наших типов. Это не ошибка, а предупреждение, так что его можно пропустить. Но желательно все-так исправить, чтобы избежать проблем в будущем.

Heap Pollution


Как мы упомянули ранее, присваивание ссылки на Raw тип переменной параметризованного типа, приводит к предупреждению «Unchecked assignment». Если мы проигнорируем его, то возможна ситуация под названием "Heap Pollution" (загрязнение кучи). Вот пример:
static List<String> t() {
   List l = new ArrayList<Number>();
   l.add(1);
   List<String> ls = l; // (1)
   ls.add("");
   return ls;
}

В строке (1) компилятор предупреждает об «Unchecked assignment».

Нужно привести и другой пример «загрязнения кучи» — когда у нас используются параметризованные объекты. Кусок кода ниже наглядно показывает, что недопустимо использовать параметризованные типы в качестве аргументов метода с использованием Varargs. В данном случае параметр метода m – это List<String>…, т.е. фактически, массив элементов типа List<String>. Учитывая правило отображения типов при затирании, тип stringLists превращается в массив raw списков (List[]), т.е. можно выполнить присваивание Object[] array = stringLists; и после записать в array объект, отличный от списка строк (1), что вызовет ClassCastException в строке (2).

static void m(List<String>... stringLists) {
   Object[] array = stringLists;
   List<Integer> tmpList = Arrays.asList(42);
   array[0] = tmpList; // (1)
   String s = stringLists[0].get(0); // (2)
}


Рассмотрим еще один пример:
ArrayList<String> strings = new ArrayList<>();
ArrayList arrayList = new ArrayList();
arrayList = strings; // (1) Ok
arrayList.add(1); // (2) unchecked call

Java разрешает выполнить присваивание в строке (1). Это необходимо для обеспечения обратной совместимости. Но если мы попытаемся выполнить метод add в строке (2), то получим предупреждение Unchecked call — компилятор предупреждает нас о возможной ошибке. В самом деле, мы же пытаемся в список строк добавить целое число.

Reflection


Хотя при компиляции параметризованные типы подвергаются процедуре стирания (type erasure), кое-какую информацию мы можем получить с помощью Reflection.

  • Все reifiable доступны через механизм Reflection
  • Информация о типе полей класса, параметров методов и возвращаемых ими значений доступна через Reflection.

Если мы хотим через Reflection получить информацию о типе объекта и этот тип не Reifiable, то у нас ничего не получится. Но, если, например, этот объект нам вернул какой-то метод, то мы можем получить тип возвращаемого этим методом значения:
java.lang.reflect.Method.getGenericReturnType()

С появлением Generics класс java.lang.Class стал параметризованным. Рассмотрим вот этот код:
List<Integer> ints = new ArrayList<Integer>();
Class<? extends List> k = ints.getClass();
assert k == ArrayList.class;


Переменная ints имеет тип List<Integer> и она содержит ссылку на объект типа ArrayList< Integer>. Тогда ints.getClass() вернёт объект типа Class<ArrayLis>, так как List<Integer> затирается в List. Объект типа Class<ArrayList> можно присвоить переменной k типа Class<? extends List>, согласно ковариантности символов подстановки? extends. А ArrayList.class возвращает объект типа Class<ArrayList>.

Вывод


Если информация о типе доступна во время выполнения программы, то такой тип называется Reifiable. К Reifiable типам относятся: примитивные типы, непараметризованные типы, параметризованные типы с неограниченным символом подстановки, Raw типы и массивы, элементы которых являются reifiable.

Игнорирование Unchecked Warnings может привести к «загрязнению кучи» и ошибкам во время выполнения программы.

Reflection не позволяет получить информацию о типе объекта, если он не Reifiable. Но Reflection позволяет получить информацию о типе возвращаемого методом значения, о типе аргументов метода и о типе полей класса.

Type Inference


Термин можно перевести как «Вывод типа». Это возможность компилятора определять (выводить) тип из контекста. Вот пример кода:
List<Integer> list = new ArrayList<Integer>();

С появлением даймонд-оператора в  Java 7 мы можем не указывать тип у ArrayList:
List<Integer> list = new ArrayList<>();

Компилятор выведет тип ArrayList из контекста – List<Integer>. Этот процесс и называется type inference.

В Java 8 сильно усовершенствовали механизм выведения типа благодаря JEP 101.
В общем случае процесс получения информации о неизвестных типах именуется выводом типа Type Inference. На верхнем уровне вывод типа можно разделить на три процесса:
  • Приведение (reduction)
  • Объединение (incorporation)
  • Разрешение (resolution)

Эти процессы тесно взаимодействуют: приведение может запустить объединение, объединение может привести к дальнейшему приведению, а разрешение — к дальнейше­му объединению.
Детальное описание механизма выведения типа доступно в спецификации языка, где ему посвящена целая глава. Мы же вернемся к JEP 101 и рассмотрим какие цели он преследовал.

Предположим у нас есть вот такой класс, который описывает связный список:
class List<E> {
   static <Z> List<Z> nil() { ... };
   static <Z> List<Z> cons(Z head, List<Z> tail) { ... };
   E head() { ... }
}

Результат обобщенного метода List.nil() может быть выведен из правой части:
List<String> ls = List.nil();

Механизм выбора типа компилятором показывает, что аргумент типа для вызова List.nil() действительно String — это работает в JDK 7, все хорошо.

Выглядит разумно, что компилятор также должен иметь возможность вывести тип, когда результат такого вызова обобщенного метода передается другому методу в качестве аргумента, например:
List.cons(42, List.nil()); //error: expected List<Integer>, found List<Object>

В JDK 7 мы получили бы compile-time error. А в JDK 8 скомпилируется. Это и есть первая часть JEP-101, его первая цель — вывод типа в позиции аргумента. Единственная возможность осуществить это в версиях до JDK 8 — использовать явный аргумент типа при вызове обобщенного метода:
List.cons(42, List.<Integer>nil());


Вторая часть JEP-101 говорит о том, что неплохо бы выводить тип в цепочке вызовов обобщенных методов, например:
String s = List.nil().head(); //error: expected String, found Object

Но данная задача не решена до сих пор, и вряд ли в ближайшее время появится такая функция. Возможно, в будущих версиях JDK необходимость в этом исчезнет, но пока нужно указывать аргументы вручную:
String s = List.<String>nil().head();


После выхода JEP 101 на StackOverflow появилось множество вопросов по теме. Программисты спрашивают, почему код, который выполнялся на 7-й версии, на 8-й выполняется иначе – или вообще не компилируется? Вот пример такого кода:
class Test {
   static void m(Object o) {
       System.out.println("one");
   }

   static void m(String[] o) {
       System.out.println("two");
   }

   static <T> T g() {
       return null;
   }

   public static void main(String[] args) {
       m(g());
   }
}


Посмотрим на байт-код после компиляции на JDK1.8:
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: invokestatic  #6                  // Method g:()Ljava/lang/Object;
         3: checkcast     #7                  // class "[Ljava/lang/String;"
         6: invokestatic  #8                  // Method m:([Ljava/lang/String;)V
         9: return
      LineNumberTable:
        line 15: 0
        line 16: 9


Инструкция под номером 0 выполняет вызов метода g:()Ljava/lang/Object; Метод возвращает java.lang.Object. Далее, инструкция 3 производит приведение типа («кастинг») объекта, полученного на предыдущем шаге к типу массива java.lang.String, и инструкция 6 выполняет метод m:([Ljava/lang/String;), что и напечатает в консоли «two».

А теперь байт-код после компиляции на JDK1.7 – то есть на Java 7:
  public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: invokestatic  #6                  // Method g:()Ljava/lang/Object;
         3: invokestatic  #7                  // Method m:(Ljava/lang/Object;)V
         6: return        
      LineNumberTable:
        line 15: 0
        line 16: 6


Мы видим, что здесь нет инструкции checkcast, которую добавила Java 8, так что вызовется метод m:(Ljava/lang/Object;), а в консоли напечатается «one». Checkcast – результат нового выведения типа, который был усовершенствован в  Java 8.

Чтобы избежать таких проблем, Oracle выпустил руководство по переходу с JDK1.7 на JDK 1.8 в котором описаны проблемы, которые могут возникнуть при переходе на новую версию Java, и то, как эти проблемы можно решить.

Например если вы хотите, чтобы в коде выше после компиляции на Java 8 все работало так же, как и на Java 7, сделайте приведение типа вручную:

public static void main(String[] args) {
   m((Object)g());
}


Заключение


На этом мой рассказ о Java Generics подходит к концу. Вот другие источники, которые помогут вам в освоении темы:


  • Bloch, Joshua. Effective Java. Third Edition. Addison-Wesley. ISBN-13: 978-0-13-468599-1

Пост является кратким пересказом одноименного доклада, на котором мы разбираем особенности работы с Java Generics.

Комментарии (31)


  1. Mercury13
    19.07.2018 10:40
    +1

    Не type interference («интерференция типов», «помехи типов»), а type inference.
    UPD. Можно, конечно, оставить и имеющееся, ради прикола, но тогда переделать 1-й абзац.


    1. Marvinorez Автор
      19.07.2018 11:37

      Спасибо! Исправил.


  1. rjhdby
    19.07.2018 11:55

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


    1. valery1707
      19.07.2018 12:49
      +1

      После стольких лет существования дженериков в Java есть ещё не знакомые с темой?


      1. AstarothAst
        19.07.2018 13:32

        После стольких лет существования рифм вы все еще не пишите, как Пушкин?


        1. valery1707
          19.07.2018 13:53

          За столько лет существования рифм уже написано много статей, книг и прочей литературы с полным разбором всего и вся.
          И для Java Generics ситуация ровно такая же: есть и вводные курсы и подробное описание всё есть.
          Зачем оно тут? Я вот даже не уверен что это 100% авторский контент — есть ощущение что эти примеры я уже видел.


          Java Generics появились в JDK 1.5, которая вышла 30 сентября 2004 года — через пару месяцев им будет 14 лет. Знаете когда эта статья была полезна? 14 лет назад — вообще must have, ну лет 10 назад (хотя и так уже было много литературы).
          А сейчас?


          Есть свежие технологии — да хотя бы лямбды — им всего 4 года (четыре, не четырнадцать).
          Ну и по ним уже есть много литературы, как англоязычной, так и русскоязычной.


      1. rjhdby
        19.07.2018 13:44
        +1

        Если статья рассчитана на тех, кто и так все это знает, то какой в ней смысл? А если на тех, кто только учится, то она излишне сложна для восприятия.


  1. Anton23
    19.07.2018 13:38

    Почему в самом первом блоке кода n = accounts.size(); i < n;, а не i < accounts.size?


    1. mayorovp
      19.07.2018 13:39
      -1

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


      1. Anton23
        19.07.2018 13:43

        А насколько сильная это экономия? С одной стороны, вы написали что это экономия на спичках, а с другой стороны, почему ради такой маленькой экономии код становится менее красивым?


        1. mayorovp
          19.07.2018 13:46

          Код не становится менее красивым. Непривычным — да, но после привыкания к такому стилю он читается столь же просто как стандартный.


      1. imanushin
        19.07.2018 14:33

        Экономия на спичках, в общем-то

        А у вас есть замеры в JMH, что экономия дает хоть какие-то плюсы? Ведь Java прекрано умеет оптимизировать "method inlining".


        1. mayorovp
          19.07.2018 14:37

          Инлайнинг через интерфейс? Нет, для JIT, конечно же, нет никаких теоретических препятствий так и сделать — но, насколько я знаю, такие оптимизации возможны только для долго работающей программы.

          А использование переменной работает и на «холодном» коде тоже.


          1. imanushin
            19.07.2018 15:13

            mayorovp, еще раз: есть ли доказательства того, что оптимизация имеет смысл? Или их нет? Да или нет?


            1. Marvinorez Автор
              19.07.2018 22:27

              А что значит «оптимизация имеет смысл»? Имеет смысл вызывать метод List.size() один раз вместо N раз? Я думаю тут есть смысл. Особенно, если учесть, что в данном примере мы не знаем какая реализация скрывается за этим листом (я могу передать в метод свою реализацию интерфейса List, которая будет вычислять size не за константное время).
              Сейчас так конечно никто не пишет. Зачем? ведь есть же foreach. И это верно! Всегда используйте foreach вместо for, если коллекция реализует интерфейс Iterable. Но в далекие времена Java 2.0 не было Iterable.


              1. imanushin
                20.07.2018 00:18

                А что значит «оптимизация имеет смысл»?

                Значит, что код от оптимизации будет работать быстрее. Ну или хотя бы выделять меньше памяти.


                Я думаю тут есть смысл.

                Нет, если нет ускорения работы программы.


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


                Я не понимаю, ну почему Вы спорите… Ну если будет код работать быстрее — так приведите доказательство, что тут сложного? Или скажите, что доказательств нет. Вы же в Сбертехе работаете, должны ведь уметь делать тесты на производительность. Вы можете всего лишь форкнуть репозиторий, а в нем сделать две реализации — с вашей идей и без. Разве это сложно?


                1. mayorovp
                  20.07.2018 17:36

                  Скажите, а AoT-компилятор тоже умеет делать девиртуализацию?


            1. mayorovp
              20.07.2018 17:57

              Извиняюсь за то что не могу привести доказательства, но на Java я не писал уже 10 лет. Не вижу смысла разбираться с методами установки jmh только ради одного комментария.

              Но все же спрошу в ответ, готовы ли вы поручиться, что эта оптимизация никогда не имеет смысла, включая следующие случаи:

              1. использование AOT-компилятора вместо JIT;
              2. частый холодный запуск утилиты;
              3. запуск на Android с его Davlik;
              4. запуск под IKVM.NET на Unity с его устаревшим форком Mono в браузере через WebAssembly;
              5. запуск в браузере через GWT;
              6. запуск на Java Card?

              Кстати, поддерживает ли JMH все перечисленные мною сценарии?


  1. Nikita1993
    19.07.2018 16:35

    Статья достаточно полезная, хотелось бы больше услышать о реализации Type Inference в jdk 8.
    Я вспомнил, где видел данные примеры: Доклад А.Маторина «Неочевидные дженерики» на JPoint и JBreak 2016.


  1. sentyaev
    19.07.2018 17:18
    +1

    У меня вопрос: зачем в первом примере делать проверку if (account instanceof Account)?
    Я понимаю что:

    Если не сделать проверку (instanceof) на принадлежность к классу Account, то на втором этапе возможен ClassCastException – то есть аварийное завершение программы.

    Но разве это не то, что ожидается? Т.е. если вместо списка объектов типа Account передали список, например, Employee, то программа должна упасть и как можно громче.

    Эта проверка на тип, по сути, как try/catch блок, просто проглатывает ошибки.
    Да еще и с NPE упадет если null передать. Получается одно проверили, а другое забыли.

    Я к тому, что, по моему мнению, пример довольно неудачный.


    1. mayorovp
      19.07.2018 17:28

      Вот с NPE как раз ничего не упадет — instanceof для null всегда ложный. А с остальным согласен.


      1. sentyaev
        19.07.2018 17:36

        Так на accounts.size() упадет же.
        Но я понимаю, что придираюсь. Это все-таки пример.


        1. mayorovp
          19.07.2018 17:37

          А, вы про это null…


    1. Marvinorez Автор
      19.07.2018 23:05

      Но разве это не то, что ожидается? Т.е. если вместо списка объектов типа Account передали список, например, Employee, то программа должна упасть и как можно громче.

      В сигнатуре метода нет никакого упоминания о том, что список должен содержать только объекты класса Account. Возможна ситуация когда нам передали список из множества объектов типа Account и Employee или список содержащий элемент равный null. По хорошему, конечно, об этом нужно писать в комментарии и это был единственный способ рассказать о том, что же ожидает метод до появления Generics.

      То что она должна упасть как можно громче… Эммм… не всегда это верно. Программа может упасть с грохотом на этапе валидации — это хорошо, это fail-fast. Но что, если программа у вас падает где-то глубоко в бизнес логике, где падение может привести к неконсистентным данным, незавершенным транзакциям и т.д. — это уже не fail-fast.

      Да еще и с NPE упадет если null передать. Получается одно проверили, а другое забыли.
      да, это хорошее замечание. Проверка на null там не будет лишней.


      1. sentyaev
        19.07.2018 23:26

        В сигнатуре метода нет никакого упоминания о том, что список должен содержать только объекты класса Account.

        Тут вы правы. Меня сбил с толку второй пример, где сигнатура метода меняется на List. Ну и не зная контекста я и навыдумывал себе, что ожидаются только объекты типа Account.

        Но что, если программа у вас падает где-то глубоко в бизнес логике, где падение может привести к неконсистентным данным, незавершенным транзакциям и т.д. — это уже не fail-fast.

        Опять же, я исходил из моего, возможно неверного, предположения, что метод getSum работает только с объектами типа Account.
        Т.к. если он может работать с объектами произвольного типа, то изменение сигнатуры метода с getSum(List accounts) на getSum(List accounts) это обратно несовместимое изменение.

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

        Я бы конечно убрал if (account instanceof Account), т.к. эта часть только вводит в заблуждение, просто потому, что мы это здесь обсуждаем.


  1. IL_Agent
    20.07.2018 00:13

    Все же после интуитивно понятных дженериков C#, здешние со своими уайлдкардами по-началу взрывают мозг. Может кто-нибудь привести пример задачи, где джавовые дженерики себя бы лучше проявили?


    1. mayorovp
      20.07.2018 17:41

      Да запросто. В Java можно написать Collection<? extends Foo>, и это будет автоматически работать для любой коллекции — а в C# для этой цели пришлось придумывать отдельный интерфейс IReadOnlyCollection<Foo>, который, конечно же, никакой класс автоматически реализовывать не начал.


      К примеру, в .net 4.5 класс HashSet почему-то не реализовывал этот интерфейс...


      1. IL_Agent
        20.07.2018 23:05
        +1

        Сомнительное "будет работать". Метод add есть, но вызвать его нельзя. По мне так это довольно абсурдно. C IReadOnlyCollection же всё понятно.


      1. imanushin
        21.07.2018 11:11

        К примеру, в .net 4.5 класс HashSet почему-то не реализовывал этот интерфейс...

        Что за гнусное вранье, зачем вы обманываете IL_Agent?


        Вот ссылка HashSet на MSDN, он реализует IReadOnlyCollection.


        И работает эта конструкция ровно так, как и ожидается: если A наследует B, то вместо IReadOnlyCollection<B> можно передавать IReadOnlyCollection<A>. В .Net еcть ковариантность и контрвариантность на уровне компилятора.


        1. mayorovp
          21.07.2018 11:16

          Вот ссылка HashSet на MSDN, он реализует IReadOnlyCollection.

          … начиная с 4.6


          И работает эта конструкция ровно так, как и ожидается: если A наследует B, то вместо IReadOnlyCollection<B> можно передавать ReadOnlyCollection<A>.

          А вот для ICollection<> такого нету. В отличии от Java, где для любого интерфейса можно автоматически получить его ковариантную и контравариантную части.


          1. imanushin
            21.07.2018 12:32

            … начиная с 4.6

            Да, ремарка есть, ок.