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

Автор оригинала: Nicolai Parlog
  • Перевод

Старый добрый 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<String, String>, но также могут быть Function<Serializable, Object> или UnaryOperator<String>.


С помощью 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 выглядит намного компактнее и не ухудшает читаемость кода. Я уверен, что буду использовать только двоеточие, если у меня возникнет необходимость в сквозном проходе.


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

  • +26
  • 7,2k
  • 4
Поделиться публикацией

Похожие публикации

Комментарии 4

    0

    Я так понимаю об использовании самодельных типов в switch выражениях можно и не мечтать...

      0
      Нет, мечтать можно. Этот JEP — первый шаг. Именно об этом в нем и написано — openjdk.java.net/jeps/8213076

      Adding type-test patterns in switch expressions and statements is just a step in a comprehensive program of enriching Java with pattern matching


      Про будущее этой фичи хорошо рассказывал lany тут 2018.jokerconf.com/2018/talks/5ni8na5s7yekqyu8icaowu
      +1
      Конкуренция с Scala благотворно сказывается. Так со временем и pattern matching появится.
        +1
        Скорее с Kotlin. Там pattern matching'а нет, поэтому и в java не завезут :-)

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.