Введение

Наверное, есть только малая часть приложений, код которых выполняется строго последовательно. Классический Hello World! как раз из таких. В таких случаях говорят, что у выполняющейся программы есть только один поток выполнения - флоу. Однако, подавляющее большинство приложений меняют свой поток выполнения в зависимости от внешних условий (контекста выполнения, переменных среды, значений пропертей) или внутренних (переменные, значения полей и т.д.). Для таких случаев в Java еще с самой первой версии, как и в остальных языках программирования, есть оператор if-else и его модификации.

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

enum FamilyMember {
    FATHER, MOTHER, SON, DAUGHTER;
}

FamilyMember member = FamilyMember.SON;

if (member == FamilyMember.SON) {
    destroyFlat();
} else if (member == FamilyMember.DAUGHTER) {
    playSilent();
} else if (member == FamilyMember.MOTHER) {
    cook();
} else if (member == FamilyMember.FATHER) {
    helpMotherCook();
} else {
    doNothing();
}

Согласитесь, что из-за всех этих открывающихся/закрывающихся скобок, ключевых слов if, else, member == код становится плохо читаемым?

Для повышаемости читаемости кода (и не только - об этом чуть ниже) в Java и существует оператор switch. Рассмотрим этот же пример, но с его использованием:

enum FamilyMember {
    FATHER, MOTHER, SON, DAUGHTER;
}

FamilyMember member = FamilyMember.SON;

switch (member) {
    case SON:
        destroyFlat();
        break;
    case DAUGHTER:
        playSilent();
        break;
    case MOTHER:
        cook();
        break;
    case FATHER:
        helpMotherCook();
        break;
    default:
        doNothing();
        break; // можно не ставить, но правило хорошего тона
}

Более читаемо, не правда ли? А теперь давайте разбираться в особенностях реализации и ограничениях.

Почему нельзя просто взять и выбросить if-else?

К сожалению, оператор switch имеет ряд существенных ограничений на case-варианты:

  1. В секции switch можно использовать только примитивные типы: char, byte, short, int, их обертки (Char, Byte, Short, Integer), enum-ы (с Java 1.5), String (c Java 8), Object (с Java 21)

  2. В case можно писать только константные выражения: значения примитивов (описанных выше), enum-ы (с Java 1.5), String (c Java 8), Pattern Matching (с Java 21)

Например, вот так написать не получится:

int i = 0;
switch (i) {
    case < 0: ...
    case == 0: ...
    case > 0: ...
}

boolean b = true;
switch (b) {
    case true: ...
    case false: ...
}

String original = "C++";
String expected = "Java";
switch (expected) {
    case expected: ...
    default: ...
}

int val = 10;
switch (val) {
    case someMethodReturningInt(): ...
}

Поэтому все такие и остальные случаи использования можно покрыть только с помощью if-else выражений, т.к. для них необходимо только одно условие, чтобы в if было любое выражение, возвращающее true/false.

Оператор switch до Java 1.5

Как было написано выше, до версии Java 1.5 оператор switch поддерживал только значения некоторых примитивов и их обертки: char, byte, short, int.

Поддержки boolean нет, но и смысла использования в switch для всего двух вариантов true/false тоже никакого нет. К тому же switch c числом вариантов меньше трех выглядит уже менее читаемым по сравнению с тем же if-else или тернарным оператором. Давайте сравним (если бы была поддержка boolean):

// код ниже не скомпилируется
boolean b;
switch (b) {
    case true:
      doSomerthingIfTrue();
      break;
    case false:
      doSomerthingIfFalse();
      break;
}

И без использования switch:

boolean b;
if (b) {
  doSomerthingIfTrue();
} else {
  doSomerthingIfFalse();
}

boolean b2;
if (b2) 
  doSomerthingIfTrue();
else 
  doSomerthingIfFalse();

boolean b3;
int result = b3 
  ? returnSomerthingIfTrue() 
  : returnSomerthingIfFalse();

Еще одно преимущество оператора switch перед if-else в том, что он быстрее, т.к. для него есть специальная инструкция в bytecode JVM, которая загружает таблицу значений case и соответствующих им действий:

case 1:

doIf1()

case 10:

doIf10()

case 100:

doIf100()

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

Кроме того, разрядность левой колонки таблицы 32 бита - поэтому switch не поддерживает значения типа long, которые, как известно, 64 бита.

Поддержки float/double нет по той причине, что они в Java хранятся согласно стандарту IEEE 754, описывающем представление в формате с плавающей точкой. Увы, но точное сравнение значений в таком формате не возможно.

Синтаксис оператора switch

Общий шаблон использования оператора switch представлен ниже:

switch (expression) {
    case 0:
      actionA();
      actionB();
      break;
    case 1:
      actionC();
      break;
    case 2:
      actionD();
      actionE();
    case 3:
      actionF();
      break;
    case 4:
      actionG();
      return;
    case 5:
    case 6:
      actionH();
      break;
    default:
      actionI();
      actionK();
      break;
}
  1. Сначала проверяется expression, который может быть одним из описанных выше примитивов - это может быть как константа, так и значение переменной, поле, метод, возвращающий примитив и т.д.

  2. Далее происходит сравнение значения с константными значениями в case - литералами, которые должны соответствовать типу expression

  3. В случае expression == 0 выполнятся два действия actionA() и actionB(), далее идет break, что значит, что произойдет выход из switch и переход к инструкции после строки 22

  4. В случае expression == 1 выполнится одно действие actionC() и также выход из switch

  5. В случае expression == 2 выполнятся два действия actionD() и actionE(), далее т.к. нет break, то флоу выполнения перейдет на строку 13, и выполнится actionF(), а потом снова выход из switch

  6. В случае expression == 3 выполнится одно действие actionF() и также выход из switch, т.к. стоит break

  7. В случае expression == 4 выполнится одно действие actionG(), а далее сразу выход из метода, внутри которого расположен switch (в зависимости от типа возвращаемого значения return может ничего не возвращать - void, либо возвращать какое-то значение)

  8. В случае expression == 5 или expression == 6 выполнится одно действие actionH() и также выход из switch, т.к. стоит break

  9. В случае любого другого значения expression произойдет переход в секцию default, выполнятся два действия actionI() и actionK(), и потом выход из switch. Ключевое слово break тут указывать не обязательно, но правилом хорошего тона все-таки считается указывать. Секция default не является обязательной - если нет никакой логики, связанной с ней, то ее и не нужно прописывать

Из приведенного выше шаблона можно сделать следующие выводы:

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

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

  3. Из блоков case можно выходить целиком из метода, содержащий этот switch, если указать return

  4. Версия switch до Java 14 не поддерживает возвращение значений из блоков case (об этом ниже)

Оператор switch до Java 8

В Java 1.5 добавили enum-ы. С этой версии Java оператор switch стал их поддерживать, как это описано во Введении к этой статье. При этом не обязательно, чтобы в case были прописаны все имеющиеся значения enum. Это приводит к еще одной наиболее частой ошибке, когда добавили новое значение enum, а case для этого значения прописать забыли (если есть default, то выполнится он, что тоже не всегда ожидаемо, но хотя бы предсказуемо).

Оператор switch до Java 14

С Java 8 в операторе switch добавили поддержку строк String. Теперь в switch стало возможно использовать строки (а так же методы, которые их возвращают), а в case - строковые литералы. Т.к. в Java строки - это объекты, то сравнения со значениями из case происходят не по ссылке == (как это было ранее), а через метод Object.equals(o) - это еще одно важное изменение.

Взглянем на блок кода:

String name = "Vova";
switch (name) {
    case "Vova":
      hiVova();
      break;
    case "Vika":
      hiVika();
      break;
    default:
      hiStranger();
      break;
}

В зависимости от name, выполняется то или иное приветствие.

Оператор switch до Java 21

Все способы применения оператора switch, что я описывал ранее, называются switch-statements. Однако, при разработке приложений часто возникают такие ситуации, что в зависимости от какого-то условия нужно вернуть значение. Давайте посмотрим, как это можно было сделать до Java 21 (на самом деле до Java 17, но фича не была финальной) при помощи тернарного оператора:

public String showLight(boolean on) {
  return on ? "It's on" : "It's off";
}

А что, если условие не да/нет? Воспользуемся if-else:

public String getPaymentState(String orderState)
  String paymentState;
  if ("ordered".equals(orderState)) {
    paymentState = "pending";
  } else if ("paid".equals(orderState)) {
    paymentState = "completed";
  } else if ("cancelled".equals(orderState)) {
    paymentState = "cancelled";
  } else {
    paymentState = "unknown";
  }
  return paymentState;
}

Мы видимим, что нам потребовалась дополнительная переменная paymentState, ну и вообще код не очень читаемый. Или то же самое, но с использованием switch-statements:

String paymentState;
switch (orderState) {
    case "ordered":
      paymentState = "pending";
      break;
    case "paid":
      paymentState = "completed";
      break;
    case "cancelled":
      paymentState = "cancelled";
      break;
    default:
      paymentState = "unknown";
      break;
}
System.out.println(paymentState);

Именно для таких случаев (когда нужно возвращать значение из switch) в Java 14 ввели новый switch (старый способ использования никуда не делся), который назвается switch-expressions.

Пример выше с использование switch-expressions можно переписать вот так:

String paymentState = switch (orderState) {
    case "ordered" -> "pending";
    case "paid" -> "completed";
    case "cancelled" -> "cancelled";
    default -> "unknown";
}
System.out.println(paymentState);

Согласитесь, куда более красиво?

Как мы видим, двоеточие : после case заменили на ->. Результат выполнения switch можно присваивать в переменную, поле, возвращать из метода, передавать как параметр метода.

Общий шаблон использования switch-expressions приведен ниже:

String value = switch (expression) {
    case 0 -> "abc";
    case 1 -> {
      String s = "def";
      yield s;
    }
    case 2, 3 -> "ghi";
    default -> "klm";
}

Как мы видим, шаблон использования несколько сократился по сравнению с switch-statements:

  1. Если expression == 0, то value примет значение "abc"

  2. Если expression == 1, то в локальную переменную s присвоится значение "abc", а далее оно будет присвоено в value

  3. Если expression == 2 или expression == 3, то value примет значение "ghi". В новом синтаксисе несколько case не пишутся друг под другом, а перечисляются через запятую

  4. По умолчанию, выполнится блок default (тоже не обязательный), в результате которого value примет значение "klm"

Какие можно сделать выводы из данного синтаксиса switch-expressions:

  1. Не требуется ставить break, т.к. всегда выполнится только соответствующая ветка case, а ее результат сразу вернется в переменную

  2. Внутри case можно, как и раньше, писать блоки кода, но в конце они должны содержать строку с возвратом значения - ключевое слово yield (аналог return в методах)

  3. Нельзя делать выход наружу из case с помощью ключевого слова return

  4. Если в качестве expression передано значение enum, то компилятором будет проверено, что в case проверены все значения этого enum. Таким образом при добавлении нового значения мы не забудем прописать его в case

Кроме того добавлена поддержка нового синтаксиса для switch-statements (aka switch-expressions):

switch (expression) {
    case 0 -> System.out.println("abc");
    case 1 -> {
      String s = "def";
      System.out.println(s);
    }
    case 2, 3 -> System.out.println("ghi");
    default -> System.out.println("klm");
}

Основные улучшения по сравнению с классическим switch-statements:

  1. Не нужно писать break, т.к. всегда выполнится только действие для сматчившегося case

  2. Более компактный синтаксис

Оператор switch с Java 21

В Java 21 финально появился Pattern Matching. Что же это такое? Давайте взглянем на код - я более чем уверен, что вам миллионы раз приходилось писать что-то такое:

interface Figure {
    int x();
    int y();
};

record Rectangle(int x, int y, int width, int height) implements Figure {
}

record Circle(int x, int y, int radius) implements Figure {
}

Figure figure = new Rectangle(0, 0, 10, 20);

if (figure instanceof Rectangle) {
    Rectangle rectangle = (Rectangle) figure;
    drawRectangle(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
} else if (figure instanceof Circle) {
    Circle circle = (Circle) figure;
    drawCircle(circle.x, circle.y, circle.radius);
}

То есть, в зависимости от того, какой пришел объект, мы вызовем соответствующий метод его отрисовки: если прямоугольник, то drawRectangle(...), если круг, то drawCircle(...). Основная проблема здесь в том, что у нас появилось два приведения типа, которые ухудшают читаемость кода: Rectangle rectangle = (Rectangle) figure; и Circle circle = (Circle) figure;.

Паттерн матчинг как раз и предназначен для решения этой проблемы. Вот как это выглядит для классических if-else выражений:

// те же самые классы

Figure figure = new Rectangle(0, 0, 10, 20);

if (figure instanceof Rectangle rectangle) {
    drawRectangle(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
} else if (figure instanceof Circle cicle) {
    drawCircle(circle.x, circle.y, circle.radius);
}

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

Теперь давайте усложним пример. Допустим, что нам нужно отрисовывать прямоугольник только, если его верхний угол находится в левой верхней части экрана (x == 0 и y == 0):

// те же самые классы

Figure figure = new Rectangle(0, 0, 10, 20);

if (figure instanceof Rectangle rectangle && rectangle.x == 0 && rectangle.y == 0) {
    drawRectangle(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
} else if (figure instanceof Circle cicle) {
    drawCircle(circle.x, circle.y, circle.radius);
}

Как мы видим, с такими условиями в if-else Pattern Matching тоже умеет работать.

Все то же самое валидно и для switch-statements (в новом синтаксисе - пример ниже), и для switch-expressions - для них стало возможно передавать в switch объект Object:

// те же самые классы

Figure figure = new Rectangle(0, 0, 10, 20);

switch (figure) {
    case Rectangle r && (r.x == 0 || r.y == 0) -> drawRectangle(r.x, r.y, r.width, r.height);
    case Circle c -> drawCircle(c.x, c.y, c.radius);
    default -> drawNothing();
}

Как мы видим, данный, код получился более читаемым. Заметим, что если мы хотим добавить в case проверку на какой-то родительский класс для Rectangle и Circle или же на сам интерфейс Figure, то она должна идти перед default - иначе не скомпилируется. Догадайтесь, почему?

Шаблон switch-expressions с Pattern Matching такой:

String value = switch (obj) {
    case ClassA a -> "It's A";
    case ClassB b && (i = 0 true && b.canHandle()) -> {
      b.doSomething();
      yield "It's B";
    }
    default -> "It's UNKNOWN";
}
  1. Если obj instanceof ClassA, то вернется строка "It's A"

  2. Если obj instanceof ClassB и выполняются условия справа (тут могут быть любые условия, которые возвращают в итоге true/false), то выполняется действие b.doSomething(); и возвращается строка "It's B"

  3. Иначе возвращается строка "It's UNKNOWN"

Остальные все правила валидны как для обычных switch-expressions. Более того Pattern Matching доступен так же и в классическом первоначальном switch-statements (через двоеточие :) с тем лишь ограничением, что в каждом блоке case должен обязательно присутствовать break.

Заключение

Как мы видим, история развития switch оператора очень богатая - они появились в самой первой версии Java. Все началось со стандартных switch-statements, которые до появления enum в Java могли только обрабатывать примитивные типы char, byte, short и int, а также их обертки.

С появлением enum - их тоже добавили в возможность использования в switch.

В Java 8 была добавлена поддержка сравнения строк, что также очень сильно упростило разработку и сделало код более читаемым.

По-тихоньку назревала необходимость возвращать значения из switch. Это было сделано в Java 14, что потребовало изменить синтаксис switch - такие выражения получили название switch-expressions.

Ну и квинтессенцией всего стала Java 21, в которой наконец-таки избавили разработчиков писать бесконечные instanceof в if-else с последующим приведением типов, а заменить это все на более лаконичный Pattern Matching, который также добавили и в switch-expressions.

Что нас ждет дальше?

Задания для самопроверки

  1. Какие способы ветвления кода вы знаете до появления оператора switch?

  2. Какие типы данных можно передавать в выражение switch? Какие нельзя и почему?

  3. Для чего нужны ключевые слова break, yield и default?

  4. Что такое switch-expressions, и в чем их отличие от switch-statements? Зачем вообще появились switch-expressions?

  5. Можно ли скомпилировать код с switch-expression, в котором не обработаны все значения enum?

  6. Какие типы выражений можно использовать в блоках case до появления Pattern Matching?

  7. Какие типы выражений стало возможным писать в блоках case после появления Pattern Matching?

  8. Каким будет switch завтра? В новых версиях Java? :)

  9. Напишите идентичный код с помощью if-ellse, switch-statements (классический через :), switch-statements (новый через ->), switch-expressions, который использует Pattern Matching и в зависимости от класса возвращает соответствующую строку

Ответы на вопросы (кратко, не развернуто)

  1. if-else, тернарный оператор

  2. char, byte, short, int, их обертки, enum, String, Object. Потому что изначально switch задумывался как табличка с 32-битной колонкой для значений case со сравнением по ссылке. hashCode() объектов тоже int - 32 бита. Сравнивать числа с плавающей точкой на == нельзя

  3. break - для выхода из switch внутри блока case, yield - для возвращения значения из блока case в switch-expressions, default - для дефолтной ветки кода, если ни один case не подошел

  4. switch-expressions позволяют возвращать значения из блоков case в переменную, поле, из метода, в параметр метода. Появились для того, чтобы уменьшить объем бойлерплэйт-кода для этих целей, связанного с созданием новой переменной

  5. Нет, компилятор выдаст ошибку

  6. Только константные выражения - литералы char, byte, short, int, String и значения enum

  7. Условные выражения, которые возвращают true/false, но только через оператор &&

  8. Поживем - увидим :)

Object obj = "I'm object";

String str = switch (obj) {
    case String s -> "string";
    case Object o -> "object";
};

switch (obj) {
    case String s: str = "string"; break;
    case Object o: str = "object"; break;
}

switch (obj) {
    case String s -> str = "string";
    case Object o -> str = "object";
}

if (obj instanceof String) {
    str = "string";
} else if (obj instanceof Object) {
    str = "object";
}

System.out.println(str);

Комментарии (3)


  1. georgiy08
    28.08.2024 16:19

    String value = switch (expression) {
    
        case 0 -> "abc";
    
        case 1 -> {
    
          String s = "def";
    
          yield s;
    
        }
    
        case 2, 3 -> "ghi";
    
        default -> "klm";
    
    }

    ```Если expression == 1, то в локальную переменную s присвоится значение "abc", а далее оно будет присвоено в value``` - тут опечатка (возвращается значение abc) или я что-то не так понял (возвращается def)


  1. vtarasoff Автор
    28.08.2024 16:19

    да, все верно, опечатка, конечно же вернется "def"


  1. WieRuindl
    28.08.2024 16:19

    Как по мне, единственно верной эволюцией switch в java было бы полное его выпиливание из языка. Очевидно, такого никогда не произойдёт, потому что, как минимум, обратная совместимость и все такое, но как по мне лучше бы switch вообще никак не трогали и не пытались улучшить. Его использование в коде - это очевиднейший bad smell, и чем он менее удобен, тем больше бы количество программистов от него отказывались самостоятельно в пользу более качественных решений

    Если есть аргументы в его защиту - прошу, будет интересно обсудить