JEP 441: Pattern Matching for switch
Pattern Matching (сопоставление с образцом) для switch
| Ответственный | Gavin Bierman |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 21 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot org |
| Связан с | JEP 433: Pattern Matching for switch (Fourth Preview) |
| Рецензенты | Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2023/01/18 14:43 |
| Обновлён | 2023/09/19 13:38 |
| Задача | 8300542 |
Аннотация
Расширить язык программирования Java механизмом Pattern Matching для выражений и операторов switch. Распространение Pattern Matching на switch позволяет проверять выражение на соответствие ряду шаблонов, для каждого из которых задано своё действие, так что сложные запросы, ориентированные на данные, можно выражать кратко и безопасно.
История
Эта возможность была впервые предложена в JEP 406 (JDK 17) и затем доработана в JEP 420 (JDK 18), 427 (JDK 19) и 433 (JDK 20). Она развивалась вместе с возможностью Record Patterns (шаблоны записей) (JEP 440), с которой она тесно взаимодействует. Этот JEP предлагает сделать возможность окончательной, внеся небольшие дополнительные доработки на основе накопленного опыта и отзывов.
Помимо различных редакционных правок, основные изменения по сравнению с предыдущим JEP таковы:
-
удалены шаблоны в скобках, поскольку они не приносили достаточной пользы;
-
разрешено использовать квалифицированные константы перечислений в качестве констант
caseв выражениях и операторахswitch.
Цели
-
Расширить выразительность и область применения выражений и операторов
switch, разрешив использовать шаблоны в меткахcase. -
Разрешить при необходимости ослаблять исторически сложившуюся враждебность
switchк null. -
Повысить безопасность операторов
switch, потребовав, чтобы операторыswitchс шаблонами охватывали все возможные входные значения. -
Обеспечить, чтобы все существующие выражения и операторы
switchпо-прежнему компилировались без изменений и выполнялись с идентичной семантикой.
Мотивация
В Java 16 JEP 394 расширил оператор instanceof, чтобы он принимал шаблон типа и выполнял Pattern Matching. Это скромное расширение позволяет упростить привычную идиому «instanceof и приведение типа», сделав её одновременно более краткой и менее подверженной ошибкам:
// Prior to Java 16
if (obj instanceof String) {
String s = (String)obj;
... use s ...
}
// As of Java 16
if (obj instanceof String s) {
... use s ...
}
В новом коде obj соответствует шаблону типа String s, если во время выполнения значение obj является экземпляром String. Если шаблон совпадает, выражение instanceof равно true, а переменная шаблона s инициализируется значением obj, приведённым к String, и её затем можно использовать во вложенном блоке.
Часто нам нужно сравнить переменную, например obj, с несколькими альтернативами. Java поддерживает многовариантные сравнения с помощью операторов switch, а начиная с Java 14 — и выражений switch (JEP 361), но, к сожалению, возможности switch очень ограничены. Выполнять switch можно только по значениям нескольких типов — целочисленных примитивных типов (кроме long), соответствующих им упакованных типов, типов enum и String, — и проверять можно только точное равенство константам. Нам могло бы понадобиться использовать шаблоны, чтобы проверять одну и ту же переменную на соответствие ряду вариантов и выполнять для каждого своё действие, но поскольку существующий switch этого не поддерживает, в итоге получается цепочка проверок if...else, например:
// Prior to Java 21
static String formatter(Object obj) {
String formatted = "unknown";
if (obj instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
formatted = String.format("long %d", l);
} else if (obj instanceof Double d) {
formatted = String.format("double %f", d);
} else if (obj instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}
Этот код выигрывает от использования выражений instanceof с шаблонами, но он далёк от совершенства. Прежде всего, такой подход позволяет ошибкам в коде оставаться скрытыми, потому что мы использовали слишком общую управляющую конструкцию. Замысел в том, чтобы в каждой ветви цепочки if...else что-то присваивалось formatted, но ничто не позволяет компилятору распознать этот инвариант и обеспечить его соблюдение. Если какой-то блок «then» — возможно, тот, что выполняется редко, — не присваивает значение formatted, у нас ошибка. (Если объявить formatted как локальную переменную без инициализатора, к этой задаче хотя бы подключится анализ определённого присваивания в компиляторе, но разработчики не всегда пишут такие объявления.) Кроме того, приведённый выше код не поддаётся оптимизации: без героических усилий компилятора его временная сложность будет O(n), хотя сама задача часто решается за O(1).
Но switch идеально подходит для Pattern Matching! Если расширить операторы и выражения switch так, чтобы они работали с любым типом, и разрешить метки case с шаблонами, а не только с константами, то приведённый выше код можно переписать понятнее и надёжнее:
// As of Java 21
static String formatterPatternSwitch(Object obj) {
return switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> obj.toString();
};
}
Семантика этого switch ясна: метка case с шаблоном применяется, если значение выражения-селектора obj соответствует шаблону. (Для краткости мы показали выражение switch, но могли бы показать и оператор switch; блок switch, включая метки case, остался бы без изменений.)
Замысел этого кода понятнее, потому что мы используем правильную управляющую конструкцию: мы говорим: «параметр obj соответствует не более чем одному из следующих условий; определи, какому, и вычисли соответствующую ветвь». Дополнительное преимущество в том, что этот код лучше поддаётся оптимизации: в данном случае больше шансов выполнить диспетчеризацию за время O(1).
switch и null
Традиционно операторы и выражения switch выбрасывают NullPointerException, если выражение-селектор вычисляется в null, поэтому проверку на null приходится выполнять вне switch:
// Prior to Java 21
static void testFooBarOld(String s) {
if (s == null) {
System.out.println("Oops!");
return;
}
switch (s) {
case "Foo", "Bar" -> System.out.println("Great");
default -> System.out.println("Ok");
}
}
Это было разумно, пока switch поддерживал лишь несколько ссылочных типов. Однако если switch допускает выражение-селектор любого ссылочного типа, а метки case могут содержать шаблоны типов, то отдельная проверка на null выглядит произвольным разграничением, которое провоцирует ненужный шаблонный код и создаёт возможности для ошибок. Лучше встроить проверку на null в switch, разрешив новую метку case null:
// As of Java 21
static void testFooBarNew(String s) {
switch (s) {
case null -> System.out.println("Oops");
case "Foo", "Bar" -> System.out.println("Great");
default -> System.out.println("Ok");
}
}
Поведение switch, когда значение выражения-селектора равно null, всегда определяется его метками case. При наличии case null switch выполняет код, связанный с этой меткой; без case null switch выбрасывает NullPointerException, как и раньше. (Чтобы сохранить обратную совместимость с текущей семантикой switch, метка default не соответствует селектору, равному null.)
Уточнение case
В отличие от меток case с константами, метка case с шаблоном может подходить ко многим значениям. Это часто приводит к появлению условного кода в правой части правила switch. Рассмотрим, например, следующий код:
// As of Java 21
static void testStringOld(String response) {
switch (response) {
case null -> { }
case String s -> {
if (s.equalsIgnoreCase("YES"))
System.out.println("You got it");
else if (s.equalsIgnoreCase("NO"))
System.out.println("Shame");
else
System.out.println("Sorry?");
}
}
}
Проблема в том, что использование единственного шаблона для различения вариантов не масштабируется за пределы одного условия. Мы бы предпочли написать несколько шаблонов, но тогда нужен какой-то способ выразить уточнение шаблона. Поэтому мы разрешаем в блоках switch использовать конструкции when, задающие guard-условия для меток case с шаблонами, например case String s when s.equalsIgnoreCase("YES"). Такую метку case мы называем guarded-меткой case, а булево выражение — guard.
При таком подходе приведённый выше код можно переписать с использованием guard-условий:
// As of Java 21
static void testStringNew(String response) {
switch (response) {
case null -> { }
case String s
when s.equalsIgnoreCase("YES") -> {
System.out.println("You got it");
}
case String s
when s.equalsIgnoreCase("NO") -> {
System.out.println("Shame");
}
case String s -> {
System.out.println("Sorry?");
}
}
}
Так получается более читаемый стиль программирования с switch, при котором сложность проверки находится в левой части правила switch, а логика, применяемая при выполнении проверки, — в правой части правила switch.
Этот пример можно дополнительно улучшить, добавив правила для других известных строковых констант:
// As of Java 21
static void testStringEnhanced(String response) {
switch (response) {
case null -> { }
case "y", "Y" -> {
System.out.println("You got it");
}
case "n", "N" -> {
System.out.println("Shame");
}
case String s
when s.equalsIgnoreCase("YES") -> {
System.out.println("You got it");
}
case String s
when s.equalsIgnoreCase("NO") -> {
System.out.println("Shame");
}
case String s -> {
System.out.println("Sorry?");
}
}
}
Эти примеры показывают, как константы case, шаблоны case и метка null в сочетании демонстрируют новые возможности программирования с switch: сложную условную логику, которая раньше была перемешана с бизнес-логикой, можно упростить до читаемого последовательного списка меток switch, где бизнес-логика находится справа от правил switch.
switch и константы перечислений
Использование констант перечислений в метках case сейчас сильно ограничено: выражение-селектор switch должно иметь тип перечисления, а метки должны быть простыми именами констант этого перечисления. Например:
// Prior to Java 21
public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }
static void testforHearts(Suit s) {
switch (s) {
case HEARTS -> System.out.println("It's a heart!");
default -> System.out.println("Some other suit");
}
}
Даже после добавления меток с шаблонами это ограничение приводит к излишне многословному коду. Например:
// As of Java 21
sealed interface CardClassification permits Suit, Tarot {}
public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}
static void exhaustiveSwitchWithoutEnumSupport(CardClassification c) {
switch (c) {
case Suit s when s == Suit.CLUBS -> {
System.out.println("It's clubs");
}
case Suit s when s == Suit.DIAMONDS -> {
System.out.println("It's diamonds");
}
case Suit s when s == Suit.HEARTS -> {
System.out.println("It's hearts");
}
case Suit s -> {
System.out.println("It's spades");
}
case Tarot t -> {
System.out.println("It's a tarot");
}
}
}
Этот код был бы читаемее, если бы вместо множества guarded-шаблонов можно было задать отдельный case для каждой константы перечисления. Поэтому мы ослабляем требование, чтобы выражение-селектор имело тип перечисления, и разрешаем константам case использовать квалифицированные имена констант перечислений. Это позволяет переписать приведённый выше код так:
// As of Java 21
static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
switch (c) {
case Suit.CLUBS -> {
System.out.println("It's clubs");
}
case Suit.DIAMONDS -> {
System.out.println("It's diamonds");
}
case Suit.HEARTS -> {
System.out.println("It's hearts");
}
case Suit.SPADES -> {
System.out.println("It's spades");
}
case Tarot t -> {
System.out.println("It's a tarot");
}
}
}
Теперь у нас есть отдельный case для каждой константы перечисления без guarded-шаблонов типов, которые раньше использовались лишь для того, чтобы обойти текущее ограничение системы типов.
Описание
Мы улучшаем операторы и выражения switch в четырёх направлениях:
-
улучшаем метки
caseс константами перечислений; -
расширяем метки
case, чтобы помимо констант они могли содержать шаблоны иnull; -
расширяем набор типов, допустимых для выражений-селекторов как операторов
switch, так и выраженийswitch(вместе с необходимым более глубоким анализом полноты охвата блоков switch); -
разрешаем необязательные конструкции
whenпосле метокcase.
Улучшенные метки case с константами перечислений
Долгое время действовало требование, что при switch по типу перечисления допустимыми константами case могут быть только константы перечисления. Но это жёсткое требование, которое становится обременительным с новыми, более богатыми формами switch.
Для сохранения совместимости с существующим кодом на Java при switch по типу перечисления константа case по-прежнему может использовать простое имя константы того типа перечисления, по которому выполняется switch.
Для нового кода мы расширяем обработку перечислений. Во-первых, мы разрешаем использовать квалифицированные имена констант перечислений в качестве констант case. Эти квалифицированные имена можно использовать при switch по типу перечисления.
Во-вторых, мы отменяем требование, чтобы выражение-селектор имело тип перечисления, когда имя одной из констант этого перечисления используется в качестве константы case. В этом случае мы требуем, чтобы имя было квалифицированным, а его значение было совместимо по присваиванию с типом выражения-селектора. (Это согласует обработку констант case перечислений с числовыми константами case.)
Например, следующие два метода допустимы:
// As of Java 21
sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS }
static void goodEnumSwitch1(Currency c) {
switch (c) {
case Coin.HEADS -> { // Qualified name of enum constant as a label
System.out.println("Heads");
}
case Coin.TAILS -> {
System.out.println("Tails");
}
}
}
static void goodEnumSwitch2(Coin c) {
switch (c) {
case HEADS -> {
System.out.println("Heads");
}
case Coin.TAILS -> { // Unnecessary qualification but allowed
System.out.println("Tails");
}
}
}
Следующий пример недопустим:
// As of Java 21
static void badEnumSwitch(Currency c) {
switch (c) {
case Coin.HEADS -> {
System.out.println("Heads");
}
case TAILS -> { // Error - TAILS must be qualified
System.out.println("Tails");
}
default -> {
System.out.println("Some currency");
}
}
}
Шаблоны в метках switch
Мы изменяем грамматику меток switch в блоке switch следующим образом (ср. JLS §14.11.1):
SwitchLabel:
case CaseConstant { , CaseConstant }
case null [, default]
case Pattern [ Guard ]
default
Главное улучшение — новая метка case, case p, где p — шаблон. Суть switch не меняется: значение выражения-селектора сравнивается с метками switch, выбирается одна из меток, и связанный с ней код выполняется или вычисляется. Разница теперь в том, что для меток case с шаблонами выбранная метка определяется результатом Pattern Matching, а не проверкой на равенство. Например, в следующем коде значение obj соответствует шаблону Long l, и вычисляется выражение, связанное с меткой case Long l:
// As of Java 21
static void patternSwitchTest(Object obj) {
String formatted = switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
case String s -> String.format("String %s", s);
default -> obj.toString();
};
}
После успешного сопоставления с шаблоном мы часто дополнительно проверяем его результат. Это может приводить к громоздкому коду, например:
// As of Java 21
static void testOld(Object obj) {
switch (obj) {
case String s:
if (s.length() == 1) { ... }
else { ... }
break;
...
}
}
Нужная проверка — что obj является String длины 1 — к сожалению, разделена между меткой case с шаблоном и следующим за ней оператором if.
Чтобы решить эту проблему, мы вводим guarded-метки case с шаблонами, разрешая после метки с шаблоном необязательное условие guard — булево выражение. Так приведённый выше код можно переписать, перенеся всю условную логику в метку switch:
// As of Java 21
static void testNew(Object obj) {
switch (obj) {
case String s when s.length() == 1 -> ...
case String s -> ...
...
}
}
Первая конструкция срабатывает, если obj является String и имеет длину 1. Второй case срабатывает, если obj является String любой длины.
Guard-условия могут быть только у меток с шаблонами. Например, нельзя написать метку с константой case и guard-условием, например case "Hello" when callRandomBooleanExpression().
При поддержке шаблонов в switch нужно рассмотреть пять основных аспектов проектирования языка:
- Расширенная проверка типов
- Полнота охвата выражений и операторов
switch - Область видимости объявлений переменных шаблонов
- Обработка
null - Ошибки
Расширенная проверка типов
Типизация выражения-селектора
Поддержка шаблонов в switch означает, что можно ослабить ограничения на тип выражения-селектора. Сейчас тип выражения-селектора обычного switch должен быть целочисленным примитивным типом (за исключением long), соответствующей упакованной формой (т. е. Character, Byte, Short или Integer), String или типом enum. Мы расширяем это правило и требуем, чтобы тип выражения-селектора был либо целочисленным примитивным типом (за исключением long), либо любым ссылочным типом.
Например, в следующем switch с шаблонами выражение-селектор obj сопоставляется с шаблонами типов, в которых участвуют тип-класс, тип enum, record-тип и тип массива, а также с меткой null case и с default:
// As of Java 21
record Point(int i, int j) {}
enum Color { RED, GREEN, BLUE; }
static void typeTester(Object obj) {
switch (obj) {
case null -> System.out.println("null");
case String s -> System.out.println("String");
case Color c -> System.out.println("Color: " + c.toString());
case Point p -> System.out.println("Record class: " + p.toString());
case int[] ia -> System.out.println("Array of ints of length" + ia.length);
default -> System.out.println("Something else");
}
}
Каждая метка case в блоке switch должна быть совместима с выражением-селектором. Для метки case с шаблоном, называемой меткой шаблона, мы используем существующее понятие совместимости выражения с шаблоном (JLS §14.30.1).
Доминирование меток case
Поддержка меток case с шаблонами означает, что для данного значения выражения-селектора теперь может подойти больше одной метки case, тогда как раньше могла подойти не более чем одна метка case. Например, если выражение-селектор вычисляется в String, то подойдут обе метки case: case String s и case CharSequence cs.
Первый вопрос, который нужно решить, — какая именно метка должна применяться в такой ситуации. Вместо того чтобы пытаться применить сложный подход с выбором наилучшего соответствия, мы принимаем более простую семантику: выбирается первая метка case в блоке switch, которая подходит к значению.
// As of Java 21
static void first(Object obj) {
switch (obj) {
case String s ->
System.out.println("A string: " + s);
case CharSequence cs ->
System.out.println("A sequence of length " + cs.length());
default -> {
break;
}
}
}
В этом примере, если значение obj имеет тип String, применится первая метка case; если оно имеет тип CharSequence, но не тип String, применится вторая метка шаблона.
Но что произойдёт, если поменять эти две метки case местами?
// As of Java 21
static void error(Object obj) {
switch (obj) {
case CharSequence cs ->
System.out.println("A sequence of length " + cs.length());
case String s -> // Error - pattern is dominated by previous pattern
System.out.println("A string: " + s);
default -> {
break;
}
}
}
Теперь, если значение obj имеет тип String, применяется метка CharSequence case, поскольку она стоит в блоке switch первой. Метка String case недостижима в том смысле, что не существует значения выражения-селектора, при котором она была бы выбрана. По аналогии с недостижимым кодом это считается ошибкой программиста и приводит к ошибке компиляции.
Точнее говоря, первая метка case case CharSequence cs доминирует над второй меткой case case String s, потому что каждое значение, соответствующее шаблону String s, соответствует и шаблону CharSequence cs, но не наоборот. Это так, потому что тип второго шаблона, String, является подтипом типа первого шаблона, CharSequence.
Метка case с шаблоном без охранного условия доминирует над меткой case с тем же шаблоном и охранным условием. Например, метка case с шаблоном (без охранного условия) case String s доминирует над меткой case с шаблоном и охранным условием case String s when s.length() > 0, поскольку каждое значение, соответствующее метке case case String s when s.length() > 0, обязательно соответствует метке case case String s.
Метка case с шаблоном и охранным условием доминирует над другой меткой case с шаблоном (с охранным условием или без него), только если шаблон первой доминирует над шаблоном второй и её охранное условие — константное выражение со значением true. Например, метка case с шаблоном и охранным условием case String s when true доминирует над меткой case с шаблоном case String s. Мы не анализируем охранное выражение дальше, чтобы точнее определить, какие значения соответствуют метке шаблона, — в общем случае эта задача неразрешима.
Метка case с шаблоном может доминировать над константной меткой case. Например, метка case с шаблоном case Integer i доминирует над константной меткой case case 42, а метка case с шаблоном case E e доминирует над константной меткой case case A, когда A — элемент типа enum-класса E. Метка case с шаблоном и охранным условием доминирует над константной меткой case, если над ней доминирует такая же метка case с шаблоном без охранного условия. Иными словами, мы не проверяем охранное условие, поскольку в общем случае это неразрешимо. Например, метка case с шаблоном case String s when s.length() > 1 доминирует над константной меткой case case "hello", как и ожидалось; но case Integer i when i != 0 доминирует над меткой case case 0.
Всё это подсказывает простой, предсказуемый и удобочитаемый порядок меток case, при котором константные метки case должны стоять перед метками case с шаблонами и охранными условиями, а те — перед метками case с шаблонами без охранных условий:
// As of Java 21
Integer i = ...
switch (i) {
case -1, 1 -> ... // Special cases
case Integer j when j > 0 -> ... // Positive integer cases
case Integer j -> ... // All the remaining integers
}
Компилятор проверяет все метки case. Если над меткой case в блоке switch доминирует любая предшествующая метка case в том же блоке switch, это ошибка компиляции. Это требование доминирования гарантирует, что если блок switch содержит только метки case с шаблонами типов, они будут расположены в порядке подтипов.
(Понятие доминирования аналогично условиям для блоков catch оператора try, где ошибкой считается, если блоку catch, перехватывающему класс исключения E, предшествует блок catch, который может перехватить E или суперкласс E (JLS §11.2.3). Логически предшествующий блок catch доминирует над последующим блоком catch.)
Также ошибкой компиляции является наличие в блоке switch выражения switch или оператора switch более чем одной метки, совпадающей с любым значением. Такие метки — это default и метки case с шаблоном, который безусловно соответствует выражению-селектору. Например, шаблон типа String s безусловно соответствует выражению-селектору типа String, а шаблон типа Object o безусловно соответствует выражению-селектору любого ссылочного типа:
// As of Java 21
static void matchAll(String s) {
switch(s) {
case String t:
System.out.println(t);
break;
default:
System.out.println("Something else"); // Error - dominated!
}
}
static void matchAll2(String s) {
switch(s) {
case Object o:
System.out.println("An Object");
break;
default:
System.out.println("Something else"); // Error - dominated!
}
}
Полнота охвата выражений и операторов switch
Покрытие типов
Выражение switch требует, чтобы в блоке switch обрабатывались все возможные значения выражения-селектора; иными словами, оно должно быть исчерпывающим. Так сохраняется свойство: успешное вычисление выражения switch всегда даёт значение.
Для обычных выражений switch это свойство обеспечивается простым набором дополнительных условий для блока switch.
Для выражений и операторов switch с шаблонами мы добиваемся этого, вводя понятие покрытия типов метками в блоке switch. Затем покрытия типов всех меток в блоке switch объединяются, чтобы определить, исчерпывает ли блок switch все возможные значения выражения-селектора.
Рассмотрим это (ошибочное) выражение switch с шаблонами:
// As of Java 21
static int coverage(Object obj) {
return switch (obj) { // Error - not exhaustive
case String s -> s.length();
};
}
В блоке switch всего одна метка, case String s. Ей соответствует любое значение obj, тип которого является подтипом String. Поэтому мы говорим, что покрытие типов этой метки — все подтипы String. Это выражение switch с шаблонами не является исчерпывающим, потому что покрытие типов его блока switch (все подтипы String) не включает тип выражения-селектора (Object).
Рассмотрим такой (по-прежнему ошибочный) пример:
// As of Java 21
static int coverage(Object obj) {
return switch (obj) { // Error - still not exhaustive
case String s -> s.length();
case Integer i -> i;
};
}
Покрытие типов этого блока switch — объединение покрытий двух его меток. Иными словами, покрытие типов — это множество всех подтипов String и множество всех подтипов Integer. Но и здесь покрытие типов по-прежнему не включает тип выражения-селектора, поэтому это выражение switch с шаблонами тоже не является исчерпывающим и вызывает ошибку компиляции.
Покрытие типов метки default — все типы, поэтому этот пример (наконец-то!) корректен:
// As of Java 21
static int coverage(Object obj) {
return switch (obj) {
case String s -> s.length();
case Integer i -> i;
default -> 0;
};
}
Исчерпываемость на практике
Понятие покрытия типов уже существует в выражениях switch без шаблонов. Например:
// As of Java 20
enum Color { RED, YELLOW, GREEN }
int numLetters = switch (color) { // Error - not exhaustive!
case RED -> 3;
case GREEN -> 5;
}
Это выражение switch над enum-классом не является исчерпывающим, потому что ожидаемое входное значение YELLOW не покрыто. Как и следовало ожидать, достаточно добавить метку case для константы перечисления YELLOW, чтобы switch стал исчерпывающим:
// As of Java 20
int numLetters = switch (color) { // Exhaustive!
case RED -> 3;
case GREEN -> 5;
case YELLOW -> 6;
}
То, что switch, записанный таким образом, является исчерпывающим, даёт два важных преимущества.
Во-первых, было бы обременительно писать блок default, который, скорее всего, просто выбрасывает исключение, ведь все случаи уже обработаны:
int numLetters = switch (color) {
case RED -> 3;
case GREEN -> 5;
case YELLOW -> 6;
default -> throw new ArghThisIsIrritatingException(color.toString());
}
Писать блок default вручную в такой ситуации не только утомительно, но и вредно, поскольку без него компилятор может лучше проверить исчерпываемость. (То же относится к любому другому блоку, совпадающему с любым значением, например default, case null, default или безусловному шаблону типа.) Если опустить блок default, то о забытой метке case мы узнаем при компиляции, а не во время выполнения — а может быть, и тогда не узнаем.
Что ещё важнее, что произойдёт, если кто-то позже добавит в перечисление Color ещё одну константу? Если у нас есть явный блок, совпадающий с любым значением, то о новой константе мы узнаем, только если она появится во время выполнения. Но если написать switch так, чтобы он покрывал все константы, известные при компиляции, и опустить такой блок, то мы узнаем об изменении при следующей перекомпиляции класса, содержащего switch. Блок, совпадающий с любым значением, рискует замести ошибки исчерпываемости под ковёр.
Вывод: исчерпывающий switch без блока, совпадающего с любым значением, лучше, чем исчерпывающий switch с таким блоком, когда это возможно.
Если говорить о времени выполнения: что произойдёт, если добавлена новая константа Color, а класс, содержащий switch, не перекомпилирован? Есть риск, что новая константа попадёт в наш switch. Поскольку для перечислений этот риск существует всегда, для исчерпывающего switch по перечислению без блока, совпадающего с любым значением, компилятор синтезирует блок default, выбрасывающий исключение. Это гарантирует, что switch не может завершиться нормально, не выбрав один из блоков.
Понятие исчерпываемости задумано так, чтобы соблюсти баланс: покрыть все разумные случаи и при этом не заставлять вас писать, возможно, множество редких граничных случаев, которые засорят или даже заполонят ваш код при малой практической пользе. Иначе говоря: исчерпываемость — это приближение на этапе компиляции к настоящей исчерпываемости во время выполнения.
Исчерпываемость и Sealed Classes (запечатанные классы)
Если тип выражения-селектора — sealed-класс (JEP 409), то при проверке покрытия типов можно учесть конструкцию permits этого sealed-класса, чтобы определить, является ли блок switch исчерпывающим. Иногда это избавляет от необходимости в блоке default, что, как объяснено выше, является хорошей практикой. Рассмотрим следующий пример sealed-интерфейса S с тремя разрешёнными подклассами A, B и C:
// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {} // Implicitly final
static int testSealedExhaustive(S s) {
return switch (s) {
case A a -> 1;
case B b -> 2;
case C c -> 3;
};
}
Компилятор может определить, что покрытие типов блока switch — типы A, B и C. Поскольку тип выражения-селектора, S, — sealed-интерфейс, разрешённые подклассы которого — ровно A, B и C, этот блок switch является исчерпывающим. Поэтому метка default не нужна.
Требуется дополнительная осторожность, когда разрешённый прямой подкласс реализует только конкретную параметризацию (обобщённого) sealed-суперкласса. Например:
// As of Java 21
sealed interface I<T> permits A, B {}
final class A<X> implements I<String> {}
final class B<Y> implements I<Y> {}
static int testGenericSealedExhaustive(I<Integer> i) {
return switch (i) {
// Exhaustive as no A case possible!
case B<Integer> bi -> 42;
};
}
Единственные разрешённые подклассы I — A и B, но компилятор может определить, что для исчерпываемости блоку switch достаточно покрыть только класс B, поскольку выражение-селектор имеет тип I<Integer> и никакая параметризация A не является подтипом I<Integer>.
Опять же, понятие исчерпываемости — это приближение. Из-за раздельной компиляции во время выполнения может появиться новая реализация интерфейса I, поэтому в этом случае компилятор вставит синтетический блок default, выбрасывающий исключение.
Понятие полноты усложняется из-за Record Patterns из JEP 440, поскольку Record Patterns могут быть вложенными. Поэтому понятие полноты должно отражать эту потенциально рекурсивную структуру.
Полнота и совместимость
Требование полноты относится как к выражениям switch с шаблонами, так и к операторам switch с шаблонами. Для обратной совместимости все существующие операторы switch будут компилироваться без изменений. Но если оператор switch использует какое-либо из описанных в этом JEP улучшений switch, компилятор проверит, что он является исчерпывающим. (Будущие компиляторы языка Java могут выдавать предупреждения для унаследованных операторов switch, которые не являются исчерпывающими.)
Точнее, полнота требуется от любого оператора switch, который использует метки с шаблонами или метки null либо у которого выражение-селектор не относится ни к одному из унаследованных типов (char, byte, short, int, Character, Byte, Short, Integer, String или тип enum). Например:
// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {} // Implicitly final
static void switchStatementExhaustive(S s) {
switch (s) { // Error - not exhaustive;
// missing clause for permitted class B!
case A a :
System.out.println("A");
break;
case C c :
System.out.println("C");
break;
};
}
Область видимости объявлений переменных шаблонов
Переменные шаблонов (JEP 394) — это локальные переменные, которые объявляются шаблонами. Объявления переменных шаблонов необычны тем, что их область видимости зависит от потока управления. Напомним это на следующем примере, где шаблон типа String s объявляет переменную шаблона s:
// As of Java 21
static void testFlowScoping(Object obj) {
if ((obj instanceof String s) && s.length() > 3) {
System.out.println(s);
} else {
System.out.println("Not a string");
}
}
Объявление s находится в области видимости в тех частях кода, где переменная шаблона s будет инициализирована. В этом примере это правый операнд выражения && и блок «then». Однако в блоке «else» s вне области видимости: чтобы управление перешло в блок «else», сопоставление с шаблоном должно завершиться неудачей, а в этом случае переменная шаблона не будет инициализирована.
Мы распространяем это зависящее от потока управления понятие области видимости объявлений переменных шаблонов на объявления в шаблонах в метках case с помощью трёх новых правил:
-
Область видимости объявления переменной шаблона, которое находится в шаблоне метки
caseс охранным условием, включает охранное условие, т. е. выражениеwhen. -
Область видимости объявления переменной шаблона, которое находится в метке
caseправилаswitch, включает выражение, блок или операторthrow, стоящие справа от стрелки. -
Область видимости объявления переменной шаблона, которое находится в метке
caseгруппы операторов с меткойswitch, включает блочные операторы этой группы операторов. Проваливание через меткуcase, объявляющую переменную шаблона, запрещено.
Этот пример показывает действие первого правила:
// As of Java 21
static void testScope1(Object obj) {
switch (obj) {
case Character c
when c.charValue() == 7:
System.out.println("Ding!");
break;
default:
break;
}
}
Область видимости объявления переменной шаблона c включает охранное условие, т. е. выражение c.charValue() == 7.
Этот вариант показывает действие второго правила:
// As of Java 21
static void testScope2(Object obj) {
switch (obj) {
case Character c -> {
if (c.charValue() == 7) {
System.out.println("Ding!");
}
System.out.println("Character");
}
case Integer i ->
throw new IllegalStateException("Invalid Integer argument: "
+ i.intValue());
default -> {
break;
}
}
}
Здесь областью видимости объявления переменной шаблона c является блок справа от первой стрелки. Областью видимости объявления переменной шаблона i является оператор throw справа от второй стрелки.
Третье правило сложнее. Сначала рассмотрим пример, где у группы операторов с меткой switch только одна метка case:
// As of Java 21
static void testScope3(Object obj) {
switch (obj) {
case Character c:
if (c.charValue() == 7) {
System.out.print("Ding ");
}
if (c.charValue() == 9) {
System.out.print("Tab ");
}
System.out.println("Character");
default:
System.out.println();
}
}
Область видимости объявления переменной шаблона c включает все операторы группы операторов, а именно два оператора if и оператор println. Область видимости не включает операторы группы default, хотя выполнение первой группы операторов может провалиться через метку switch default и выполнить эти операторы.
Мы запрещаем проваливание через метку case, объявляющую переменную шаблона. Рассмотрим этот ошибочный пример:
// As of Java 21
static void testScopeError(Object obj) {
switch (obj) {
case Character c:
if (c.charValue() == 7) {
System.out.print("Ding ");
}
if (c.charValue() == 9) {
System.out.print("Tab ");
}
System.out.println("character");
case Integer i: // Compile-time error
System.out.println("An integer " + i);
default:
break;
}
}
Если бы это было разрешено и значением obj был бы Character, то выполнение блока switch могло бы провалиться во вторую группу операторов, после case Integer i:, где переменная шаблона i не была бы инициализирована. Поэтому, если выполнение может провалиться через метку case, объявляющую переменную шаблона, это ошибка компиляции.
Поэтому метка switch, состоящая из нескольких меток с шаблонами, например case Character c: case Integer i: ..., не допускается. Аналогичные рассуждения относятся к запрету нескольких шаблонов в одной метке case: не допускается ни case Character c, Integer i: ..., ни case Character c, Integer i -> .... Если бы такие метки case были разрешены, то и c, и i находились бы в области видимости после двоеточия или стрелки, но инициализирована была бы только одна из них — в зависимости от того, было ли значение obj объектом Character или Integer.
С другой стороны, проваливание через метку, которая не объявляет переменную шаблона, безопасно, как показывает этот пример:
// As of Java 21
void testScope4(Object obj) {
switch (obj) {
case String s:
System.out.println("A string: " + s); // s in scope here!
default:
System.out.println("Done"); // s not in scope here
}
}
Обработка null
Традиционно switch выбрасывает NullPointerException, если выражение-селектор вычисляется в null. Это хорошо понятное поведение, и мы не предлагаем менять его для существующего кода с switch. Однако для Pattern Matching и значений null существует разумная семантика без выбрасывания исключений, поэтому в блоках switch с шаблонами мы можем обрабатывать null более единообразно, сохраняя совместимость с существующей семантикой switch.
Во-первых, мы вводим новую метку case null. Затем мы снимаем общее правило, согласно которому switch сразу выбрасывает NullPointerException, если значение выражения-селектора равно null. Вместо этого мы анализируем метки case, чтобы определить поведение switch:
-
Если выражение-селектор вычисляется в
null, то считается, что подходит любая метка casenull. Если в блоке switch такой метки нет, тоswitchвыбрасываетNullPointerException, как и раньше. -
Если выражение-селектор вычисляется в значение, отличное от
null, то мы, как обычно, выбираем подходящую меткуcase. Если ни одна меткаcaseне подходит, то подходящей считается любая меткаdefault.
Например, при приведённом ниже объявлении вычисление nullMatch(null) выведет null!, а не выбросит NullPointerException:
// As of Java 21
static void nullMatch(Object obj) {
switch (obj) {
case null -> System.out.println("null!");
case String s -> System.out.println("String");
default -> System.out.println("Something else");
}
}
Блок switch без метки case null обрабатывается так, как если бы в нём было правило case null, тело которого выбрасывает NullPointerException. Другими словами, этот код:
// As of Java 21
static void nullMatch2(Object obj) {
switch (obj) {
case String s -> System.out.println("String: " + s);
case Integer i -> System.out.println("Integer");
default -> System.out.println("default");
}
}
эквивалентен следующему:
// As of Java 21
static void nullMatch2(Object obj) {
switch (obj) {
case null -> throw new NullPointerException();
case String s -> System.out.println("String: " + s);
case Integer i -> System.out.println("Integer");
default -> System.out.println("default");
}
}
В обоих примерах вычисление nullMatch(null) приведёт к выбрасыванию NullPointerException.
Мы сохраняем унаследованное от существующей конструкции switch интуитивное представление о том, что выполнение switch по null — исключительная ситуация. Отличие switch с шаблонами в том, что этот случай можно обработать непосредственно внутри switch. Если вы видите в блоке switch метку null, то этой метке будет соответствовать значение null. Если вы не видите в блоке switch метки null, то switch по значению null выбросит NullPointerException, как и раньше. Таким образом, обработка значений null в блоках switch становится единообразной.
Объединять случай null с default осмысленно и требуется не так уж редко. Для этого мы разрешаем меткам case null иметь необязательный default, например:
// As of Java 21
Object obj = ...
switch (obj) {
...
case null, default ->
System.out.println("The rest (including null)");
}
Значение obj соответствует этой метке, если оно является нулевой ссылкой либо если не подходит ни одна другая метка case.
Если в блоке switch есть одновременно метка null case с default и метка default, это ошибка компиляции.
Ошибки
Pattern Matching может завершиться аварийно. Например, при сопоставлении значения с record-шаблоном метод доступа record-класса может завершиться аварийно. В этом случае сопоставление с шаблоном по определению завершается аварийно с выбрасыванием MatchException. Если такой шаблон указан в качестве метки в switch, то switch также завершится аварийно с выбрасыванием MatchException.
Если у шаблона case есть охранное условие и вычисление этого условия завершается аварийно, то switch завершается аварийно по той же причине.
Если ни одна метка в switch с шаблонами не соответствует значению выражения-селектора, то switch завершается аварийно с выбрасыванием MatchException, поскольку switch с шаблонами должны быть исчерпывающими.
Например:
// As of Java 21
record R(int i) {
public int i() { // bad (but legal) accessor method for i
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}
Вызов exampleAnR(new R(42)) приводит к выбрасыванию MatchException. (Метод доступа record-класса, который всегда выбрасывает исключение, крайне нетипичен, а исчерпывающий switch с шаблонами, выбрасывающий MatchException, встречается крайне редко.)
Напротив:
// As of Java 21
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
Вызов example(new R(42)) приводит к выбрасыванию ArithmeticException.
Для согласования с семантикой switch с шаблонами выражения switch по классам enum теперь выбрасывают MatchException, а не IncompatibleClassChangeError, если во время выполнения не подходит ни одна метка switch. Это незначительное несовместимое изменение языка. (Исчерпывающий switch по перечислению не находит совпадения, только если класс enum был изменён после компиляции switch, что крайне необычно.)
Дальнейшая работа
-
Сейчас
switchс шаблонами не поддерживает примитивные типыboolean,long,floatиdouble. Разрешение этих примитивных типов означало бы также их разрешение в выраженияхinstanceofи согласование шаблонов примитивных типов с шаблонами ссылочных типов, что потребовало бы значительной дополнительной работы. Это оставлено для возможного будущего JEP. -
Мы ожидаем, что в будущем произвольные классы смогут объявлять деконструирующие шаблоны, задающие, как выполнять сопоставление с ними. Такие деконструирующие шаблоны можно использовать с
switchс шаблонами, получая очень лаконичный код. Например, если у нас есть иерархияExprс подтипами дляIntExpr(содержит одинint),AddExprиMulExpr(содержат по дваExpr) иNegExpr(содержит одинExpr), мы можем выполнить сопоставление сExprи обработать конкретные подтипы за один шаг:// Some future Java int eval(Expr n) { return switch (n) { case IntExpr(int i) -> i; case NegExpr(Expr n) -> -eval(n); case AddExpr(Expr left, Expr right) -> eval(left) + eval(right); case MulExpr(Expr left, Expr right) -> eval(left) * eval(right); default -> throw new IllegalStateException(); }; }Без такого Pattern Matching для выражения подобных ad-hoc полиморфных вычислений приходится использовать громоздкий паттерн «Посетитель». Pattern Matching, как правило, прозрачнее и проще.
-
Также может быть полезно добавить шаблоны AND и OR, чтобы сделать метки
caseс шаблонами более выразительными.
Альтернативы
-
Вместо поддержки
switchс шаблонами мы могли бы определитьswitchпо типу, который поддерживает только switch по типу выражения-селектора. Эту возможность проще специфицировать и реализовать, но она значительно менее выразительна. -
Для меток с шаблонами и охранным условием существует множество других синтаксических вариантов, например
p where e,p if eили дажеp &&& e. -
Альтернатива меткам с шаблонами и охранным условием — поддерживать шаблоны с охранным условием непосредственно как особую форму шаблона, например
p && e. Мы экспериментировали с этим в предыдущих версиях Preview (предварительная версия), и возникающая неоднозначность с булевыми выражениями привела нас к тому, что мы предпочли меткиcaseс охранным условием шаблонам с охранным условием.
Зависимости
Этот JEP основан на Pattern Matching for instanceof (JEP 394), выпущенном в JDK 16, а также на улучшениях из Switch Expressions (JEP 361). Он развивался совместно с Record Patterns (JEP 440).