Как устроен вывод Generic-типов в Java
Добрый день! Меня зовут Владислав Верминский, я отвечаю за развитие профессии JVM-разработчика в Райффайзенбанке. В этой статье я расскажу про неоднозначность вывода типов в Java. На первый взгляд с ним всё очевидно, но когда сталкиваешься со странным поведением, возникают вопросы — начинает казаться, что какие-то части кода работают неправильно. Однако, после анализа становится понятно, что всё очень непросто, но при этом всё работает по своей спецификации.
Если вы знаете, что такой код (он в примере ниже) скомпилируется, и можете чётко ответить почему — эта статья не для вас. Если у вас есть сомнения, то надеюсь, статья поможет вам понять ответ.
Приведение Runnable типа к String
Именно с похожим кодом ко мне пришёл коллега и сказал: «Java — не торт, в ней даже generic не работает, вот, смотри, какой код — и он компилируется!»
Пример действительно показался мне очень интересным, как вообще Runnable может быть приведён к String? Стал разбираться, как такое возможно: оказалось, мы не одни, тот же случай упоминается на последнем слайде в докладе «Неочевидные Дженерики. JPoint 2016. Александр Маторин».
В JDK создавали issue с этой проблемой, но issue был закрыт с объяснением — это не баг, а корректное поведение компилятора. Код из примера похож на код из JDK-issue, и тут есть баг. Я расскажу, почему это правильное поведение, как его можно избежать и где баг в примере.
В статье я использую скриншоты вместо встроенного кода. Я сделал это для того, чтобы были видны подсказки от Intellij IDEA, а также для сохранения форматирования. Если же вы хотите использовать код, то перед каждой иллюстрацией есть ссылка на GitHub с исходниками.
Решаем задачу методом Чака Норриса
Status quo
Есть интерфейс Resource, его реализация FileResource, и ReportService, который создает отчёт по переданному списку ресурсов.
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
Ссылка на Github с кодом
Постановка задачи
Необходимо создать фабрику ресурсов, чтобы отделить логику создания объектов. В фабрику передается URI и возвращается реализация ресурса, либо кидается ошибка.
Раз компилятор умеет выводить типы, то мы будем использовать эту возможность, а не ручное присвоение.
Реализация
Ссылка на GitHub с кодом
Проверка решения
Ссылка на GitHub с кодом
Задача решена, использовать удобно, пора пойти и попросить больше денег!
Никогда не было и вот опять
Предположим, что код не такой простой, и кто-то решил сделать рефакторинг, удалив лишние переменные.
Ссылка на GitHub с кодом
Как выглядит такой код — дело вкуса, не будем обсуждать. Тут проблема в том, что этот кто-то забыл обернуть ресурс в список, поэтому ожидается, что такой код не скомпилируется.
Компилятор должен сказать, что вместо List extends Resource> передается Resource. Но это не так — такой код компилируется даже без предупреждения, но падает в рантайме с ClassCastException.
Это ж-ж-ж неспроста! © Винни Пух
Для начала нужно понять, что не так в методе у ResourceFactory. Давайте подумаем, как компилятор должен его интерпретировать? Ведь возвращается не какой-то тип, возвращается любой наследник от этого типа, и как тогда правильно выполнить вывод типа? Ответ такой — компилятор использует пересечение типов. По факту, компилятор воспринимает сигнатуру так:
И по месту уже решает, что такое T. То есть, если разложить наш пример глазами компилятора, то получается, такая запись:
Как такое возможно? В Java применяется полиморфизм, и компилятор не может проверять, какая реализация фабрики в текущий момент используется. Реализация действительно может создавать класс, который наследует и Resource и List extends Resource>, поскольку это два интерфейса. В Java разрешено множественное наследование интерфейсов, и тут нет проблем. На этом моменте должно быть понятно, почему компилятор использует объединение типов при выводе, и почему этот код валидный с точки зрения компилятора. Идем дальше.
Чеснок, осиновый кол или святая вода?
Проверка правильности решения
При проверке качества решения будем проверять следующие параметры:
Можно ли совершить ошибку при присваивании переменной?
Можно ли совершить ошибку при вызове в качестве параметра метода?
К финальному классу, который не реализует нужный интерфейс, нельзя привести, или сделать автоматический вывод типа.
При приведении или выводе типа к классу, который не реализует интерфейс — получаем предупреждение.
Использование возвращаемого типа в качестве параметра метода не должно отличаться от использования в виде переменной.
Для этого использую такой код:
Ссылка на GitHub с кодом
Переход на классы вместо интерфейсов
Это плохой подход, который противоречит принципу Low coupling и dependency inversion, сужает возможности полиморфизма и возможности наследование. Тем не менее — нашу проблему можно починить таким способом.
Идея заключается в том, что для интерфейсов компилятор не проверяет реализации, а вот для классов компилятор начинает проверять, возможно ли возвращаемый класс привести к нужному типу. То есть, нам нужно преобразовать Resource и List в классы, тогда компилятор будет проверять, а возможно ли такое пересечение.
Для этого сделаем Resource абстрактным классом, а List заменим на ArrayList.
Что же, попробуем.
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
Теперь проверочный код сломался, компилятор вывел такое объединение (Resource & ArrayList extends Resource>). ArrayList не наследует Resource, а создать класс, который будет расширять оба класса, невозможно — это запрещено спецификацией (множественное наследование классов).
Давайте посмотрим, что у нас в классе для проверки.
Ссылка на GitHub с кодом
Теперь всё работает правильно. Мы используем класс и получаем не только предупреждение, но и ошибку, когда пытаемся привести или вывести тип класса не из иерархии классов.
Конечно, это не хорошее решение проблемы, и мы нарушили рекомендуемые принципы программирования, потеряли гибкость. Тем не менее — это возможный вариант.
Если у вас при выводе типов появляется интерфейс в возвращаемом типе — это потенциальный выстрел в ногу. По закону Мерфи, кто-нибудь обязательно вызовет метод и приведёт его к недопустимому типу.
Использование контейнера для типа
Следующий вариант добавляет расходные накладные, но защищает от случайного неправильного вызова метода. Идея фикса в том, что нужно возвращать не выводимый тип, а всегда обёртку. В Java у нас для этого есть Optional, но я его не буду использовать для примера, почему — объясню ниже.
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
Теперь не получится передать в метод неправильный аргумент по ошибке, нужно явно получать значение, поэтому возможность совершить ошибку меньше.
Давайте посмотрим, что теперь в файле проверки.
Ссылка на GitHub с кодом
Добавил везде вызов get () — автовывод не сработал, так как он выводит в тип Resource и не может его привести к FileResource. Это вроде как правильно, но хотелось бы тут иметь предупреждение, а не ошибку.
Теперь мы отчётливо видим, что приведение и автовывод работают по разному. Автовывод к Exception невозможен, а приведение возможно.
Наш код стал надежнее. Мы поставили защиту от случайной ошибки, и нам явно приходится указывать, какой конкретно тип должен быть в контейнере. Но мы по-прежнему можем привести к неправильному типу возвращаемый тип, без получения значения (комментарий »// OOPS we can do incorrect cast»):
Этого мы тоже можем избежать, сделав наш контейнер финальным.
Ссылка на GitHub с кодом
Теперь так привести нельзя:
With final Container
Именно поэтому я не стал использовать в примере Optional, так как он final.
Тем не менее, можно приводить возвращаемое значение к неверному типу.
With final Container and get
Но тут нужно обойти несколько проверок, как говорится:»Сам себе злобный Буратино».
Внимательный читатель уже может понять, где ошибка в примере из вступления.
Отказ от автоматического вывода
Возможно, мы пытаемся лечить не ту проблему, может быть проблема в том, что мы неправильно поставили задачу, решили полагаться на автоматический вывод? Обычный компромисс: простота использования или простота понимания.
Давайте поменяем сигнатуру фабрики, чтобы всегда возвращать интерфейс Resource. Так мы будем вручную приводить конкретный тип к типу в местах, в которых мы уверены.
Ссылка на GitHub с кодом
Это привело к тому, что мы не полагаемся на автовывод. В тех местах, где использовался автовывод, теперь придётся руками приводить тип. Но случайной ошибки уже не получится.
Ссылка на GitHub с кодом
Без явного приведения типа теперь нельзя использовать наш api. Именно поэтому в примере при использовании контейнеров не получился автовывод, всё согласуется со спецификацией — нужно явно указывать тип. Чуть ниже есть описание какой тип выводится без указания.
Это неудобно.
Мы по-прежнему можем привести не к тому типу, а еще — возвращаемся в мир до generic, где огребаем множество проблем. Если в методе поменялась реализация, мы не увидим ошибку во время компиляции. Ох тяжко. Не советую идти по этому пути — кажется, это не решение.
Указание типа в параметрах
Ещё один вариант — использовать класс типа Class ожидаемого типа в параметрах метода, а уже в самой фабрике делать проверку правильности возвращаемого типа.
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
Даже после того, как мы указываем неправильный тип, компилятор не позволяет нам совершить такую ошибку.
Такое решение часто используется в библиотеках, и вообще, рекомендуется к применению.
Почему этот вариант лучше?
В нашей фабрике теперь можно добавлять больше проверок на параметры, выводить больше отладочной информации в log, и делать приведение типов без предупреждений или `@SuppressWarnings («unchecked»)`.
Но если копать глубже — например, если у нас сам выводимый тип имеет generic, то как быть тогда?
Ссылка на GitHub с кодом
Что делать? Добавлять ещё один тип в параметры или использовать какой-то дополнительный класс, который будет содержать информацию о типе, как Jackson TypeReference? Как говорится — думайте сами, решайте сами исходя из контекста и доменной области, конечно же.
Java 17 sealed class
В 17 Java у нас появится возможность использовать ограниченный набор реализаций — sealed classes and interfaces. Может быть это нас спасёт?
Давайте сделаем наш интерфейс Resource sealed и проверим это:
Ссылка на GitHub с кодом
Теперь наш FileResource придётся делать final или sealed:
Ссылка на GitHub с кодом
Проверяем гипотезу:
Ссылка на GitHub с кодом
Увы — автовывод приводит и к интерфейсу, и к обычному классу, и к финальному классу.
А вот ручное приведение не работает — получаем ошибки. Компилятор проверяет возможность приведения типа.
Попробуем переделать Resource в класс:
Ссылка на GitHub с кодом
Ссылка на GitHub с кодом
И проверяем:
Ссылка на GitHub с кодом
Теперь уже работает правильно — приведение и автовывод работают одинаково.
Вывод: нам частично поможет использование sealed-классов. Это лучше, чем было раньше, но тут тоже есть над чем работать. Если у нас все классы sealed известны заранее, то компилятор может проверить, расширяют ли они интерфейсы, к которым пытаются привести их в коде. Как минимум можно выводить предупреждение, если один наследник расширяет интерфейс, а другой нет. Тогда можно показывать предупреждение, что во время исполнения можно получить ClassCastException.
Sealed classes ещё совсем новое решение, и, не смотря на длинные листы обсуждения, далеко не все случаи проработаны. Кажется, это один из таких случаев. Если у меня дойдут руки, я просмотрю issue tracker JDK, поищу такой баг и если его нет, то создам и позже прикреплю к этой статье номер issue.
Type inference
В Java 9 появилась возможность использовать вывод типов для переменных. Какой тип будет у такого вызова (если мы не применяли предложенные решения)?
Ссылка на GitHub с кодом
Тут нет никакой магии — берётся граница типа из сигнатуры и var = Resource. Это как раз то, что было в примере с контейнером для типа: нам нужно было правильно указать тип в контейнере, что равнозначно приведению.
Ошибка компилятора в примере
Теперь попробуем разобраться, где ошибка компилятора в примере из вступления.
Посмотрим на пример ещё раз.
Ссылка на GitHub с кодом
Приведение Runnable типа к String
RunnableType имеет границу Runnable, и в автовыводе типа должен получаться Runnable & String. Но String является final, и мы никак не можем создать такого наследника. Следовательно, компилятор может это проверить. Что тут нужно сделать? Всё правильно, засучить рукава, покопаться в issue tracker JDK, поискать обсуждение и, если его нет, создать bug issue, а ещё лучше — сразу написать патч. Будет время — займусь.
Инспекции в IntelliJ IDEA
На момент написания статьи актуальная версия IntelliJ IDEA 2021.2.3
Собственно, чего лично мне хочется от такого классного инструмента? Чтобы были предупреждения, где возможно появление ошибок. Даже если в спецификации есть ошибка, хотелось, чтобы со стороны IDE были подсказки. Создадим ещё один простой класс и посмотрим, как работают подсказки.
Ссылка на GitHub с кодом
Intellij IDEA дает подсказку только в одном месте, когда я делаю автовывод к переменной final типа. Но при использовании автовывода в параметре, подсветки нет. На эту тему уже завели баг, инспекция появится в 2021.3.
А вот при попытке привести к не final-классу, который не расширяет возвращаемый тип, инспекции нет. Я попытался предложить улучшение и завёл issue в JetBrains YourTrack, но его отклонили, по причине того, что данная инспекция будет слишком зашумлять код. Мои доводы, что такое поведение обычно не типично в коде, и инспекция будет срабатывать не часто, а добавить такое предупреждение будет не лишним, не оказалось убедительным. Мне предложили реализовать эту инспекцию самому в виде плагина. Если я не один такой, кто считает, что показывать предупреждение будет полезной подсказкой именно в ядре IntelliJ IDEA, проголосуйте за изменение и попросите переоткрыть .
Заключение
Использование generic типов — это непросто. Недостаточно понять, зачем они нужны. Важно понимать, с какими проблемами сталкиваются при реализации generic, уметь смотреть на код «глазами» компилятора, а иногда нужно очень хорошо знать документацию языка.
Есть две задачи — проверка правильного приведения типа и автоматический вывод типа. Это разные части спецификации языка (Type inference и Reference Type Casting). Кажется, что автовывод типа должен покрывать приведение, но это не так, по разным причинам. Поэтому вещи, которые кажутся очевидными на первый взгляд, не работают, так как автоматизировать вывод типа намного сложнее — он требует не только проверки типов, но и учитывать контекст использования переменной.
Использовать выводимые типы в качестве возвращаемого значения — не очень хорошая идея. Лучше всего, чтобы возвращаемый generic-тип ещё использовался и в параметрах метода, тогда меньше шансов допустить ошибку.
P.S. Большое спасибо @lanyза техническое ревью статьи, а также читателям за уделённое время. Я с удовольствием получу обратную связь по статье.
Все исходники к статье можно найти тут.