[Swift] Swiftのエラー処理についてざっくりとまとめてみた

2020.09.02

はじめに

CX事業本部の中安です。まいどです。

今回は「Swiftのエラー処理」についてザザッとまとめてみます。

タイトルがなんだか「エラー処理について全部教えてやんよ」みたいな仰々しいものになってしまいましたが、 どちらかというと忘れがちな自分のためのメモ書きのようなアウトプットになります。

Swift初学者の方にも役に立つように丁寧めに書いていこうと思います。

iOSアプリ開発におけるエラーハンドリング

エラーハンドリング

プログラムの処理中に処理が妨げられる事象が発生した際、その処理をエラーとして対処する処理のことである。「例外処理」とも呼ばれる。

エラーハンドリングが組み込まれていないプログラムは、想定範囲外の入力データが与えられたなどの実行時ランタイムエラーが起きると即座に異常終了する。エラーハンドリングではエラーの発生を検知し、プログラム内にこれを処理するルーチンなどが用意されているかを調べ、存在する場合には対応する処理を行う。用意されていなければ通常と同じように終了する。

「エラーハンドリング」の言葉の定義としては引用した通りですが、 あらためてまとめると、ここでいうエラーハンドリングとは以下のようなことを指したいと思います。

  • エラーを捕捉することができる
  • エラーの内容を知ることができる
  • エラーの内容で処理を分岐することができる

Objective-Cでのエラーハンドリング

ファイルの操作等を行うクラスFileManagerを例に取ります。

Swiftではバージョンアップの過程で今の名前に変わりましたが、 元々は接頭語のNSがついていてNSFileManagerという名前のクラスでした。

Objective-Cにて、このNSFileManagerには 下のようなシグネチャでファイルを削除するメソッド (メッセージとも言います) が用意されています。

- (BOOL)removeItemAtPath:(NSString *)path 
                   error:(NSError * _Nullable *)error;

実際の使い方はこのような感じです。(久々にObjective-C 書いた・・・)

NSString *path = @"ファイル/パス/";
NSError *error = nil;
[[NSFileManager defaultManager] removeItemAtPath: path error: &error];

if (error != nil) {
   // エラー処理
}

引数errorNSError型(後述)の参照ポインタを渡しています。 渡すときに&errorと書くことで、エラーがあった場合にメソッド内部でこの参照が更新され、NSErrorの実体が代入されます。

つまり、ファイル削除中にエラーが起きた場合は、このerrorの中身を確認すればハンドルできるということですし、 逆に言うとエラーハンドリングをしないときはerror:にはnilを渡すこともできます。

Swiftでのエラーハンドリング

これと同じ内容をSwiftに置き換えてみます。

まずはメソッドのシグネチャですが、FileManagerクラスの定義を見ると以下のようになっています。

open func removeItem(atPath path: String) throws

error引数はなくなり、throws句が足されています。

throws句があるメソッドは、 例外が投げられる可能性があるのでtry文を使ってあげる必要がありますが、 エラーの取扱いによって書き方が変わってきます。

try!

let path = "ファイル/パス/"
try! FileManager.default.removeItem(atPath: path)

渡すファイルパスにファイルが確実に存在していて、削除が可能であるファイルであることが確実である状態。 つまり、エラーが発生しないことが確実なのであれば、try!を呼び出しの前につけます。 これらの保証がされていない状態でのtry!はアプリがクラッシュする原因となるので注意しましょう。

エラーが起きないのですから、エラーハンドリングしてやる必要もありません。

try?

let path = "ファイル/パス/"
try? FileManager.default.removeItem(atPath: path)

前述とは違いtry?を使う場合は「エラーは起きるかもしれない。でも起きたとしても無視する」というときです。 ファイルの削除を試みてみるが、失敗したらそのまま何もせずに次の行へと進みます。

エラーを無視するのですから、エラーハンドリングしてやる必要もありません。

戻り値がある場合は、try?をつかうことで成功失敗問わずオプショナル型になります。

下の例では、ディレクトリパスを渡すとそのディレクトリにあるすべてのファイル名を返すメソッドを定義しています。 FileManagerに要求をしてエラーだった場合は空の配列を返しています。

func contents(at directoryPath: String) -> [String] {
    guard let contents = try? FileManager.default.contentsOfDirectory(atPath: directoryPath) else {
        return []
    }
    return contents
}

「エラーが発生したらこうする」ということは書いてあるのですが「実際には何のエラーが起きたのか」は捕捉できていないので、 完全なエラーハンドリングとはいえません。

中にファイル存在するはずのディレクトリのパス文字列を渡しているのに、 常に空配列で返ってきてくる場合に、 なぜそうなるのかはブレークポイントを貼ってデバッグしていかないと原因がわからないわけです。

do try catch

さて、ではSwiftでエラーハンドリングしたい場合は以下のようにします。

do {
    try FileManager.default.removeItem(atPath: path)
} catch {
    // エラー処理
}

このように do-catch文の中に例外を投げる可能性のある処理を書きます。 例外を投げる可能性のあるメソッドを呼び出すときはtryを先頭に書かなくてはなりません。 また、try!でもtry?でもないtryは、do文の中でしか書けないので注意です。

さて、ここで実行時にエラーが発生すると、その時点でcatch文に進むことになります。

catchしか書いていない場合、自動的にErrorオブジェクトであるerrorという非オプショナルな変数が渡されてきます。 ここでいうErrorオブジェクトとはクラスや構造体などの具象型ではなく、プロトコルによる抽象型のオブジェクトとなります。

では、先ほど作ったディレクトリ内のファイル名を返すメソッドを次のように改修してみます。

func contents(at directoryPath: String) -> [String] {
    do {
        return try FileManager.default.contentsOfDirectory(atPath: directoryPath)
    } catch {
        print(error.localizedDescription)
        return []
    }
}

このようにerrorという変数が定義されていないにも関わらず、catch文のブロックの中でerrorという変数を使うことができます。

Errorにはエラー内容を文字列で返すlocalizedDescriptionプロパティが付与されています。 その中身をコンソール出力しているだけですが、エラーを捕捉することができ、そのエラーの内容を知ることができるようになりました。

NSError

NSErrorObjective-Cにおいても存在するエラーがモデル化されたクラスです。

NSErrorにはdomaincodeuserInfoという3つの情報が含有されています。 ハンドルしたErrorオブジェクトはNSErrorに常にキャストすることができ、それらの情報を参照することができます。

ドメイン(domain)とコード(code)は、エラーの種類を識別するための文字列と整数値として使われてきました。 そして、ユーザー情報(userInfo)はエラーに関する付加情報になります。

それでは、先程のファイルを削除するサンプルソースを例にとって、ErrorオブジェクトをNSErrorにキャストして情報を見てみます。

do {
    let path = "/wrong/path" // 不正なファイルパス
    try FileManager.default.removeItem(at: URL(fileURLWithPath: path))
} catch let error {
    let nsError = error as NSError
    print(nsError.domain)
    print(nsError.code)
    print(nsError.userInfo)
}

出力結果は

NSCocoaErrorDomain
4
[
  "NSFilePath": /wrong/path, 
  "NSUnderlyingError": Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory", 
  "NSUserStringVariant": <__NSSingleObjectArrayI 0x600002a083b0>(
    Remove
  )
]

このように、as句をつけることでErrorオブジェクトをNSErrorとして振る舞わせることができます。

NSErrorの使いどころ

始めにも書いたとおりNSErrorObjective-Cで標準的に使われていたエラークラスです。 SwiftではObjective-Cとの互換性を保つために存在している格好(StringNSStringのような関係)になっています。

後述するSwiftでの強力なエラーキャッチ機構のおかげもあって、 NSErrorで使っていた識別用のドメインやコードを使う機会も減ってしまいました。

SwiftObjective-Cが混在するプロジェクトであれば、もしかしたらNSErrorを意識する場面はあるかもしれませんが、 Swiftネイティブなものであれば意識する場面はあまりないかと思います。 (混在してても意識しないかも・・・)

ただ、元々の仕組みを知っておくのは悪くはないことだと思いますので、この記事に書かせていただきました。

カスタムエラーの定義

標準で様々なエラーについての定義がなされていますが、開発するアプリ独自のエラーも起きうると思います。 そういったエラーについては積極的に独自のエラーを定義していきましょう。

先ほども書いたようにSwiftでのErrorはプロトコルになります。 言ってしまうとErrorプロトコルに準拠していれば何でもエラーとしてスローできてしまうのですが、 あまりそういった乱暴なことはしてはいけないと思います。 エラーはエラー型として定義しておくことが大事です。

エラーのカテゴリーと、その具体的な種別を定義することができるという意味でenumを使うことがオススメの方法になります。

具体例を書くと

/// WebAPIのエラー
enum WebApiError: Error {
    /// トークンが不正
    case invalidToken
    /// パラメータが不正
    case invalidParameters
    /// ユーザが見つからない
    case userNotFound
}

といったような感じです。

例外を投げたいときにはthrow文を使って以下のように書けばいいので簡単です。

throw WebApiError.userNotFound

NSErrorにキャストすると?

ちなみに、先程のNSErrorの項のように、 catch時にカスタムなエラー型をキャストしたときはどうなるかを試してみましょう。

/// WebAPIのエラー
enum WebApiError: Error {
    /// トークンが不正
    case invalidToken
    /// パラメータが不正
    case invalidParameters
    /// ユーザが見つからない
    case userNotFound
}

do {
    throw WebApiError.userNotFound
} catch let error {
    let nsError = error as NSError
    print(nsError.domain)
    print(nsError.code)
    print(nsError.userInfo)
}

出力結果は

WebApiError
2
[:]

このようにドメインとコードはクラス名と2が自動的に付与され、ユーザ情報は空辞書になるようです。

WebApiErrorIntRawValueEnumではないにも関わらず、自動的にコードが採番される挙動になるようです。 もちろん、以下のようにRawValueEnumとして定義した場合には、

/// WebAPIのエラー
enum WebApiError: Int, Error {
    /// トークンが不正
    case invalidToken = 100 
    /// パラメータが不正
    case invalidParameters
    /// ユーザが見つからない
    case userNotFound
}

出力結果は102になります。

RawValueEnumではない列挙型でのエラーコード

前項のようにIntRawValueEnumでは任意のエラーコードを指定することができそうですが、 値を渡せる列挙型(AssociatedValuesEnum)である場合などは、これを使用することができません。

/// WebAPIのエラー
enum WebApiError: Error {
    /// トークンが不正
    case invalidToken 
    /// パラメータが不正
    case invalidParameters
    /// ユーザが見つからない
    case userNotFound
    /// その他
    case other(message: String) // <- Associated Value がある
}

そのような場合は_codeというプロパティを定義することで任意のエラーコードを指定することができます。

/// WebAPIのエラー
enum WebApiError: Error {
    // 省略
    
    var _code: Int {
        return 1000 // ここを任意の数値にする
    }
}

先程のようにNSErrorにキャストしてcodeプロパティを出力しようとすると、1000が吐き出されることが確認できると思います。

同じく_domainというプロパティを定義すると、NSErrorキャスト時のdomainの戻り値を上書きすることができます。

/// WebAPIのエラー
enum WebApiError: Error {
    // 省略
    
    var _domain: String {
        return "HogeHoge" // ここを任意の文字列にする
    }
}

しかし、先程も書いたとおりNSErrorベースでエラーハンドリングするケースも少ないでしょうし、 このあたりを上書きするメリットもなさそうなので、 こちらのTipsは「こういうこともできる」というオマケ程度として読んでください。

defer

他の言語での try-catch

例外処理でtryを含むcatch文といえば、Javaなどで見られるfinallyです。 途中で例外が発生しても「最終的にはこれを行う」という処理を書いておくブロックになります。

使いどころの例でいうと、下記のようにファイルを開いて最後にはcloseしなければならない場合に、 途中でエラーが起きてしまった場合でもfinallycloseを書くことで最後にはそれを行うことが保証されます。

// ※ Javaでの例
File file = new File("ファイル/パス/");
FileReader fileReader = null;
try {
    fileReader = new FileReader(file);

    int data;
    while ((data = fileReader.read()) != -1) {
        System.out.print((char) data);
    }
} catch (IOException e) {
    e.printStackTrace();
} finally {
    try {
        if (fileReader != null) {
            fileReader.close();
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

この場合、fileReader.read()が何かしらの理由でエラーが発生してもcatchを経てfinallyに進んでくれます。

Swiftの場合

Swiftではfinallyという構文はありません。 その代替としてdefer文という構文が存在しています。

同じく途中で例外が発生しても「最終的にはこれを行う」という処理を書いておくブロックになりますが、 書き方や挙動は少し気をつける必要があります。

do文の中にdefer文を書く

先述のJavaのようにtry-catch-finallyという構文ではなく、do文のブロックの中にネストする形でdefer文を書きます。

do {
    let file = File("ファイル/パス")
    
    let fileReader = FileReader(file)
    defer {
        fileReader.close()
    }
    
    while let data = try fileReader.read() as Int?, data != -1 {
        print(data)
    }
} catch let error {
    print(error.localizedDescription)
    return []
}

雑な例になってしまいますが、先ほどのJavaで書いたような処理をSwiftに焼き直してみるとこのような感じでしょうか。 (実際にはFileReaderという標準クラスはありませんが)

基本的にtryを行う前にdefer文を書く

先ほどの例でいうと、fileReader.read()でエラーが発生するかもしれないのでtryを書いています。 そのあとにdefer文を書いてしまうと、そこに到達しないという言語仕様になっています。 思わぬバグになるのでdefer文の定義は先にしておきます。

do {
    let file = File("ファイル/パス")
    
    let fileReader = FileReader(file)
    
    while let data = try fileReader.read() as Int?, data != -1 {
        print(data)
    }
    
    defer {
        // ここに書いてしまうとエラー発生時に入ってこない
        fileReader.close()
    }
} catch let error {
    print(error.localizedDescription)
    return []
}

defer文は何度も書ける

defer文は実は同一スコープ内で何個も書くことができます。 しかし、その呼び出し順は気をつけなくてはいけません。defer文は定義されたのが後のほうが先に実行されます。

例を見てみます。

do {
    defer {
        print("defer 1")
    }
    defer {
        print("defer 2")
    }
    try hogehoge() // エラーが起きるかもしれない
    defer {
        print("defer 3")
    }
    defer {
        print("defer 4")
    }
} catch {
    print("エラーが発生")
}

func hogehoge() throws {
    
}

このようにdo文の中にdefer文を複数書くことができます。

hogehoge()というエラーが発生するかもしれないメソッドがあるとして、 発生しなかった場合の実行結果は

defer 4
defer 3
defer 2
defer 1

というように、後ろから実行されていることが分かります。

では、逆にエラーが発生した場合はというと

defer 2
defer 1
エラーが発生

また、先ほども書いたようにtry実行のあとのdefer文はエラー発生時には実行されないため、 defer 4defer 3という文字列は出力されません。 エラー発生の直前のdefer文から遡及的に実行されます。

deferが実行されてからcatchに入るという順番になることも注意しておきましょう。

ややこしいけど理にかなっている

「プログラミングとは上から下に流れていくもの」という頭で考えると、 defer文はパッと見たところややこしい挙動ではあります。

しかし、プログラミングの順序として defer 1よりもdefer 2が先に実行されることによって、 defer 1で何かをしたか(前述の例でいうとファイルを開いたか)をチェックする必要がなくなります。 そういう意味では理にかなった順番ではあります。

また、実行されていない処理に対する終了処理が呼ばれないという点も理にかなっているといえます。

「何かの準備をしたら、その終了処理をその直後に書く」という書き方にすると、 defer文はとても見通しの良い文になるはずです。

逆に言うと見通しを悪くするdefer文は、非常に追いにくいソースコードにもなりやすいかもしれません。 defer文の中で色々なものを書き込まないように工夫するのも大事かと思います。

エラーキャッチ

ここでは、エラーキャッチの方法について確認をしたいと思います。

NSErrorの項にて、 FileManagerremoveItemが失敗したときに、 NSCocoaErrorDomainというドメインが出力されたことが確認できました。

NSCocoaErrorDomain
4
[
  "NSFilePath": /wrong/path, 
  "NSUnderlyingError": Error Domain=NSPOSIXErrorDomain Code=2 "No such file or directory", 
  "NSUserStringVariant": <__NSSingleObjectArrayI 0x600002a083b0>(
    Remove
  )
]

この場合CocoaErrorというエラーオブジェクトが投げられていることがドメインから推測でき、 "No such file or directory"という内容のエラーであることが分かります。

ここまで分かると、ファイルやディレクトリが見つからなかった場合の分岐を簡単に行うことができます。 catch文によるエラーの分岐をswitch文と同じように行うことができるためです。

do {
    let path = "/wrong/path" // 不正なファイルパス
    try FileManager.default.removeItem(at: URL(fileURLWithPath: path))
} catch CocoaError.fileNoSuchFile {
    // ファイルやディレクトリが見つからなかった場合の処理
} catch {
    // その他のエラー時
}

この場合、CocoaError.fileNoSuchFileに入ってくる時はすでにエラーの種類が判別できているため、 単なるcatch文(例では「その他のエラー時」)の時とは違い、 errorというオブジェクトは自動的には渡されません。

その他のエラーを足したいときもswitch文のcaseと同じように、catch文を増やしていけばOKです。

// 省略
} catch CocoaError.fileNoSuchFile {
    // ファイルやディレクトリが見つからなかった場合の処理
} catch CocoaError.fileLocking {
    // ファイルがロックされている場合の処理
} catch {
    // その他のエラー時
}

このようにシンプルにエラーの場合分けができるので、ソースコードもすっきりしますね。

デフォルトのエラーキャッチ処理について

単なるcatch文を定義すると、errorという変数が自動的に渡されることは何度か書いてきました。

これを何かしらの理由により変数名を変更したい場合は、以下のようにletを使って定義することができます。

// 省略
} catch let hoge {
    print(hoge.localizedDescription)
    print(error.localizedDescription) // これはコンパイルエラーになる
}

こうすることでerrorに代わってhogeがエラーオブジェクトとして扱えるようになります。

エラードメインごとに共通の処理をするとき

「カスタムエラーの定義」の項では、 エラーのカテゴリーと具体的な種別を定義するために列挙型でエラー定義する方法を書きました。

この「エラーのカテゴリー」を「エラードメイン」と呼ぶこととして、 エラードメインによってエラーのキャッチを分岐させたい時にはどうしたらよいでしょうか。

その場合はis句を使うことで実現することができます。

// 省略
} catch is CocoaError {
    // CocoaErrorだった時
} catch {
    // その他のエラー時
}

このようにすることで、エラー分岐がすっきりとカテゴライズできます。

しかし、is句を使ったcatchのブロックではerror変数を使うことができません。 error変数を使いたい場合はまた違う書き方になります。

// 省略
} catch let error as CocoaError {
    // CocoaErrorだったときの共通処理
    doSomething()
    
    switch error.code {
    case .fileNoSuchFile:
        // ファイルやディレクトリが見つからなかった場合の処理
    case .fileLocking {
        // ファイルがロックされている場合の処理
    default:
        // その他のCocoaError時
    }
} catch {
    // その他のエラー時
}

エラードメインごとの共通処理を噛ませてから各エラーを分岐させたい場合には、 この例のような書き方になると思います。

let error as CocoaErrorとしているので、errorは抽象型のErrorではなく、 具象型のCocoaErrorとして扱うことができます。

なので、error.codeを使ってswitch分岐も容易くできることになります。

AssociatedValuesEnumのエラーの場合

エラーが値を渡すことができる列挙型(AssociatedValuesEnum)の場合は、 その値をcatch内で使用することもできます。

具体例を書くとこのような感じです。

/// カスタムエラー
enum CustomError: Error {
    case something(message: String)
}

do {
    throw CustomError.something(message: "何かのエラーです")
} catch CustomError.something(let message) {
    print(message)
} catch {
    // 何かエラー処理
}

かなり雑な例になりましたが、エラーに何かしらのメッセージ文が渡されるとして、 こちらもswitch文と同じようにその値を取得することができます。

渡されたmessageを使って何かをする場合に便利ですが、使用しないというときには省略することもできます。

// 省略
} catch CustomError.something {
    // 何かエラー処理
} catch {
    // 何かエラー処理
}

最後に

Swiftのエラーについての機構はなかなか強力です。 それゆえに「こういう書き方ができる」という作法をふと忘れてしまうこともあり、 調べ直したり、面倒な書き方で書いてしまったりする場面がありました。

この記事で全網羅しているわけではないと思いますが、 そういったエラーハンドルの処理のTipsをまとめることで後々の自分にとっても役立ちそうです。

そして、この記事がどなたかの役に立てば幸いです。

では、またー。