Странности Generic типов Java

в 18:34, , рубрики: generic, java, Программирование

Я множество раз слышал о том, что дизайн Generic типов в Java является неудачным. По большей части претензии сводятся к отсутствию поддержки примитивных типов (которую планируют добавить) и к стиранию типов, а конкретнее — невозможности получить фактический тип параметра в рантайме. Лично я не считаю стирание типов проблемой, как и дизайн Generic-ов плохим. Но есть моменты, которые меня порядком раздражают, но при этом не так часто упоминаются.

1

Например, мы знаем, что метод Class#getAnnotation параметризован и имеет следующую сигнатуру: public <A extends Annotation> A getAnnotation(Class<A> annotationClass). Значит, можно писать вот такой код:

Deprecated d = Object.class.getAnnotation(Deprecated.class);

Тут я решаю вынести Object.class в отдельную переменную и код перестаёт компилироваться:

Class clazz = Object.class;
// incompatible types:
// java.lang.annotation.Annotation cannot be converted to java.lang.Deprecated
Deprecated d = clazz.getAnnotation(Deprecated.class);

Где я ошибся?

Ошибся я в том, что не параметризовал тип переменной clazz.
Получается, что стирание у типа Class так же стирает типы во всех его методах! Зачем так было делать — понятия не имею. Вносим минимальное исправление в код и всё работает как надо.

Class<Object> clazz = Object.class;
Deprecated d = clazz.getAnnotation(Deprecated.class);

2

Второй пример немного надуманный, но показательный. Представьте, есть у вас такой простой класс:

class Ref<T> {
    private T value = null;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

Имея переменную ref я решу написать такой код. Что с ним может произойти плохого?

ref.setValue(ref.getValue());

Разумно было бы считать, что он всегда скомпилируется, но это не так! Вам всего лишь стоит объявить переменную ref с типом Ref<?> и вы получите ошибку incompatible types: java.lang.Object cannot be converted to capture#1 of ?
То есть wildcard в геттере автоматически раскрывается в Object. Мы не можем полагаться на то, что в наших выражениях он будет равен самому себе. Выстрелить себе в ногу этим пусть и сложно, но точно можно.

3

Далее, пусть есть такая очень простая иерархия классов:

class HasList<T extends List> {
    protected final T list;

    public HasList(T list) {
        this.list = list;
    }

    public T getList() {
        return list;
    }
}

class HasArrayList<T extends ArrayList> extends HasList<T> {
    public HasArrayList(T list) {
        super(list);
    }
}

Напишем следующий код:

HasArrayList<?> h = new HasArrayList<>(new ArrayList<>());
ArrayList list = h.getList();

Параметр T класса HasArrayList имеет верхнюю границу равную ArrayList, а значит при стирании типов код всё ещё должен компилироваться.

HasArrayList h = new HasArrayList<>(new ArrayList<>());
// incompatible types: java.util.List cannot be converted to java.util.ArrayList
ArrayList list = h.getList();

Ну вот, опять не работает. Сейчас то что не так?

Не так то, что в сигнатуре метода getList возвращаемым типом является List, а компилятору просто лень расставлять явные приведения типов. Исправляется всё очень просто — надо переопределить данный метод в подклассе.

class HasArrayList<T extends ArrayList> extends HasList<T> {
    public HasArrayList(T list) {
        super(list);
    }

    @Override
    public T getList() {
        return super.getList();
    }
}

При этом компилятор сгенерирует синтетический bridge метод, возвращающий ArrayList, и именно он и будет вызван. Очевидно же...

Вообще, если у класса есть тип-параметр, то лучше в коде его не игнорировать, в крайнем случае можно указать <?>. Иначе компилятор наделает подобной ерунды, а нам потом нервничать.

4

Последняя ситуация наиболее хитрая. Решил я написать подобный класс:

class MyArrayList<T extends Cloneable & BooleanSupplier> extends ArrayList<T> {
    public void removeTrueElements() {
        this.removeIf(BooleanSupplier::getAsBoolean);
    }
}

Как вы считаете, будут ли при вызове этого метода какие-нибудь проблемы?

new MyArrayList<>().removeTrueElements();

Может показаться смешным, но этот код при запуске выбросит исключение:

Exception in thread "main" java.lang.BootstrapMethodError: call site initialization exception
...
Caused by: java.lang.invoke.LambdaConversionException: Invalid receiver type interface java.lang.Cloneable; not a subtype of implementation type interface java.util.function.BooleanSupplier
...

Хотя нет, я вас обманул. Этот код будет работать, если компилировать его из JDK9, а вот компилятор JDK8 допускает на нём ошибку.

Да, всё именно так, это ошибка не рантайма, а компилятора. Почему нельзя предупредить при компиляции, что генерация этой лямбды упадёт, я не понимаю.

Как исправить код без перехода на Java 9? Вот так:

public void removeTrueElements() {
    this.removeIf(t -> t.getAsBoolean());
}

Ну и откомментировать, конечно, чтобы ваш коллега не сконвертировал всё обратно в method reference.

Вместо заключения

Уверен, у многих из вас тоже встречались ситуации, когда поведение дженериков вызывало недоумение.
Делитесь в комментариях, обсудим.

Автор: ibessonov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js