Недавно я столкнулся с ситуацией, что замена Object на var в программе на Java 10 приводит к исключению в процессе выполнения. Мне стало интересно, много ли разных способов добиться такого эффекта, и я обратился с этим вопросом к сообществу:
Оказалось, что добиться эффекта можно разными способами. Хотя все они несильно сложные, но на примере такой задачки интересно вспомнить о разных тонкостях языка. Давайте посмотрим, какие удалось найти способы.
Участники
Среди ответивших оказалось много известных и не очень людей. Это и Сергей bsideup Егоров, сотрудник Pivotal, спикер, один из создателей Testcontainers. Это и Виктор Полищук, знаменитый докладами про кровавый энтерпрайз. Так же отметились Никита Артюшов из Google; Дмитрий Михайлов и Maccimo. Но особенно я обрадовался приходу Wouter Coekaerts. Он известен своей прошлогодней статьёй, где прошёлся по системе типов Java и рассказал, как безнадёжно она сломана. Кое-что из этой статьи мы с jbaruch даже использовали в четвёртом выпуске Java Puzzlers.
Задача и решения
Итак, суть нашей задачи такова: есть Java-программа, в которой присутствует объявление переменной вида Object x = ...
(честный стандартный java.lang.Object
, никаких подмен типов). Программа компилируется, запускается и печатает что-нибудь типа "Ok". Мы заменяем Object
на var
, требуя автоматического вывода типа, после этого программа продолжает компилироваться, но при запуске падает с исключением.
Решения можно грубо поделить на две группы. В первой после замены на var переменная становится примитивной (то есть изначально был автобоксинг). Во второй тип остаётся объектным, но более специфичным, чем Object
. Тут можно выделить интересную подгруппу, которая использует дженерики.
Боксинг
Как отличить объект от примитива? Есть много разных способов. Самый простой — проверить на идентичность. Такое решение предложил Никита:
Object x = 1000;
if (x == new Integer(1000)) throw new Error();
System.out.println("Ok");
Когда x
— объект, он точно не может быть равен по ссылке новому объекту new Integer(1000)
. А если это примитив, то по правилам языка new Integer(1000)
тут же разворачивается тоже в примитив, и числа сравниваются как примитивы.
Другой способ — перегруженные методы. Можно написать свои, но Сергей придумал более изящный вариант: использовать стандартную библиотеку. Печально известен метод List.remove
, который перегружен и может удалить либо элемент по индексу, если передать примитив, либо элемент по значению, если передать объект. Это неоднократно приводило к багам в реальных программах, если вы используете List<Integer>
. Для нашей задачи решение может выглядеть так:
Object x = 1000;
List<?> list = new ArrayList<>();
list.remove(x);
System.out.println("Ok");
Сейчас мы пытаемся удалить из пустого списка несуществующий элемент 1000, это просто бесполезное действие. Но если заменить Object
на var
, мы вызовем другой метод, который удаляет элемент с индексом 1000. А это уже приводит к IndexOutOfBoundsException
.
Третий способ — это оператор преобразования типов. Мы можем успешно преобразовать к примитивному типу другой примитив, но объект преобразуется только если там обёртка над тем же самым типом, к которому преобразуем (тогда произойдёт анбоксинг). Вообще-то нам нужен обратный эффект: исключение в случае примитива, а не в случае объекта, но с помощью try-catch этого легко добиться, чем и воспользовался Виктор:
Object x = 40;
try {
throw new Error("Oops :" + (char)x);
} catch (ClassCastException e) {
System.out.println("Ok");
}
Здесь ClassCastException
— ожидаемое поведение, тогда программа завершается нормально. А вот после использования var
это исключение пропадает, и мы кидаем другое. Интересно, навеяно ли это реальным кодом из кровавого энтерпрайза?..
Другой вариант с преобразованием типов предложил Воутер. Можно воспользоваться странной логикой оператора ?:
. Правда его код просто даёт разные результаты, поэтому придётся его как-нибудь доработать, чтобы было исключение. Вот так, мне кажется, достаточно изящно:
Object x = 1.0;
System.out.println(String.valueOf(false ? x : 100000000000L).substring(12) + "Ok");
Отличие этого метода в том, что мы не используем значение x
напрямую, но тип x
влияет на тип выражения false ? x : 100000000000L
. Если x
— Object
, то и тип всего выражения Object
, и тогда мы просто имеем боксинг, String.valueOf()
выдаст строку 100000000000
, для которой substring(12)
— это пустая строка. Если же использовать var
, то тип x
становится double
, а значит и тип false ? x : 100000000000L
тоже double
, то есть 100000000000L
превратится в 1.0E11
, где сильно меньше 12 символов, поэтому вызов substring
приводит к StringIndexOutOfBoundsException
.
Наконец, воспользуемся тем, что переменную вообще-то можно менять после создания. И в объектную переменную в отличие от примитивной можно положить null
. Поместить null
в переменную несложно, есть много способов. Но здесь Воутер тоже проявил творческий подход, использовав смешной метод Integer.getInteger
:
Object x = 1;
x = Integer.getInteger("moo");
System.out.println("Ok");
Не все знают, что этот метод читает системное свойство с именем moo
и если оно есть, пытается преобразовать его в число, а иначе возвращает null
. Если свойства нет, мы спокойно присваиваем null
в объект, но падаем с NullPointerException
при попытке присвоить в примитив (там происходит автоматический анбоксинг). Можно было и проще, конечно. Грубый вариант x = null;
не пролезет — это не компилируется, но вот такое уже компилятор проглотит:
Object x = 1;
x = (Integer)null;
System.out.println("Ok");
Объектный тип
Предположим, что с примитивами играться больше нельзя. Что ещё можно придумать?
Ну во-первых, простейший вариант с перегрузкой методов, предложенный Дмитрием:
public static void main(String[] args) {
Object x = "Ok";
sayWhat(x);
}
static void sayWhat(Object x) { System.out.println(x); }
static void sayWhat(String x) { throw new Error(); }
Линковка перегруженных методов в Java происходит статически, на этапе компиляции. Здесь вызовется метод sayWhat(Object)
, но если мы выведем тип x
автоматически, то выведется String
, и поэтому будет слинкован более специфичный метод sayWhat(String)
.
Другой способ сделать неоднозначный вызов в Java — с помощью переменных аргументов (varargs). Про это вспомнил опять же Воутер:
Object x = new Object[] {};
Arrays.asList(x).get(0);
System.out.println("Ok");
Когда тип переменной Object
, компилятор думает, что это переменный аргумент и заворачивает массив в ещё один массив из одного элемента, поэтому get()
отрабатывает успешно. Если же использовать var
, выведется тип Object[]
, и дополнительного оборачивания не будет. Таким образом мы получим пустой список, и вызов get()
завершится аварийно.
Maccimo пошёл по хардкору: он решил вызвать println
через MethodHandle API:
Object x = "Ok";
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findVirtual(
PrintStream.class, "println",
MethodType.methodType(void.class, Object.class));
mh.invokeExact(System.out, x);
Метод invokeExact
и ещё несколько методов из пакета java.lang.invoke
имеют так называемую "полиморфную сигнатуру". Хотя он объявлен как обычный vararg метод invokeExact(Object... args)
, но стандартной упаковки в массив не происходит. Вместо этого в байткоде генерируется сигнатура, которая соответствует типам фактически переданных аргументов. Метод invokeExact
создан для супербыстрого вызова метод-хэндлов, поэтому он не делает никаких стандартных преобразований аргументов вроде приведения типов или боксинга. Ожидается, что тип метод-хэндла в точности соответствует сигнатуре вызова. Это проверяется во время выполнения и так как в случае с var
соответствие нарушается, мы получаем WrongMethodTypeException
.
Дженерики
Конечно, параметризация типов может добавить огоньку в любую задачку на Java. Дмитрий принёс решение, похожее на код, на который я изначально и натолкнулся. Решение Дмитрия многословнее, поэтому я покажу своё:
public static void main(String[] args) {
Object x = foo(new StringBuilder());
System.out.println(x);
}
static <T> T foo(T x) { return (T)"Ok"; }
Тип T
выводится как StringBuilder
, но в данном коде компилятор не обязан вставлять в байткод проверку типа в точке вызова. Ему достаточно, что StringBuilder
можно присвоить в Object
, а значит, всё хорошо. Никто не против, что метод с возвращаемым значением StringBuilder
на самом деле вернул строку, если результат вы всё равно присвоили в переменную типа Object
. Компилятор честно предупреждает, что у вас есть unchecked cast, а значит, он умывает руки. Однако при замене x
на var
тип x
уже тоже выводится как StringBuilder
, и тут уже нельзя без проверки типа, потому что присваивать в переменную типа StringBuilder
что-то другое никуда не годится. В результате после замены на var
программа благополучно падает с ClassCastException
.
Воутер предложил вариант этого решения с использованием стандартных методов:
Object o = ((List<String>)(List)List.of(1)).get(0);
System.out.println("Ok");
Наконец ещё один вариант от Воутера:
Object x = "";
TreeSet<?> set = Stream.of(x)
.collect(toCollection(() -> new TreeSet<>((a, b) -> 0)));
if (set.contains(1)) {
System.out.println("Ok");
}
Здесь в зависимости от использования var
или Object
тип стрима выводится либо как Stream<Object>
, либо как Stream<String>
. Соответственно выводится тип TreeSet
и тип компаратора-лямбды. В случае с var
в лямбду обязаны прийти строки, поэтому при генерации рантайм-представления лямбды автоматически вставляется преобразование типов, которое и даёт ClassCastException
при попытке привести единицу к строке.
В общем, в итоге получилось весьма нескучно. Если вы можете придумать принципиально другие методы сломать var
, то пишите в комментариях.
Комментарии (15)
bvn13
27.09.2019 10:01Я думал, что речь пойдет про баг в самой Java, а вы тут какие-то сферические примеры придумываете...
tsypanov
27.09.2019 10:54Не скажите, пример с
Object x = 1000; List<?> list = new ArrayList<>(); list.remove(x);
вполне себе жизненный, по меньшей мере один раз я столкнулся с плавающей ошибкой при наличии
List<Integer>
и вызова на нём методаremove()
.daiver19
27.09.2019 20:51Вообще здесь проблема в, пожалуй, одном из самых кривых решений в дизайне Java, а именно в разнице поведения в перегрузке remove. Как создатели пошли на этот хак, особенно учитывая общую многословность Java, для меня загадка.
tsypanov
27.09.2019 21:18Как создатели пошли на этот хак, особенно учитывая общую многословность Java, для меня загадка.
Тут нужно помнить, что грабли кроются в автоматическом разворачивании/заворачивании
Integer
-a иint
-а, но так было не всегда. До 5 явыint
нужно было явно заворачивать вInteger
и наоборот.
Опять же, сами по себе методы
List.remove(Object)
иList.remove(int)
, ИМХО, довольно логичны в контексте других методов интерфейса "список". Просто именно сint
-ом /Integer
-ом получилась бяка.
tsypanov
27.09.2019 15:44+1Кстати, про баги в самой яве: в заметке же была ссылка на эту статью http://wouter.coekaerts.be/2018/java-type-system-broken
tsypanov
27.09.2019 10:56Статья огонь, читал не отрываясь!
Умеет ли "Идея" распознавать подобный код с плавающими ошибками?
lany Автор
27.09.2019 11:04Про метод-референс мы говорим, что а-та-та будет:
Ну сравнение c
new Integer()
понятно что подозрительное:
x = (Integer)null
— дело святое (конечно, варнинг исчезает, если тип Object):
Кое-что из остального поддержать можно, но не факт, что нужно...
tsypanov
27.09.2019 11:47Проверил,
var x = 1.0; System.out.println(String.valueOf(false ? x : 100000000000L).substring(12) + "Ok");
определяется на ура. А то я уж было собрался расчехлять задачесоздатель ;)
lany Автор
27.09.2019 12:01А, кстати да. Я и забыл, что я сам это сделал.
List.remove тоже поддержал (добавил требование, что индекс должен быть от 0 до длины списка), хотя здесь простой пример, всё известно заранее. Не факт, что в реальной жизни пригодится.
regent
30.09.2019 15:17wouter.coekaerts.be/2018/java-type-system-broken
Тип T выводится как StringBuilder, но в данном коде компилятор не обязан вставлять в байткод проверку типа в точке вызова. Ему достаточно, что StringBuilder можно присвоить в Object, а значит, всё хорошо.
Все носятся с null'ом — типа ошибка на миллион долларов… Реальная ошибка на миллион это реализация дженериков в Java 5… Эх, Мартин Мартин…
Вот интересно, кто то смог бы реализовать дженерики в Java по другому? (не создавая другой язык для jvm)lany Автор
01.10.2019 10:06+1Всё же по-моему, от нулла больше реальных ошибок, чем от дженериков со стиранием.
maxzh83
Для фана и тренировки мозга — ок, но вопрос в другом, а почему вообще ожидается, что это гарантировано будет работать? И почему неработающие кейсы должны удивлять? Это примерно как «меняем double на float: что может пойти не так».
lany Автор
Не то чтобы ожидается, что работать не будут. Однако большинство нормальных использований либо ничего не сломают, либо сломают компиляцию. А чтобы сохранить компиляцию, но сломать рантайм, надо подумать. Так что да, для фана и тренировки мозга.