Опытные разработчики должны знать эти 3 трюка, чтобы устранить распространенные проблемы с дженериками

Деловое фото создано yanalya — www.freepik.com
Деловое фото создано yanalya — www.freepik.com

Вы знаете о системе PECS? Знаете ли вы о типах пересечений? Знаете ли вы, где используется создание дженерик массивов?

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

Давайте ответим на эти вопросы.

1. Вы можете использовать extends с интерфейсами

Согласно этим документам, вы можете использовать interfaceType после ключевого слова extends. Вы можете соединить их в цепочку, чтобы сформировать типы пересечения. Это может привести к странным ошибкам во время выполнения.

Давайте рассмотрим следующий вопрос на Stack Overflow.

Код в вопросе следующий:

<X extends CharSequence> X getCharSequence() {
    return (X) "hello";
}
<X extends String> X getString() {
    return (X) "hello";
}

Если мы запустим его в jShell 11, то получим следующий результат:

jshell> <X extends CharSequence> X getCharSequence() {
   ...>     return (X) "hello";
   ...> }
   ...> 
   ...> <X extends String> X getString() {
   ...>     return (X) "hello";
   ...> }

|  Warning:
|  unchecked cast
|    required: X
|    found:    java.lang.String
|      return (X) "hello";
|                 ^-----^
|  created method getCharSequence()
|  Warning:
|  unchecked cast
|    required: X
|    found:    java.lang.String
|      return (X) "hello";
|                 ^-----^
|  created method getString()

jshell> Integer x = getCharSequence();
   ...> 

|  Exception java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
|        at (#3:1)

jshell> Integer y = getString();
   ...> 

|  Error:
|  incompatible types: inference variable X has incompatible upper bounds java.lang.Integer,java.lang.String
|  Integer y = getString();

В обоих примерах мы получаем предупреждение о непроверенном приведении. Это потому, что в обоих примерах мы возвращаем String, а не X. Тем не менее, оба метода создаются.

Когда мы загружаем getCharSequence, мы получаем не ошибку времени компиляции, а ошибку времени выполнения. Для getString мы получаем ошибку времени компиляции.

Почему так происходит?

Для первого метода Java определяет тип пересечения. В данном случае это Integer & CharSequence а Integer может быть подклассом CharSequence. Вот почему с точки зрения компиляции это корректное присваивание, несмотря на то, что Integer является final class.

Ошибка во время выполнения возникает, поскольку мы возвращаем значение String, не соответствующее типу Integer & CharSequence. Поскольку проверки во время выполнения нет, мы получаем исключение ClassCastException.

Почему во втором примере возникает ошибка времени компиляции?

String - это final класс, и вы не можете расширить его как String. Он не относится к типу CharSequence, который является интерфейсом. Ему невозможно иметь тип пересечения Integer & String, и это известно во время компиляции.

2. Вы можете создавать типобезопасные generic массивы

Один из способов создания generic массивов заключается в следующем:

public <T> T[] array(T... values) {
    return values;
}

Но даже в этом случае выдается следующее предупреждение:

jshell> public <T> T[] array(T... values) {
   ...>     return values;
   ...> }

|  Warning:
|  Possible heap pollution from parameterized vararg type T
|  public <T> T[] array(T... values) {
|                       ^---------^
|  created method array(T...)

Так что же означает загрязнение кучи (heap pollution)?

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

Heap pollution (загрязнение кучи) происходит, когда переменная параметризованного типа ссылается на объект, не относящийся к этому параметризованному типу. Такая ситуация возникает, если программа выполнила какую-то операцию, которая вызывает непроверенное предупреждение во время компиляции. Непроверенное предупреждение выдается, если либо во время компиляции (в рамках правил проверки типов во время компиляции), либо во время выполнения невозможно проверить корректность операции с параметризованным типом (например, приведение или вызов метода). Например, загрязнение кучи происходит при смешивании необработанных и параметризованных типов или при выполнении непроверенных приведений типов — source

Вы можете подавить эти предупреждения. И это используется в одном методе, который мы все используем: Collections#emptyList()

Так почему же этот сценарий хорош? 

Вы можете быть уверены, что даже при стирании типов  (type erasure) будет создан пустой список нужного типа. Вы можете быть спокойны, потому что это пустой список, поэтому подойдет любой тип.

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

А вот наша собственная реализация.

jshell> class Generic<E> {
   ...>    
   ...>     private E[] array;
   ...> 
   ...>     GenericSet(IntFunction<E[]> generator) {
   ...>         this.array = generator.apply(0);
   ...>     }
   ...> 
   ...>     public static void main(String[] args) {
   ...>         Generic<String> ofString =
   ...>             new Generic<>(String[]::new);
   ...>         Generic<Double> ofDouble =
   ...>             new Generic<>(Double[]::new);
   ...>     }
   ...> }

3. Producer Extends, Consumer Super

Я полагаю, вы встречали и extends и super в Java коде с generic.

Так почему вы должны использовать extends? Каковы ограничения extends? И какова цель super?

Мнемоника PECS взята из выступления Джошуа на эту тему.

PECS означает:

  • Producer Extends — если вам нужен List, чтобы выдавать значения T (вы хотите читатьT из списка), вам нужно объявить его с помощью ? extends T, например, List<? extends Integer>. Но вы не можете добавить значение в этот список.

  • Consumer Super — если вам нужен List для получения значений типа T (вы хотите записывать значения типа T в список), вам нужно объявить его с помощью ? super T, например, List<? super Integer>. Но нет никаких гарантий, какой тип объекта вы можете прочитать из этого списка.

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

Вот доказательства:

jshell> List<? extends Number> foo3 = List.of(1.23);
foo3 ==> [1.23]

jshell> foo3.get(0)
$27 ==> 1.23

jshell> foo3.add(1.34)

|  Error:
|  incompatible types: double cannot be converted to capture#10 of ? extends java.lang.Number
|  foo3.add(1.34)
|           ^--^
jshell> List<? super Number> foo3 = new ArrayList<Number>();
foo3 ==> []
  
jshell> foo3.add(1)
$32 ==> true
  
jshell> foo3
foo3 ==> [1]
  
jshell> foo3.get(0)
$34 ==> 1
 

jshell> Number r = foo3.get(0)

|  Error:
|  incompatible types: capture#11 of ? super java.lang.Number cannot be converted to java.lang.Number
|  Number r = foo3.get(0);
|             ^---------^

Почему это происходит?

Нет гарантии с обеих сторон, так как происходит стирание типов.

Когда вы используете extends, у вас есть гарантия того, что вы можете читать. Это тип после wildcard  знака extends или его подклассов. И у вас нет гарантии того, что вы можете добавить, так как тип стирается во время выполнения.

Когда вы используете super, у вас есть гарантия того, какой тип вы можете добавить. Это тип после wildcard super или его подклассов. И у вас нет гарантии того, что вы можете прочитать, так как тип стирается во время выполнения.

Еще одно хорошее и простое объяснение (source):

Collections#copy — хороший пример этого принципа.

dst будет потреблять значения типа T, поэтому wildcard super гарантирует это. src будет производить значения, поэтому extends будет гарантировать это. Но даже в этом случае нет гарантии типа при добавлении в src или чтении из dst.

Что, если вы заранее знаете типы и хотите использовать wildcard?

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

Допустим, мы используем super (Consumer из PECS) для foo3. С помощью явного приведения к List<Number> вы можете гарантировать какой тип вы читаете из foo3Unchecked. В данном случае мы гарантируем, что это Number что чтение Integer приведет к ошибке преобразования.

Какие особенности дженериков вы знаете? Дайте мне знать в разделе комментариев.

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


  1. aleksandy
    09.11.2022 21:00

    1. val6852 Автор
      09.11.2022 21:08

      Спасибо за ссылку!

      Однако доклад был воспринят не однозначно:
      Дмитрий Пухов

      1 год назад

      Невероятно бесполезный доклад, если вы ещё не владеете дженериками как докладчик.

      kolombo

      1 год назад

      Главная суть доклада: "Дженерики в джава это не логичное, кривое и убогое дерьмо, для использования которых тебе нужно заучить наизусть кучу правил их использования, понаписать себе шпаргалок, конспектов и в котором до конца можно разобраться только если у тебя в голове свежий компилятор джава и ты знаешь все "нюансы" и прочие "подводные камни" этой недотехнологии которую из-за обратной совместимости внедряли по принципу ломаного костыля".

      Руслан Соловьев

      6 лет назад

      Отличный доклад. Можно ли все-таки пояснить последний момент со String'ом? К сожалению, обсуждение прервали на самом интересном месте.

      Crystal Whale

      2 года назад

      Пойду учить питон


  1. dejecher
    10.11.2022 12:40

    А вот наша собственная реализация.
    ...

    У меня один вопрос - зачем?

    и касается он не только "собственной реализации" (но и ее тоже конечно)