756
555

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Swiftのエラーハンドリングはなぜ最先端なのか

Last updated at Posted at 2018-11-25

Swiftのエラーハンドリングは他のメジャーなプログラミング言語のどれとも異なる新しい仕様を持っています。特に、検査例外を持っているのですが、これはJavaで採用された以降はほとんどの言語で採用されていないため、現代では否定されている過去の間違いだったと広く認識されていると思います。そのため、Swiftユーザーで無い人は、検査例外という言葉をみた瞬間に興味を失ってしまうため、その詳細がなかなか世の中に伝わっていないと感じています。一方、私はこんなSwiftのエラーハンドリングをとても気に入っていて、様々な言語の進化の歴史を踏まえた産まれた最も優れた最先端の仕様だと思っています。この記事ではその考えを説明します。

Javaのエラーハンドリング

Javaは検査例外を持っています。これにより、あるメソッドがエラーを送出するかどうかを関数のシグネチャとして静的に表明できます。

// 検査例外の例
public class Cat {
    public int eat() throws Exception { ... }
}

検査例外を持ったメソッドを呼び出すコードでは、そのエラーが発生した場合にどのように対処するか、プログラマが明示的に書かねばなりません。つまり、エラーハンドリングが強制されています。なんの対処もしていないコードは、コンパイルエラーとして報告されます。よって、検査例外についてのエラーハンドリングを忘れてしまう事がありません。

// コンパイルエラーの例
public static void _main() {
    Cat cat = new Cat();
    cat.eat();
    //     ^
    // error: unreported exception Exception; must be caught or declared to be thrown
}

このような場合、プログラマには2つの対応方法があります。1つは、例外が生じたその時点で、その関数自身もエラーとし、例外を再送することです。そのためには呼び出し側の関数にも検査例外を宣言します。

// throws をつけたことでコンパイルが通るようになる。
public static void _main() throws Exception {
    Cat cat = new Cat();
    cat.eat();
}

もう1つは、try-catch構文を使って例外を捕捉することです。

// try-catch で包んだことでコンパイルが通るようになる。
public static void _main() {
    Cat cat = new Cat();
    try {
        cat.eat();
    } catch (Exception e) {
        ...
    }
}

例外を捕捉した時点でcatch節の中に制御が移動します。

このように、Javaの検査例外は、エラーハンドリングを強制してくれます。これによって、プログラマがエラーの可能性を見逃す可能性がなくなり、良いプログラムが書きやすいです。しかし実際にはそううまくいかなかったと思いますし、後続のメジャー言語で検査例外を採用しているものはほぼありません。なぜうまくいかなかったのか、以下で私の考えを述べます。

Javaの非検査例外

Javaには検査例外があります。これはハンドリングを強制されません。私は非検査例外がある事自体は悪くないと思っていますが、非検査例外の使い方に対する正しい方針が示されなかったために、間違って使われてしまったことで、検査例外の価値が失われてしまったと考えています。

私の考えでは、検査例外と非検査例外の関係は、本当は以下のようになっていると良いと思います。

  • (1) 原則として検査例外でコーディングする。こうすることで予測可能なエラーはすべてハンドリングされた状態になる。
  • (2) プログラマが考えても仕方ないような特殊なエラーだけ、非検査例外として扱う。例えば、メモリ不足でオブジェクトが生成できない、など。
  • (3) 非検査例外は検査例外用のハンドリングをすり抜けることにする。

しかし、Javaでは上記のスタイルは満たしてプログラミングすることは実質的に不可能です。それは、以下のような問題があるからです。

null安全ではない

Javaはnull安全ではなく、nullに関する静的な検査機構が存在しません。ここではあらゆる参照が型の上ではnullableであるため、ある参照型変数の値がnullになりうるのか、なりえないのか、プログラマが正確に追跡するのが困難です。そのため、nullになりえる場面でnull検査することを忘れてしまい、実行時エラーを発生させてしまいます。参照型に対してアクセスする際に、値がnullである可能性があってエラーになることは、予測可能なエラーであるため、ハンドリングを強制したいです。しかしJavaではnullアクセスエラーは非検査例外であるため、(1)が満たせません。逆にもしJavaがnullに関して検査例外を採用したとしても、あらゆる式やメソッド呼び出しが検査例外となるため、プログラミングが困難になってしまうでしょう。よって、静的なnull安全性が導入されていないと、予測可能なエラーを検査例外とすることは実質的に無理だと思います。そうでないと、nullアクセスという頻発するエラーが非検査例外となってしまって、検査例外によって安全性が保証された環境が成り立たないからです。

検査例外だけ捕捉するのが困難

言語仕様として(3)が満たされていません。というのも、Exceptionは検査例外ですが、そのサブクラスであるRuntimeExceptionは非検査例外だからです。そのため、検査例外すべてを補足しようとして、Exceptionのcatch節を書いてしまうと、それではRuntimeExceptionも捕捉してしまいます。RuntimeExceptionを捕捉しないようにするためには、そこでcatchしうるExceptionのサブクラスをすべて列挙する必要があります。それ自体はコンパイラサポートがあるため不可能ではありませんが、実際のエラーハンドリングで例外の種類別に制御を分岐したいことはすくないため、コードが無駄に冗長になります。また、一つでも throws Exception と書いてある関数を呼出した時点で、Exceptionでのcatchが必要となるのでこの問題が回避できません。

標準ライブラリで、予測可能なエラーに非検査例外が使われている

java.lang.Integerstatic int parseInt(String s)というメソッドがあります。これは文字列を解釈して整数に変換するメソッドです。このメソッドは、文字列が整数に変換できない内容である場合に失敗することが明らかです。これは予測可能なエラーであるため検査例外であってほしいです(1)。しかし残念ながら、このメソッドはそのようなエラーを非検査例外として通知します。

仮定として、このメソッドが契約として、呼び出し側に「整数に変換可能な文字列」を要求しているのだと解釈することができます。そのような場合、契約に違反しているのは呼び出し側ですから、その関数のふるまいとしては、変換できないのは予測不可能な特殊な状況ですから、非検査例外を送出することが妥当と考えられます(2)。しかしこの仮定は成り立ちません。なぜなら、「整数に変換可能な文字列」であるかどうかを、実行時に判定する方法が存在しないからです。よって、この契約を満たす正しいコードは書くことが不可能です。自前で判定関数を書こうとしても、その判定ロジックが、parseIntの考えている「変換可能な条件」と一致していることが保証できないので、不完全です。

結論: 予測可能なエラーに非検査例外が含まれている

ここまでの理由を総括すると、発生が予測可能なエラーであっても非検査例外で表現する場合がある、というのがJavaのスタイルであることがわかります。標準ライブラリを見てみても、RuntimeExceptionがそのようなケースで使われているように見えます。一方、予測不可能な特殊なエラーは、Error型を使用しているようです。Error型はException型を継承していません。よって、予測可能であればException型、その中で非検査例外はRuntimeException、予測不可能であればError型、と使い分けていると思われます。

私はこのRuntimeExceptoinが最大の間違いだと思います。予測可能なエラー、つまり、普通にプログラミングしていて当然発生するゆえに対処しなければならないエラーが、プログラマによって正しく対処されているかどうかを、コンパイラが静的に保証できないということだからです。このようなプログラミング環境では、プログラマからすれば、検査例外に基づくコンパイルエラーに対処したところでどうせ非検査例外がすり抜けるのだから意味がない、ということになります。結果として、検査例外は面倒なだけの無意味な言語機能、と受け止められてしまいます。

そのように考えるプログラマは、自分が設計する関数も非検査例外を同様に使用するでしょう。そのように作られたサードパーティ製のライブラリが発展すれば、そのユーザーも同じ考えに突き当たることになります。こうして、検査例外を検査することの意味の無さというコンセンサスは加速的に強化されていくでしょう。

このようにして、検査例外はうまく機能しないと考えられるようになり、Java以降の言語で採用されないことになったのだと思います。

かといって、これを実現するためには、上記で説明したように、まず先にnull安全性が必要なので、これが普及するには歴史の進歩を待たねばならないので、仕方なかったと思います。

構文上の問題

非検査例外との立ち位置以外に、try-catch構文自体にも問題があります。

どこが捕捉されているのか曖昧

Javaのthrows宣言とtry-catch構文は、どちらにも同じ問題として、どのエラーが捕捉されているのか曖昧である、という問題があります。下記に例を示します。

public static void _main() throws Exception {
    Cat cat = new Cat();
    cat.eat();
    cat.speak()
    Dog dog = new Dog();
    dog.eat();
    cat.talk(dog);
}

この関数は全部で4つのメソッドを呼出していますが、このコードだけ見ても、どのメソッドが検査例外を送出しうるのか全くわかりません。コードを書くときは良かったかもしれませんが、コードを読むときにエラーハンドリングが見えません。これでは、コードレビューでエラーについての安全性を評価するのが困難です。アプリケーションに問題が起きたとき、エラーハンドリングで何が起きているか調べたいことは多いでしょう。そうしたときに、この仕様はあまり役に立ちません。

さらに、この関数の中身を追記するときにも問題があります。この関数の中はthrowsが効いているので、検査例外についての対処が検査されません。例えば何か追記するときに、それが検査例外を送出する関数であり、プログラマがうっかりそれを忘れていたとしても、コンパイラは何も伝えてくれず、そのまま再送出するコードになってしまいます。プログラマがエラーの可能性に対処する事を保証するのが便利であるはずが、これではあまり役に立ちません。

この話は、throws宣言に限らず、try-catchで囲われたtryスコープ内部においても全く同様です。

非検査例外への変換が煩雑

エラーハンドリングにおいては、関数のシグネチャとしては予測可能なエラーが表明されているが、利用側からすればそれがエラーを起こさないことが明確にわかっている場合があります。そのような場合には、エラーハンドリングとしては、エラーが起きたらクラッシュする、つまり、非検査例外に変換して送出する、というのが正しい対処です。コンパイラの静的な検査に対してはそれで筋が通るし、実際の動作としてはその挙動は生じないのだから問題ない、という考え方です。

例えば、正規表現をコンパイルして、正規表現の文法がおかしければエラーとする関数を考えてみます。テキストエディタなどを実装していて、正規表現をユーザが入力するような場合であれば、それが不正な文字列かもしれないので、このエラーを正しくハンドリングする必要があるでしょう。例えば、ステータスバーに「正規表現が不正です」といったメッセージを表示するなどの処理が考えられます。下記に例を示します。

// 検査例外にハンドリングの必要がある例
public void onRegexFieldEdit(String text) {
    try {
        Regex regex = Regex.compile(text);
        matchRegex(regex);
    } catch (Exception e) {
        showStatusMessage("正規表現が不正です");
    }
}

一方で、ソースコードの中にリテラルで書かれた正規表現であれば、それが正しくコンパイルできる事が確認できていれば、そのソースコードを何度実行したところで絶対にエラーは起こりません。

// 検査例外をハンドリングしていないため、コンパイルエラー。
public static boolean isNumber(String text) {
    Regex regex = Regex.compile("[0-9]+");
    return regex.matches(text);
}

そこで、先述したように、非検査例外への変換を書いてみます。

public static boolean isNumber(String text) {
    Regex regex;
    try {
        regex = Regex.compile("[0-9]+");
    } catch (Exception e) {
        throw new Error(e);
    }
    return regex.matches(text);
}

これは記述がめんどくさいです。関数の本体をすべてtry節に包めば少しは楽になりますが、しかしこれをやってしまうと、上述した捕捉範囲の曖昧性の問題を含むコードになってしまうため、それもよくありません。

非検査例外のエラーハンドリング

Ruby, PHP, Python, JavaScript, C#, Kotlinなどの多くの言語がエラーハンドリングとして非検査例外を使用しています。

エラーをハンドリングしているかどうかの静的な検査はありません。Javaで言えばすべての関数にthrows Exceptionがついているような状態であり、例外が発生して、それがtry-catchされていなければ、処理系のエントリポイントまで到達して異常終了します。

プログラマは関数を使用する際に、シグネチャとして例外を送出するかどうかを確認し、必要であれば捕捉します。

非検査例外の問題点は、エラーハンドリングを忘れてしまった際に、実行時に予期せぬ処理の中断や、プロセスのクラッシュを引き起こす可能性があることです。

これはシンプルに、良くない言語仕様で機能不足だと思いますが、実際に使用されているであろう状況では、そこまで問題にならない場合もあると思っています。

例えば、スクリプト言語がよく使われるウェブサービスのサーバサイド実装では、あまり深刻な問題にはならないと思います。CGIであれば、プログラムが異常終了しても、ウェブサーバのレスポンスとしては500系のエラーを返せば十分です。CGIではなくウェブサーバ自体がその言語で組まれているような場合でも同様で、リクエストを受けてレスポンスを返すところでさえ例外を捕捉しておけば、個別のリクエストハンドラでエラーが発生していようとも、エラーページを返却することができます。これらのケースで、エラーページを返さずに、途中で発生したエラーを補足して、部分的に壊れた状態のウェブページをレンダリングする、といった仕様はほぼ無いでしょうし、あっても洗い出せる程度のバリエーションでしょう。よって、エラーは捕捉しなくて良く、むしろ、リクエストハンドラ内のエラーは処理系まで再送出して処理は中断、というのがそもそも必要十分な制御であり、非検査例外の振る舞いとマッチします。一般に処理の中断に関しては、状態が不正なものになる恐れがありますが、リクエストハンドラの場合はそもそも状態はDB側にシリアライズされていて、アプリケーション言語側の状態には依存しないのが普通であるため、その点からも過去のハンドラでエラーが発生した事による状態異常は生じにくいです。

もう一つのユースケースとして、作業自動化などのコマンドラインツールの実装に使用しているような場合ですが、これも異常終了したところで、ユーザがパラメータなどを修正して再度実行すれば済むことが多いでしょう。そもそもの複雑性が高くないのと、ウェブと同様に一度の実行単位で使い捨てだからです。

一方、JavaScriptのウェブフロントエンドや、KotlinによるAndroidアプリ、C#によるUnityアプリなどでは、非検査例外では苦しいのではないかと思います。これらの環境では、アプリケーションプログラマの書いたコードは、何らかのGUIイベントなどをエントリポイントとして、オンメモリな状態を維持したまま処理系によって複数回呼ばれます。その結果としてプログラマの意図しない例外が発生した場合、アプリがクラッシュして終了したり、コンソールではエラーメッセージを出しつつ処理系は動き続けたりします。終了するのは当然問題で、ユーザは元の画面に復帰してくるためにいくらかの操作が必要です。終了しない環境であっても同様に問題で、実行環境の状態は継続しているため、アプリケーションが復帰不可能な異常状態を取る可能性があります。そうなると正常系が機能しないなど二次的なエラーが発生したり、データ消去など深刻な誤動作をする恐れもあります。今後複雑化が進むであろうウェブフロントエンドでは、この問題がだんだんと大きくなると予想しています。

Goのエラーハンドリング

最近人気の高い言語の中で、Goは独特なエラーハンドリングをしています。言語仕様には非検査例外に似ているpanic/recoverという機能がありますが、通常のエラーハンドリングではこれを用いません。Goでは、エラーと返り値をペアにして両方返して、呼び出し側でエラーがあったかどうか検査します。

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %v\n", err)
    return
}
fmt.Println("Converted integer:", i)

これは言語機能としては特別にエラーハンドリングの仕組みを使っているわけではなく、複数返り値の言語機能を使ってそのようにコードを書く、という形です。言語機能としての例外機能と比べると、シンプルですが、様々な長所があります。

呼び出し側が必ずエラーを意識する

この方法では、呼び出し側が関数を呼び出して返り値を受け取る際に、エラーがあればT, errで受け取り、エラーが無ければTで受け取ることになります。これを間違えると両辺の型が合わずにコンパイルエラーで検出されます。つまり、呼び出し側が、その関数がエラーを返すかどうかを必ず正しく意識することが保証されます。これは、Javaの検査例外にあった長所と同じです。

捕捉箇所が明示的

エラーを返す関数の呼出しのたびにこれが必要なので、必然的に書かれたコードはエラーの捕捉が明示的です。追加される処理についてもハンドリングが強制されます。また、呼び出し対象の関数のエラーの有無が変化した場合にはコンパイラが検出します。これらは、Javaの検査例外における短所と比べて、良くなっていると言えます。

panicへの変換が簡単

前述したとおり、呼び出し側から見て、失敗しないことがわかっている場合には、エラーを検査から外すのが望ましいです。この書き方ならそれもJavaと比べて簡単に書けます。

func main() {
    r, err := regexp.Compile("[0-9]+")
    if err != nil {
        panic(err)
    }
    fmt.Println(r)
}

ただし、この例の場合、MustCompileメソッドを使えば最初からそのような挙動になるので、そちらを使いましょう。

ここまで長所を説明しましたが、短所もあります。

複数の式の合成が面倒

例えば、エラーを返す関数を2つ実行して、その結果をまた別のエラーを返す関数に渡す、といったコードは、下記のようになります。

func hoge() (int, error) { ... }

func fuga(int, int) error { ... }

func _main() error {
    a, err := hoge()
    if err != nil {
        return err
    }
    b, err := hoge()
    if err != nil {
        return err
    }
    return fuga(a, b)
}

これは本来表現したい正常系のロジックに対して、エラーハンドリングによってコードが肥大化してしまっていて、わかりづらくなっています。仮にこれらの関数がエラーを返さなかった場合、以下のようなコードで済みます。

func _main() {
    return fuga(hoge(), hoge())
}

これが主となる正常系のロジックです。それに対して、「エラーがあったらそのまま送出したい」という意味を付与したいだけなのに、そのために追加したコードのほうが多くなってしまっています。

例外仕様であればこれは実現できていたことです。

// Java
static int _main() throws Exception {
    return fuga(hoge(), hoge())
}

強制されないパターンがある

呼び出し側が必ずエラー仕様を意識する、と先述しましたが、厳密には例外があります。それは返り値が無いパターンです。このスタイルではそのようなケースは下記のようになります。

func apply() error { ... }
func commit() error { ... }
func _main() error {
    err := apply()
    if err != nil {
        return err
    }
    return commit()
}

ここで、applyは返り値が無くエラーを返すので、言語の上での表現としてはerrorだけを返すシグネチャになっています。ここでもし、applyの返り値を意図せず無視してしまっていても、コンパイルエラーは生じないため気がつけません。

// applyのエラーハンドリングを忘れたコード
func apply() error { ... }
func commit() error { ... }
func _main() error {
    apply()
    return commit()
}

この問題は特に、あるバージョンでは返り値もエラーもなかった関数が、後の仕様変更によってエラーを送出するようになった場合により顕在化しやすいでしょう。その関数のエラー仕様の変更に対して、呼び出し側の修正が強制されないということです。

規約でしかない

この方式は規約でしかないため、そもそも守られる保証が無いという問題があります。ある実装では、返り値とエラーの両方を返すかもしれません。エラーと返り値の並び順が逆かもしれません。複数のエラーを返すかもしれません。エラーをポインタを用いた参照渡しで返すかもしれません。

モナドによるエラーハンドリング

検査例外の無い静的言語で、モナドを構築して静的なエラーハンドリングを実現する事ができます。Scalaではこれが標準とされています。Kotlinでは標準ではありませんが採用できます。Swiftは2.0より前では検査例外がなかったため、これを採用する場合がありました。

ここではSwiftによる実装を用いて説明します。仕様の詳細はいろいろなバリエーションがありますが、基本的なところは変わりません。

// Resultモナドの例
enum Result<T> {
    case ok(T)
    case error(Error)
}

extension Result {
    func map<U>(_ f: (T) -> U) -> Result<U> {
        switch self {
        case .error(let error): return .error(error)
        case .ok(let t): return .ok(f(t))
        }
    }
    
    func flatMap<U>(_ f: (T) -> Result<U>) -> Result<U> {
        switch self {
        case .error(let error): return .error(error)
        case .ok(let t): return f(t)
        }
    }
}

これを導入した上で、エラーを持つ関数はResultを返すようにします。定義から、呼び出し側でのエラー検査が強制されます。素朴には、結果を取り出すためにswitch文が必要になるため、必ずエラー検査の分岐を書かねばなりません。

func hoge() -> Result<Int> { ... }
func _main1() -> Result<Void> {
    switch hoge() {
    case .ok(let _): return .ok(())
    case .error(let error): return .error(error)
    }
}

エラーの再送出はイディオムとして、mapを使うと簡潔にかけます。

func _main2() -> Result<Void> {
    let a = hoge()
    return a.map { _ in () }
}

Goの例で見た合成のケースも、flatMapで簡潔にかけます。

func _main3() -> Result<Void> {
    return hoge().flatMap { (a) in
        hoge().flatMap { (b) in
            fuga(a, b)
        }
    }
}

ユーティリティを追加するともう少し良くできます。

func flatMap<T1, T2, U>(
    _ a: Result<T1>, _ b: Result<T2>,
    _ f: (T1, T2) -> Result<U>) -> Result<U> {
    return a.flatMap { (a) in
        b.flatMap { (b) in
            f(a, b)
        }
    }
}

func _main4() -> Result<Void> {
    return hoge().flatMap(hoge()) { (a, b) in
        fuga(a, b)
    }
}

このスタイルの長所を述べます。

エラーハンドリングが強制される

ここまで見たように、値がモナド内部にあるため、呼び出し側でのエラーハンドリングが強制されます。

エラーハンドリングが暗黙的

flatMapを使ったパターンでは、エラーの再送出が利用側のコードに暗黙に組み込まれるため、正常系に比重を置いたコードになります。

検査しないエラーへの変換が簡単

失敗しない事がわかっているときに、静的検査されないエラーへ変換するのは簡単です。そのようなユーティリティを用意すれば良いでしょう。

extension Result {
    func valueOrFatal() -> T {
        switch self {
        case .ok(let t): return t
        case .error(let e): fatalError("\(e)")
        }
    }
}

func _main() {
    let regex = Regex.compile("[0-9]+").valueOrFatal()
}

このSwiftの例ではfatalErrorにしていますが、Kotlinであれば非検査例外に変換することもできます。

型であるため運用が強制される

Goのスタイルでは規約であるゆえに運用が保証されないという問題がありました。このスタイルではResult型を使う限りその表現方法が固定されるので、間違った使い方をする事はありません。

パターンの追加ができる

ここまで見てきたように、煩雑になりそうなら道具を用意できるのは、このスタイルの便利な点です。_main4では引数の多いflatMapの例を示しましたが、カリー化とアプリカティブスタイルで書くパターンなどもあります。

ここまで見てきたように、モナドによるスタイルは多くの長所を持ちます。しかし、短所もあります。

標準の制御構文と相性が悪い

モナドによるスタイルは標準の制御構文と相性が悪いです。例えば、配列の要素を一つずつ変換して新しい配列を作るケースを、Goのスタイルのように書いてみます。

extension Result {
    var value: T? {
        switch self {
        case .ok(let t): return t
        case .error: return nil
        }
    }
    
    var error: Error? {
        switch self {
        case .ok: return nil
        case .error(let error): return error
        }
    }
}

func parse(_ a: String) -> Result<Int> {
    fatalError()
}

func conv1(_ array: [String]) -> Result<[Int]> {
    var result: [Int] = []
    for str in array {
        let int = parse(str)
        if let error = int.error {
            return .error(error)
        }
        result.append(int.value!)
    }
    return .ok(result)
}

これで正しく書けていますが、Resultが.error.valueのどちらかを持つという事が、静的に検査されなくなり、プログラマが.value!と強制キャストしているところが微妙です。エラーの再送出も明示的になってしまいました。このスタイルでは、Goの多値を使う代わりにResult型を使っている程度の利点しかありません。

素朴にswitchで書くと下記のようになります。

func conv2(_ array: [String]) -> Result<[Int]> {
    var result: [Int] = []
    for str in array {
        switch parse(str) {
        case .error(let error):
            return .error(error)
        case .ok(let value):
            result.append(value)
        }
    }
    return .ok(result)
}

一応これでうまく書けていますが、エラーの再送出についてはやはりだめですし、正常系もネストしているのが微妙です。もしこのループの中で行う処理が複数必要になったら、だんだんわかりづらくなるでしょう。

再送出の問題があるので、flatMapで書こうとすると、例えば下記のように書けます。

func conv3(_ array: [String]) -> Result<[Int]> {
    var result: Result<[Int]> = .ok([])
    for str in array {
        result = result.flatMap(parse(str)) { (result: [Int], int: Int) in
            .ok(result + [int])
        }
    }
    return result
}

あるいは、for文をやめて下記のように書けます。

func sequence<T>(_ array: [Result<T>]) -> Result<[T]> {
    var result: [T] = []
    for x in array {
        switch x {
        case .error(let error): return .error(error)
        case .ok(let ok): result.append(ok)
        }
    }
    return .ok(result)
}

func conv4(_ array: [String]) -> Result<[Int]> {
    return sequence(array.map(parse))
}

この例はかなり簡潔になっています。

しかし、conv3とconv4は、これまでの例とは動作が異なっていることに気がついたでしょうか。conv1, conv2では、変換エラーがあった時点で変換を中断していますが、conv3, conv4では、変換エラーがあってもそのまますべての要素を変換してしまいます。最終的には途中でエラーがあった時点で全体がエラーになるので、返る値は同じですが、動作は変わっています。これは処理負荷の違いをもたらします。もちろん、もし途中に副作用があるならば結果も変わってしまいます。私はこの動作の違いは見落としやすく気が付きにくいと思います。

もちろん、forループの挙動を再現できるユーティリティを用意することはできますが、break, continue, returnなどを考え始めるとそれなりに複雑になってしまいます。

この悩ましい状況の根本的な原因は、ループや大域脱出のreturnなどの制御構文と高階関数の相性の悪さにあると思います。全てをうまくやろうとすると、どんどん関数型プログラミングに寄せていく必要があるでしょう。関数型に寄せる方針が取れる事自体は言語の柔軟性として好ましいと思いますが、ここでは手続き的な言語においてエラーハンドリングをどうするべきかを論じたいので、この方針は対象外です。

強制されないパターンがある

モナドによるパターンは、結局のところGoのスタイルをモナドで表現しているだけなので、値を無視できてしまうという問題点は同様に残っています。

Swiftのエラーハンドリング

Swiftのエラーハンドリングは、ここまで議論してきた問題をすべて解決できています。どのような言語仕様なのか説明します。

Swiftでは検査例外を採用しています。Javaと異なり、エラーの型指定はなく、エラーを送出するかしないかだけを指定します。

// エラーを送出しない関数
func piyo() -> Int { ... }

// エラーを送出する関数
func hoge() throws -> Int { ... }

エラーを送出する関数を呼び出すのは、do-catch文のdo節の内側か、throws指定された関数の中でなければなりません。ここまではJavaと同様ですが、Swiftでは更に、エラーを送出する関数の呼び出しに対して、try演算子を指定しなければなりません。以下に例を示します。

func main1() throws {
    // throwsの中なので呼び出せる
    // tryをつけているので呼び出せる
    let a = try hoge()
}
func main2() {
    do {
        // do-catchの中なので呼び出せる
        let a = try hoge()
    } catch {
        showError(error) // 暗黙の変数error
    }
}

基本的な規則はこれだけです。この文法を評価します。

エラーハンドリングが強制される

予測可能なエラーは検査例外として表明されるので、ハンドリングが静的に強制されます。非検査例外は存在しないため、捕捉は可能だが静的に強制されないというパターンも存在しません。

nullアクセスによる非検査なエラーが無い

本題からそれますが、Javaにおいて言及したので触れておきます。SwiftではOptionalによるnull安全仕様が提供されているため、nullアクセスに関するエラーについてはOptionalの範囲内で検査を強制される事によって、静的に分岐を強制しています。そのため、nullアクセスに関しては例外機能を持ち出すことはありません。

捕捉箇所が明示的

エラーを捕捉する必要のある関数呼び出しはtry演算子を前置する必要があります。そのため、複数のエラーしうる処理が混在している場合であっても、どの部分でエラーが生じうるのか明示されたコードになります。レビューする際に警戒すべき箇所がわかりやすいですし、その関数に新たにエラーしうる処理が追加される場合は、そこでtry演算子を指定する必要があり、プログラマに判断の機会が強制されます。

// aとcの行がエラー捕捉されていることがわかる
func main3() throws {
    let a = try aaa()
    let b = bbb()
    let c = try ccc(a, b)
    let d = ddd(c)
}

複数の式の合成が簡単

エラーしうる関数を合成するのも簡単です。

func main4() throws {
    try fuga(try hoge(), try hoge())
}

さらに、同一の文であれば、try演算子を一括して適用することもできます。

func main5() throws {
    // まとめて1つのtryにする
    try fuga(hoge(), hoge())
}

もちろんこの集約には、その文に含まれるどの式がエラー捕捉されているのかわかりづらい、というここまで批判したような問題が含まれます。これについては、わかりやすさのバランスをプログラマが判断して調整するような言語仕様として設計されています。

非検査なエラーへの変換が簡単

これまでにも説明したように、契約の前提条件や静的な入力により、エラーが生じない事がわかっている場合には、コンパイラに対して検査を除去させる変換が欲しくなります。これはSwiftではtry!オペレータによって簡単に実現できます。

func main6() {
    let regex = try! Regex("[0-9]+")
}

いささか簡単に書けすぎると思うかもしれませんが、Swiftではこのような、コンパイラでは判断できないがプログラマには静的に判断できる検査の回避に対して、!のついた記法を使用するように統一されていて、コードにおける目印として機能するようになっています。レビューしていて!があったら、その記述がなぜ静的に正当であるのか検証すれば良いです。

エラーの無視が簡単

場合によっては、エラーが生じようが無視しても良いことがあります。例えば、あるテンポラリファイルを削除する際、もし削除に失敗しても、いずれシステムによって削除されるので問題ない、という場合があります。そのような場合、try?演算子によって式の値をOptionalに変換する事ができます。これを読み捨てる代入文と合わせて次のように書けます。

func main7() {
    _ = try? file.delete()
}

いささか簡単に書けすぎると思うかもしれませんが、Swiftには基本的に、書きづらくすることで間違ったことを避ける、といった考え方は見られません。プログラマがどのように対処するのが正しいかがわかっていることを前提として、その場合においてそれが簡潔に書けるように設計されていると思います。前提としての正しいやり方がわかっていないのに、とりあえずコンパイルエラーが無くなるように適当に書くようなスタイルでは、いずれにせよまともなものは出てこないので、これは妥当なスタンスだと僕は思っています。

あわせて読みたい

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

756
555
15

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
756
555

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?