SwiftはどのようにJavaの検査例外を改善したか

  • 106
    Like
  • 0
    Comment

Swift には Java の検査例外によく似た仕組みがあります。

func foo() throws -> String { ... } // Java のような `throws` 節
let s = foo() // `catch` しないとコンパイルエラー
do {
  let s = try foo() // OK
} catch let error {
  // エラー処理
}

僕は、 Java の検査例外のコンセプトは素晴らしいと考えていますが、世間ではあまり好かれていないようです。 C# や Scala, Kotlin などの後続言語では採用されず、僕の知る限り Java 以降、検査例外(的なもの)を採用したメジャー言語は Swift だけです。

ただし、Swift の検査例外(的なもの)はいくつかの点で Java の検査例外と異なっています。 Swift は後続だけあって何かしらの改善を試みているわけです。その取り組みがおもしろいので、 Swift の検査例外的なものが Java の検査例外と何が違い、それがどのような意味を持つのかを紹介します

throws 節でエラーの型を指定できない

Java では

// Java
String foo() throws FooException { ... }

のように throws 節に例外の型を記述することができますが、 Swift では現時点( Swift 3 )ではできません。単に throws と書くことができるだけで、 throws の後ろにエラーの型を記述することはできません。

// Swift
func foo() throws -> String { ... }

これだと Java の 検査例外と同じに見えるかもしれません。しかし、 検査例外とは大きな違いがあります。何が検査例外的なのかと言うと、 throws の付いた関数/メソッドをコールするときには catch が強制されるという点です。 catch するか throws の付いた関数/メソッドの中でコールしないとコンパイルエラーになります。 Java の 検査例外は catch が強制されないので大きく違います。

// Swift
let s = foo() // コンパイルエラー
// Swift
do {
  let s = try foo() // OK
} catch let error {
  // エラー処理
}

エラーの型を指定できなくて大丈夫か

throws 節にエラー型を指定できないと網羅的なエラー処理ができません。それで問題ないのでしょうか?

Swift ではエラーを 4 種類に分け、そのうち Simple domain error を Optionalnil で、 Recoverable error を throws で表します(その他の 2 種類のエラーは実行時にハンドリングしません)。 Simple domain error は String をパースして Int に変換するような、主にエラーが発生する前提条件が明確で失敗したことだけがわかればよい場合に利用されます。 Recoverable error は I/O やネットワーク関連の処理など、多様な原因(外的な要因を含む)によってエラーが引き起こされる場合に利用されます。

throws が使われるのは Recoverable error についてなので、原因を網羅して記述するようなことは滅多にありません。大抵は、一つか二つのエラーだけに特別なエラー処理を記述し、後はその他としてまとめて処理するはずです。

// Swift
do {
  let data = try download(url)
} catch NetworkError.unconnected // ネットワークに接続できない場合
  print("ネットワークに接続できません。設定を確認して下さい。")
} catch { // その他のエラー
  print("サーバーエラーです。しばらくしてから再度アクセスして下さい。")
}

ネットワークエラーの原因を羅列して網羅的に(たとえばレスポンスのステータスコードごとに)エラー処理を書いた経験がある人などほぼいないはずです。エラーの型が厳密であることは素晴らしいですが、それがなくても現実的に困ることは多くありません(ただ、 throws 節に型が記述できるとドキュメントとしての価値はあると思います)。

Java の検査例外には二つの性質があり、 catch して エラーを処理することを強制する ことと、起こり得るエラーを網羅することです。相対的に重要なのは前者です。その点で Swift の throws 節にエラー型を記述できないという仕様は、 Java でよく見られるサブクラスでメソッドをオーバーライドするときにエラーの型を足せないなどの問題を回避しつつも、最も重要なエラー処理を強制することだけは担保する、バランスの良い選択のように思えます(ただ、個人的にはスーパークラスで想定していないエラーがサブクラスで発生するのは、そのオーバーライド自体が誤りなのではないかと思います)。

Swift 5 か 6 でエラーの型を指定できるようになるかも?

とはいえ、エラーの型が厳密だとうれしいと考える人は多いので(僕も、実際には指定できるとうれしいように思います)、実は今、 Swift の言語仕様を話し合うメーリングリスト上で throws 節でエラー型を指定できるようにしようという話が出て議論されています。ただし、その場合でも Java と違い throws 節には一つのエラー型だけを記述できるようにしよう という案が有力です。僕はこれはなかなかいい案だと考えています。

throws 節で指定されるエラー型は API のシグネチャの一部です。つまり、その関数/メソッドの実装に引っぱられるのではなく、その API の視点でとらえた抽象度でエラーの種類が記述されるべきです。たとえば、コンテンツの ID を指定してファイルをダウンロードし、ローカルの DB に保存する関数を考えましょう。結果として、成功か、不正な ID が指定されたか、それともダウンロードに失敗したかだけを知りたいとします。そのようなケースでは、内部でネットワークエラーが起ころうが DB エラーが起ころうが、全部「ダウンロードの失敗」として扱いたいはずです。 IOExceptionSQLException をそのまま上層に投げるのは正しい設計ではありません。 DownloadException という例外を作って、その causeIOExceptionSQLException を渡すべきです。

しかし、 Java では throws 節に複数の例外の型が記述できるがために、内部で発生した例外がルーズにそのまま上層に投げられるケースが多いように思います。きっと一度は IOExceptionSQLException がずらずらと並べられた throws 節を見たことがあるでしょう。もしエラー型が一つしか記述できなければ別の抽象度のエラーでラップすることが半強制されるため、そのような事態を改善することができます。

なお、 Swift では enum をエラー型にすることができるので、複数の原因を一つのエラー型で表すのは簡単です。たとえば、

  • ネットワークに未接続
  • タイムアウト
  • レスポンスのステータスコードが不正

のいずれかを表す NetworkError は次のように記述できます。

// Swift
enum NetworkError: Error {
  case unconnected
  case timeout
  case badResponse(statusCode: Int)
}

このような Tagged Union 的なデータ構造が作れないと、一つのエラー型しか記述できないのは現実的でないかもしれません。

throws 節にエラーの型を記述できない、もしくは一つだけ記述できることの評価

正直、この点に関しては必ずしも「改善」とは呼べないと思います。選択の問題であり、ユースケースや好みに左右されるでしょう。説明の順序の問題で本節の内容を最初に持ってきましたが、次節以降が「改善」と呼べるものだと考えています。

try - catch ではなく do - try - catch する

Java の try - catch には Implicit control flow problem があります。コードを見てもどこの行で例外が throw され得るのかということがひと目でわからないという問題です。

// Java
try {
  foo();
  bar();
  baz();
} catch (QuxException e) {
  // どこから来たの??
}

これが困るのは、たとえば bar で発生し得る QuxExceptioncatch しているつもりなのに、間違えて baz で発生する QuxExceptioncatch してしまい不適切なエラー処理をしてしまったというようなケースです。 IOExceptionSQLException のような、関連する複数のメソッドで同じ種類の例外が投げられる場合にこういうことが起こりがちです。

しかし、 Swift では Marked propagation によってこの問題を軽減しています。 Marked propagation とは次のコードのように、エラーを throw し得る関数/メソッドをコールする箇所にマーク( Swift では try )を付けなければならないというものです。

// Swift
do {
  foo(); // `try` がないのでここから `throw` されることはない
  try bar(); // ここでエラーが `throw` され得る
  baz(); // `try` がないのでここから `throw` されることはない
} catch let error {
  // bar() からのみ来る
}

これなら try を書くときにそこからの throwcatch してよいかプログラマが判断する機会が必ず与えられることになり、誤って catch に巻き込んでしまうことを防げます。

なお、このマークとして try というキーワードを使うので、 Swift では Java の try の意味で do を使います(実はこの Swift の do が Haskell の do 記法と理論的に対応しており、 Swift の try は Haskell の <- と対応するのもおもしろい点です)。

try! でエラーを握りつぶすことなくコンパイラを黙らせる

この節で述べることは本投稿で 最も重要なこと です。本当は投稿の最初に書きたかったんですが、説明の順序の問題でこの位置になっています。

Java の検査例外で一番辛いのが、例外が発生しないことがわかっているケースで例外処理を回避する簡単な方法がないことです。たとえば、次のコードでは UI 側でがんばって入力できるフォーマットが強制されている場合、 toDate は絶対に失敗しません。無駄なエラー処理は書きたくありませんが、 Java では次のようなコードを書かなければなりません。

// Java
Date date;
try {
  date = toDate(string);
} catch (FormatException e) { // ここには絶対に到達しない
  throw new RuntimeException("Never reaches here.", e);
}

Swift では、 try! を付けて try! とするだけです。

// Swift
let date = try! toDate(string) // `catch` は必要ない

もし try! が付与されているにも関わらず toDate がエラーを throw した場合にはクラッシュします。これは、 try! は前述のエラー分類上 Logic failure (コードそのものの誤り)として扱われており、実行時に動的に処理すべきものではない(コードを修正することで対応すべき)と考えられているからです。これは、 Java でいう RuntimeException に対応します( Java の RuntimeExceptioncatch することができますが、 Effective Java では Programming error を表すために RuntimeException を使い、基本的に catch しないことが推奨されています)。

try! のような仕組みが Java になくて 一番問題なのは、コードを書くのが面倒なことではなく、コンパイラを黙らせるために例外が握りつぶされてしまうことです

Java でコンパイラを黙らせる一番簡単な(短いコードで書ける)方法は次のように例外を握りつぶすことです。

// Java
try {
  foo();
} catch (FooException e) { // 例外を握りつぶす
}

このようなコードは例外が発生しないことを想定していても、もしコードのミスで例外が発生してしまった場合に予期せぬ挙動をし、バグの原因を解明することを困難にします。最悪なのは、よくわかっていない初心者がコンパイラを黙らせるために上記のようなコードを書いてしまうことです。例外を握りつぶす行為は例外を catch し忘れることよりもひどい事態を引き起こします。安全なエラー処理を実現するための検査例外が、危険なコードを生んでしまうのでは本末転倒です。

Swift でもエラーを握りつぶすことはできますが、そのコードは try! よりも長いので、コンパイラを黙らせるためにエラーを握りつぶすということは起きづらいです。

// Swift
// `try!` でコンパイラを黙らせる
try! foo()
// Swift
// エラーを握りつぶしてコンパイラを黙らせる
do {
  foo()
} catch {
}

このように、 エラー処理を強制する構文を持つ言語は、エラーを握りつぶすよりも簡単な方法でエラー処理を回避(して非検査なエラー処理に)させる方法を提供することが重要です

5, 6 年ほど前に僕と @omochimetaru が↓のような構文が Java にほしいと話していたんですが、まさにそれと同じようなものを Swift は実現してくれました。 Java にもこのような構文が追加されるとうれしいのですが・・・。

// Java
foo()!; // 検査例外の非検査例外化

高階関数と rethrows

ときどき、検査例外は高階関数/メソッドを用いた関数型プログラミングと相性が悪いという話を聞きます。

高階関数/メソッドとは次のようなものです。たとえば、 map メソッドを使えば次のように簡単に要素を 2 乗することができます。

// Java
Stream<Integer> numbers = Stream.of(2, 3, 5);
Stream<Integer> squared = numbers.map(x -> x * x); // 4, 9, 25

しかし、 Java では map に検査例外を throw し得る Function を渡すことができません。

// Java
Stream<String> strings = Stream.of("2017-03-09", "unknown", "1980-01-01");
try {
  Stream<Date> dates = strings.map(s -> toDate(s)); // コンパイルエラー
} catch (FormatException e) {
  // エラー処理
}

このようなコードを書きたいのですが、 toDatethrow し得る検査例外 FormatExceptionmap に渡した Functioncatch していないのでコンパイルエラーになります。

Swift には rethrows という仕組みがあり、 map に渡す関数がエラーを throw しないなら map もエラーを throw しないメソッドとして振る舞い、

// Swift
let numbers = [2, 3, 5]
let squared = numbers.map { $0 * $0 } // 4, 9, 25

map に渡す関数がエラーを throw する場合には map もエラーを throw するメソッドとして振る舞うということを実現することができます。

// Swift
let strings = ["2017-03-09", "unknown", "1980-01-01"]
do {
  let dates = try strings.map { toDate($0) } // OK: `map` が `throws` なメソッドになる
} catch error {
  // エラー処理
}

Java でも throws で指定する例外の種類が一つだけで良いのであれば↓にようにして似たようなことは実現できます。

// Java
interface ThrowableFunction<T, R, E> {
  R apply(T t) throws E;
}
// Java
interface Stream<T> {
  ...
  // `map` のオーバーロード
  <R> Stream<R> map(ThrowableFunction<? super T,? extends R, ? extends E> mapper) throws E;
}

しかし、 map が複数の種類の例外を throws し得るようにするには rethrows のような新しい構文が必要になるでしょう。

余談ですが、 Swift の throws 節にエラー型が一つだけ書けるようになった場合には、 rethrows次のように表す案が提案がされています

func foo<E: Error>(f: () throws E -> ()) throws E { ... }

エラーを throw しない関数では ENever を指定することになります。 Swift 3 時点では Never is an Error ではないですが、 Never は Scala や Kotlin の Nothing のようなものであり、 NothingBottom type (すべての型のサブタイプ)です。もし Swift の Never も Bottom type になれば、 Never is an Error ということになり、この解釈は自然です。 throws なしの関数を throws Never と解釈するのはおもしろいです。

finally ではなく defer する

Swift には finally はありません。代わりに Go 言語のような(ただし関数レベルではなくスコープレベルで実行される) defer があり、これを使って終了処理を書きます。 defer の一番わかりやすい利点は、開始処理と終了処理がコード上で近くなり、コードの見通しがよくなることです。

// Swift
do {
  let foo = Foo.open("foo") // 開始処理
  defer { // スコープを抜けるときに実行される
    foo.close() // 終了処理: 開始処理の近くに書ける
  }

  // 長い処理
} catch {
  // エラー処理
}

複数の終了処理が必要なケースでも defer はうまく機能します。

// Swift
do {
  let foo = Foo.open("foo")
  defer {
    foo.close()
  }

  // 何らかの処理

  let bar = Bar.start("bar")
  defer {
    bar.stop()
  }

  // 何らかの処理

  let baz = Baz.create("baz")
  defer {
    baz.destroy()
  }

  // 何らかの処理
} catch {
  // エラー処理
}

これを Java で書こうとすると finally で条件分岐を書くか、 try - catch をネストしなければなりません。

// Java
Foo foo = null;
Bar bar = null;
Baz baz = null;
try {
  foo = Foo.open("foo");

  // 何らかの処理

  bar = Bar.start("bar");

  // 何らかの処理

  baz = Baz.create("baz");

  // 何らかの処理
} catch (FooException|BarException|BazException e) {
  // エラー処理
} finally {
  // 適切に条件分岐して終了処理を記述しなければならない
  if (baz != null) {
    baz.destroy();
  }

  if (bar != null) {
    bar.stop();
  }

  if (foo != null) {
    foo.close();
  }
}
// Java
try (Foo foo = Foo.open("foo")) {
  // 何らかの処理

  try (Bar bar = Bar.open("bar")) {
    // 何らかの処理

    try (Baz baz = Baz.open("baz")) {
      // ネストが深い
    } catch (BazException e) {
      // エラー処理
    }
  } catch (BarException e) {
    // エラー処理
  }
} catch (FooException e) {
  // エラー処理
}

try-with-resources 自体は僕は好きですが、上記のようなケースでネストが深くなってしまうのは微妙です。また、 try-with-resources 方式はスコープをまたいで(主にメソッドをまたいで) close しなければならないようなケースでは使えないので、 try-with-resources が使えなくても便利な構文はやはり必要です。

なお、 Swift でも try-with-resources と同じことは次のようにして実現できます。

// Swift
extension Foo {
  static func open(_ input: String, operation: () throws -> ()) rethrows {
    let foo = Foo.open(input)
    operation()
    foo.close()
  }
}
// Swift
do {
  Foo.open("foo") {
    // 何らかの処理
  } // スコープを抜けたら `close`
} catch let error {
  // エラー処理
}

Swift の検査例外(的なもの)が Java の検査例外と比べて劣っているところ

本投稿は Swift の検査例外(的なもの)が Java の検査例外を「改善」した点を紹介する目的のものなので、 Swift の良い点ばかりを挙げてきましたが、 Swift がすべての点で Java に優れているわけではありません。本題とずれますが、 Swift の検査例外(的なもの)を使っていて困っていることも簡単に紹介します。

エラーからスタックトレースが取得できない

当然 Swift にもコールスタックは存在しますが、 Error からスタックトレースを取得することができません。これは、 Swift がシステム言語の領域でも有用な言語を目指しており、速度を重視していることを考えるとしかたない選択かもしれませんが、大して速度が重要でないアプリ開発などで Swift を使っていると不便です。

Exception chaining のような仕組みがない

Swift の Error には cause でエラーをチェーンする、 Java の Exception chaining のような仕組みもありません。これも、エラーを上層の抽象度でラップし直そうと思うと辛い点です。

対応策

実は、 Swift の標準ライブラリにはエラーを throw する関数/メソッドは一つもありません( Objective-C 由来の Foundation という付属ライブラリにはありますが、それは Swift 用に設計されたものではありません)。 Swift の標準ライブラリはミニマルな状態に保たれており、 Int, Float, Double, Bool, Array, Dictionary などの必要最低限の機能を提供するだけです。具象エラー型も一つもありません。

そのため、 Swift では基本的にはエラー型は利用者が自分で設計することになります。スタックトレースがほしければスタックトレースをもたせ、 Exception chaining がしたければそのような仕組みを作ることはできます。自分が作ったエラーの世界はすべてそのようにしてしまえば、 Java に近いことは実現できます(ためしに causecallStack を持った Exceptionライブラリを作ってみました)。ただし、サードパーティのライブラリを使う場合にはその限りではありません。

まとめ

  • Swift では throws 節にエラーの型を記述できず、 catch の強制の意味だけを持つ
    • エラーを網羅的に処理するケースは少ないので、網羅性の重要度は低い
    • 最も重要な catch 忘れを防ぐことができ、かつサブクラスでエラー型が足せないという問題を回避できる
    • Swift にもエラー型指定は将来的に追加されるかもしれず、その場合でも throws 節で指定できるエラーの種類は一つになる予定
    • 一つしかエラー型を指定できないことで、その API の抽象度に合わせてエラーをラップすることが半強制される
  • エラーを throw し得る関数/メソッドをコールする箇所ではマーク( try )を付与しないといけない
    • 想定していない関数/メソッドの throw するエラーを誤って巻き込んで catch してしまうことが防げる
  • try! でエラーを catch せずに済ませられる( Java 的に言えば RuntimeException 化)
    • catch (Exception e) {} などと握りつぶされてしまうことを防げる
  • 高階関数に渡す関数が throws かどうかでその高階関数自身が throws かどうか決まる rethrows という仕組みがある
    • mapfilter などを多用する関数型プログラミングのスタイルと相性が良い
  • finally ではなく Go に似た defer によって終了処理を記述する
    • 終了処理のコードが開始処理の近くに記述できるので可読性が高い
    • Java のように try ブロックのどこまで進んだかによって必要な終了処理を分岐する必要がない
    • Swift で try-with-resources のようなこともできる
  • Swift がすべて素晴らしいわけではない
    • エラーからスタックトレースがとれなくて辛い
    • Exception chaining のような仕組みもなくて辛い
    • 標準ライブラリには具象エラー型がないので、自分でコールスタックと cause を持ったエラー型を設計することはできる