Недавно я столкнулся с ситуацией, что замена 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
, то пишите в комментариях.
Автор: Тагир Валеев