Заменить Object на var: что может пойти не так? +40


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




К сожалению, не доступен сервер mySQL