Странности 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.


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


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

    Поделиться публикацией
    Комментарии 24
      +3

      Насчёт пункта 2 — тут вполне логично всё. Если мы используем wildcard, то мы явно заявляем компилятору, что мы не знаем соответствующий тип. ? до некоторой степени эквивалентен ? extends Object, что делает Ref<?> ковариантным; следовательно, методы, которые принимают значение типа-параметра в качестве аргумента вызывать больше нельзя. В противном случае в компиляторе должен быть механизм отслеживания того, откуда какое значение пришло, что в общем случае мне кажется эквивалентным проблеме останова.


      Насчёт 3 пункта мне тоже не кажется наличие проблемы очевидной. В конце концов, вы там мешаете дженерики и сырые типы, чего делать очень не рекомендуется. Решение, как вы заметили — не использовать сырые типы. Про сырые типы лучше вообще забыть в аспекте написания кода, это страшенный костыль для обратной совместимости.


      Первое это вообще треш, да. Никогда правда с таким не сталкивался.

        +3

        Первый пункт тоже логичен, у "сырого" типа не может быть параметризованных членов:

        The type of a constructor, instance method, or non-static field of a raw type C that is not inherited from its superclasses or superinterfaces is the raw type that corresponds to the erasure of its type in the generic declaration corresponding to C.

          0

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

            +2

            Мне кажется, здесь очень простая логика: любой сырой тип в выражении эквивалентен заявлению "я хочу работать в рамках java <1.5". Просто, понятно, легко запомнить.


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


            Даже если вам в код пришел сырой тип из какой-нибудь легаси библиотеки — гораздо проще его руками привести к "нужному" параметризованному, и дальше не знать горя (тут должна быть саркастическая усмешка).

          0
          В противном случае в компиляторе должен быть механизм отслеживания того, откуда какое значение пришло
          А почем бы и нет: https://pastebin.com/ELrG1kdG.
          Это, конечно, не java, но примеры систем типов, в которых это отслеживается, существуют.
            0

            Удивлён, что скала такое умеет. Это не Dotty, случайно?

              +2
              В scala понадобится 1 доп. строчка: https://scalafiddle.io/sf/jNsFHSn/0.

              А так — да, Dotty (Scala.next в начале).
                0

                А, вот оно что. Я пристально за дотти не слежу, получается что там как-то унифицировали типовые параметры и ассоциированные типы? Я ещё удивился синтаксису refA.T/refB.T, который вроде как подразумевает что T это type T а не параметр. Про ассоциированные типы у меня с самого начала была мысль, да, но тут речь шла про параметры всё-таки)

                  +1
                  Принципиальной разницы между type members и type parameters в общем-то нет — они это продемонстрировали унификацией.
                  И в scala зачастую поднимали type members в type parameters — см Aux паттерн в shapeless.

                  Но если придираться, то можно сказать, что wildcard type в Dotty просто нет: Dropped: Existential Types.
                    0

                    Понятно, спасибо за ссылку! Надо будет про отличия дотти почитать поподробнее.

          –2

          Коллега, извиниие, если грубо, но учите матчасть.
          Согласен с предыдущим комментатором: во всех случаях действуют обычные правила Type Erasure. В последнем надо поменять местами ограничения: чистка идёт до первого параметра. Про первый я тоже не совсем понял, но посмотрю, когда буду у компа.

            +1
            Я не утверждаю, что это не по спецификации. Просто сама спецификация местами странная очень.
            Например, в случае 3, на мой взгляд, спецификация идёт вразрез со здравым смыслом, да и в случае 1 тоже.
            А в последнем, если поменять местами ограничения, то отвалятся референсы для другого типа, так что хорошим решением это тоже не назовёшь.
              –1
              третий случай как раз самый понятный, вы из ссылки на интерфейс пытаетесь получить ссылку на реализацию что противоречит идеи интерфейсов, в силу особенностей явы это технически возможно но в общем не приветствуется, перекрыв метод вы принудили компилятор сделать это, вы думаете что это должно быть сделано автоматически? совершенно не факт, допустим компилятор делает как вам надо, а мне на оборот нужно чтобы getList() возвращал интерфейс мне также перекрывать метод?, получается шило на мыло.
                +1
                идёт вразрез со здравым смыслом, да и в случае 1 тоже.

                Чего бы это вдруг?
                Тип переменной clazz — raw type.
                Либо программист может использовать generics и тогда это будет явно указано при декларации clazz.
                Либо же программист попал в тяжёлые жизненные обстоятельства и использовать generics не может, в этом случае и у методов тоже никаких generics быть не может.

                  0
                  Вот почему стирание типа T должно тянуть за собой стирание совершенно с ним не пересекающегося типа A? Я не считаю это логичным.
                  И да, обстоятельства бывают разные и часто в проектах можно встретить raw types
                    +4

                    Потому что научить компилятор разбирать комбинированные (сырые+параметризованные) выражения — это требует ресурсов, и усложняет и так непростую тему параметризованных типов. Какой профит будет от этой траты ресурсов?


                    Ответ: профит очень мал. Гораздо проще привести сырой тип руками к нужному параметризованному. Если у вас в проекте легаси код с сырыми типами — сделайте вокруг него обертку-адаптер.


                    Т.е. моя идея в том, что никто никогда не обещал сохранять сырые типы как "first class citizen". Их время ушло, давай, до свидания. Компилятор поддерживает их для обратной совместимости, но не более.

                      +1
                      В целом я с вами согласен, сырыми типами пользоваться не стоит.
                      Но, к сожалению, класс Class (как минимум) слишком часто используется без параметра. Получается, что компилятор вынуждает нас писать <?> в месте неизвестного типа вместо того, чтобы разрешить вообще игнорировать параметр.
                        0
                        Я ща доку посмотрел так там так и написано
                        For example, the type of String.class is Class. Use Class<?> if the class being modeled is unknown.

                        т.е. просто Class даже не рассматривается.
                          +1
                          И это логично раз getClass возвращает Class<?>

                          эх маловато времени на редактирование
                      0
                      Вот почему стирание типа T должно тянуть за собой стирание совершенно с ним не пересекающегося типа A?

                      Ну как же не пересекающегося?
                      Тип T параметризует класс, тип A параметризует метод, вложенный в этот класс.


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


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

                0
                Вы думаете, все эти странности просто так от хотения взяты? Отправьтесь в 2004 год, когда выходила Java 5, и скажите — «А давайте забьём на совместимость с Java 1.4 и перепишем JVM» — чем вас закидают?
                  +1
                  Небольшое дополнение к первому примеру: использование raw-типов удаляет даже типоаргументы:
                  class A<T> {
                  	List<Integer> list = Arrays.asList(1);
                  }
                  
                  // Type mismatch: cannot convert from Object to Integer
                  Integer i = new A().list.get(0);
                  
                    0

                    cheremin выше дал хороший комментарий на этот счёт.

                    0

                    Да, в дженериках встречаются странности, иногда контринтуитивное поведение, но все проблемы разрешаются в compile-time.

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

                    Самое читаемое