Uniform handling of failure in switch

原文はこちら。
The original article was written by Brian Goetz (Java Language Architect at Oracle).
https://inside.java/2023/12/15/switch-case-effect/

OCamlからのいくつかのインスピレーションをもらいつつ、そしてこれまでのswitchの大幅なアップグレードにより、以前よりも多くのことができるようになったことを踏まえ、switchをさらに改良して障害処理も取り入れることを模索しています。

Overview

セレクタ式 (selector expression) の評価中に発生する例外にマッチするcaseラベルをサポートし、正常な結果と例外的な結果の均一な処理を提供するために、switch構造を強化します。

Background

switch構文の目的は、1つの式(セレクタ)の評価に基づいて1つのアクションを選択することです。switch構文は厳密には、Javaで必要というわけではありません。なぜならswitchでできることは、すべてif-elseでできるからです。しかし、Javaにswitchが含まれているのは、switchでコードが効率化し、より包括的なエラー・チェックを可能にする有用な制約を備えているためです。

switchの初期バージョンは非常に限定的なものでした。セレクタ式は少数のプリミティブ型に限定され、caseラベルは数値リテラルに限定され、switch本体は副作用による操作に限定されていました(statementのみで、expressionは対象外)。こうした制限のため、switchの使用は通常、パーサーやステート・マシンなどの低レベルのコードに限定されていました。Java 5とJava 7で、switchはプリミティブ・ラッパー型、列挙型、文字列をセレクタとしてサポートするようにマイナー・アップグレードされましたが、「これらの定数の中から1つを選ぶ」という役割は大きく変わらりませんでした。

最近、switchはさらに大幅にアップグレードされ、日常的なプログラム・ロジックでより大きな役割を担えるようになりました。switchは文 (statement) だけでなく、式 (expression) としても使用できるようになり、より大きな構成と、より合理的なコードが書けるようになりました。セレクタ式には任意の型を利用できます。switchブロックのcaseラベルは、定数だけでなく、richなパターンを使え、ガードとして任意の述語を持ち得ます。sealed typeを含むセレクタを切り替える際の、網羅性のための型チェックがより豊富になりました。これらを総合すると、switchを使用することで、以前よりもはるかに多くのプログラム・ロジックを簡潔かつ確実に表現できます。

Bringing nulls into switch

歴史的に、switch構文はnullを嫌っていました。セレクタがnullと評価すると、switchは直ちにNullPointerExceptionで突然終了しました。switchで使用できる参照型がプリミティブ・ラッパーと列挙型 (enum) のみで、nullがほとんどの場合エラーを示すものであったときには、これはある程度理にかなっていましたが、switchがより強力になるにつれて、switchでやりたいこととのミスマッチが増えてきました。開発者はこれを回避することを余儀なくされましたが、回避策は望ましくない結果をもたらしました(switch exressionの代わりにswitch statementを使わざるを得なくなるなど)。以前は、nullを処理するには、ifを使用してセレクタを個別に評価し、nullと比較する必要がありました。

SomeType selector = computeSelector();
SomeOtherType result;
if (selector == null) {
     result = handleNull();
} else {
     switch (selector) {
         case X:
             result = handleX();
             break;
         case Y:
             result = handleY();
             break;
     }
}

これは面倒で簡潔でないだけでなく、switchの主な仕事である「セレクタ式に基づいて1つのパスを選ぶ」という決定の合理化にも反します。結果が一律に扱われず、そして一カ所で扱われず、式としてすべてを表現できないため、他の言語機能との組み合わせが制限されます。

Java21では、nullcase句のセレクタのもうひとつのとり得る値として扱うことができるようになりました(さらに、null処理とdefaultを組み合わせることもできます)。これにより、上記のとっちらかったコードは以下のようになります。

SomeOtherType result = switch (computeSelector()) {
     case null -> handleNull();
     case X -> handleX();
     case Y -> handleY();
}

このほうが読みやすく、エラーが起こりにくく、他の言語との相互作用もうまくいきます。nullを帯域外の条件として扱うのではなく、単なる別の値として一律に扱うことで、switchがより便利になり、Javaコードがよりシンプルで優れたものになりました(互換性のために、case nullがないswitchでは、nullセレクタがあるとNullPointerExceptionをスローします。case nullがあると新しい振る舞いを選択します)。

Other switch tricks

switchに新たな機能が加わったことで、当初認識していたよりも多くの状況でswitchが利用できるようになりました。そのひとつが、三項条件式をboolean switch式に置き換えることです。switchがbooleanセレクタをサポートできるようになったので、次のように置換できます。

```
expr ? A : B
``` with the switch expression

switch (expr) {
     case true -> A;
     case false -> B;
}

三項式のほうが簡潔であるため、すぐに好ましいとは思えないかもしれませんが、switchの方がより明確であることは間違いありません。また、三項式を他の三項式に(場合によっては深く)入れ子にすると、すぐに読めなくなってしまいますが、一方、対応する入れ子のswitchは、数レベルまで入れ子になっても読みやすいままです。人々が一夜にしてすべての三項式をswitchに変えるとは思っていませんが、三項式よりもboolean switchのほうが望ましい用途が、今後ますます増えていくことは予想されます(もしJavaに最初からboolean switch式があれば、三項式はなかったかもしれません)。

もう1つのあまり目立たない例は、switchが想定している「1つのパスを選択する」という範囲内で、ガードを使用して選択を行うというものです。例えば、古典的な “FizzBuzz “の練習問題はこのように記述できます。

String result = switch (getNumber()) {
     case int i when i % 15 == 0 -> "FizzBuzz";
     case int i when i % 5 == 0 -> "Fizz";
     case int i when i % 3 == 0 -> "Buzz";
     case int i -> Integer.toString(i);
}

新しく改良されたswitchの、より議論を引き起こす使い道は、block式の代わりの利用です。(メソッドにパラメータを渡すときなど)式を使いたいことがありますが、その値はステートメントを使わないと作成できないことがあります。

String[] choices = new String[2];
choices[0] = f(0);
choices[1] = f(1);
m(choices);

やや「保険適用外」ではありますが、これをswitch式に置換できます。

m(switch (0) {
     default -> {
         String[] choices = new String[2];
         choices[0] = f(0);
         choices[1] = f(1);
         yield choices;
     }
})

この使い方はswitchをアップグレードする際に想定していた主な使用例ではありませんでしたが、switchの改良の組み合わせによって、switchを一種の「スイスアーミーナイフ」になったさまを示しています。

Handling failure uniformly

以前は、nullセレクタ値は帯域外イベントとして扱われていたため、ユーザーはnullセレクタを統一されていない方法で処理する必要がありました。Java 21でのswitchの改良により、nullはセレクタ値として、単なる別の値として一様に扱えるようになりました。

switchにおける帯域外イベントの同様の発生源は例外(exception)です。セレクタの評価で例外がスローされた場合、switchはその例外で直ちに完了します。セレクタの評価で例外がスローされた場合、switchはその例外で即座に完了します。これはまったく正当な設計上の選択ですが、nullセレクタのときと同じように、ユーザーは別のメカニズム、多くの場合は面倒な手法での例外処理を強いられます。

Number parseNumber(String s) throws NumberFormatException() { ... }

try {
     switch (parseNumber(input)) {
         case Integer i -> handleInt(i);
         case Float f -> handleFloat(f);
         ...
     }
}
catch (NumberFormatException e) {
     ... handle exception ...
}

switchは「セレクタの評価に基づいて1つのパスを選択する」ことを処理するように設計されており、「解析エラー」はセレクタを評価した結果の可能性の1つであるため、これは残念なことです。nullのときのように、エラーケースを成功ケースとを一律に扱うことができればいいのですが。さらに悪いことに、このコードはわれわれが望んでいるものではありません。catchブロックは、セレクタの評価時にスローされる例外だけでなく、switch本体によってスローされる例外もキャッチするからです。

意図を実現するには、さらに不幸なコードが必要です。

var answer = null;
try {
     answer = parseNumber(input);
}
catch (NumberFormatException e) {
     ... handle exception ...
}

if (answer != null) {
     switch (answer) {
         case Integer i -> handleInt(i);
         case Float f -> handleFloat(f);
         ...
     }
}

改善のためにnullをセレクタ式の別の潜在的な値として一律に扱うようになったように、正常完了(normal completion)と例外完了(exceptional completion)も一律に扱うことで、同様に改善されることでしょう。正常完了と例外完了は相互に排他的であり、try-catchにおける例外の処理は、switch文における正常な値の処理とすでに多くの共通点があります(catch句は事実上、型パターンへのマッチングです)。想定される失敗モード(failure mode)があるアクティビティでは、1つのメカニズムで成功完了を、別のメカニズムで失敗完了を扱うと、コードが読みにくくなり、保守もしにくくなります。

Proposal

nullを扱うようにswitchを拡張したのと同様に、throws casesを導入することで、より均一に例外を扱うようにswitchを拡張できます。throws casesは、互換性のある例外でセレクタ式の評価が突然完了した場合にマッチします。

String allTheLines = switch (Files.readAllLines(path)) {
     case List<String> lines -> 
lines.stream().collect(Collectors.joining("\n"));
     case throws IOException e -> "";
}

これはプログラマーの意図をずっと明確に捕捉します。なぜなら、期待される成功ケースと期待される失敗ケースが同じ場所で一様に扱われ、その結果はswitch式の結果に流し込めるからです。

caseラベルの文法は、case throwsという新しい形式を含むように拡張され、その後に型パターンが続きます:

 case throws IOException e:

例外ケースはすべての形式のswitch(switch expressionとswitch statement、伝統的な(コロン)または単一シーケンス(矢印)のcaseラベルを使用するswitch)で使用できます。例外ケースは他のパターンケースと同様にガードを持つことができます。

例外ケースは、他の例外ケースとの明らかな優劣順序(try-catchにおけるcatch句の順序検証時に使われるものと同じもの)を持ち、非例外ケースとの優劣順序には関与しません。例外ケースが、セレクタ式でスローできない例外の型を指定したり、Throwableを拡張しない型を指定したりすると、コンパイル時エラーになります。わかりやすくするために、例外ケースは他のすべての非例外ケースの後に来るべきです。

switch文やswitch式の評価時、セレクタ式を評価します。セレクタ式の評価が例外をスローし、switchの例外ケースの1つが例外にマッチする場合、制御は例外にマッチする最初の例外ケースに移されます。例外に一致する例外ケースがない場合、switchはその例外で突然終了します。

これにより、switchがスローする一連の例外が少々調整されます。セレクタ式が例外をスローし、switch本体が例外をスローしない場合に、その例外がガードされていない例外ケースにマッチする場合、switchはその例外をスローしないとみなされます。

Examples

場合によっては、例外が発生したときにフォールバック値を渡すことで、部分的な計算を積算したいことがあります。

Function<String, Optional<Integer>> safeParse =
     s -> switch(Integer.parseInt(s)) {
             case int i -> Optional.of(i);
             case throws NumberFormatException _ -> Optional.empty();
     };

他のケースでは、例外的な値を完全に無視したい場合があります。

stream.mapMulti((f, c) -> switch (readFileToString(url)) {
                     case String s -> c.accept(s);
                     case throws MalformedURLException _ -> { };
                 });

他には、Future::getのようなメソッドの結果をより統一的に処理したい場合もあります。

Future<String> f = ...
switch (f.get()) {
     case String s -> process(s);
     case throws ExecutionException(var underlying) -> throw underlying;
     case throws TimeoutException e -> cancel();
}

Discussion

この提案に対して、最初は不快な反応があると予想しています。なぜなら、歴史的にtry文は例外処理を制御する唯一の方法だったからです。tryの完全な一般性には、まだ明らかに役割があります。しかし、より一般的なif-else構文で処理可能な状況の制約されたサブセットを処理することでswitchが利益を得るように、より一般的なtry-catch構文で処理可能な状況の制約されたサブセットを処理することで利益を得ることができます。具体的には、式を評価し、その評価結果に基づいて1つのパスを選択するというswitchが作られた状況は、失敗した評価の判別にも同様に当てはまります。クライアントは、例外完了だけでなく、成功完了も処理したいと思うことが多いでしょうし、1つの構文の中で統一的に処理する方が、2つの構文にまたがって処理するよりも明確で、エラーも少ないでしょう。

Java APIには、Future::getのように、結果を出すか例外を投げるかのどちらかのメソッドがたくさんあります。このようなAPI表現は、API作者にとって自然です。なぜなら、API作者は自然な方法で計算を処理できるからです。計算を進めたくないポイントに到達したら例外をスローできますし、計算終了ポイントに到達したら値を返すことができます。しかし残念ながら、API作者にとってこの利便性と統一性は、API利用者に余分な負担を強いることになります。失敗処理は成功時の処理よりも面倒だからです。クライアントが、計算が完了する可能性のあるすべての経路を切り替えられるようにすることで、この断絶を修復します。

switchif-elseを時代遅れにしたのと同じように、try-catchが時代遅れだと言っているのではありません。複数の箇所で失敗する可能性のある大きなコードブロックがある場合、そのブロックからの例外をすべてまとめて処理した方が、それぞれの例外の発生箇所で処理するよりも便利なことが多いのです。

しかし、try-catchを1つの式にスケールダウンすると、厄介なことになります。この影響は、Lambda式で最も深刻に感じられるでしょう。Lambda式は、自分自身の例外を処理したい場合、構文を大幅に拡張する必要があります。


この投稿の内容は、もともとOpenJDKメーリングリストで公開されていたものです。メーリングリストをフォローするか、会話に参加することをお勧めします。

Effect cases in switch
https://mail.openjdk.org/pipermail/amber-spec-experts/2023-December/003959.html

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください