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


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


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

    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 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

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

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