Полное руководство по switch в Java

Введение
Старый добрый 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());
    default -> throw new IllegalArgumentException("Seriously?!");
};
Результат работы switch-выражения теперь можно сохранять в переменную; ушла необходимость использовать break в каждой ветке case благодаря лямбда-синтаксису и многое другое.
Когда дело доходит до switch после Java 14, необходимо выбрать стиль его использования:
  • оператор или выражение (с Java 14)
  • двоеточия или стрелки (с Java 14)
  • метки или шаблоны (3-й превью в Java 19)
В этом руководстве я расскажу обо всем, что необходимо знать о switch-выражениях, и как их лучше всего использовать в современной Java.
1. Недостатки оператора switch
Прежде, чем перейти к обзору нововведений, давайте рассмотрим один пример кода. Допустим, мы столкнулись с «ужасным» тернарным boolean и хотим преобразовать его в обычный boolean. Вот один из способов сделать это:
// Тернарный boolean
enum Bool { 
    TRUE, 
    FALSE, 
    FILE_NOT_FOUND
};
boolean result;
switch (ternaryBool) {
    case TRUE:
        result = true;
        break;
    case FALSE:
        result = false;
        break;
    case FILE_NOT_FOUND:
        // объявление переменной для демонстрации проблемы в default
        var ex = new UncheckedIOException("This is ridiculous!",
                new FileNotFoundException());
        throw ex;
    default:
        // А вот и проблема: мы не можем объявить еще одну переменную с именем ex
        var ex2 = new IllegalArgumentException("Seriously?!");
        throw ex2;
}
Реализация данного кода хромает:
  • наличие break в каждой ветке, которые легко забыть
  • можно не учесть все возможные значения ternaryBool (забыть реализовать какой-то case)
  • с переменной result не все гладко — область видимости не соответствует ее использованию
  • нельзя объявить в разных ветках переменные с одинаковым именем
Согласитесь, что данное решение выглядит крайне громоздко и неудобно — тут явно есть, что улучшить.
А вот пример попроще, демонстрирующий похожие проблемы:
int result;
switch (number) {
    case 1:
        result = callMethod("one");
        break;
    case 2:
        result = callMethod("two");
        break;
    default:
        result = callMethod("many");
        break;
}
Давайте попробуем устранить все недостатки, поместив 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());
        // без default метод не скомпилируется
        default:
            throw new IllegalArgumentException("Seriously?!");
      }
}
Так намного лучше: отсутствует фиктивная переменная result, нет break, загромождающих код и сообщений компилятора об отсутствии default (даже если в этом нет необходимости, как в данном случае).
Но если подумать, то мы не обязаны создавать методы только для того, чтобы обойти неуклюжую особенность языка. И это даже без учёта, что такой рефакторинг не всегда возможен. Нет, нам нужно решение получше!
2. Представляем 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());
    // в ветке `default` уже нет необходимости
    default -> throw new IllegalArgumentException("Seriously?!");
};
Я думаю, что это довольно очевидно: если ternartBool равен TRUE, то result будет присвоено true, а FALSE становится false.
Сразу возникают две мысли:
  • switch теперь может иметь результат
  • какие возможности предоставляют стрелки?
Прежде чем углубляться в детали новых возможностей, вначале я расскажу об этих двух аспектах.
3. Выражение vs оператора
Возможно, вы удивлены, что switch теперь является выражением. А чем же он был до этого? До Java 12 switch был оператором — императивной конструкцией, управляющей исполняющим потоком.
Думайте о различиях старой и новой версии switch, как о разнице между if и тернарным оператором. Они оба проверяют логическое условие и выполняют ту или иную ветку в зависимости от его результата.
Разница состоит в том, что if просто выполняет соответствующий блок, тогда как тернарный оператор возвращает какой-то результат:
if (condition) {
    result = doThis();
} else {
    result = doThat();
}

result = condition ? doThis() : doThat();
То же самое и у switch: до Java 12, если вы хотели вычислить значение и сохранить результат, то должны были либо присвоить его переменной, либо вернуть из метода, созданного специально для оператора switch.
Теперь же результат вычислений оператора switch может быть присвоен переменной.
Еще одно отличие заключается в том, что поскольку выражение является частью оператора, то оно должно заканчиваться точкой с запятой, в отличие от классического оператора switch.
4. Стрелка vs двоеточия
В самом начале статьи использовался пример с новым синтаксисом в лямбда-стиле со стрелкой между меткой и выполняющейся частью. Эквивалентный ему код без лямбда-стиля можно записать так:
boolean result = switch (ternaryBool) {
    case TRUE:
        yield true;
    case FALSE:
        yield false;
    case FILE_NOT_FOUND:
        throw new UncheckedIOException(
                "This is ridiculous!",
                new FileNotFoundException());
    default:
        throw new IllegalArgumentException("Seriously?!");
};
Обратите внимание, что вам нужно использовать новое ключевое слово yield, чтобы вернуть значение из ветки case (этот синтаксис появился в Java 13. В Java 12 вместо yield применялся break, т. е. break true вместо yield true, что выглядело странно).
Исторически сложилось, что метки с двоеточием определяют точку входа в блок операторов. С этого места начинается выполнение всего кода ниже, даже когда встречается другая метка (при отсутствии break). Механизм такой работы известен, как сквозной переход к следующему case. Для его прерывания нужен break или return.
Использование же стрелки позволяет выполнять только блок справа от нее. И никакого «проваливания».
5. Подробнее об эволюции switch
5.1. Несколько меток на case
Отсутствие break в case часто используется для применения одинакового поведение к веткам с разными метками. При этом программа будет переходить к следующему case, пока не наткнется на break. Из этого можно сделать вывод, что оператор switch в каждом case поддерживает наличие только одной метки:
switch (number) {
    case 1:
    case 2:
        callMethod("few");
        break;
    default:
        callMethod("many");
        break;
}
А в новом switch один case может соответствовать нескольким меткам:
String result = switch (ternaryBool) {
    case TRUE, FALSE -> "sane";
    default -> "insane";
};
Поведение этого кода очевидно: TRUE и FALSE приводят к одному и тому же результату — вычисляется выражение "sane".
5.2. Поддержка других типов помимо Enum
Все примеры в этом посте в основном ориентированы на использование Enum. А как насчет других типов? На данный момент нет никаких изменений: switch может работать с String, int, short, byte, char и их обертками. Хотя стоит отметить, что использование float, double и long находится на рассмотрении (предпоследний абзац).
6. Подробнее о стрелке
Начиная с Java 14, switch позволяет использовать лямбда-стрелку для "сопоставления" case с кодом:
switch (number) {
    case 1 -> callMethod("one");
    case 2 -> callMethod("two");
    default -> callMethod("many");
}
Давайте рассмотрим два свойства, характерных для стрелочной формы записи разделителя:
  • отсутствие сквозного перехода к следующему case
  • блоки операторов
6.1. Отсутствие перехода к следующему case
Вот, что говорится в JEP 325 об этом:
«…Текущий дизайн оператора switch в Java тесно связан с такими языками, как C и C++ и по умолчанию поддерживает сквозную семантику. Хотя этот традиционный способ управления часто полезен для написания низкоуровневого кода (такого как парсеры для двоичного кодирования), но поскольку switch используется и в коде более высокого уровня, ошибки такого подхода начинают перевешивать его гибкость.»
Я полностью согласен и приветствую возможность использовать switch без поведения по умолчанию:
switch (ternaryBool) {
    case TRUE, FALSE -> System.out.println("Bool was sane");
    default -> System.out.println("Bool was insane");
};
Стрелка позволяет вывести "Bool was sane" в единственном экземпляре, в то время, как с двоеточием это же сообщение отобразилось бы дважды.
6.2. Блоки операторов
Как и в случае с лямбдами, стрелка может указывать либо на один оператор (как выше), либо на блок, выделенный фигурными скобками:
boolean result = switch (Bool.random()) {
    case TRUE -> {
        System.out.println("Bool true");
        yield true;
    }
    case FALSE -> {
        System.out.println("Bool false");
        yield false;
    }
    case FILE_NOT_FOUND -> {
        var ex = new UncheckedIOException(
                "This is ridiculous!",
                new FileNotFoundException());
                throw ex;
    }
    default -> {
        var ex = new IllegalArgumentException(
                "Seriously?!");
                throw ex;
    }
};
Блоки необходимы для использования более одной строки кода в case. При этом они имеют дополнительное преимущество — позволяют создавать одинаковые имена переменных в разных ветках за счет локальной области видимости для каждой ветки.
Если вам показался необычным способ выхода из блоков с помощью yield, а не return, то это необходимо, чтобы избежать путаницы: return может быть неправильно истолкован, как выход из метода. Мы лишь завершаем работу switch, оставаясь в том же методе.
7. Подробнее о выражениях switch
Рассмотрим особенности использования switch в качестве выражения:
  • множественные выражения
  • ранний возврат
  • охват всех значений (исчерпываемость)
7.1. Множественные выражения
Switch-выражения являются множественными выражениями. Это означает, что они не имеют своего собственного типа, но могут быть одним из нескольких типов. Наиболее часто в качестве таких выражений используются лямбда-выражения: s -> s + " ", могут быть и Function<String, String>, и Function<Serializable, Object> или UnaryOperator<String>.
Тип switch-выражения определяется исходя из типов его веток, а также из места его использования. Если результат работы switch-выражения присваивается типизированной переменной, передается в качестве аргумента или используется в контексте, где известен точный тип (целевой тип), то все его ветки должны соответствовать этому типу. Вот, что мы делали до сих пор:
String result = switch (ternaryBool) {
    case TRUE, FALSE -> "sane";
    default -> "insane";
};
Как итог — switch присваивается переменной String 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, то он вычисляется путем нахождения наиболее конкретного супертипа из типов, создаваемых ветками.
7.2. Ранний возврат
Для выхода из оператора switch (да, для старой версии switch тоже можно использовать оператор стрелку, но от этого он не становится выражением) и метода можно использовать return:
public String sanity(Bool ternaryBool) {
    switch (ternaryBool) {
        // `return` is only possible from block
        case TRUE, FALSE -> { return "sane"; }
        default -> { return "This is ridiculous!"; }
    }
}
Оператор стрелку можно использовать для обычного switch не только для выхода из метода, что позволяет не использовать break и не проваливаться в последующие case:
int result = 0;
switch (sign) {
    case '+' -> result = a + b;
    case '^' -> {
        result = 1;    
        for (int i = 1; i <= b; i++) {
            result *= a;
        }
    }
    default -> System.out.println("Мат. операция не поддерживается");
}
А вот внутри выражения использовать 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!"; }
    };
}
Это имеет смысл независимо от того, используете ли вы стрелку или двоеточие.
7.3. Охват всех значений (исчерпываемость)
Если вы используете switch в качестве оператора, тогда не имеет значения, охвачены все варианты или нет. Конечно, вы можете случайно пропустить case, и код будет работать неправильно, но компилятору все равно — вы, ваша IDE и ваши инструменты анализа кода останетесь с этим наедине.
Switch-выражения усугубляют эту проблему. Куда следует перейти switch, если нужная метка отсутствует? Единственный ответ, который может дать Java — это возвращать null для ссылочных типов и значение по умолчанию для примитивов. Это породило бы множество ошибок в основном коде.
Чтобы предотвратить такой исход, компилятор может помочь вам. Для switch-выражений компилятор будет настаивать, чтобы все возможные варианты были охвачены. Для каждого возможного значения в 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, что позволяет не использовать бесполезные значения в default!
Что касается исчерпываемости, я стараюсь избегать ветвей по умолчанию, когда это возможно, предпочитая получать ошибки компиляции, когда что-то меняется.
Хотя, это все же вызывает один вопрос. Что делать, если кто-то возьмет и превратит сумасшедший Bool в кватернионный (с четырьмя значениями) boolean, добавив четвертое значение? Если вы перекомпилируете switch-выражение для расширенного Bool, то получите ошибку компиляции (т. к. выражение больше не будет исчерпывающим). Чтобы отловить эту проблему, компилятор переходит в ветку default, которая ведет себя так же, как та, которую мы использовали до сих пор, вызывая исключение.
В настоящее время охват всех значений без ветки default работает только для enum, но когда switch в будущих версиях Java станет более мощным, он также сможет работать и с произвольными типами. Если метки case смогут не только проверять равенство, но и проводить сравнения (например _ < 5 -> …) — это позволит охватить все варианты для числовых типов.
8. Как пользоваться switch в современной Java
До этого мы рассматривали изменения, которые произошли до Java 14. Теперь обсудим то, что было реализовано после.
Только начиная с Java 21 все основные функции pattern matching'а были доведены до состояния релиза. До этой версии они выступали в качестве предварительных версий (preview): sealed типы, шаблоны типов, улучшенный switch, записи и шаблоны записей. Рассмотрим эти фичи.
8.1. Сопоставление с образцом (Pattern Matching)
Реализация сопоставления с образцом в switch все еще находится в процессе разработки, но есть три аспекта, которые особенно интересны по данной теме.
8.2. Паттерны типов
На момент написания статьи Java поддерживает только паттерны типов (Type Patterns) с паттернами деконструкции для записей (records), предложенными JEP 405. Их уже можно использовать в операторах if и switch:
Object obj = // ...

// работает с Java 16
if (obj instanceof String str)
    callStringMethod(str);
else if (obj instanceof Number no)
    callNumberMethod(no);
else
    callObjectMethod(obj);

// работает (как превью) с JDK 17+
switch (obj) {
    case String str -> callStringMethod(str);
    case Number no -> callNumberMethod(no);
    default -> callObjectMethod(obj);
}
Я думаю, что с такими возможностями switch станет более функциональным и интуитивным за счет того, что:
  • более четко выражает намерение выполнить ровно одну ветвь на основе свойств obj
  • компилятор проверяет исчерпываемость
  • если необходимо вычислить значение, то использование switch в качестве выражения является более кратким
8.3. Применение уточнений (Clauses)
Уточнения (ранее — guarded patterns) расширяют возможности паттерна с помощью дополнительных логических проверок. Это может быть представлено следующим образом (синтаксис, придуманный мной):
String str = // ...
String length = switch (str) {
    case str.length() > 42 -> "long";
    case str.length() > 19 -> "medium";
    case str.length() > 1 -> "small";
    case null || str.length() == 0 -> "empty";
};
По мере того, как switch становится все более мощным, я предполагаю, что он начнет поглощать части кода, для реализации которых используется if-else-if.
Заключение
Из статьи мы узнали, что Java превращает switch в выражение, наделяя его новыми возможностями:
  • теперь один case может соответствовать нескольким меткам
  • новая стрелочная форма case … -> … следует синтаксису лямбда-выражений:
    • допускаются однострочные операторы или блоки
    • предотвращается сквозной переход к следующему case
  • теперь все выражение оценивается, как значение, которое затем может быть присвоено переменной или передано, как часть более крупного оператора
  • множественное выражение: если целевой тип известен, то все ветки должны ему соответствовать. В противном случае определяется конкретный тип, который соответствует всем веткам
  • yield возвращает значение из блока
  • для выражения switch, использующее enum, компилятор проверяет охват всех его значений. Если default отсутствует, добавляется ветка, которая вызывает исключение
  • если шаблоны станут более функциональными, то они смогут сделать switch предпочтительнее if
Оцените статью, если она вам понравилась!