Эволюция оператора switch в Java

2b9ae7ec68267521e80ccd7d31ac731b

Введение

Наверное, есть только малая часть приложений, код в которых выполняются строго последовательно. Классический 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 17 — об этом чуть позже)

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

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

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 можно выходить целиком из метода, если указать 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 (string) стало возможно использовать строки (а так же методы, которые их возвращают), а в 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();
}

Как мы видим, данный, код получился более читаемым. Плюс появился еще один важный момент — обязательно требуется default секция (без этого код не скомпилируется). Ноги здесь растут из специфики switch-expressions, который обязан вернуть какое-то значение, а значит должны быть обработаны все case, даже если какой-то класс не найден (написали новый класс, но в case добавить забыли).

Шаблон 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.

Заключение

Как мы видим, история развития 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-expression.

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

© Habrahabr.ru