JEP draft: Exception handling in switch (Preview)
Обработка исключений в switch (версия Preview (предварительная версия))
| Автор | Angelos Bimpoudis & Brian Goetz |
| Ответственный | Angelos Bimpoudis |
| Тип | Feature |
| Область | SE |
| Статус | Draft |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot org |
| Трудоёмкость | S |
| Длительность | S |
| Рецензенты | Alex Buckley |
| Создан | 2024/01/12 10:43 |
| Обновлён | 2024/04/19 22:27 |
| Задача | 8323658 |
Аннотация
Расширить конструкцию switch так, чтобы исключения, выброшенные селектором (e в switch (e) ...), можно было обрабатывать в блоке switch. Вместе с предыдущим улучшением, позволившим обрабатывать селекторы null, это делает switch ещё полезнее для Pattern Matching (сопоставление с образцом). Это возможность языка в статусе Preview.
Цели
-
Улучшить читаемость и сопровождаемость кода, позволив
switchлаконично обрабатывать все возможные результаты вычисления селектора. -
Упростить использование API, выбрасывающих проверяемые исключения, когда они вызываются в селекторе
switch.
Что не является целью
-
Обработка в блоке switch исключений, выброшенных самим блоком switch, не является целью. То есть если код предложения
caseилиdefaultвыбрасывает исключение, его должны обрабатывать предложенияcatchвнеswitch. -
Введение новых видов шаблонов, сопоставляемых с исключениями, не является целью. То есть исключения не низводятся до обычных значений, которые можно единообразно сопоставлять во всех конструкциях, принимающих шаблоны.
-
Встраивание поддержки обработки исключений в другие операторы и выражения не является целью.
-
Изменение модели проверяемых и непроверяемых исключений, как в целом, так и в пределах
switch, не является целью.
Мотивация
Конструкция switch воплощает многовариантный выбор на основе значения выражения-селектора (e в switch (e) ...). Традиционно switch был враждебен к значениям null: если выражение-селектор вычислялось в null, то switch выбрасывал NullPointerException. switch был враждебен и к исключениям: если выражение-селектор выбрасывало исключение вместо того, чтобы вернуть значение, то switch повторно выбрасывал это исключение.
Из-за этой склонности выбрасывать исключения switch было трудно использовать. Разработчикам приходилось проверять селектор на null, написав код перед switch. Кроме того, им приходилось обрабатывать исключения селектора, как правило, заключая switch в try-catch. Хотя результаты вычисления селектора взаимоисключающие — либо значение, отличное от null, либо значение null, либо исключение, — их нельзя было разобрать по вариантам внутри switch.
В последние годы switch получил новую жизнь ради поддержки программирования, ориентированного на данные: появились Record Patterns (шаблоны записей), case с охранными условиями и исчерпываемость. В рамках этой работы switch был изменён в JDK 21, чтобы не выбрасывать NullPointerException автоматически для селектора null. Разработчики могут явно обработать селектор null с помощью case null в блоке switch: (без case null switch, как и раньше, выбрасывает NullPointerException)
String s = ...
switch (s) {
case "foo" -> System.out.println("Great");
case null -> System.out.println("Oops");
default -> System.out.println("OK");
}
Возможность обработать null всего лишь ещё одним case делает конструкцию switch более единообразной, а код — более читаемым и менее подверженным ошибкам. К сожалению, исключения селектора по-прежнему нельзя разобрать по вариантам внутри блока switch, поэтому их приходится обрабатывать предложениями catch вне switch:
record Box(String in) { }
Future<Box<T>> f = ...
try {
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
}
} catch (CancellationException ce) { // an unchecked exception of Future::get
...ce...
} catch (ExecutionException ee) { // a checked exception of Future::get
...ee...
} catch (InterruptedException ie) { // a checked exception of Future::get
...ie...
}
Заключать switch в try-catch не просто неуклюже: у этого есть реальные недостатки, из-за которых программы становятся хуже. Во-первых, блок catch перехватывает не только исключения, выброшенные селектором, но и исключения, выброшенные блоком switch; это, скорее всего, приведёт к трудноуловимым ошибкам. Во-вторых, try-catch может обернуть только оператор switch, но не выражение switch; это создаёт существенные неудобства при попытке использовать API, ориентированные на выражения, например Streams.
Чтобы switch было удобнее использовать для Pattern Matching, было бы полезно, если бы разработчики могли обходиться без try-catch и обрабатывать исключение селектора всего лишь ещё одним case — аналогично обработке селектора null с помощью case null. По аналогии с тем, как предложение throws обозначает исключения метода, мы вводим case throws для обозначения исключений селектора:
Future<Box> f = ...
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
case throws InterruptedException ie -> ...ie...
}
Возможность обработать исключение селектора локально, в блоке switch, означает, что выражения switch становятся своего рода универсальным вычислительным механизмом, способным задавать поток управления в API, ориентированных на выражения. Например:
stream
.map(Future<Box> f ->
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws Exception e -> { log(e); yield score(0); }
})
.reduce(0, (subtotal, element) -> subtotal + element);
Итак, расширение switch для обработки исключений селектора упростит использование библиотек, выбрасывающих исключения, улучшит читаемость и уменьшит число ошибок.
Описание
Операторы switch и выражения switch поддерживают новый вид метки switch: case исключения, обозначаемый case throws.
SwitchLabel:
case CaseConstant {, CaseConstant}
case CasePattern [Guard]
case null [, default]
default
case throws CasePattern [Guard]
Первые четыре вида меток switch (включая default) называются обычными case.
Case исключения могут находиться в блоке switch, использующем как традиционную форму :, так и форму правил ->. У case исключения также могут быть охранные условия. Например:
case throws CancellationException ce: ...ce...case throws CancellationException ce -> ...ce...case throws ExecutionException ee when ee.getCause() != null -> ...ee...
Если в case исключения указан тип исключения, которое не может быть выброшено выражением-селектором, или тип, не расширяющий Throwable, это ошибка компиляции.
Это возможность языка в статусе Preview, по умолчанию отключённая
Чтобы попробовать приведённые ниже примеры в JDK NN, нужно включить Preview-возможности:
-
Скомпилируйте программу с
javac --release NN --enable-preview Main.javaи запускайте её сjava --enable-preview Main; или -
При использовании средства запуска исходного кода запускайте программу с
java --enable-preview Main.java; или -
При использовании
jshellзапускайте его сjshell --enable-preview.
Форма блока switch
Блок switch можно рассматривать как разделённый на две секции: одна состоит из обычных case, другая — из case исключения. Выполнение программы проходит через одну или другую секцию, но не через обе:
- Если вычисление селектора завершилось успешно, значение имеют только обычные case. Case исключения никогда не выполнятся, даже если обычный case в ходе выполнения выбросит исключение.
- Если вычисление селектора завершилось неудачей, значение имеют только case исключения. Обычные case никогда не выполнятся. Метка
defaultсреди обычных case не служит для обработки исключений, которые могли быть упущены в case исключения. - Если блок switch имеет традиционную форму
:, то сквозного перехода от обычных case к case исключения или от case исключения к обычным case нет. Кроме того, нет сквозного перехода от одного case исключения к другому case исключения.
Настоятельно рекомендуется группировать обычные case вместе, а case исключения — вместе:
case Box(String s) when isGoodString(s) -> ...
case Box(String s) -> ...
case null -> ...
default -> ...
case throws ExecutionException ee -> ...
case throws InterruptedException ce -> ...
Чередование обычных case с case исключения синтаксически допустимо, но почти никогда не бывает хорошей идеей:
case Box(String s) when isGoodString(s) -> ...
case throws ExecutionException ee -> ...
case Box(String s) -> ...
case null -> ...
case throws InterruptedException ce -> ...
default -> ...
Доминирование
Case исключения не участвуют в упорядочивании по доминированию вместе с обычными case. Порядок case исключения регулируется так же, как порядок предложений catch в try-catch: более конкретные исключения должны обрабатываться/перехватываться раньше менее конкретных. Например, этот try-catch и этот switch ведут себя одинаково:
try {
throw new IOException();
}
catch (Exception e) {
}
catch (IOException ioe) { // compile-time error: IOException has already been caught
}
switch (...assume selector can throw IOException...) {
default -> ...
case throws Exception e -> ...e ...
case throws IOException ioe -> ...ioe... // compile-time error: exception case is dominated by previous case
}
Исчерпываемость
Каждое проверяемое исключение, которое может выбросить селектор, должно быть:
- обработано с помощью
case throwsв блоке switch, или - перехвачено предложением
catchохватывающегоtry-catch, или - объявлено в предложении
throwsохватывающего метода.
Например, следующий метод допустим, потому что все проверяемые исключения Future.get учтены: ExecutionException обрабатывается case throws, а InterruptedException объявлено в предложении throws.
void m(Future<Box> f) throws InterruptedException {
switch (f.get()) {
case Box b -> defaultAction(b);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
}
}
Перехват нескольких исключений
В Java 22 появились безымянные переменные шаблонов (JEP 456), а также возможность указывать несколько шаблонов в одной метке case, если ни один из них не объявляет именованных переменных шаблона. Case исключения может использовать несколько шаблонов с безымянными переменными шаблона, чтобы обработать несколько исключений селектора в одном case:
case throws CancellationException _, ExecutionException _ -> ...
Это заменяет синтаксис multi-catch из try-catch, например catch (CancellationException | ExecutionException x) {...}. Синтаксис multi-catch в case исключения не допускается; см. Альтернативы.
Точный повторный выброс
В Java 7 try-catch был улучшен так, что исключения, повторно выбрасываемые из блока catch, получают более точные типы, если параметр исключения объявлен final или является фактически финальным. Та же точность действует, когда case исключения в switch повторно выбрасывают исключение селектора. Например:
Object f() throws A, B, C {...}
void m() {
switch (f()) {
case throws Exception e -> throw e; // switch statement can throw A, B, C
...
}
}
Время выполнения
При вычислении оператора или выражения switch вычисляется выражение-селектор. Если при вычислении выражения-селектора выбрасывается исключение и хотя бы один из case исключения в блоке switch может его обработать, управление передаётся первому case исключения, способному обработать это исключение. Если ни один case исключения не соответствует исключению, оператор или выражение switch завершается аварийно с тем же исключением. Это согласуется с поведением всех существующих switch.
Другие исключения, которые могут быть выброшены из блока switch, могут возникать при вычислении охранного условия или в правой части метки case. Case исключения такие исключения не обрабатывают, а обрабатывают только исключения, возникшие при вычислении селектора.
Сопоставление исключений и обработка исключений
Селектор switch может вернуть значение, которое само является исключением. То есть селектор не выбрасывает исключение, а лишь вычисляется в него. Обычные метки case могут сопоставлять такое значение с шаблонами типов, например,
Exception m() { return new ... }
switch (m()) {
case CancellationException ce -> ... // This is a case, not a case throws
case ExecutionException ee -> ... // This is a case, not a case throws
...
}
Один из сценариев, в котором могут появиться такие метки case, — выражение switch, анализирующее исключение, обработанное «внешним» case throws, например,
case throws Exception ex -> switch (ex) {
case CancellationException ce -> ...ce...
case ExecutionException ee -> ...ee...
...
}
При таком анализе исключения нужно быть внимательным с «внутренним» выражением switch (switch (ex) ...). Обычные case выражения switch всегда должны быть исчерпывающими: либо метки case должны соответствовать всем возможным значениям селектора, либо должна быть метка default. Ниже внутреннее выражение switch имеет метки case, соответствующие трём типам исключений (тем, что выбрасывает селектор внешнего switch), но эти метки не являются исчерпывающими относительно его селектора ex типа Exception; поэтому внутренний switch недопустим.
void m(Future<Box> f) {
switch (f.get()) {
case Box b -> defaultAction(b);
case throws Exception ex -> switch (ex) { // compile-time error: normal cases not exhaustive
case CancellationException ce -> ...ce...
case ExecutionException ee -> ...ee...
case InterruptedException ie -> ...ie...
}
};
}
(Если правила исчерпываемости для обычных case учитывают только метки case и default в блоке switch, то правила проверки исключений, выбрасываемых селектором, смотрят гораздо шире. Они учитывают исключения, обрабатываемые case throws в блоке switch, и любые исключения, перехватываемые охватывающими операторами try-catch, и любые исключения, объявленные в предложении throws метода.)
Идиома: упрощённая инициализация статических полей
Иногда разработчики не могут написать «очевидный» код, потому что он может выбросить проверяемое исключение там, где проверяемые исключения не допускаются. Раньше это означало болезненный рефакторинг кода, но с case исключения разработчики могут обернуть исходный код в выражение switch и обработать проверяемые исключения на месте.
Например, инициализатору статического поля не разрешено выбрасывать проверяемое исключение:
class Foo {
Foo() throws IOException {...}
}
class Bar {
public static final Foo THE_FOO = new Foo(); // error: unhandled exception IOException
}
Обычно инициализацию THE_FOO выполняли бы следующим образом, что тяжело читать:
class Bar {
public static final Foo THE_FOO;
static {
try {
THE_FOO = new Foo();
} catch (IOException ex) {
throw new ExceptionInInitializerError(ex);
}
}
}
С помощью выражения switch и case исключения инициализацию можно выполнить на месте:
class Bar {
public static final Foo THE_FOO = switch (new Foo()) {
case var o -> o;
case throws Exception ex -> throw new ExceptionInInitializerError(ex);
};
}
Альтернативы
catch настолько хорошо известен, что иногда предлагают использовать его повторно различными способами, например:
switch (...) {
...
} catch (Exception e) {
...
}
или:
switch (...) {
...
case catch Exception e -> ...
}
Первое предложение выносит catch за пределы блока switch, что подразумевает, что он может перехватывать исключения, выброшенные правыми частями case внутри блока switch. Это была бы другая возможность. Мы хотим перехватывать только исключения, выброшенные селектором switch, поэтому нам нужно перехватывать их «локально», внутри блока switch.
Второе предложение использует case catch вместо case throws. Это выглядит как безобидное изменение синтаксиса, но оно исказило бы понимание кода. Как обсуждалось в OpenJDK, суть switch — «вычислить что-то, а затем в зависимости от результата выполнить одно из следующих действий». Какое действие выполнить, мы выбираем на основе предложений case. Сейчас есть предложения case для констант, шаблонов и для всех остальных случаев (default). Каждое предложение case должно отсылать к вычислению в селекторе switch. case <constant> означает «результат вычисления равен этой константе?». case <pattern> означает «результат вычисления соответствует этому шаблону?». case throws <pattern> означает «было ли выброшено что-то, соответствующее этому шаблону?». case catch означал бы «результат вычисления перехватывает это исключение?», что не имеет смысла.
Начиная с Java 7, try-catch поддерживает «multi-catch», например catch (Exception1|Exception2 e). Тип e является объединением двух альтернатив. Хотя распространение multi-catch на case для исключений может показаться очевидным шагом, это может закрыть путь к другим, потенциально более ценным возможностям, поэтому мы рассмотрим это в будущем.
Этот JEP предлагает поддержку перехвата исключений в switch на уровне языка. Многословность рабочего примера, показанного в разделе «Мотивация», можно было бы уменьшить, вынеся сложность try-catch во вспомогательный метод и отобразив возможный результат в подходящий тип, например Optional:
// a potential helper method
<T> Optional<T> toOptional(Supplier<T> thunk) { ... }
Future<Box<T>> f f = ...
switch (toOptional(() -> f.get())) { // unavoidable wrapping
case Optional o when o.isPresent() -> process(o.get());
default -> // handle exceptional cases
}
Хотя приведённый выше код оборачивает вызов f.get(), такое представление полностью игнорирует различные виды исключений, которые могут быть выброшены. Optional можно было бы заменить другим типом данных, который также хранит исключения. Следовательно, оборачивание выражения селектора было бы неизбежным, и каждая библиотека отвечала бы за предоставление собственного специализированного протокола обработки ошибок. К сожалению, такой подход — лишь обходной путь: он уменьшает многословность, но не решает фундаментальную проблему.