[Swift] Swiftのエラー処理についてざっくりとまとめてみた
はじめに
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) { // エラー処理 }
引数error
はNSError
型(後述)の参照ポインタを渡しています。
渡すときに&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
NSError
はObjective-C
においても存在するエラーがモデル化されたクラスです。
NSError
にはdomain
、code
、userInfo
という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の使いどころ
始めにも書いたとおりNSError
はObjective-C
で標準的に使われていたエラークラスです。
Swift
ではObjective-C
との互換性を保つために存在している格好(String
とNSString
のような関係)になっています。
後述するSwift
での強力なエラーキャッチ機構のおかげもあって、
NSError
で使っていた識別用のドメインやコードを使う機会も減ってしまいました。
Swift
とObjective-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
が自動的に付与され、ユーザ情報は空辞書になるようです。
WebApiError
はInt
のRawValueEnum
ではないにも関わらず、自動的にコードが採番される挙動になるようです。
もちろん、以下のようにRawValueEnum
として定義した場合には、
/// WebAPIのエラー enum WebApiError: Int, Error { /// トークンが不正 case invalidToken = 100 /// パラメータが不正 case invalidParameters /// ユーザが見つからない case userNotFound }
出力結果は102
になります。
RawValueEnumではない列挙型でのエラーコード
前項のようにInt
のRawValueEnum
では任意のエラーコードを指定することができそうですが、
値を渡せる列挙型(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
しなければならない場合に、
途中でエラーが起きてしまった場合でもfinally
にclose
を書くことで最後にはそれを行うことが保証されます。
// ※ 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 4
とdefer 3
という文字列は出力されません。
エラー発生の直前のdefer
文から遡及的に実行されます。
defer
が実行されてからcatch
に入るという順番になることも注意しておきましょう。
ややこしいけど理にかなっている
「プログラミングとは上から下に流れていくもの」という頭で考えると、
defer
文はパッと見たところややこしい挙動ではあります。
しかし、プログラミングの順序として defer 1
よりもdefer 2
が先に実行されることによって、
defer 1
で何かをしたか(前述の例でいうとファイルを開いたか)をチェックする必要がなくなります。
そういう意味では理にかなった順番ではあります。
また、実行されていない処理に対する終了処理が呼ばれないという点も理にかなっているといえます。
「何かの準備をしたら、その終了処理をその直後に書く」という書き方にすると、
defer
文はとても見通しの良い文になるはずです。
逆に言うと見通しを悪くするdefer
文は、非常に追いにくいソースコードにもなりやすいかもしれません。
defer
文の中で色々なものを書き込まないように工夫するのも大事かと思います。
エラーキャッチ
ここでは、エラーキャッチの方法について確認をしたいと思います。
NSError
の項にて、
FileManager
のremoveItem
が失敗したときに、
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をまとめることで後々の自分にとっても役立ちそうです。
そして、この記事がどなたかの役に立てば幸いです。
では、またー。