Мы, разработчики на Java, используем параметризованные типы каждый день: List<String>, Map<Long, User>, Optional<Order> и так далее. Однако у параметризованных типов есть важная особенность: в Java они существуют в полном виде только на этапе компиляции.

Что такое стирание типов

В Java generics реализованы через type erasure, то есть через стирание типов. Это значит, что после компиляции информация о конкретных параметрах типа в большинстве случаев исчезает.

Например, для рантайма:

List<String>
List<Integer>

выглядят одинаково — как просто List.

А параметр типа T после стирания превращается в:

  • Object, если ограничений нет;

  • верхнюю границу, если она задана.

Пример:

class Box<T extends Number> {
    T value;

    T getValue() {
        return value;
    }
}

После стирания это можно мысленно представить примерно так:

class Box {
    Number value;

    Number getValue() {
        return value;
    }
}

Компилятор при этом добавляет проверки типов и нужные приведения в местах использования. Поэтому generics в Java — это в первую очередь механизм проверки типов на этапе компиляции, а не полноценная часть runtime-модели.

Почему с массивами всё иначе

Здесь начинается самое интересное.

Массивы в Java старше generics и работают по другим правилам. Они помнят тип элементов во время выполнения. Например, массив String[] в рантайме действительно знает, что он массив строк.

Именно поэтому такой код компилируется, но падает в рантайме:

Object[] array = new String[1];
array[0] = 42; // ArrayStoreException

JVM видит, что на самом деле это String[], и запрещает класть туда Integer.

С generics такого нет: List<String> и List<Integer> в рантайме уже неразличимы. Из-за этого часть операций, которые казались бы естественными, Java просто запрещает.

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

1. Нельзя создать массив параметризованного типа

Вот так писать нельзя:

List<String>[] array = new List<String>[10];

Компилятор выдаст ошибку:

generic array creation

Почему? Потому что массив обязан знать точный тип своих элементов в рантайме. А у List<String> после стирания никакого отдельного runtime-типа уже нет: остаётся просто List.

Если бы такое разрешили, можно было бы сломать типобезопасность. Например, мысленно это выглядело бы так:

List<String>[] strings = ...;
Object[] objects = strings;
objects[0] = List.of(1, 2, 3);
String s = strings[0].get(0); // тут было бы очень плохо

Именно поэтому массивы параметризованных типов запрещены.

При этом есть известный обходной путь:

List<String>[] array = new ArrayList[10];

Такой код компилируется, но с предупреждением. Это уже не создание массива List<String>, а создание массива raw type. Формально вы обходите систему типов и берёте ответственность на себя.

2. Нельзя создать массив из type parameter

Если у нас есть параметр типа T, вот так тоже нельзя:

class Box<T> {
    T[] array = new T[10];
}

Причина та же: в рантайме у JVM нет информации о том, что такое T.

После стирания T превращается в Object или в верхнюю границу, но точный тип элементов всё равно неизвестен. А массив без точного runtime-типа Java создавать не хочет.

3. Нельзя напрямую создать экземпляр параметра типа

Вот так писать тоже нельзя:

class Box<T> {
    T value = new T();
}

На первый взгляд кажется, что всё логично: если есть T, почему бы не создать объект этого типа? Но после стирания T уже не существует как конкретный тип, и компилятор не знает, какой конструктор нужно вызывать.

Поэтому в generic-коде объекты такого типа обычно создают не через new T(), а через:

  • передачу готового объекта;

  • Supplier<T>;

  • Class<T> и reflection.

4. Нельзя перегружать методы, если после стирания сигнатуры совпадут

Например, такой код не скомпилируется:

void print(List<String> items) {}
void print(List<Integer> items) {}

Почему? Потому что после стирания обе сигнатуры превращаются в одно и то же:

void print(List items) {}
void print(List items) {}

Для JVM это уже не две разные перегрузки, а дублирующее объявление одного и того же метода.

То есть проблема не в перегрузке методов вообще, а именно в том случае, когда различие между методами держалось только на generic-параметрах.

5. Нельзя проверять instanceof с конкретным параметризованным типом

Вот так нельзя:

if (obj instanceof List<String>) {
    ...
}

Причина простая: в рантайме никто уже не знает, был это List<String> или List<Integer>. Есть только List.

Поэтому такой код не имеет смысла для JVM.

Можно проверять только сам «внешний» тип:

if (obj instanceof List<?>) {
    ...
}

То есть Java может ответить на вопрос «это список или нет?», но не может честно ответить на вопрос «это именно список строк или нет?».

6. Нельзя получить class literal параметризованного типа

Вот так тоже нельзя:

Class<?> clazz = List<String>.class;

Потому что отдельного runtime-класса List<String> просто не существует. Существует только List.class.

То есть компилятор не может дать вам class literal для того, чего в runtime-модели нет.

Допустимый вариант выглядит так:

Class<?> clazz = List.class;

Но это уже класс интерфейса List без какой-либо информации о параметре типа.

При этом важно не путать Class с reflection API для generic-сигнатур. Если класс, поле или метод были объявлены с параметризованным типом, эту информацию можно прочитать через Type и ParameterizedType:

Field field = GenExample.class.getField("strings");
ParameterizedType type = (ParameterizedType) field.getGenericType();
System.out.println(type.getActualTypeArguments()[0]); // class java.lang.String

Но здесь мы получаем не Class<List<String>>, а метаданные объявления, сохранённые в class-файле.

7. Нельзя создавать параметризованные классы исключений

Например, такой код не скомпилируется:

class MyException<T> extends Exception {
}

На первый взгляд кажется, что ничего страшного тут нет. Однако механизм исключений в Java тесно завязан на runtime-типы: throw выбрасывает объект определённого класса, а catch ловит исключения по реальному типу во время выполнения.

Но после стирания типов:

MyException<String>
MyException<Integer>

для рантайма выглядели бы одинаково — как просто MyException.

Тогда возникла бы неприятная ситуация: в исходном коде это как будто разные типы исключений, а для JVM — один и тот же класс. Например, такой код выглядел бы двусмысленно:

try {
    ...
} catch (MyException<String> e) {
    ...
}

После стирания JVM уже не смогла бы различить MyException<String> и MyException<Integer> как разные типы в catch.

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