Пробуем улучшенный оператор instanceof в Java 14

Не за горами новая, 14-я версия Java, а значит самое время посмотреть, какие новые синтаксические возможности будет содержать эта версия Java. Одной из таких синтаксических возможностей является паттерн-матчинг по типу, который будет осуществляться посредством улучшенного (расширенного) оператора instanceof.

Сегодня я хотел бы поиграться с этим новым оператором и рассмотреть особенности его работы более детально. Так как паттерн-матчинг по типу ещё не вошёл в главный репозиторий JDK, мне пришлось скачать репозиторий проекта Amber, в котором ведётся разработка новых синтаксических конструкций Java, и собрать JDK из этого репозитория.

Итак, первое, что мы сделаем – проверим версию Java, чтобы убедиться, что мы действительно используем JDK 14:

> java -version
openjdk version "14-internal" 2020-03-17
OpenJDK Runtime Environment (build 14-internal+0-adhoc.osboxes.amber-amber)
OpenJDK 64-Bit Server VM (build 14-internal+0-adhoc.osboxes.amber-amber, mixed mode, sharing)

Всё верно.

Теперь напишем небольшой кусок кода со «старым» оператором instanceof и запустим его:

public class A {
    public static void main(String[] args) {
        new A().f("Hello, world!");
    }

    public void f(Object obj) {
        if (obj instanceof String) {
            String str = (String) obj;
            System.out.println(str.toLowerCase());
        }
    }
}
> java A.java
Hello, world!

Работает. Это стандартная проверка на тип с последующим приведением. Подобные конструкции мы пишем изо дня в день, какую бы версию Java мы бы не использовали, хоть 1.0, хоть 13.

Но теперь у нас в руках Java 14, и давайте перепишем код с использованием улучшенного оператора instanceof (повторяющиеся строки кода в дальнейшем буду опускать):

if (obj instanceof String str) {
    System.out.println(str.toLowerCase());
}
> java --enable-preview --source 14 A.java
Hello, world!

Прекрасно. Код стал чище, короче, безопаснее и читабельнее. Было три повторения слова String, стало одно. Заметьте, что мы не забыли указать аргументы --enable-preview --source 14, т.к. новый оператор является preview feature. Кроме того, внимательный читатель, наверное, заметил, что мы запустили исходный файл A.java напрямую, без компиляции. Такая возможность появилась в Java 11.

Давайте попробуем написать что-нибудь более навороченное и добавим второе условие, которое использует только что объявленную переменную:

if (obj instanceof String str && str.length() > 5) {
    System.out.println(str.toLowerCase());
}

Компилируется и работает. А что если поменять условия местами?

if (str.length() > 5 && obj instanceof String str) {
    System.out.println(str.toLowerCase());
}
A.java:7: error: cannot find symbol
        if (str.length() > 5 && obj instanceof String str) {
            ^

Ошибка компиляции. Чего и следовало ожидать: переменная str ещё не объявлена, а значит не может быть использована.

Кстати, что с мутабельностью? Переменная final или нет? Пробуем:

if (obj instanceof String str) {
    str = "World, hello!";
    System.out.println(str.toLowerCase());
}
A.java:8: error: pattern binding str may not be assigned
    str = "World, hello!";
    ^

Ага, переменная final. Это значит, что слово «переменная» здесь вообще не совсем корректно. Да и компилятор использует специальный термин «pattern binding». Поэтому предлагаю отныне говорить не «переменная», а «биндинг паттерна» (к сожалению, слово «binding» не очень хорошо переводится на русский).

С мутабельностью и терминологией разобрались. Поехали экспериментировать дальше. Вдруг у нас получится «сломать» компилятор?

Что если назвать переменную и биндинг паттерна одним и тем же именем?

if (obj instanceof String obj) {
    System.out.println(obj.toLowerCase());
}
A.java:7: error: variable obj is already defined in method f(Object)
if (obj instanceof String obj) {
                          ^

Логично. Перекрытие переменной из внешней области видимости не работает. Это эквивалентно тому, как если бы мы просто завели переменную obj второй раз в той же области видимости.

А если так:

if (obj instanceof String str && obj instanceof String str) {
    System.out.println(str.toLowerCase());
}
A.java:7: error: illegal attempt to redefine an existing match binding
if (obj instanceof String str && obj instanceof String str) {
                              ^

Компилятор надёжен, как бетон.

Что ещё можно попробовать? Давайте поиграемся с областями видимости. Если в ветке if определён биндинг, то будет ли он определён в ветке else, если инвертировать условие?

if (!(obj instanceof String str)) {
    System.out.println("not a string");
} else {
    System.out.println(str.toLowerCase());
}

Сработало. Компилятор не только надёжен, но ещё и умён.

А если так?

if (obj instanceof String str && true) {
    System.out.println(str.toLowerCase());
}

Опять сработало. Компилятор корректно понимает, что условие сводится к простому obj instanceof String str.

Неужели не удастся «сломать» компилятор?

Может, так?

if (obj instanceof String str || false) {
    System.out.println(str.toLowerCase());
}
A.java:8: error: cannot find symbol
    System.out.println(str.toLowerCase());
                       ^

Ага! Вот это уже похоже на баг. Ведь все три условия абсолютно эквивалентны:

  • obj instanceof String str
  • obj instanceof String str && true
  • obj instanceof String str || false

С другой стороны, правила flow scoping довольно нетривиальны, и возможно такой случай действительно не должен работать. Но если смотреть чисто с человеческой точки зрения, то я считаю, что это баг.

Но да ладно, давайте попробуем ещё что-нибудь. Будет ли работать такое:

if (!(obj instanceof String str)) {
    throw new RuntimeException();
}
System.out.println(str.toLowerCase());

Скомпилировалось. Это хорошо, поскольку этот код эквивалентен следующему:

if (!(obj instanceof String str)) {
    throw new RuntimeException();
} else {
    System.out.println(str.toLowerCase());
}

А так как оба варианта эквивалентны, то и программист ожидает, что они будут работать одинаково.

Что насчёт перекрытия полей?

public class A {
    private String str;

    public void f(Object obj) {
        if (obj instanceof String str) {
            System.out.println(str.toLowerCase());
        } else {
            System.out.println(str.toLowerCase());
        }
    }
}

Компилятор не заругался. Это вполне логично, потому что локальные переменные всегда могли перекрывать поля. Для биндингов паттернов, видимо, тоже решили не делать исключения. С другой стороны, такой код довольно хрупок. Одно неосторожное движение, и вы можете не заметить, как ваша ветка if «сломалась»:

private boolean isOK() {
    return false;
}

public void f(Object obj) {
    if (obj instanceof String str || isOK()) {
        System.out.println(str.toLowerCase());
    } else {
        System.out.println(str.toLowerCase());
    }
}

В обеих ветвях теперь используется поле str, чего может не ожидать невнимательный программист. Чтобы как можно раньше обнаруживать подобные ошибки, используйте инспекции в IDE и разную подсветку синтаксиса для полей и переменных. А ещё я рекомендую всегда использовать квалификатор this для полей. Это добавит ещё больше надёжности.

Что ещё интересного? Как и «старый» instanceof, новый никогда не матчит null. Это значит, что можно всегда полагаться на то, что биндинги паттернов никогда не могут быть null:

if (obj instanceof String str) {
    System.out.println(str.toLowerCase());
    // Никогда не выбросит NullPointerException
}

Кстати, используя это свойство, можно укоротить подобные цепочки:

if (a != null) {
    B b = a.getB();
    if (b != null) {
        C c = b.getC();
        if (c != null) {
            System.out.println(c.getSize());
        }
    }
}

Если использовать instanceof, то код выше можно переписать так:

if (a != null
    && a.getB() instanceof B b
    && b.getC() instanceof C c) {
    System.out.println(c.getSize());
}

Что вы думаете по поводу такого стиля? Стали ли бы вы использовать такую идиому?

Что насчёт дженериков?

import java.util.List;

public class A {
    public static void main(String[] args) {
        new A().f(List.of(1, 2, 3));
    }

    public void f(Object obj) {
        if (obj instanceof List<Integer> list) {
            System.out.println(list.size());
        }
    }
}
> java --enable-preview --source 14 A.java
Note: A.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
3

Очень интересно. Если «старый» instanceof поддерживает только instanceof List или instanceof List<?>, то новый работает с любым конкретным типом. Ждём первого человека, который попадётся вот в такую ловушку:

if (obj instanceof List<Integer> list) {
    System.out.println("Int list of size " + list.size());
} else if (obj instanceof List<String> list) {
    System.out.println("String list of size " + list.size());
}
Почему это не работает?Ответ: отсутствие reified generics в Java.

ИМХО, это довольно серьёзная проблема. С другой стороны, я не знаю, как можно было бы её исправить. Похоже, опять придётся полагаться на инспекции в IDE.

Выводы

В целом, новый паттерн-матчинг по типу работает очень круто. Улучшенный оператор instanceof позволяет делать не только тест на тип, но ещё и объявлять готовый биндинг этого типа, избавляя от необходимости ручного приведения. Это означает, что в коде будет меньше шума, и читателю будет гораздо проще разглядеть полезную логику. Например, большинство реализаций equals() можно будет писать в одну строчку:

public class Point {
    private final int x, y;
    …

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public boolean equals(Object obj) {
        return obj instanceof Point p
            && p.x == this.x
            && p.y == this.y;
    }
}
Код выше можно написать ещё короче. Как?С помощью записей, которые также войдут в Java 14.

С другой стороны, вызывают небольшие вопросы несколько спорных моментов:

  • Не полностью прозрачные правила области видимости (пример с instanceof || false).
  • Перекрытие полей.
  • instanceof и дженерики.

Однако это скорее мелкие придирки, нежели серьёзные претензии. В целом, огромные преимущества нового оператора instanceof определённо стоят его добавления язык. А если он ещё выйдет из состояния preview и станет стабильной синтаксической конструкцией, то это будет большой мотивацией наконец-то уйти с Java 8 на новую версию Java.

Подписывайтесь на канал в Telegram, чтобы не пропускать новости.

Все материалы на этом сайте выложены под лицензией CC BY-SA 4.0
© Евгений Козлов, 2017-2020