Swift 2.0 の try, catch ファーストインプレッション

  • 502
    Like
  • 0
    Comment
More than 1 year has passed since last update.

WWDC 2015 で Swift 2.0 が発表されました。オープンソース化などのうれしいニュースでも盛り上がっていますが、言語仕様としては try, throw, catch が導入されるという大きな変更がありました。本投稿は、 The Swift Programming Language の新章 Error Handling を読み、多少のコードを書いた上での個人的な感想です。

結論から言うと、 try, catch の導入は良い変更だと思えないけど、 try, catch を導入する前提なら考え得る限りベストに近い仕様だった、って感じです。 よかったのは、

  • ErrorType は enum
  • タイプセーフなエラー情報
  • エラー処理が強制されている(検査例外のような形)
  • try! でエラーを無視できる

あたりです。個人的には、 try, catch でなく Either 的なものを公式サポートして、 Optional ばりの便利な構文を用意してくれたらうれしかったです。

try, catch を導入する前提なら考え得る限りベストに近い仕様

ErrorType は enum

Swift の try, catch では例外ではなく ErrorTypethrow します。 Java ではエラーの種類をクラスで扱うのでエラーの種類だけクラスが増えてしまって散らかりやすいです。

Swift の ErrorType は関連するエラーを enum としてまとめることができるので整理がしやすそうです。

例えば、 UILabel から受け取った文字列を Int に変換する関数 toInt を考えてみましょう。 UILabeltext プロパティの型は String? なので、エラーには次の 2 種類が考えられます。

  • 文字列が nil だった
  • 文字列が Int としてパースできなかった

これを ErrorType として実装すると次のようになります。

enum ParseError: ErrorType {
    case Nil // nil だった場合
    case IllegalFormat // Int としてパースできなかった場合
}

タイプセーフなエラー情報

ErrorType には、エラーの種類を表すだけでなく、エラー情報も保持してほしいところです。

先の例で言えば、 IllegalFormat のときに元の文字列が何だったのか知りたいこともあるでしょう。次のようにして、 IllegalFormat に文字列を保持させることができます。

enum ParseError: ErrorType {
    case Nil
    case IllegalFormat(String)
}

この方式の素晴らしいところは、エラーの種類ごとに異なる型を持たせて、それらをタイプセーフに取り出すことができる点です。

switch error {
case .Nil:
    print("The text is nil.")
case .IllegalFormat(let string):
    print("Illegal format: \(string)")
}

Objective-C の NSError にエラー情報を持たせることができますが、型情報が消えてしまうのでタイプセーフに扱えません。

エラー処理が強制されている(検査例外のような形)

Swift の try, catch で一番おどろいたのは、 Java の 検査例外 と似たような方式だったことです。

検査例外 とは例外を throw する可能性のある関数・メソッドをコールするときに、処理しないとコンパイルエラーになってしまうような例外です。対義語は 非検査例外 で、 非検査例外 は処理しなくてもコンパイルエラーとならず、実行時に処理されない例外が発生すると実行時エラーとなります。

検査例外 はエラー処理を強制するのでエラー処理忘れが発生しませんが、必ず成功することがわかっている場合でもエラー処理が必須だったり、コードが本質ではない try, catch だらけになってしまったりで、 Java 以外のメジャーな言語には取り入れられてきませんでした。

// Java でありがちな try, catch だらけのコード
BufferedReader reader;
try {
    reader = ...;

    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch(IOException e) {
    e.printStackTrace();
} finally {
    if (reader != null) {
        try {
            reader.close()
        } catch( IOException e) {
            e.printStackTrace();
        }
    }
}

また、 検査例外非検査例外 の境界もあいまいでした。例えば、 Java の Integer クラスの parseInt メソッドは文字列をパースして int に変換するのですが、失敗したときに throw される NumberFormatException非検査例外 でした。パースに失敗したかで分岐して処理するのは頻繁に書く処理なので、僕は、エラー処理を忘れないように NumberFormatException には 検査例外 であってほしいと思います。

かといって、すべてを 検査例外 にすると、↑のコードよりもひどいことになってしまいます。例えば、整数のわり算でも 0 除算をするかもしれないので try, catch しなければならなくなります。

そんなわけで、 検査例外 自体はタイプセーフな優れたアイデアだけれども、現実的には何か新しい記法が見つからないと取り入れるのは辛いというのが一般的な認識だと思っていました(ソースがなくてすみませんが、確か C# の設計者も 検査例外 を取り入れなかった理由としてそんなことを語ってた気がします)。

なので、 Swift が 検査例外 的な方式を採用したのには驚きました。一方で、もし Swift が 非検査例外 を取り入れてたらひどいことになっていたと思います。 Swift はその頑強な型システムが特徴の一つなので、 非検査例外 を導入したらその頑強性が台無しです。 Swift が 検査例外 的な方式を採用したのは try, catch の仕様として最も良かった点だと思います。

do {
    let number = try toInt(label.text)

    // number を使った処理

} catch ParseError.Nil {
    print("The text is nil.")
} catch ParseError.IllegalFormat(let string) {
    print("Illegal format: \(string)")
}

また、 Optional と共存することで、軽微なエラー処理は Optional で、複雑なエラー処理は try, catch でと使い分けることができるので、 Java ほど try, catch だらけになることもないと思います。

try! でエラーを無視できる

では、何があれば 検査例外 がより実用的になるのでしょうか。

以前そのテーマで同僚と話していたのですが、最低限、例外を無視するシンプルな構文が必要だという話になりました。例えば、数字しか入力できないフォームから得た文字列をパースするときに、絶対に起こらないエラー処理を書かなければならないのはナンセンスです1

// ※ Java の NumberFormatException が検査例外だったとする
int number;
try {
    number = Integer.parseInt(text);
} catch(NumberFormatException e) {
    throw new Error("ここには絶対に来ない");
}

そのときは、例えば ! 一つで無視して、もし例外が起こったら実行時エラーという構文があるといいね、という結論になりました。

// Java に ! があるイメージ
int number =  Integer.parseInt(text)!;

Swift の Forced Unwrapping はまさにこのような構文で「これだよ!」と思いました。後置の !Forced Unwrapping にとられてしまいましたが、 try にも try! という構文が提供されました。これを使えば簡単にエラーを無視することができます。

let number = try! toInt(label.text) // エラー時はクラッシュ

また、 trytry! が演算子なのも素晴らしいです。もし try 文だったとすると、複数の値を使って何かをやるのはとても大変です。

// ※これは正しい Swift のコードではありません
try! let foo = foo()
try! let bar = bar()
let result = foo + bar

trytry! が演算子なことで、次のように一つの式の中に書くことができます。

let result = try! foo() + (try! bar())

あと足りなかったものとして、 try? があるとよかったですね。エラーの種類やエラー情報は不要で成功したか失敗したかだけが知りたいというケースも多いはずです。 try! では失敗したときに即死してしまうので、エラー処理するには do-catch するしかないのですが、それだと式ではなく文になってしまい辛いです。エラーは nil として返してくれる try? があればよかったのに・・・。

Xcode 7 beta 6 で try? 演算子が追加されました!素晴らしい!!

なので↓の tryq は不要になりました。

try? のような tryq を作ってみた

try? は将来的に提供されるんじゃないかと思いますが、ないと不便なので try? の代わりに使える tryq という関数を作ってみました。 → GitHub

public func tryq<T>(@autoclosure f: () throws -> T) -> T? {
    do {
        return try f()
    } catch {
        return nil
    }
}

次のように使えます。

// let result: Int? = try? foo(42)
let result: Int? = tryq(try foo(42))

これは、下記のコードと同じ結果となります。 tryq を使えば、成功か失敗かを知りたいだけのときはとてもシンプルに書けます( tryq に加えてわざわざ try を書かないといけないのが微妙ですが・・・)。

let result: Int?
do {
    result = try foo(42)
} catch {
    result = nil
}

また、次のように式の中でも使えます。これを do-catch でやろうとすると相当面倒です。

// let result = (try? foo(42)).map { $0 * $0 }
let result = tryq(try foo(42)).map { $0 * $0 }

try, catch の導入は良い変更だと思えない

僕はエラーや例外を throw する構文が好きではありません。理由はいくつかありますが、一番の理由はコードを頭から順に書くのが難しいことです。例えば、

let result = foo().bar().baz()

と書いて、 baz がエラーを throw するので構文エラーになったとします。そうすると、前の行に戻って do を書き足して変数宣言を { } の外に出してというように、後戻りしてコードを書かなければなりません。これはとても面倒です。

var result: Baz
do {
    result = try foo().bar().baz()
} catch {
    // エラー処理
}

結果的に、細かく do-catch するのは面倒なので、大きすぎる do 文の中で複数の try が書かれてまとめて catch するというコードであふれてしまうのではないでしょうか。まとめて catch するとエラー処理も適当になりがちです。

Optional ならひとまずは

let result = foo().bar().baz() // baz に失敗したら nil

としておいて、利用する時にエラー処理を書けば OK です。 Optionalnil チェックは必須なので、エラー処理を忘れてしまうこともありません。不必要に複数のエラー処理がまとめられてしまうことないですし、 mapflatMap を使えば自然な順序でコードを書けます。

僕は try, catch ではなく Optional でエラー処理することは進化だと感じ、 Optional の取り扱いが重要だと感じたからこそ "SwiftのOptionalを極める" を書きました。なので、 try, catch に後戻りするのは気乗りしないです。

Optional の問題点と Either

Optional の問題点はエラー情報を保持することができないことです。エラーの種類によって処理を変えたいような場合には Optional では不十分です。しかし、 try, catch でないと問題を解決できないわけではありません。

一部の言語ではそのような目的に Either が用いられます2

enum Either<T, U> {
    case Left(T)
    case Right(U)
}

OptionalTnil かを持つのに対して、 EitherTU の値を持ちます。この U にエラー情報を持たせればエラー情報を持った Optional のようなものを作れます。エラー処理に特化すれば、次のような型でもいいかもしれません。

enum Failable<T, E: ErrorType> {
    case Success(T)
    case Failure(E)
}

これなら、 Optional 同様に if let ...bind できて、 ? でチェーンできるなども自然です。さらに、 Foo?Optional<Foo> を表すように、 Foo|BarErrorEither<Foo, BarError>Failable<Foo, BarError> を表すなんてことも考えられたでしょう。

僕がほしかったのは↓のようなものです。

// ※これは正しい Swift のコードではありません
let a: Int|ParseError = toInt(label.text)

if let value = a {
    print(value)
}

switch a {
case .Success(let value):
    print(value)
case .Failure(.Nil):
    print("The text is nil.")
case .Failure(.IllegalFormat(let string)):
    print("Illegal format: \(string)")
}

let b = a.map { $0 * $0 }
let c = a.flatMap { a0 in b.flatMap { b0 in a0 + b0 } }
let d: Int = a ?? 0
let e = a?.successor()

ただ、値を返さないようなメソッド、副作用のある(値を返すだけでなく、プロパティを変更するなどする)メソッド等では OptionalEither では対応できません。あらゆるケースにおけるエラー処理をカバーするためには、現時点では try, catch は必要なのかもしれません。

(追記: 2015-12-08 ) Swift がオープンソース化されたので、試しに Int|String のような構文が使えるようにコンパイラを改造してみました

(追記: 2015-09-10 )次期 Swift で Either が追加されるかもしれないようなので、 Either と do-try-cath についてもっと掘り下げて "Swift 3.0で追加されそうなEitherについて" を書きました。

(おまけ) Swift 2.0 で Failable を作ってみました

(追記: 2015-08-30 )デファクトになっている antitypical/Result が Swift 2.0 対応で ErrorType に対応し、下記の Failable と同等のものになっているので、ライブラリとして使う場合はそちらをオススメします。下記は、 try 等を学ぶためのものとして役立てて下さい。

言語仕様に組み込まれたサポートが受けられないので限界はありますが、 Swift 2.0 で↑の Failable を作ってみました( → FailSwif )。↑のコードと見比べると、何が実現できて何ができなかったのか、僕がほしかったのは何なのかわかっておもしろいと思います。

また、テストコード では try, catch, throw, guard なども使っているので Swift 2.0 のサンプルコードとしてもどうぞ。

let a: Failable<Int, ParseError> = toInt(label.text)

if let value = a.value {
    print(value)
}

switch a {
case .Success(let value):
    print(value)
case .Failure(.Nil):
    print("The text is nil.")
case .Failure(.IllegalFormat(let string)):
    print("Illegal format: \(string)")
}

let value: Int? = a.value
let error: ParseError? = a.error

let b = a.map { $0 * $0 }
let c = a.flatMap { a0 in b.flatMap { b0 in .Success(a0 + b0) } }
let d: Int = a ?? 0
let e = a.flatMap { .Success($0.successor()) }


  1. Java の NumberFormatException検査例外 だったら嫌だということではなく、安全な方が望ましいのであくまで 検査例外 であってほしいけど、その上で簡単に例外を無視する構文が欲しいという意見です。 

  2. Swift 2.0 から Box で包むようなワークアラウンドは必要なくなりました。