[Перевод] Полное руководство по 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) {
switch(ternaryBool) {
case TRUE: return true;
case FALSE: return false;
case FILE_NOT_FOUND:
throw new UncheckedIOException("This is ridiculous!",
new FileNotFoundException());
// without default branch, the method wouldn't compile
default:
throw new IllegalArgumentException("Seriously?!");
}
}
Так намного лучше: отсутствует фиктивная переменная, нет 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?!");
};
Я думаю, что это довольно очевидно: если ternartBool
равен TRUE
, то result
'у будет присвоено true
(иными словами TRUE
превращается в true
). FALSE
становится false
.
Сразу возникают две мысли:
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?!!?");
};
Обратите внимание, что теперь вы можете использовать break
со значением! Это идеально согласуется с инструкциями switch
старого стиля, которые используют break
без какого-либо значения. Так в каком случае стрелка означает выражение вместо оператора, для чего она здесь? Просто хипстерский синтаксис?
Исторически сложилось, что метки с двоеточием просто отмечают точку входа в блок операторов. С этого места начинается выполнение всего кода ниже, даже когда встречается другая метка. В switch
нам это известно, как сквозной переход к следующему case
(fall-through): метка case
определяет, куда перепрыгивает поток управления. Для его завершения нужен break
или return
.
В свою очередь, использование стрелки означает, что будет выполнен только блок справа от нее. И никакого «проваливания».
Подробнее об эволюции 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
. А как насчет других типов? Выражения и операторы switch
также могут работать с String
, int
, (проверьте документацию) short
, byte
, char
и их обертками. Здесь пока что ничего не изменилось, хотя идеи об использовании таких типов данных, как float
и long
по прежнему остаются в силе (со второго по последний абзац).
Подробнее о стрелке
Давайте рассмотрим два свойства, характерных для стрелочной формы записи разделителя:
- отсутствие сквозного перехода к следующему
case
; - блоки операторов.
Отсутствие сквозного перехода к следующему case
Вот, что говорится в JEP 325 об этом:
Текущий дизайн оператора
switch
в Java тесно связан с такими языками, как C и C++ и по умолчанию поддерживает сквозную семантику. Хотя этот традиционный способ управления часто полезен для написания низкоуровневого кода (такого как парсеры для двоичного кодирования), посколькуswitch
используется в коде более высокого уровня, ошибки такого подхода начинают перевешивать его гибкость.
Я полностью согласен и приветствую возможность использовать 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
не требует специальной обработки.
В случае, если вам показался необычным способ выхода из блоков с помощью break
, а не через return
, то не переживайте — меня это тоже озадачило и показалось странным. Но потом я задумался и пришел к выводу, что это имеет смысл, поскольку сохраняет старый стиль конструкции switch
, которая использует break
без значений.
Подробнее о выражениях switch
И последнее, но не менее важное — особенности использования switch
в качестве выражения:
- множественные выражения;
- ранний возврат (досрочный
return
); - охват всех значений.
Обратите внимание, что при этом не имеет значения, какая форма используется!
Множественные выражения
Switch-выражения являются множественными выражениями. Это означает, что они не имеют своего собственного типа, но могут быть одним из нескольких типов. Наиболее часто в качестве таких выражений используются лямбда-выражения: s -> s + " "
, могут быть Function
, но также могут быть Function
или UnaryOperator
.
С помощью switch-выражений тип определяется по взаимодействию между тем, где используется switch и типами его веток. Если switch-выражение назначается типизированной переменной, передается в качестве аргумента или иным образом используется в контексте, где известен точный тип (это называется целевым типом), то все его ветки должны соответствовать этому типу. Вот что мы делали до сих пор:
String result = switch (ternaryBool) {
case TRUE, FALSE -> "sane";
default -> "insane";
};
Как итог — switch
присваивается переменной result
типа String
. Следовательно, String
является целевым типом, и все ветки должны возвращать результат типа 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!"; }
};
}
Это имеет смысл независимо от того, используете ли вы стрелку или двоеточие.
Покрытие всех вариантов
Если вы используете switch
в качестве оператора, тогда не имеет значения, охвачены все варианты или нет. Конечно, вы можете случайно пропустить case
, и код будет работать неправильно, но компилятору все равно — вы, ваша IDE и ваши инструменты анализа кода останетесь с этим наедине.
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
, которая ведет себя так же, как та, которую мы использовали до сих пор, вызывая исключение.
В Java 12 охват всех значений без ветки default
работает только для enum
, но когда switch
в будущих версиях Java станет более мощным, он также сможет работать с произвольными типами. Если метки case
'ов смогут не только проверять равенство, но и проводить сравнения (например _ < 5 ->…) — это позволит охватить все варианты для числовых типов.
Размышление
Из статьи мы узнали, что Java 12 превращает switch
в выражение, наделяя его новыми возможностями:
- теперь один
case
может соответствовать нескольким меткам; - новая стрелочная форма
case … -> …
следует синтаксису лямбда-выражений:- допускаются однострочные операторы или блоки;
- предотвращается сквозной переход к следующему
case
;
- теперь всё выражение оценивается, как значение, которое затем может быть присвоено переменной или передано, как часть более крупного оператора;
- множественное выражение: если целевой тип известен, то все ветки должны ему соответствовать. В противном случае определяется конкретный тип, который соответствует всем веткам;
break
может возвращать значение из блока;- для выражения
switch
использующееenum
, компилятор проверяет охват всех его значений. Еслиdefault
отсутствует, добавляется ветка, которая вызывает исключение.
Куда это нас приведет? Во-первых, поскольку это не окончательная версия switch
, у вас все еще есть время, чтобы оставить отзыв в списке рассылки Amber, если вы с чем-то не согласны.
Затем, предполагая, что switch остается таким, каким он является в данный момент, я думаю, что стрелочная форма станет новым вариантом по умолчанию. Без сквозного перехода к следующему case
и с лаконичными лямбда-выражениями (это очень естественно иметь case и один оператор в одной строке) switch
выглядит намного компактнее и не ухудшает читаемость кода. Я уверен, что буду использовать только двоеточие, если у меня возникнет необходимость в сквозном проходе.
Что вы думаете? Довольны тем, как все сложилось?
Оригинальная статья Definitive Guide To Switch Expressions In Java 12, автор Nicolai Parlog.