W3docs

Введение в обобщения Java

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

Введение в обобщения Java

Обобщения (generics) — это возможность, позволяющая классу, интерфейсу или методу работать с неуказанным типом, а компилятору затем зафиксировать этот тип в том месте, где вы его используете. List<String> — это список строк, компилятор это знает, и любая попытка положить туда Date отклоняется ещё до того, как программа запустится. До появления обобщений в Java 5 тот же список был List из Object, и каждое чтение из него требовало написанного вручную приведения, которое во время выполнения могло удаться, а могло и нет. Обобщения превратили эту азартную игру времени выполнения в проверку времени компиляции, и почти каждый современный Java API сформирован ими.

Проблема, которую решают обобщения

Чтобы понять, зачем существуют обобщения, представьте Java без них. Контейнер, хранящий произвольные вещи, вынужден объявлять своё содержимое как Object:

// Pre-Java-5 style — what the standard library actually looked like.
List names = new ArrayList();
names.add("Ada");
names.add("Linus");

String first = (String) names.get(0);   // cast required, never checked by the compiler

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

names.add(new java.util.Date());        // compiler is fine with this
String oops = (String) names.get(2);    // ClassCastException at runtime

Ошибка проявляется при чтении, далеко от записи. Приведение лжёт — оно говорит «это есть String», и JVM обнаруживает это лишь тогда, когда уже слишком поздно дать вам полезный кадр стека рядом с настоящей неисправностью.

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

List<String> names = new ArrayList<>();
names.add("Ada");
names.add("Linus");
names.add(new Date());        // ❌ compile error — won't even build

String first = names.get(0);  // no cast — the compiler already knows it's a String

Угловые скобки <String> — это параметр типа. Они говорят компилятору «этот список хранит строки», и с этого момента каждый add и get проверяется на соответствие этому обещанию.

Три вещи, которые вы получаете даром

Обобщения дают вам три конкретных преимущества, и именно они — причина, по которой каждая коллекция, стрим и optional в современном JDK обобщены:

  • Более строгие проверки времени компиляции. Вставка неверного типа выше ловится на этапе сборки, а не в продакшене. Целый класс ClassCastException просто перестаёт происходить.
  • Больше никаких приведений. Чтение из Map<String, User> даёт вам User, а не Object, который надо приводить. Меньше синтаксического шума, меньше читать, меньше сопровождать.
  • Повторное использование кода без копипасты. Один класс List<E> работает для каждого типа элемента. До обобщений стандартная библиотека либо принимала Object повсюду, либо поставляла StringList, IntList, DateList и так далее. Теперь вы пишете один класс и позволяете вызывающему его параметризовать.

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

Первый обобщённый класс

По соглашению параметр типа именуют одной заглавной буквой — T для обобщённого «типа», E для «элемента» коллекции, K/V для «ключа» и «значения» map, R для «возврата». Вот простейший возможный обобщённый класс — пара из двух вещей одного типа:

public class Pair<T> {
  private final T first;
  private final T second;

  public Pair(T first, T second) {
    this.first  = first;
    this.second = second;
  }

  public T first()  { return first; }
  public T second() { return second; }
}

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

Pair<String>  names   = new Pair<>("Ada", "Grace");
Pair<Integer> scores  = new Pair<>(100, 87);
String n1 = names.first();      // already a String, no cast
int s1    = scores.first();     // auto-unboxed from Integer

Пустые <> справа (оператор «ромб», Java 7+) говорят компилятору вывести тип из объявления слева — повторять аргумент типа почти никогда не приходится.

Что параметризуется, а что нет

Параметр типа может заменять:

  • Тип поля (private T value;)
  • Тип параметра или возвращаемого значения метода (public T get() { ... }, void put(T value))
  • Тип элемента массива этого типа (T[] items — с некоторыми оговорками)

Параметр типа не может заменять:

  • Примитив (Pair<int> недопустим — используйте Pair<Integer> и пусть автоупаковка сделает работу)
  • Параметр типа статического поля или статического метода (параметр принадлежит экземпляру, а не самому классу)
  • Цель new T() или instanceof T — Java стирает обобщения во время выполнения, поэтому у программы нет T, который можно сконструировать или с которым можно сравнить

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

Разобранный пример: типобезопасность против сырых типов, бок о бок

Программа ниже строит один и тот же контейнер дважды — один раз как сырой List (форма до обобщений) и один раз как List<String>. Оба компилируются; безопасен только параметризованный.

java— editable, runs on the server

Сырая версия рушится посреди итерации, потому что цикл доверился приведению, которому доверять было не за что. Обобщённая версия сделала ту же ошибку непредставимой — плохое add(42) вообще не скомпилируется. Этот сдвиг от времени выполнения к времени компиляции и есть вся причина, по которой существуют обобщения.

Что охватывает эта часть книги

Остальные главы этой части разбирают обобщения по одной части за раз:

  • Обобщённые классы — параметр типа уровня класса, который вы только что видели, подробнее.
  • Обобщённые методы — методы, вводящие собственный параметр типа, независимо от класса.
  • Обобщённые интерфейсы — проектирование контрактов API, параметризованных по типу.
  • Ограниченные параметры типа — указание «T должен расширять Number», чтобы можно было вызывать методы на T.
  • Подстановочные знаки (wildcards)? extends T, ? super T и правило PECS, решающее, когда какой использовать.
  • Стирание типов — как JVM реализует обобщения под капотом и почему некоторые ожидаемые вещи не работают.
  • Ограничения — каталог того, что язык отказывается вам позволять, с причинами за каждым.

Читайте их по порядку — каждая глава предполагает предыдущие.

Что дальше

Начните с самой распространённой формы — класса, поля и методы которого параметризованы по типу, выбираемому вызывающим. Продолжайте к Обобщённым классам Java.

Practice

Практика

A method declares `public static List getNames() { ... }` (no type parameter on the list). The caller writes `String first = getNames().get(0);`. Why does the compiler warn — and what's the danger if you ignore the warning?