company_banner

Пришел, увидел, обобщил: погружаемся в Java Generics

    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.
    Сбербанк 223,48
    Компания
    Поделиться публикацией
    Комментарии 46
      +1
      Не type interference («интерференция типов», «помехи типов»), а type inference.
      UPD. Можно, конечно, оставить и имеющееся, ради прикола, но тогда переделать 1-й абзац.
        0
        Спасибо! Исправил.
        0
        За статью спасибо, но хочется отметить, что стиль изложения ближе к научному труду, а не к познавательной статье. Мне, местами, приходилось несколько раз перечитывать один и тот же абзац, чтобы понять, что же имелось в виду. Про людей совсем не знакомых с темой читать будет очень тяжко.
          –1

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

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

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


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


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

              +2
              Если статья рассчитана на тех, кто и так все это знает, то какой в ней смысл? А если на тех, кто только учится, то она излишне сложна для восприятия.
            0
            Почему в самом первом блоке кода n = accounts.size(); i < n;, а не i < accounts.size?
              –1
              Чтобы не делать лишний вызов на каждой итерации цикла. Экономия на спичках, в общем-то, но ничего страшного от такой оптимизации не случается.
                0
                А насколько сильная это экономия? С одной стороны, вы написали что это экономия на спичках, а с другой стороны, почему ради такой маленькой экономии код становится менее красивым?
                  –1
                  Код не становится менее красивым. Непривычным — да, но после привыкания к такому стилю он читается столь же просто как стандартный.
                  +3
                  Экономия на спичках, в общем-то

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

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

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

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

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

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


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

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


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


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

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

                              Вы не правы. Вообще-то правильно выносить вычисления, чтобы сделать их один раз, чем делать их много раз.


                              Ну и про инлайн тут вообще при чем? Если тут и идёт о чем-то речь, то о подстановке результата вызова функции как значения, но есть разные вещи, когда такие оптимизации могут быть недопустимы и при большом значении N, накладные расходы могут быть значимы.


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

                                0
                                Вообще-то правильно выносить вычисления, чтобы сделать их один раз, чем делать их много раз.

                                Пруф? Или это Ваше мнение?


                                при большом значении N

                                Вы говорите про алгоритмическую сложность? Мы же обсуждаем, что константа (в терминах O(...)) одинакова как для функции с оптимизацией, так и для функции без неё.


                                Ну и про инлайн тут вообще при чем?

                                Если мы говорим про оптимизацию производительности, то хотелось бы видеть обоснование — почему добавление строк кода (а зачастую это ведет к созданию менее читаемого кода) дает прирост производительности? Какой прирост тогда будет?


                                Всё дело в том, что замерить производительность не просто, а очень просто.


                                Смотрите:


                                • Шаг 1: скачиваете код с проектом, который меряет производительность. Например, этот.
                                • Шаг 2: пишете два варианта функции: с оптимизацией и без
                                • Шаг 3: запускаете замеры из командной строки ./gradle jmh

                                На всё про всё — максимум 30 минут, учитывая, что еще надо поставить JRE и IntelliJ Idea Community.


                                А теперь, вопрос: если при такой простоте замеров нет доказательств производительности, то есть ли вообще ускорение? Может, всем и так известно, что прироста нет никакого, потому никто и не публикует замер?

                                  +1
                                  Замер будет зависеть от самой виртуальной машины и реализации интерфейса List, экземпляр которого метод получает в качестве аргумента.
                                  Если виртуальная машина поддерживает и сможет доказать возможность девиртуализации, то разницы между n = list.size() и i < list.size() нету.
                                  Если же мы, например, передадим в качестве параметра экземпляр SynchronizedList, то разница будет.

                                  Да и зацепились вы за пример из проекта написанного в эпоху динозавров. В то время HotSpot не умел выполнять такого рода оптимизации.
                                    0
                                    зацепились вы за пример

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


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

                                    А какая? Код будет работать в 10 раз быстрее? Или в 10 раз медленнее? Зачем усложнять код на ровном месте-то?

                                      +2

                                      Я понимаю что бремя доказательства лежит на Marvinorez, а не на вас и вам доказывать ничего не нужно.
                                      Хотя именно у вас есть опыт в создании JMH-тестов — могли бы и помочь человеку, но не захотели — бывает :)


                                      Я решил помочь. Вот тесты производительности, вот их сырые результаты — если я что-то не то или не так тестировал — милости просим в PR.


                                      И вот табличка с результатами (Habr не смог переварить такую большую таблицу :( ).
                                      И я, из результатов тестов, вижу что кеширование размера коллекции может давать профит, но в зависимости от типа коллекции:


                                      • java.util.HashSetвыгодно
                                      • java.util.TreeSet — не выгодно
                                      • java.util.ArrayListвыгодно
                                      • java.util.LinkedListвыгодно
                                        0
                                        Спасибо. Но большинство проверок — лишние. Нет никакого смысла проверять HashSet, TreeSet и LinkedList — потому что их никто и никогда не обходит по индексу.

                                        Что действительно нужно сравнивать — так это ArrayList и обычные массивы.
                                          0
                                          большинство проверок — лишние.

                                          Я это понимаю — я тут заодно прокачивал свой навык написания бенчмарков на JMH :)


                                          Что действительно нужно сравнивать — так это ArrayList и обычные массивы.

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


                                          Сильно подозреваю что доступ к элементу массива будет быстрее доступа к элементу ArrayList-а, но опять же копеечно и только из-за того что каждый вызов ArrayList#get(int) это:


                                          • собственно вызов метода
                                          • проверка выхода за правую границу
                                          • вызов внутреннего метода для каста
                                          • обращение к массиву по индексу

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

                                            0
                                            Нет, вы не поняли. Я предложил сравнить работу метода size в кешированном и некешированном варианте цикла у какого-нибудь Arrays.asList
                                              0

                                              Да я и сейчас не понял.


                                              Вы предлагаете узнать профит от кеширования размера коллекции при обходе new int[]{1, 2, 3} и Arrays.asList(1, 2, 3)?

                                                0
                                                Ну да…
                                                  0

                                                  Бенчмарк, сырые данные, табличка, вывод: с кешем быстрее.
                                                  Возможно сказывается то что кешированный размер лежит в локальной переменной, а не достаётся через обращения к полю/методу другого объекта.

                                    0

                                    Наткнулся в видео на аналогичный пример в .Net, правда с массивом.


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


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

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

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

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

                              Кстати, поддерживает ли JMH все перечисленные мною сценарии?
                      +1
                      Статья достаточно полезная, хотелось бы больше услышать о реализации Type Inference в jdk 8.
                      Я вспомнил, где видел данные примеры: Доклад А.Маторина «Неочевидные дженерики» на JPoint и JBreak 2016.
                        +1
                        У меня вопрос: зачем в первом примере делать проверку if (account instanceof Account)?
                        Я понимаю что:
                        Если не сделать проверку (instanceof) на принадлежность к классу Account, то на втором этапе возможен ClassCastException – то есть аварийное завершение программы.

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

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

                        Я к тому, что, по моему мнению, пример довольно неудачный.
                          0
                          Вот с NPE как раз ничего не упадет — instanceof для null всегда ложный. А с остальным согласен.
                            +1
                            Так на accounts.size() упадет же.
                            Но я понимаю, что придираюсь. Это все-таки пример.
                              0

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

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

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

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

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

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

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

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

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

                              Я бы конечно убрал if (account instanceof Account), т.к. эта часть только вводит в заблуждение, просто потому, что мы это здесь обсуждаем.
                            +1
                            Все же после интуитивно понятных дженериков C#, здешние со своими уайлдкардами по-началу взрывают мозг. Может кто-нибудь привести пример задачи, где джавовые дженерики себя бы лучше проявили?
                              0

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


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

                                +1

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

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

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


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


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

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

                                    … начиная с 4.6


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

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

                                      0
                                      … начиная с 4.6

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

                                        +1
                                        Но ведь это пример не «джавовые дженерики себя бы лучше проявили», а как раз наоборот.
                                        Мутабельная коллекция ковариантна в геттерах и контравариантна с сеттерах. То есть хрен ее знает в каждом конкретном случае как еще можно или нельзя приводить к наследникам/родителям.
                                        Делим на 2 интерфейса — один с сеттерами, другой с геттерами — и вуаля, всё начинает работать по единой схеме.
                                        На халяву получаем возможность указать дженерик 1 раз в определении класса/интерфейса, а не в каждом методе.
                                        И выбрали такой упоротый метод как раз чтобы коллекции не пришлось распиливать на 2 интерфейса из-за обратной совместимости.
                                          0
                                          И выбрали такой упоротый метод как раз чтобы коллекции не пришлось распиливать на 2 интерфейса из-за обратной совместимости.

                                          Ну а в C# решили распилить, из-за чего местами появилась та самая несовместимость.

                                            +1
                                            Ну и правильно, я считаю, сделали. Теперь писанины меньше и понятнее.
                                            На jvm вон и котлин и цейлон тоже сделали так же.

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

                                Самое читаемое