Главная » Хабрахабр » [Перевод] Полное руководство по switch-выражениям в Java 12

[Перевод] Полное руководство по switch-выражениям в Java 12

Мы все используем его и привыкли к нему — особенно к его причудам. Старый добрый switch был в Java с первого дня. (Кого-нибудь еще раздражает break?) Но теперь все начинает меняться: в Java 12 switch вместо оператора стал выражением:

boolean result = switch(ternaryBool) { case TRUE -> true; case FALSE -> false; case FILE_NOT_FOUND -> throw new UncheckedIOException( "This is ridiculous!", new FileNotFoundException()); // as we'll see in "Exhaustiveness", `default` is not necessary default -> throw new IllegalArgumentException("Seriously?!");
};

В switch появилась возможность возвращать результат своей работы, который можно присвоить переменной; вы также можете использовать синтаксис в стиле "лямбда", который позволяет избавиться от сквозного прохода по всем case, в которых нет оператора break.

В этом руководстве я расскажу Вам обо всем, что необходимо знать о switch-выражениях в Java 12.

Предварительный обзор

Согласно предварительной спецификации языка, switch-выражения только начинают внедряться в Java 12.

Это означает, что данная управляющая конструкция может быть изменена в будущих версиях спецификации языка.

Что бы начать использовать новую версию switch необходимо применить опцию командной строки --enable-preview как во время компиляции, так и во время запуска программ (также необходимо использовать --release 12 при компиляции — примечание переводчика).

Так что имейте ввиду, что switch, как выражение, не имеет на данный момент окончательного варианта синтаксиса в Java 12.

Если у вас возникло желание поиграть со всем этим самим, то вы можете посетить мой демо-проект Java X на гитхабе.

Проблема с операторами в switch

Допустим, мы столкнулись с "ужасным" тернарным булеаном и хотим преобразовать его в обычный булеан. Прежде, чем мы перейдем к обзору нововведений в switch, давайте быстро оценим одну ситуацию. Вот один из способов сделать это:

boolean result;
switch(ternaryBool) { case TRUE: result = true; // don't forget to `break` or you're screwed! break; case FALSE: result = false; break; case FILE_NOT_FOUND: // intermediate variable for demo purposes; // wait for it... var ex = new UncheckedIOException("This is ridiculous!", new FileNotFoundException()); throw ex; default: // ... here we go: // can't declare another variable with the same name var ex2 = new IllegalArgumentException("Seriously?!"); throw ex2;
}

Как и многие другие варианты switch, встречающиеся в "природе", представленный выше пример просто вычисляет значение переменной и присваивает его, но реализация обходная (объявляем идентификатор result и используем его позже), повторяющаяся (мои break'и всегда результат copy-pasta) и подвержена ошибкам (забыл еще одну ветку? Согласитесь, что это очень неудобно. Тут явно есть, что улучшить. Ой!).

Давайте попробуем решить эти проблемы, поместив switch в отдельный метод:

private static boolean toBoolean(Bool ternaryBool)
}

Так намного лучше: отсутствует фиктивная переменная, нет break'ов, загромождающих код и сообщений компилятора об отсутствии default (даже если в этом нет необходимости, как в данном случае).

И это даже без учёта, что такой рефакторинг не всегда возможен. Но, если подумать, то мы не обязаны создавать методы только для того, чтобы обойти неуклюжую особенность языка. Нет, нам нужно решение получше!

Представляем switch-выражения!

Как я показал в начале статьи, начиная с Java 12 и выше, вы можете решить вышеуказанную проблему следующим образом:

boolean result = switch(ternaryBool) { case TRUE -> true; case FALSE -> false; case FILE_NOT_FOUND -> throw new UncheckedIOException( "This is ridiculous!", new FileNotFoundException()); // as we'll see in "Exhaustiveness", `default` is not necessary default -> throw new IllegalArgumentException("Seriously?!");
};

FALSE становится false. Я думаю, что это довольно очевидно: если ternartBool равен TRUE, то result'у будет присвоено true (иными словами TRUE превращается в true).

Сразу возникают две мысли:

  • switch может иметь результат;
  • что там со стрелками?

Прежде чем углубляться в детали новых возможностей switch, в начале я расскажу об этих двух основных аспектах.

Выражение или оператор

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

До Java 12 switch был оператором — императивной конструкцией, регулирующей поток управления.

Они оба проверяют логическое условие и выполняют ветвление в зависимости от его результата. Думайте о различиях старой и новой версии switch, как о разнице между if и тернарным оператором.

Разница в том, что if просто выполняет соответствующий блок, тогда как тернарный оператор возвращает какой-то результат:

if(condition) { result = doThis();
} else { result = doThat();
} result = condition ? doThis() : doThat();

То же самое для switch: до Java 12, если вы хотели вычислить значение и сохранить результат, то должны были либо присвоить его переменной (а затем break), либо вернуть из метода, созданного специально для оператора switch.

Теперь же всё выражение оператора switch оценивается (выбирается для выполнения соответствующая ветка), и результат вычислений может быть присвоен переменной.

Еще одним отличием между выражением и оператором является то, что выражение switch, поскольку оно является частью оператора, должно заканчиваться точкой с запятой, в отличие от классического оператора switch.

Стрелка или двоеточие

Важно понимать, что для этого не обязательно использовать switch в качестве выражения. В вводном примере использовался новый синтаксис в лямбда-стиле со стрелкой между меткой и выполняющейся частью. Фактически, пример ниже эквивалентен приведенному в начале статьи коду:

boolean result = switch(ternaryBool) { case TRUE: break true; case FALSE: break false; case FILE_NOT_FOUND: throw new UncheckedIOException( "This is ridiculous!", new FileNotFoundException()); default: throw new IllegalArgumentException("Seriously?!!?");
};

Это идеально согласуется с инструкциями switch старого стиля, которые используют break без какого-либо значения. Обратите внимание, что теперь вы можете использовать break со значением! Просто хипстерский синтаксис? Так в каком случае стрелка означает выражение вместо оператора, для чего она здесь?

С этого места начинается выполнение всего кода ниже, даже когда встречается другая метка. Исторически сложилось, что метки с двоеточием просто отмечают точку входа в блок операторов. Для его завершения нужен break или return. В switch нам это известно, как сквозной переход к следующему case (fall-through): метка case определяет, куда перепрыгивает поток управления.

И никакого "проваливания". В свою очередь, использование стрелки означает, что будет выполнен только блок справа от нее.

Подробнее об эволюции switch

Несколько меток на case

Но теперь все изменилось — один case может соответствовать нескольким меткам: До сих пор каждый case содержал только одну метку.

String result = switch(ternaryBool) { case TRUE, FALSE -> "sane"; // `default, case FILE_NOT_FOUND -> ...` does not work // (neither does other way around), but that makes // sense because using only `default` suffices default -> "insane";
};

Поведение должно быть очевидным: TRUE и FALSE приводят к одному и тому же результату — вычисляется выражение "sane".

Это довольно приятное нововведение, которое пришло на смену множественному использованию case, когда требовалось реализовать сквозной переход к следующему case.

Типы за пределами Enum

А как насчет других типов? Все примеры со switch в этой статье используют enum. Здесь пока что ничего не изменилось, хотя идеи об использовании таких типов данных, как float и long по прежнему остаются в силе (со второго по последний абзац). Выражения и операторы switch также могут работать с String, int, (проверьте документацию) short, byte, char и их обертками.

Подробнее о стрелке

Давайте рассмотрим два свойства, характерных для стрелочной формы записи разделителя:

  • отсутствие сквозного перехода к следующему case;
  • блоки операторов.

Отсутствие сквозного перехода к следующему case

Вот, что говорится в JEP 325 об этом:

Хотя этот традиционный способ управления часто полезен для написания низкоуровневого кода (такого как парсеры для двоичного кодирования), поскольку switch используется в коде более высокого уровня, ошибки такого подхода начинают перевешивать его гибкость. Текущий дизайн оператора switch в Java тесно связан с такими языками, как C и C++ и по умолчанию поддерживает сквозную семантику.

Я полностью согласен и приветствую возможность использовать switch без поведения по умолчанию:

switch(ternaryBool) { case TRUE, FALSE -> System.out.println("Bool was sane"); // in colon-form, if `ternaryBool` is `TRUE` or `FALSE`, // we would see both messages; in arrow-form, only one // branch is executed default -> System.out.println("Bool was insane");
}

Решающим фактором тут является стрелка против двоеточия. Важно усвоить, что это не имеет никакого отношения к тому, используете ли вы switch в качестве выражения или оператора.

Блоки операторов

Как и в случае с лямбдами, стрелка может указывать либо на один оператор (как выше), либо на блок, выделенный фигурными скобками:

boolean result = switch(Bool.random()) { case TRUE -> { System.out.println("Bool true"); // return with `break`, not `return` break true; } case FALSE -> { System.out.println("Bool false"); break false; } case FILE_NOT_FOUND -> { var ex = new UncheckedIOException("This is ridiculous!", new FileNotFoundException()); throw ex; } default -> { var ex = new IllegalArgumentException("Seriously?!"); throw ex; } };

Блоки, которые приходится создавать для многострочных операторов имеют дополнительное преимущество (что не требуется при применении двоеточия), которое заключается в том, что для использования одинаковых имен переменных в разных ветках, switch не требует специальной обработки.

Но потом я задумался и пришел к выводу, что это имеет смысл, поскольку сохраняет старый стиль конструкции switch, которая использует break без значений. В случае, если вам показался необычным способ выхода из блоков с помощью break, а не через return, то не переживайте — меня это тоже озадачило и показалось странным.

Подробнее о выражениях switch

И последнее, но не менее важное — особенности использования switch в качестве выражения:

  • множественные выражения;
  • ранний возврат (досрочный return);
  • охват всех значений.

Обратите внимание, что при этом не имеет значения, какая форма используется!

Множественные выражения

Это означает, что они не имеют своего собственного типа, но могут быть одним из нескольких типов. Switch-выражения являются множественными выражениями. Наиболее часто в качестве таких выражений используются лямбда-выражения: s -> s + " ", могут быть Function<String, String>, но также могут быть Function<Serializable, Object> или UnaryOperator<String>.

Если switch-выражение назначается типизированной переменной, передается в качестве аргумента или иным образом используется в контексте, где известен точный тип (это называется целевым типом), то все его ветки должны соответствовать этому типу. С помощью switch-выражений тип определяется по взаимодействию между тем, где используется switch и типами его веток. Вот что мы делали до сих пор:

String result = switch (ternaryBool) { case TRUE, FALSE -> "sane"; default -> "insane";
};

Следовательно, String является целевым типом, и все ветки должны возвращать результат типа String. Как итог — switch присваивается переменной result типа String.

То же самое происходит и здесь:

Serializable serializableMessage = switch (bool) { case TRUE, FALSE -> "sane"; // note that we don't throw the exception! // but it's `Serializable`, so it matches the target type default -> new IllegalArgumentException("insane");
};

А что произойдет сейчас?

// compiler infers super type of `String` and
// `IllegalArgumentException` ~> `Serializable`
var serializableMessage = switch (bool) { case TRUE, FALSE -> "sane"; // note that we don't throw the exception! default -> new IllegalArgumentException("insane");
};

(Про использование типа var читайте в нашей прошлой статье 26 рекомендаций по использованию типа var в Java — примечание переводчика)

Если целевой тип неизвестен, из-за того, что мы используем var, тип вычисляется путем нахождения наиболее конкретного супертипа из типов, создаваемых ветками.

Ранний возврат

Следствием различия между выражением и оператором switch является то, что вы можете использовать return для выхода из оператора switch:

public String sanity(Bool ternaryBool) { switch (ternaryBool) { // `return` is only possible from block case TRUE, FALSE -> { return "sane"; } default -> { return "This is ridiculous!"; } };
}

… вы не можете использовать return внутри выражения …

public String sanity(Bool ternaryBool) { String result = switch (ternaryBool) { // this does not compile - error: // "return outside of enclosing switch expression" case TRUE, FALSE -> { return "sane"; } default -> { return "This is ridiculous!"; } };
}

Это имеет смысл независимо от того, используете ли вы стрелку или двоеточие.

Покрытие всех вариантов

Конечно, вы можете случайно пропустить case, и код будет работать неправильно, но компилятору все равно — вы, ваша IDE и ваши инструменты анализа кода останетесь с этим наедине. Если вы используете switch в качестве оператора, тогда не имеет значения, охвачены все варианты или нет.

Куда следует перейти switch, если нужная метка отсутствует? Switch-выражения усугубляют эту проблему. Это породило бы множество ошибок в основном коде. Единственный ответ, который может дать Java — это возвращать null для ссылочных типов и значение по умолчанию для примитивов.

Для switch-выражений компилятор будет настаивать, чтобы все возможные варианты были охвачены. Чтобы предотвратить такой исход, компилятор может помочь вам. Давайте посмотрим на пример, который может привести к ошибке компиляции:

// compile error:
// "the switch expression does not cover all possible input values"
boolean result = switch (ternaryBool) { case TRUE -> true; // no case for `FALSE` case FILE_NOT_FOUND -> throw new UncheckedIOException( "This is ridiculous!", new FileNotFoundException());
};

Интересным является следующее решение: добавление ветки default, конечно, исправит ошибку, но это не является единственным решением — еще можно добавить case для FALSE.

// compiles without `default` branch because
// all cases for `ternaryBool` are covered
boolean result = switch (ternaryBool) { case TRUE -> true; case FALSE -> false; case FILE_NOT_FOUND -> throw new UncheckedIOException( "This is ridiculous!", new FileNotFoundException());
};

Давайте посидим минуту в безмолвной благодарности. Да, компилятор наконец-то сможет определить, охватываются ли все значения enum (исчерпывают ли все варианты), и не установить бесполезные значения по умолчанию!

Что делать, если кто-то возьмет и превратит сумасшедший Bool в кватернионный Boolean, добавив четвертое значение? Хотя, это все же вызывает один вопрос. Без перекомпиляции это превратится в проблему во время выполнения. Если вы перекомпилируете switch-выражение для расширенного Bool, то получите ошибку компиляции (выражение больше не является исчерпывающим). Чтобы отловить эту проблему, компилятор переходит в ветку default, которая ведет себя так же, как та, которую мы использовали до сих пор, вызывая исключение.

Если метки case'ов смогут не только проверять равенство, но и проводить сравнения (например _ < 5 -> …) — это позволит охватить все варианты для числовых типов. В Java 12 охват всех значений без ветки default работает только для enum, но когда switch в будущих версиях Java станет более мощным, он также сможет работать с произвольными типами.

Размышление

Из статьи мы узнали, что Java 12 превращает switch в выражение, наделяя его новыми возможностями:

  • теперь один case может соответствовать нескольким меткам;
  • новая стрелочная форма case … -> … следует синтаксису лямбда-выражений:
    • допускаются однострочные операторы или блоки;
    • предотвращается сквозной переход к следующему case;
  • теперь всё выражение оценивается, как значение, которое затем может быть присвоено переменной или передано, как часть более крупного оператора;
  • множественное выражение: если целевой тип известен, то все ветки должны ему соответствовать. В противном случае определяется конкретный тип, который соответствует всем веткам;
  • break может возвращать значение из блока;
  • для выражения switch использующее enum, компилятор проверяет охват всех его значений. Если default отсутствует, добавляется ветка, которая вызывает исключение.

Во-первых, поскольку это не окончательная версия switch, у вас все еще есть время, чтобы оставить отзыв в списке рассылки Amber, если вы с чем-то не согласны. Куда это нас приведет?

Без сквозного перехода к следующему case и с лаконичными лямбда-выражениями (это очень естественно иметь case и один оператор в одной строке) switch выглядит намного компактнее и не ухудшает читаемость кода. Затем, предполагая, что switch остается таким, каким он является в данный момент, я думаю, что стрелочная форма станет новым вариантом по умолчанию. Я уверен, что буду использовать только двоеточие, если у меня возникнет необходимость в сквозном проходе.

Довольны тем, как все сложилось? Что вы думаете?


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Анализ кода CUBA Platform с помощью PVS-Studio

Для Java программистов существуют полезные инструменты, помогающие писать качественный код, например, мощная среда разработки IntelliJ IDEA, бесплатные анализаторы SpotBugs, PMD и другие. Всё это уже используется в разработке проекта CUBA Platform, и в этом обзоре найденных дефектов кода я расскажу, ...

[Из песочницы] Подготовка к промышленному производству ДО-РА

1. Транспортировка образцов после ядерной катастрофы на АЭС Фукусима в Японии и задумывался в виде гаджета – персонального дозиметра-радиометра работающего с одноименным ПО – DO-RA. Проект DO-RA DO-RA.com был рождён в марте 2011 г. Soft на любом смартфоне под мобильные ...