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

Комментарии (15)


  1. maxzh83
    27.09.2019 09:43

    Для фана и тренировки мозга — ок, но вопрос в другом, а почему вообще ожидается, что это гарантировано будет работать? И почему неработающие кейсы должны удивлять? Это примерно как «меняем double на float: что может пойти не так».


    1. lany Автор
      27.09.2019 10:52

      Не то чтобы ожидается, что работать не будут. Однако большинство нормальных использований либо ничего не сломают, либо сломают компиляцию. А чтобы сохранить компиляцию, но сломать рантайм, надо подумать. Так что да, для фана и тренировки мозга.


  1. bvn13
    27.09.2019 10:01

    Я думал, что речь пойдет про баг в самой Java, а вы тут какие-то сферические примеры придумываете...


    1. tsypanov
      27.09.2019 10:54

      Не скажите, пример с


      Object x = 1000;
      List<?> list = new ArrayList<>();
      list.remove(x);

      вполне себе жизненный, по меньшей мере один раз я столкнулся с плавающей ошибкой при наличии List<Integer> и вызова на нём метода remove().


      1. daiver19
        27.09.2019 20:51

        Вообще здесь проблема в, пожалуй, одном из самых кривых решений в дизайне Java, а именно в разнице поведения в перегрузке remove. Как создатели пошли на этот хак, особенно учитывая общую многословность Java, для меня загадка.


        1. tsypanov
          27.09.2019 21:18

          Как создатели пошли на этот хак, особенно учитывая общую многословность Java, для меня загадка.

          Тут нужно помнить, что грабли кроются в автоматическом разворачивании/заворачивании Integer-a и int-а, но так было не всегда. До 5 явы int нужно было явно заворачивать в Integer и наоборот.


          Опять же, сами по себе методы List.remove(Object) и List.remove(int), ИМХО, довольно логичны в контексте других методов интерфейса "список". Просто именно с int-ом / Integer-ом получилась бяка.


          1. daiver19
            27.09.2019 21:24
            +1

            Да какая там логика, это просто злоупотребление перегрузкой. Нигде больше такой фигни нет, везде есть разница между семантикой remove и removeAt.


            1. tsypanov
              27.09.2019 21:44

              Да, сегодня с высоты 2 с хвостиком десятков лет мы это видим. На заре не разглядели, бывает :)


    1. tsypanov
      27.09.2019 15:44
      +1

      Кстати, про баги в самой яве: в заметке же была ссылка на эту статью http://wouter.coekaerts.be/2018/java-type-system-broken


  1. tsypanov
    27.09.2019 10:56

    Статья огонь, читал не отрываясь!


    Умеет ли "Идея" распознавать подобный код с плавающими ошибками?


    1. lany Автор
      27.09.2019 11:04

      Про метод-референс мы говорим, что а-та-та будет:



      Ну сравнение c new Integer() понятно что подозрительное:



      x = (Integer)null — дело святое (конечно, варнинг исчезает, если тип Object):



      Кое-что из остального поддержать можно, но не факт, что нужно...


      1. tsypanov
        27.09.2019 11:47

        Проверил,


        var x = 1.0;
        System.out.println(String.valueOf(false ? x : 100000000000L).substring(12) + "Ok");

        определяется на ура. А то я уж было собрался расчехлять задачесоздатель ;)


        1. lany Автор
          27.09.2019 12:01

          А, кстати да. Я и забыл, что я сам это сделал.


          List.remove тоже поддержал (добавил требование, что индекс должен быть от 0 до длины списка), хотя здесь простой пример, всё известно заранее. Не факт, что в реальной жизни пригодится.


  1. regent
    30.09.2019 15:17

    wouter.coekaerts.be/2018/java-type-system-broken

    Тип T выводится как StringBuilder, но в данном коде компилятор не обязан вставлять в байткод проверку типа в точке вызова. Ему достаточно, что StringBuilder можно присвоить в Object, а значит, всё хорошо.


    Все носятся с null'ом — типа ошибка на миллион долларов… Реальная ошибка на миллион это реализация дженериков в Java 5… Эх, Мартин Мартин…

    Вот интересно, кто то смог бы реализовать дженерики в Java по другому? (не создавая другой язык для jvm)


    1. lany Автор
      01.10.2019 10:06
      +1

      Всё же по-моему, от нулла больше реальных ошибок, чем от дженериков со стиранием.