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 を Optional
の nil
で、 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 エラーが起ころうが、全部「ダウンロードの失敗」として扱いたいはずです。 IOException
や SQLException
をそのまま上層に投げるのは正しい設計ではありません。 DownloadException
という例外を作って、その cause
に IOException
や SQLException
を渡すべきです。
しかし、 Java では throws
節に複数の例外の型が記述できるがために、内部で発生した例外がルーズにそのまま上層に投げられるケースが多いように思います。きっと一度は IOException
や SQLException
がずらずらと並べられた 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
で発生し得る QuxException
を catch
しているつもりなのに、間違えて baz
で発生する QuxException
も catch
してしまい不適切なエラー処理をしてしまったというようなケースです。 IOException
や SQLException
のような、関連する複数のメソッドで同じ種類の例外が投げられる場合にこういうことが起こりがちです。
しかし、 Swift では Marked propagation によってこの問題を軽減しています。 Marked propagation とは次のコードのように、エラーを throw
し得る関数/メソッドをコールする箇所にマーク( Swift では try
)を付けなければならないというものです。
// Swift
do {
foo(); // `try` がないのでここから `throw` されることはない
try bar(); // ここでエラーが `throw` され得る
baz(); // `try` がないのでここから `throw` されることはない
} catch let error {
// bar() からのみ来る
}
これなら try
を書くときにそこからの throw
も catch
してよいかプログラマが判断する機会が必ず与えられることになり、誤って 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 の RuntimeException
は catch
することができますが、 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) {
// エラー処理
}
このようなコードを書きたいのですが、 toDate
が throw
し得る検査例外 FormatException
を map
に渡した Function
が catch
していないのでコンパイルエラーになります。
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
しない関数では E
に Never
を指定することになります。 Swift 3 時点では Never
is an Error
ではないですが、 Never
は Scala や Kotlin の Nothing
のようなものであり、 Nothing
は Bottom 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 に近いことは実現できます(ためしに cause
と callStack
を持った Exception
のライブラリを作ってみました)。ただし、サードパーティのライブラリを使う場合にはその限りではありません。
まとめ
- Swift では
throws
節にエラーの型を記述できず、catch
の強制の意味だけを持つ- エラーを網羅的に処理するケースは少ないので、網羅性の重要度は低い
- 最も重要な
catch
忘れを防ぐことができ、かつサブクラスでエラー型が足せないという問題を回避できる - Swift にもエラー型指定は将来的に追加されるかもしれず、その場合でも
throws
節で指定できるエラーの種類は一つになる予定- 一つしかエラー型を指定できないことで、その API の抽象度に合わせてエラーをラップすることが半強制される
- エラーを
throw
し得る関数/メソッドをコールする箇所ではマーク(try
)を付与しないといけない- 想定していない関数/メソッドの
throw
するエラーを誤って巻き込んでcatch
してしまうことが防げる
- 想定していない関数/メソッドの
-
try!
でエラーをcatch
せずに済ませられる( Java 的に言えばRuntimeException
化)-
catch (Exception e) {}
などと握りつぶされてしまうことを防げる
-
- 高階関数に渡す関数が
throws
かどうかでその高階関数自身がthrows
かどうか決まるrethrows
という仕組みがある-
map
やfilter
などを多用する関数型プログラミングのスタイルと相性が良い
-
-
finally
ではなく Go に似たdefer
によって終了処理を記述する- 終了処理のコードが開始処理の近くに記述できるので可読性が高い
- Java のように
try
ブロックのどこまで進んだかによって必要な終了処理を分岐する必要がない - Swift で try-with-resources のようなこともできる
- Swift がすべて素晴らしいわけではない
- エラーからスタックトレースがとれなくて辛い
- Exception chaining のような仕組みもなくて辛い
- 標準ライブラリには具象エラー型がないので、自分でコールスタックと
cause
を持ったエラー型を設計することはできる