В Java 5 появились generic-типы, а вместе с ним и концепция type erasure, которая буквально означает стирание информации о generic-типе после компиляции. Действительно, во многих случаях это просто синтаксический сахар, помогающий писать типо-безопасный код на уровне компиляции, и в runtime с такими типами работать нельзя. Например, невозможно получить тип T внутри ArrayList<T>, поэтому он в своей реализации создает массив Object[], а не T[] для хранения элементов.

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanPostProcessor;
...
@Autowired
private Set<BeanPostProcessor> beanPostProcessors;

и spring в него заинжектит все объекты контекста, которые реализуют интерфейс BeanPostProcessor.

Можно написать и так:

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
...
List<String> strings = new ObjectMapper()
     .readValue("[1, 2, 3]", new TypeReference<>() {});
// все элементы strings - строки (не Integer и не Long)
List<Integer> ints = new ObjectMapper()
     .readValue("[1, 2, 3]", new TypeReference<>() {});
// все элементы ints - Integer (не String и не Long)

Можно написать даже так:

public abstract class AbstractComposite<T> {
    @Autowired
    private Set<T> autowiredBeans; 
}

@Service
public class BeanPostProcessorComposite extends AbstractComposite<BeanPostProcessor> {

}

И в runtime созданный бин BeanPostProcessorComposite будет содержать все BeanPostProcessor в поле autowiredBeans.

Дело в том, что после компиляции информация о generic-типах частично остается на уровне байт-кода и ее возможно получить в runtime.

Почему компилятор стирает информацию о типе
Это связано с сильными контрактами совместимости между Java-версиями: модули скомпилированные до Java 5, продолжат работать — вызывать классы с generic-типами. Это работает и назад. В случае более строгой реализации generic-типов без erasure совместимость была бы нарушена.

После компиляции такой типо-безопасный код

List<String> list = new ArrayList<>();
list.add("value");
String value = list.get(0);

превратится в такой код

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

Эта тема хорошо раскрыта в этой и этой статьях.

JDK дает нам достаточно богатый функционал для работы с типами. Самый очевидный — Class, но он нам не сильно поможет:

import java.lang.reflect.Field;

public class ListType {
    private List<Integer> list;

    public static void main(String[] args) throws NoSuchFieldException {
        Field field = ListType.class.getDeclaredField("list");
        Class fieldType = field.getType();
        // "interface java.util.List"
        System.out.println(fieldType);
    }
}

Метод getType() лишь дает информацию, что тип поля — интерфейс List, но не его generic-тип. Кроме getType() у Field есть метод Type getGenericType() и тут уже сильно больше подробностей:

import java.lang.reflect.Type;
import java.lang.reflect.ParameterizedType;
...
        Type fieldGenericType = field.getGenericType();
        // "class sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl"
        System.out.println(fieldGenericType.getClass());
        // "java.util.List<java.lang.Integer>"
        System.out.println(fieldGenericType);

        if (fieldGenericType instanceof ParameterizedType) {
            Type[] typeArguments = ((ParameterizedType) fieldGenericType).getActualTypeArguments();
            // "[class java.lang.Integer]"
            System.out.println(Arrays.toString(typeArguments));
        }
    }
}

Таким образом, мы получили и тип коллекции List и ее generic-тип Integer. И это средствами JDK без посторонних библиотек и магии.

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

import java.lang.reflect.Type;
import java.lang.reflect.ParameterizedType;
...
    private List<Set<Integer>> nestedList;
...
        Field field = ListType.class.getDeclaredField("nestedList");
        Type fieldGenericType = field.getGenericType();
        // "java.util.List<java.util.Set<java.lang.Integer>>"
        System.out.println(fieldGenericType);

        if (fieldGenericType instanceof ParameterizedType) {
            Type[] typeArguments = ((ParameterizedType) fieldGenericType).getActualTypeArguments();
            // "[java.util.Set<java.lang.Integer>]"
            System.out.println(Arrays.toString(typeArguments));
        }
}

Трюк с TypeReference


В начале статьи был пример парсинга через Jackson:

List<Integer> ints = new ObjectMapper()
     .readValue("[1, 2, 3]", new TypeReference<>() {});

Работает это так: вызов конструктора полностью выглядит длиннее:

List<Integer> ints = new ObjectMapper()
     .readValue("[1, 2, 3]", new TypeReference<List<Integer>>() {});

и в момент компиляции создается анонимный класс-наследник:

class ListType$1 extends TypeReference<List<Integer>> {
}

который и используется в вызове:

List<Integer> ints = new ObjectMapper()
     .readValue("[1, 2, 3]", new ListType$1());

В итоге, jackson получает информацию о типе через класс. Стоит отметить, что такая короткая запись работает только начиная с JDK 9.

Kotlin


Язык Kotlin работает поверх JVM, поэтому подвержен всем ограничениям платформы. Тем не менее благодаря хитростям, kotlin привносит несколько новых возможностей работы с типами. Например, можно писать так:

import com.fasterxml.jackson.databind.ObjectMapper

fun main(args: Array<String>) {
    val strings: List<String> = parseJson("[1, 2, 3]")
    println(strings)
    // strings-список строк (но не чисел)
}

inline fun <reified T> parseJson(str: String): T {
    return ObjectMapper().readValue(str, T::class.java);
}

Здесь используется связка inline+reified — фактически тело метода parseJson вставляется в место вызова, поэтому jackson в качестве аргумента неявно получает дескриптор типа, а в коде мы можем писать T::class.java, что невозможно в обычной java.

Параметризованные типы


Особый случай — вычисление типа по параметру, когда значение зависит от конкретного подтипа. Например

public abstract class AbstractComposite<T> {
    @Autowired // в runtime тип зависит от наследника
    private Set<T> autowiredBeans; 
}

public static class BeanPostProcessorComposite extends AbstractComposite<BeanPostProcessor> {
}

Для вычисления типа можем использовать утилитарные методы. Например, из guava:

import com.google.common.reflect.TypeToken;
...
TypeToken<?> modelType = TypeToken.of(BeanPostProcessorComposite.class);
Type actualType = modelType.resolveType(
        AbstractComposite.class.getDeclaredField("autowiredBeans").getGenericType()
).getType();
// "java.util.Set<org.springframework.beans.factory.config.BeanPostProcessor>"
System.out.println(actualType);

Или spring:

import org.springframework.core.GenericTypeResolver;
...
Type actualType = GenericTypeResolver.resolveType(
        AbstractComposite.class.getDeclaredField("autowiredBeans").getGenericType(),
        BeanPostProcessorComposite.class
);
// "java.util.Set<org.springframework.beans.factory.config.BeanPostProcessor>"
System.out.println(actualType);

Мы рассмотрели случаи с полями класса, но все то же самое применимо к generic-аргументам и типам возвращаемого значения методов.

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


  1. Anarchist
    17.07.2022 17:15
    +2

    implicit ev: ClassTag[T] :)


  1. Lewigh
    19.07.2022 06:36

    Это связано с сильными контрактами совместимости между Java-версиями: модули скомпилированные до Java 5, продолжат работать — вызывать классы с generic-типами. Это работает и назад. В случае более строгой реализации generic-типов без erasure совместимость была бы нарушена.

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