Topjava – онлайн-школа по обучению программированию на самом популярном в мире языке Java

*Открытые вступительные занятия с выполнением домашнего задания
Полное руководство по оператору 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 как во время компиляции, так и во время запуска программ (также необходимо использовать --source, либо --release — примечание переводчика).

Так что имейте ввиду, что 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 в форме двоеточия, которые должны определить значение (было бы странным, если бы пришлось заменить 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 -> "норма";
    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, если case не охвачен? Единственный ответ, который может дать 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

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

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

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

Что вы думаете? Довольны тем, как все сложилось?
Оригинальная статья Definitive Guide To Switch Expressions In Java 12, автор Nicolai Parlog.