Изучая Java и подойдя к теме дженериков осознал необходимость вернуться к некоторым азам, постепенно наматывая на ус новые знания. Для этого сформулировал для себя поэтапное рассуждение, которое помогло мне закрепить знания. Надеюсь поможет и еще кому-то. Это не всеобъемлющее представление о дженериках, а лишь некоторые аспекты, которые помогают понять часть проблематики, из-за которой они появились.

Итак. Есть такой простейший код:

List list = new ArrayList();
list.add("text");
String str = (String) list.get(0);

Чтобы подойти к дженерикам во все оружия необходимо проговорить почему в 3й строке требуется приведение типа элемента к String? Ведь мы видим, что в список положили строку "text", т.е. она и так String.

  1. Нужно различать тип переменной и тип фактического значения. Разберем на примере: Object obj = "word";

    1. Тип переменной. Переменной здесь является имя obj и у нее указан конкретный тип - Object.

    2. Тип фактического значения. Фактическим значением является строка "word" и ее тип, очевидно, String.

    3. В случае если тип фактического значения является дочерним для типа переменной, то такое присваивание корректно. В нашем случае String является дочерним типом к типу Object (любая строка является объектом).

  2. Вернемся к нашему коду: list.add("text"). Где у нас тут переменная, где фактическое значение и какие у них типы?
    "Под капотом" списка, а точнее ArrayList'а используется массив. У массивов должен быть строго определенный тип элементов. Чтобы в список можно было добавлять любые значения (исторически коллекции в Java были спроектированы так, чтобы работать с любыми объектами) необходимо чтобы массив в его основе был объявлен с типом Object, который является супертипом любых существующих объектов. Поэтому и элементы списка list по-умолчанию типа Object.

  3. Теперь можно создание списка и добавление в него первого элемента можно представить в виде:

    Object[] list = new Object[1];
    list[0] = "text";

    Т.е., проводя аналогию, можно сказать, что list[0] здесь является переменной с типом Object (задан при объявлении массива/списка), а фактическое значение "text" с типом String. Все работает, т.к. String является наследником от Object. При этом помним, что в процессе назначения фактического значения переменной происходит лишь присваивание переменной ссылки на объект-значения. Т.е. фактически переменная не равна объекту, а равна ссылке на объект.

  4. Переходим к 3й строке:

    String str = (String) list.get(0)

    Тип переменной str - String. Мы пытаемся присвоить ей ссылку на объект, который лежит в нулевом элементе списка. Как мы знаем, этот объект - "text" типа String. Мы даже можем дополнительно перепроверить тип значения: System.out.println(list.get(0).getClass());
    В консоль будет выведено: class java.lang.String
    Возникает ощущение, что приведение типа здесь избыточно. Мы же видим, что в список положили строку, значит list.get(0) - это String.

    Но это ощущение обманчиво.

    Человек читает код целиком и легко восстанавливает цепочку событий: в список положили строку - из списка достаём строку.
    Компилятор же смотрит на код иначе. Он не выполняет программу (не запускает метод get) и не знает, какое конкретное значение окажется в списке во время выполнения.

    Единственное, на что он может опереться - это тип возвращаемого значения метода get(), а в случае List без дженериков это Object.

    Поэтому с точки зрения компилятора мы пытаемся присвоить переменной типа String значение типа Object. Чтобы явно сказать компилятору: «я уверен, что здесь лежит строка», требуется приведение типа.

В то же время мы можем столкнуться с другой проблемой. Что если на предыдущем этапе мы положили в лист не строку, а Integer?

List list = new ArrayList();
list.add(100);
String str = (String) list.get(0);

Компилятор и в этом случае не будет знать тип фактического значения (Integer) и будет считать его как Object, а значит будет требовать приведение к String. Можно ли на самом деле привести это значение к String компилятор не знает, поэтому оставляет это на ответственность разработчика. Разработчик же может не доглядеть и тогда ошибка (Exception) проявит себя не во время компиляции, а в рантайм (например, во время работы программы у заказчика), что очень плохо, т.к. разработчик может узнать об ошибке довольно поздно.

Это одна из причин, по которой появляются дженерики. Механизм дженериков при создании списка предлагает сразу указать какого типа элементы будут в нем храниться. Если мы указали, что это будут String (указывается в угловых скобках после типа списка, например, List<String>), то получим 2 преимущества:

  1. Безопасность. Компилятор будет проверять тип всех объектов, которые мы пытаемся добавить в лист и, если он не будет соответствовать заявленному (String), то сообщит об этом на этапе компиляции (не даст скомпилировать код), а не в рантайм.

  2. Удобство. Когда нам понадобится присвоить элемент такого списка строке (таже самая строка: String str = list.get(0)), то приведение н�� понадобится, т.к. компилятору заранее сообщили, что все элементы списка являются String

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