Добрый день! Меня зовут Владислав Верминский, я отвечаю за развитие профессии JVM-разработчика в Райффайзенбанке. В этой статье я расскажу про неоднозначность вывода типов в Java. На первый взгляд с ним всё очевидно, но когда сталкиваешься со странным поведением, возникают вопросы — начинает казаться, что какие-то части кода работают неправильно. Однако, после анализа становится понятно, что всё очень непросто, но при этом всё работает по своей спецификации.
Если вы знаете, что такой код (он в примере ниже) скомпилируется, и можете чётко ответить почему — эта статья не для вас. Если у вас есть сомнения, то надеюсь, статья поможет вам понять ответ.
Именно с похожим кодом ко мне пришёл коллега и сказал: «Java — не торт, в ней даже generic не работает, вот, смотри, какой код — и он компилируется!»
Пример действительно показался мне очень интересным, как вообще Runnable может быть приведён к String? Стал разбираться, как такое возможно: оказалось, мы не одни, тот же случай упоминается на последнем слайде в докладе «Неочевидные Дженерики. JPoint 2016. Александр Маторин».
В JDK создавали issue с этой проблемой, но issue был закрыт с объяснением — это не баг, а корректное поведение компилятора. Код из примера похож на код из JDK-issue, и тут есть баг. Я расскажу, почему это правильное поведение, как его можно избежать и где баг в примере.
В статье я использую скриншоты вместо встроенного кода. Я сделал это для того, чтобы были видны подсказки от Intellij IDEA, а также для сохранения форматирования. Если же вы хотите использовать код, то перед каждой иллюстрацией есть ссылка на GitHub с исходниками.
Решаем задачу методом Чака Норриса
Status quo
Есть интерфейс Resource, его реализация FileResource, и ReportService, который создает отчёт по переданному списку ресурсов.
Постановка задачи
Необходимо создать фабрику ресурсов, чтобы отделить логику создания объектов. В фабрику передается URI и возвращается реализация ресурса, либо кидается ошибка.
Раз компилятор умеет выводить типы, то мы будем использовать эту возможность, а не ручное присвоение.
Реализация
Проверка решения
Задача решена, использовать удобно, пора пойти и попросить больше денег!
Никогда не было и вот опять
Предположим, что код не такой простой, и кто-то решил сделать рефакторинг, удалив лишние переменные.
Как выглядит такой код — дело вкуса, не будем обсуждать. Тут проблема в том, что этот кто-то забыл обернуть ресурс в список, поэтому ожидается, что такой код не скомпилируется.
Компилятор должен сказать, что вместо List<? extends Resource> передается Resource. Но это не так — такой код компилируется даже без предупреждения, но падает в рантайме с ClassCastException.
Это ж-ж-ж неспроста! © Винни Пух
Для начала нужно понять, что не так в методе у ResourceFactory. Давайте подумаем, как компилятор должен его интерпретировать? Ведь возвращается не какой-то тип, возвращается любой наследник от этого типа, и как тогда правильно выполнить вывод типа? Ответ такой — компилятор использует пересечение типов. По факту, компилятор воспринимает сигнатуру так:
И по месту уже решает, что такое T. То есть, если разложить наш пример глазами компилятора, то получается, такая запись:
Как такое возможно? В Java применяется полиморфизм, и компилятор не может проверять, какая реализация фабрики в текущий момент используется. Реализация действительно может создавать класс, который наследует и Resource и List<? extends Resource>, поскольку это два интерфейса. В Java разрешено множественное наследование интерфейсов, и тут нет проблем. На этом моменте должно быть понятно, почему компилятор использует объединение типов при выводе, и почему этот код валидный с точки зрения компилятора. Идем дальше.
Чеснок, осиновый кол или святая вода?
Проверка правильности решения
При проверке качества решения будем проверять следующие параметры:
Можно ли совершить ошибку при присваивании переменной?
Можно ли совершить ошибку при вызове в качестве параметра метода?
К финальному классу, который не реализует нужный интерфейс, нельзя привести, или сделать автоматический вывод типа.
При приведении или выводе типа к классу, который не реализует интерфейс — получаем предупреждение.
Использование возвращаемого типа в качестве параметра метода не должно отличаться от использования в виде переменной.
Для этого использую такой код:
Переход на классы вместо интерфейсов
Это плохой подход, который противоречит принципу Low coupling и dependency inversion, сужает возможности полиморфизма и возможности наследование. Тем не менее — нашу проблему можно починить таким способом.
Идея заключается в том, что для интерфейсов компилятор не проверяет реализации, а вот для классов компилятор начинает проверять, возможно ли возвращаемый класс привести к нужному типу. То есть, нам нужно преобразовать Resource и List в классы, тогда компилятор будет проверять, а возможно ли такое пересечение.
Для этого сделаем Resource абстрактным классом, а List заменим на ArrayList.
Что же, попробуем.
Теперь проверочный код сломался, компилятор вывел такое объединение (Resource & ArrayList<? extends Resource>). ArrayList не наследует Resource, а создать класс, который будет расширять оба класса, невозможно — это запрещено спецификацией (множественное наследование классов).
Давайте посмотрим, что у нас в классе для проверки.
Теперь всё работает правильно. Мы используем класс и получаем не только предупреждение, но и ошибку, когда пытаемся привести или вывести тип класса не из иерархии классов.
Конечно, это не хорошее решение проблемы, и мы нарушили рекомендуемые принципы программирования, потеряли гибкость. Тем не менее — это возможный вариант.
Если у вас при выводе типов появляется интерфейс в возвращаемом типе — это потенциальный выстрел в ногу. По закону Мерфи, кто-нибудь обязательно вызовет метод и приведёт его к недопустимому типу.
Использование контейнера для типа
Следующий вариант добавляет расходные накладные, но защищает от случайного неправильного вызова метода. Идея фикса в том, что нужно возвращать не выводимый тип, а всегда обёртку. В Java у нас для этого есть Optional, но я его не буду использовать для примера, почему — объясню ниже.
Теперь не получится передать в метод неправильный аргумент по ошибке, нужно явно получать значение, поэтому возможность совершить ошибку меньше.
Давайте посмотрим, что теперь в файле проверки.
Добавил везде вызов get() — автовывод не сработал, так как он выводит в тип Resource и не может его привести к FileResource. Это вроде как правильно, но хотелось бы тут иметь предупреждение, а не ошибку.
Теперь мы отчётливо видим, что приведение и автовывод работают по разному. Автовывод к Exception невозможен, а приведение возможно.
Наш код стал надежнее. Мы поставили защиту от случайной ошибки, и нам явно приходится указывать, какой конкретно тип должен быть в контейнере. Но мы по-прежнему можем привести к неправильному типу возвращаемый тип, без получения значения (комментарий "// OOPS we can do incorrect cast"):
Этого мы тоже можем избежать, сделав наш контейнер финальным.
Теперь так привести нельзя:
Именно поэтому я не стал использовать в примере Optional, так как он final.
Тем не менее, можно приводить возвращаемое значение к неверному типу.
Но тут нужно обойти несколько проверок, как говорится: «Сам себе злобный Буратино».
Внимательный читатель уже может понять, где ошибка в примере из вступления.
Отказ от автоматического вывода
Возможно, мы пытаемся лечить не ту проблему, может быть проблема в том, что мы неправильно поставили задачу, решили полагаться на автоматический вывод? Обычный компромисс: простота использования или простота понимания.
Давайте поменяем сигнатуру фабрики, чтобы всегда возвращать интерфейс Resource. Так мы будем вручную приводить конкретный тип к типу в местах, в которых мы уверены.
Это привело к тому, что мы не полагаемся на автовывод. В тех местах, где использовался автовывод, теперь придётся руками приводить тип. Но случайной ошибки уже не получится.
Без явного приведения типа теперь нельзя использовать наш api. Именно поэтому в примере при использовании контейнеров не получился автовывод, всё согласуется со спецификацией — нужно явно указывать тип. Чуть ниже есть описание какой тип выводится без указания.
Это неудобно.
Мы по-прежнему можем привести не к тому типу, а еще — возвращаемся в мир до generic, где огребаем множество проблем. Если в методе поменялась реализация, мы не увидим ошибку во время компиляции. Ох тяжко. Не советую идти по этому пути — кажется, это не решение.
Указание типа в параметрах
Ещё один вариант — использовать класс типа Class ожидаемого типа в параметрах метода, а уже в самой фабрике делать проверку правильности возвращаемого типа.
Даже после того, как мы указываем неправильный тип, компилятор не позволяет нам совершить такую ошибку.
Такое решение часто используется в библиотеках, и вообще, рекомендуется к применению.
Почему этот вариант лучше?
В нашей фабрике теперь можно добавлять больше проверок на параметры, выводить больше отладочной информации в log, и делать приведение типов без предупреждений или `@SuppressWarnings("unchecked")`.
Но если копать глубже — например, если у нас сам выводимый тип имеет generic, то как быть тогда?
Что делать? Добавлять ещё один тип в параметры или использовать какой-то дополнительный класс, который будет содержать информацию о типе, как Jackson TypeReference? Как говорится — думайте сами, решайте сами исходя из контекста и доменной области, конечно же.
Java 17 sealed class
В 17 Java у нас появится возможность использовать ограниченный набор реализаций — sealed classes and interfaces. Может быть это нас спасёт?
Давайте сделаем наш интерфейс Resource sealed и проверим это:
Теперь наш FileResource придётся делать final или sealed:
Проверяем гипотезу:
Увы — автовывод приводит и к интерфейсу, и к обычному классу, и к финальному классу.
А вот ручное приведение не работает — получаем ошибки. Компилятор проверяет возможность приведения типа.
Попробуем переделать Resource в класс:
И проверяем:
Теперь уже работает правильно — приведение и автовывод работают одинаково.
Вывод: нам частично поможет использование sealed-классов. Это лучше, чем было раньше, но тут тоже есть над чем работать. Если у нас все классы sealed известны заранее, то компилятор может проверить, расширяют ли они интерфейсы, к которым пытаются привести их в коде. Как минимум можно выводить предупреждение, если один наследник расширяет интерфейс, а другой нет. Тогда можно показывать предупреждение, что во время исполнения можно получить ClassCastException.
Sealed classes ещё совсем новое решение, и, несмотря на длинные листы обсуждения, далеко не все случаи проработаны. Кажется, это один из таких случаев. Если у меня дойдут руки, я просмотрю issue tracker JDK, поищу такой баг и если его нет, то создам и позже прикреплю к этой статье номер issue.
Type inference
В Java 9 появилась возможность использовать вывод типов для переменных. Какой тип будет у такого вызова (если мы не применяли предложенные решения)?
Тут нет никакой магии — берётся граница типа из сигнатуры и var = Resource. Это как раз то, что было в примере с контейнером для типа: нам нужно было правильно указать тип в контейнере, что равнозначно приведению.
Ошибка компилятора в примере
Теперь попробуем разобраться, где ошибка компилятора в примере из вступления.
Посмотрим на пример ещё раз.
RunnableType имеет границу Runnable, и в автовыводе типа должен получаться Runnable & String. Но String является final, и мы никак не можем создать такого наследника. Следовательно, компилятор может это проверить. Что тут нужно сделать? Всё правильно, засучить рукава, покопаться в issue tracker JDK, поискать обсуждение и, если его нет, создать bug issue, а ещё лучше — сразу написать патч. Будет время — займусь.
Инспекции в IntelliJ IDEA
На момент написания статьи актуальная версия IntelliJ IDEA 2021.2.3
Собственно, чего лично мне хочется от такого классного инструмента? Чтобы были предупреждения, где возможно появление ошибок. Даже если в спецификации есть ошибка, хотелось, чтобы со стороны IDE были подсказки. Создадим ещё один простой класс и посмотрим, как работают подсказки.
Intellij IDEA дает подсказку только в одном месте, когда я делаю автовывод к переменной final типа. Но при использовании автовывода в параметре, подсветки нет. На эту тему уже завели баг, инспекция появится в 2021.3.
А вот при попытке привести к не final-классу, который не расширяет возвращаемый тип, инспекции нет. Я попытался предложить улучшение и завёл issue в JetBrains YourTrack, но его отклонили, по причине того, что данная инспекция будет слишком зашумлять код. Мои доводы, что такое поведение обычно не типично в коде, и инспекция будет срабатывать не часто, а добавить такое предупреждение будет не лишним, не оказалось убедительным. Мне предложили реализовать эту инспекцию самому в виде плагина. Если я не один такой, кто считает, что показывать предупреждение будет полезной подсказкой именно в ядре IntelliJ IDEA, проголосуйте за изменение и попросите переоткрыть .
Заключение
Использование generic типов — это непросто. Недостаточно понять, зачем они нужны. Важно понимать, с какими проблемами сталкиваются при реализации generic, уметь смотреть на код «глазами» компилятора, а иногда нужно очень хорошо знать документацию языка.
Есть две задачи — проверка правильного приведения типа и автоматический вывод типа. Это разные части спецификации языка (Type inference и Reference Type Casting). Кажется, что автовывод типа должен покрывать приведение, но это не так, по разным причинам. Поэтому вещи, которые кажутся очевидными на первый взгляд, не работают, так как автоматизировать вывод типа намного сложнее — он требует не только проверки типов, но и учитывать контекст использования переменной.
Использовать выводимые типы в качестве возвращаемого значения — не очень хорошая идея. Лучше всего, чтобы возвращаемый generic-тип ещё использовался и в параметрах метода, тогда меньше шансов допустить ошибку.
P.S. Большое спасибо @lanyза техническое ревью статьи, а также читателям за уделённое время. Я с удовольствием получу обратную связь по статье.
Все исходники к статье можно найти тут.
Комментарии (17)
souls_arch
19.11.2021 19:01Все аналогично древним математическим софизмам. На одном из этапов мы допускаем то, чего допускать нельзя и присваиваем что попало. JVM обмануть можно, но зачем, - чтобы обмануть себя?
Поддержу автора: люди сами себе злобные буратины.
Mingun
19.11.2021 19:26Корень проблемы, насколько я понял, в особенной трактовке компилятором ограничения типа
T extends U
какT & U
, хотя так делать нельзя, так как эти выражения не эквивалентны — во втором случаеU
может вообще не иметь кT
никакого отношения. Интересно, по какой причине сделано так.artglorin Автор
19.11.2021 19:32+1Проблема в полиморфизме :) все хорошо работает, если вывод будет на классах, компилятор проверит, а вот интерфейсы не проверяются. Так как реализация не известна, реализация может быть в рантайм через прокси.
Mingun
19.11.2021 19:40Не понимаю, какие проблемы в проверке интерфейсов? Компилятор же видит, что в конечном выражении (с левой стороны присваивания, например) используется класс, не реализующий интерфейс, требуемый ограничением типа. Почему же он позволяет такое скомпилировать?
artglorin Автор
19.11.2021 19:55+1он проверит, если класс финальный или у нас пересечение двух классов, так как множественное наследование классов запрещено. А если нет, то разницы с интерфейсом нет. Я пытался это объяснить в блоке «Переход на классы вместо интерфейсов».
AntNay
20.11.2021 12:00На мой взгляд эта проблема связана как минимум с функционалом генерации класса на лету (в рантайме). И соответственно метод createResource может сгенерировать класс реализующий интерфейсы Resource и List<? extends Resource>. И по идее это вполне логично и допустимо. А это известно только в рантайме.
artglorin Автор
20.11.2021 12:04Не обязательно только генерация. Конкретная реализация может быть скомпилирована и подтягиваться из другой библиотеки, но верно, что реализация известна только в рантайме и компилятор не может это поверить никак, если это не финальный класс.
yeputons
20.11.2021 11:52Мне кажется, что проблема скорее в том, что метод
createResource()
по своей сигнатуре возвращает некоторый типT
, единственное ограничение на который со стороны самого методаcreateResource()
— он должен реализовыватьResource
. Не очень понятно, как такое вообще трактовать.Например, если считать, что generic-методы — это методы, которые работают для какого-то выбранного
T
, то есть вопрос: а кто "выбирает" типT
?Если тип
T
выбирает сам метод (как сейчас по факту и происходит: для набора параметров возвращается какой-то конкретный типT
), то вызывающий код не имеет права пользоваться никакими знаниями про типT
, кроме того, что он реализуетResource
. Такая конструкция в языке Java уже есть: просто ставим возвращаемый типResource
без всякихextends
.Если тип
T
выбирается вызывающим кодом, то получается, что метод должен уметь возвращать экземпляр произвольного типаT
. Вызывающий код выбрал, какойT
ему нужен (например, одновременно реализующийResource
иList<>
— такие теоретически существуют), а метод должен этому удовлетворить. Что, разумеется, некоторый абсурд: метод даже не знает, что заT
выбрал вызывающий код. Именно поэтому внутри тела метода приходится делать небезопасное преобразование к типуT
— компилятор абсолютно правильно не может доказать, чтоFileResource
является каким-то произвольнымT
.artglorin Автор
20.11.2021 12:37+1Да, идея в том, что вот для такой сигнатуры метода
<T> T get();
тип <T>, определяет не метода, а вызывающий код.
И такая сигнатура<T extends Runnable> T get();
будет тоже определяться вызывающим кодом, но в самом методе будет создаваться реализация, которая точно будет расширять Runnable и будет совместима с типом Т.
Да, это выглядит очень странно, получается есть нелогичная конструкция в языке. Ведь мы не можем узнать какой тип хочет получить вызывающий код. Единственная возможность это использовать такую сигнатуру
<T> T get(Class<T> type);
А такой конструкции, вызывающий код должен нам сообщить, какой тип он хочет вернуть. Ну а если не сообщает, передавая null, это уже выстрел в ногу :) Именно поэтому, что такая сигнатура имеет хоть какой-то смысл, используется в библиотеках и является рекомендуемой в блоке "Указание типа в параметрах".
В Kotlin используется ключевое слово refied для типа, но это просто синтаксический сахар, который под капотом использует как раз Class<T>.
Mingun
20.11.2021 17:37+1Тогда остаётся вопрос, почему же в случае с сигнатурой
<T extends Runnable> T get();
компилятор не бьёт по рукам при таком вызове
String s = get();
ведь он точно знает, что
String
не реализует интерфейсRunnable
, так что это даже компилироваться не должно. И не важно, как там внутри устроен методget()
.
artglorin Автор
20.11.2021 17:42+1Я вроде написал, что это похоже на баг. Если попробовать использовать cast, то скомпилировать код не получится, а вот автовывод типа работает. Это потому, что в документации это разные разделы. Автовывод появился только в 9 версии Java и видимо не учли этот момент в проработке компилятора. Мне кажется, даже сделать багфикс этого момента будет не сложно.
TimReset
21.11.2021 01:36Классная статья! Тоже сталкивался с такой особенностью Java несколько лет назад. Я её хотел в тестах применить - типа избежать явного каста возвращаемого значения, что бы код чуть меньше был. Но в итоге всё равно оставили как есть как раз из за того что в коде можно переменной любого типа присвоить значение. Про передачу ожидаемого типа в качестве параметра не додумался - спасибо за инфу! А не могли бы вы привести ещё примеров, где этот подход используется, с передачей класса в параметре? А то я не примоню чтобы его встречал.
artglorin Автор
21.11.2021 11:03+1Спасибо большое за оценку. Очень рад, что статья понравилась.
Использование класса Class используется очень часто в различных библиотеках, там где нельзя заранее знать все случаи использования. В groove зачастую используется, в Spring Framework сплошь и рядом, в сериализаторах, наподобие Jackson. Уверен, если вы поищите в своем проекте через Intellij IDEA по области Scope -
Class<
,то найдёте не одно место использования.
excentro
Можно завести правило, на пример, для SonarLint?
artglorin Автор
Неплохой вариант. И так как нужно проверять именно сигнатуру, кажется, такое правило не сложно будет создать. Спасибо за подсказку, нужно будет посмотреть в эту сторону.