https://youtu.be/CCCK54VV3Es
今回は、前回から見てきている A Swift Tour
の Error Handling
の続きです。エラー型の定義方法は確認したので、それを使ってエラーを送出する方法とその周りの特徴、そして送出されたエラーを捕捉して対応する方法あたりを眺めていくことになりそうです。どうぞよろしくお願いしますね。
———————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #71
00:00 開始 00:24 前回のおさらい 01:02 エラー送出 (throw) 01:52 throws 02:36 検査例外 04:20 エラー送出の基本 04:31 構造体によるエラー型 05:19 エラー送出する関数の定義 07:10 エラーとしてインスタンスを送出する 08:10 Error プロトコル 08:28 Sendable 09:33 エラー処理と並行処理 10:10 スレッドセーフ 10:52 Sendable と Error プロトコル 13:07 @unchecked Sendable 16:09 Sendable を継承したプロトコルを用いたときの静的解析 18:28 エラー型を作るときにはスレッドセーフを意識すること 21:47 Task が見つからない 22:39 ここまでの所感 22:56 import _Concurrency 24:07 Concurrency への移行は進んでいる? 28:32 順次非同期と並列非同期 29:15 API を2系統用意して対応していく案 31:06 エラー送出の要所 31:20 エラー対応 34:16 try? 36:58 try? の特徴 38:11 try! 39:25 エラーの原因を取得したいとき 42:58 do-try-catch 44:59 次回の展望 ————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #71
では、始めていきましょう。今日はエラーハンドリングについてお話しします。前回少し話を始めていましたが、その続きです。
前回はエラー型をSwiftの型を使って表現することについてお話ししました。その型はSwift標準のエラープロトコルに準拠しています。このような形で作られていることをお話ししました。今日は実際にこれを使う場面についてお話しします。
まず、エラーが起こった場合、それを通知する、つまり「throw」するという言い方をします。Swiftではこれを「throw」というキーワードを使って行います。この後にエラー型のインスタンスを指定することによって、そのインスタンスがエラーとして送出される仕組みです。
Swiftの場合、他の一般的なエラー処理の仕組みとは少し異なります。エラーを送出する機能に対しては、それを明示する必要があります。つまり、「throws」というキーワードを付けて、この関数はエラーを送出する可能性があることを示し、APIを使う側に伝える仕様になっています。これが基本です。
他の言語では、どこでも自由にエラーを送出できるものもありますし、Swiftのようにエラーが送出される場所が限定される言語もあります。さらに、Javaのように送出されるエラーを限定する言語もあります。ただ、Swiftの場合は、この関数がエラーを出すかもしれないという情報まではありますが、そのエラーが何なのかまではコードに組み込めない仕様になっているという点があります。
では、エラーハンドリングの基本的な送出の仕方をプレイグラウンドでおさらいしてみましょう。まず、エラー型を何かしら作り、これをエラープロトコルに準拠したものとして作成します。列挙型が一般的ですが、あえてストラクトにしてみます。例えば、エラーコードやエラーメッセージなど、エラーに関する情報をプロパティとして持たせます。
struct MyError: Error {
var code: Int
var message: String
}
このコードをエラーとして投げる場合、通常のメソッド定義だけではエラーは投げられないので、「throws」を関数定義に付ける必要があります。
func throwError() throws {
throw MyError(code: 404, message: "Not Found")
}
これにより、関数がエラーを発生させる可能性があることを示し、エラーハンドリングをしなければならないということをAPIの利用者に伝えます。また、「throws」があることで、関数のボディの中でエラーを送出することができます。このようにして、エラーハンドリングが実現されています。 なので、普通に let error =
のようにしてインスタンス化を先にして、それでエラーをスローすることも問題なくできます。これだけ書くと使い道がなさそうに見えるかもしれませんが、時折これが役立つことがあります。とにかく、他の Swift 標準の型のインスタンスと同じようにエラー型のインスタンスとして扱って、普通にそれを投げることができるという特徴があります。
エラー型、というかエラープロトコルですね。一応、定義を見てみますか。あまり見たことがなかったな、そういえば。Sendable
なプロトコルだけで特に中身は規定されていないって感じですね。
ちなみに、この Sendable
プロトコルというのは、コンカレンシー(並行処理)で使われるものです。勘違いしてなければそれで問題ないはずです。コンカレンシーの場合、ランダムにどのスレッドにもインスタンスが渡される可能性があるため、どのスレッドに渡っても安全に動くようなインスタンスに対して Sendable
というプロトコルが割り当てられます。つまり、Sendable
プロトコルに準拠した型はマルチスレッド対応になっていて、データの矛盾が起こらないようになっています。これにより、自由にコンカレンシーの中で使うことができるわけです。
エラー型に限って言えば、スレッドをまたがない場合には特に関係ないお話になりますが、async throws
のように、非同期と並行処理、エラー処理を絡めるようなときに使うことが多いです。教えてもらった async throws
ですね。 そうですね、try await
です。こういったときに Sendable
プロトコルが使われることがあります。
余談になりますが、Sendable
な型というのは、要はマルチスレッドをまたげるもので、スレッドセーフな型です。シンプルな例としては、イミュータブルクラスなどが挙げられますし、構造体であれば、通常はスレッドセーフです。なぜなら構造体の場合、代入文を使った時点でコピーされるため、基本的にはスレッドセーフです。ただ、ここにクラスを混ぜるとスレッドセーフではなくなります。これによりエラー型として準拠しなくなるのかは不明です。まだあまり Sendable
について詳しく見ていないのでわかりませんが、プレイグラウンドが動かなくなっているので確認できません。
せっかくですから、Sendable
についてもう少し見てみましょう。おそらく今後役に立つ、あるいは使わざるを得ない場面がたくさん出てくると思います。コンパイラーがこれを見てくれる可能性もありますし、見てくれない可能性も残っていますので、まずはちょっと見ていきたいですね。コンパイルが通ってランタイムに入るところまで確認してみましょうか。 これがエラープロトコルじゃなくてSendable
に適用させるだけだとどうなんだろう。Sendable
、こうするとこれもパスするかな。ダメだ、ほら。
Sendable
に直接準拠させると、オブジェクトがクラス型で、別にイミュータブルクラスで作ってるわけじゃないんで、Sendable
タイプの型じゃないっていうことでエラーになるんですよ。これを通すためにはオブジェクトをSendable
にするんだけど、なんだっけ、アトリビュート、忘れちゃったな。これだとエラーが出て教えてくれるかな。エラーで教えてくれると助かるけど、なんとかSendable
っていう属性、あった、アンチェックドだ。
アンチェックドSendable
っていう形にしてあげると、ここのSomeError
のほうのSendable
もパスして、スルーはできないって言われてるけど、エラー型じゃないんでこういうふうに動いてくれる。ちなみにアンチェックドSendable
というのは、言語自体はそれがスレッドセーフかは保証できないけど、これを設計した人がSendable
って言ってるんだからSendable
なんだろうなっていう、そういったフラグですね。属性だから、この中でスレッドセーフな設計にする責任はプログラマー側にあるんです。
構造体だとこのアンチェックドなんていうのはいらなくて、とにかくストラクトであって、中身がクラスみたいなSendable
じゃない型が使われてさえいなければ、自動的に構造体全体がSendable
として扱われます。何でSendable
で扱われるかというと、構造体は絶対にそのインスタンスそのものは複製が取られるので、なのでこれをどんなメソッドに渡したとしても、このときに5行目のバリューは絶対に値が6行目の先で何をされても影響を受けません。たとえこれがバーでもね。このアクションが仮にasync
だったとして別のスレッドへ投げたとしても基本的には何の問題もないです。
構造体だけ、値型だけで作られたものであれば何の問題もないので、標準的にSendable
として扱えます。Sendable
って付けないとダメなんだったかな。この辺りはコンカレンシーなので、今日はいいか。
とりあえずこういうふうな特徴を見せるよというところだけ押さえておけばオッケーでしょう。プロトコルの多段だとダメなのかな。例えば、Aがあって、ここがSendable
とかになってたときに、ここでクラスオブジェクトを使うと、こうすると11行目がエラーよね。そうね、何か違うのが出たな。丸括弧いらないね。これで11行目だけがエラーになるのかな。そうね、これがAに準拠させたときに11行目はこのままエラーか。そうか、クラスだからSendable
アンチェックドを付けないとダメだよって言われちゃったね。違うな、ここじゃないな。付けたかったのはこっちだ。間違いだ。これで11行目がエラーになるか。なるね。
おや、そうするとエラー型だけ検出できないのか。そうなんだ。これは何なのかな、バグなのかな。それとも予期に...予期に計らるわけないか。予期に計られないもんね。これは検出ミスかもしれないですね。なるほど、こういうこともあるんだ。確かに特殊なことをやってそうな可能性はあるけど、でもどんなに特殊なことをやろうともオブジェクト型がスレッドセーフにはならないですよね。 だから、特殊なことをやっているわけではないと思います。コンパイルのエラーの検出の仕方などについて、何か特殊な理由があってエラーが検出できなくなってる、という方が自然な気がします。あくまでも自分の想像ですけどね。エラーが出ないからといって安心しないほうが良さそうだなという気がします。
ここで大事なのは、エラー型として独自の型を作ったとき、その型がスレッドセーフでないとちょっと危険だということです。具体的には、クラスで作る場合や、中でクラスを内包する場合などが挙げられます。構造体でエラーを作った場合でも問題になることがありますね。例えば、enum
でエラー型を作り、その中でクラス型オブジェクトをエラーの候補として使った場合、スレッドセーフでなくなる可能性があります。
具体的には、例えばエラー型がenum
の場合で、エラーの候補としてcase someError(SomeObject)
のようにクラス型オブジェクトを使うと、スレッドセーフではなくなります。このようにオブジェクトのインスタンスを渡してエラーを返すようにすると、コンパイラはエラーを検出せず、問題なくコンパイルが通ります。しかし、これはスレッドセーフでないはずです。例えば、これがasync throws
を使う場合に、何か問題が起きる可能性があります。
問題が起きたとしても、たまたま正常に動いているだけかもしれません。それが怖いところです。この部分は自分の想像で話していますが、なかなかそういうところが怖いですね。このスレッドセーフではない状況が検出できないというのは怖いです。
一般的なプログラミング言語では、スレッドセーフでない状況は検出しないことが多いです。プロトコルの場合でも同様です。通常の言語仕様では、それがスレッドセーフかどうかまでは言語が干渉しないのが普通です。しかし、Swiftでは幸いにもそうした問題を検出してくれるところがあります。これが嬉しい点ですね。
さて、他に何かありましたかね。エラー生成の基本的なところはだいたいオッケーです。ここまでの話を軽くまとめると、エラープロトコルがSendable
に準拠しているのがとても興味深かったです。
それと、import Concurrency
が必要だということを試しにやってみたら、エラーが解消されました。見やすい位置に配置してみます。import
をそんな場所に入れられるのは面白いなと思いました。これでコンカレンシー関連の遊びがうまく進みますね。プレイグラウンドでも苦労している部分があるようですが、それでも動いてくれるなんて、良い情報ですね。この情報でコンカレンシーの勉強がはかどりそうです。
余談ですが、皆さんはコンカレンシーの対応はうまくいっていますか?私が作っているTwitterに投稿するアプリでは、非同期処理を多く使っていて、それにコンカレンシーを導入し始めてしばらく経ちました。これまではResult
を使っていて、要はコールバックですね。コールバックを使っていた部分をコンカレンシーに置き換えています。ただ素直にコールバックで受け取っている部分は問題ないのですが、DispatchQueue
を使ってメッセージを整列させて管理している部分や、UIのメインスレッドで動かさなければならない部分などがこんがらがってきて、コンパイルエラーが続出しています。そのため、なかなか進みません。
もう少し手探りで試行錯誤すれば、ここはこうすべきだというポイントが見えてくるかもしれませんが、現時点ではなかなか見えません。 とりあえず、見えてきたところとしては、ディスパッチキューを使ったスレッド管理とコンカレンシー、つまり並行処理は全然違うというところまでは分かってきました。そんな程度なんですけど、他の方々でサクサクっとできる人や、分かったよという人、あるいはここがポイントだったよという話には需要が結構あるんじゃないかなと思います。自分が分からなかっただけなんですけど、とにかく手に負えなくなっていて、またゼロからやり直すか、このままエラーだらけの中で試行錯誤するかを葛藤しながら、後者を選んでいる状況です。
やはりあまり触っていない方が多いんでしょうね。コメントを見ての感想です。本当にいきなり置き換えるのは難しい印象ですよね。新しいところから追加していくのが賢明なように思いますが、ついつい全部書き換えたい欲が出てしまいますね。とにかく、少し突破してみようとしています。
影響範囲が広いんですよね。最初はそんなに影響が広がるとは想像していませんでした。最初はコールバックだけ変更すれば済むかなと思っていましたが、スレッドの管理方法が全然違っていて、特にディスパッチキューを使っていた部分がアクターに変わると、たくさんエラーが出てきました。特にメインアクターとの衝突や Sendable
が足りないなどの問題が発生します。 Sendable
はいろいろなところで言及されていますね。
順次非同期と並列非同期という言葉があるんですね。順次非同期は初めて聞きました。後で調べてみようと思います。並列非同期は並行処理と言い換えてもいいかどうか微妙ですが、似たようなものだと思います。例えば、デッドロックがないから楽だという話もありますね。デッドロックには昔ずいぶん悩まされました。
全部置き換えるのではなくて、併用できるようにするのが良いのですね。なるほど、APIを2系統用意するなどして、部分的に対応するのが確かに賢いですね。コンパイルが1回通らないと試せなくなってしまいますからね。コンパイルが通っていたときはランタイムエラーを頼りに修正できていたので、地味にその点が重要です。いろいろと大変ですね。
並列非同期はAとBの非同期処理を同時に行うということです。並行処理に似ていますね。このまま書くと順次非同期になる、といったこともあります。await
の話でしょうね。await
で一旦プロセスが中断して、また戻ってきます。そのたびにどのスレッドで動くか分からないという状況が、並行処理の特徴です。まだ完全に把握できていないので、間違っているところもあるかもしれませんが、並行処理を調べるとまた面白い発見があるかもしれません。
脱線しましたが話を戻しましょう。エラー対応の話でしたね。キーポイントとしては、APIにthrows
を付けることでエラーをスローできるようになります。エラー対応の方法としては、do-catch
でエラーを捕獲する方法、try?
でエラーがあったときにnil
として扱う方法、そしてエラーをさらに上流に伝達する方法の3つが用意されています。 ただ、オプショナルに変換する方法もありますが、エラーが発生した場合にランタイムエラーで落とす方法もあります。それが「try!
」です。これらを一色他にすると3通り、「try!
」を別の方法と見なすと4通りのエラーハンドリング方法があると言えます。
このあたりをプレイグラウンドで見ていきましょう。「try?
」と「try!
」がまずは簡単です。非同期処理(async
)は情報量が多くなりすぎるので避けましょう。デシンダブルやオブジェクト、エラーも今回は浅く取らなくていいでしょう。仮にこんな状況を考えます。ある関数がエラーを返すことが確実な場合です。
通常は条件処理を行い、問題があればエラーになるような形にします。例えばフラグがあって「guard flag else throw
」というのが基本形で、値を返す関数を作ります。例えば整数(Int
)型の値を返すとします。インスタンス化の説明は今回は省略します。エラーの一種として"some error"
を設定します。
func someFunction() throws -> Int {
guard flag else { throw SomeError.some }
return someValue
}
この状況でエラーハンドリングの一つの方法としてtry?
を使います。
let result = try? someFunction()
これにより、エラーが発生した場合にはnil
が返ってきます。エラーが発生しなければ通常の戻り値が得られるため、この方法はとてもシンプルなエラーハンドリングとなります。これにより戻り値がオプショナル型になります。try?
を使うことで関数の型がInt
を返すのに対し、結果はOptional<Int>
型になります。これにより、Swiftの豊富な補助機能を使って柔軟にエラーハンドリングが可能となります。
例えば、ガード文を使ってエラーハンドリングを行う場合は以下の通りです。
guard let result = try? someFunction() else {
// エラーハンドリング
exit(1)
}
こうすることで、まるでオプショナルバインディングのようにエラーハンドリングができます。このtry?
の面白いところは、エラーが発生した場合にエラーの詳細がなくなり、nil
が返ることです。エラーの詳細が不要な場合には便利です。
例えば、会員登録の際にエラーが発生して登録できなかった場合など、エラーの詳細が不要でシンプルに処理したい場合にはtry?
が便利です。
対して、エラーが発生したら即座に処理を中断させたい場合にはtry!
を使います。これを使うことで値を直接取得し、エラーが発生した場合にはランタイムエラーで処理が中断されます。
let result = try! someFunction()
この場合、result
はInt
型となり、エラーがなければ通常の戻り値を取得できます。エラーが発生した場合にはランタイムエラーでプログラムがクラッシュします。
このように、シンプルなコードを書く際やコマンドラインツールのような場合には、try!
が非常に便利です。ただし、エラーの原因が取得できないという欠点もあるため、使い所を考慮する必要があります。例えば、シェルスクリプトを作成する場合には、try!
のランタイムエラーでエラーコードを返すときには注意が必要です。エクステンションでカスタムエラー情報を追加する手法もあります。 候補が1個しかないからなんとも言えないですが、例えばサムだった場合には print("サムエラー")
みたいにしてみるとどうなるのでしょうか。ランタイムエラーはどうなるのか、サムエラーになるのでしょうか?少し分かりにくいですかね。カスタムエラーメッセージとして設定するとどうなるのか、カスタムエラーメッセージって取れるのでしょうか。きっと取れるはずです。
こういうふうにカスタマイズしていけるから、カスタムエラーメッセージが出ましたね。try!
でランタイムエラーを起こしても、エラー情報が取得できるので、それをログに残しておけば良いわけですよね。何かしらの情報は取れてるんだと思います。ですが、try?
でエラー情報が取れるわけではないですよね。何か面白い方法があれば、直前のエラーメッセージなども取れたら面白いのですが。直前のエラーメッセージを取る関数があれば便利ですね。例えば、C言語の場合、直前のエラーメッセージを取るのは strerror
ですが、これは文字列変換です。もしかして取れるかもしれませんが、どうなんでしょうね。
エラーメッセージをログとして残しておけば、try?
を使いつつログが取れる、みたいなことができるかもしれませんが、キャッチしておけば良いのかなと思います。
さて、try?
の他にも do-try-catch
の話もしたかったのですが、時間がないので少しだけやります。他のやり方としては、do
ブロックに try
を書いて、エラーが起こったときには catch
ブロックへ飛ぶという、例外処理のお馴染みの書き方がありますね。こういった書き方もできます。
この方法を使うと、エラーが起こったときにはエラー情報が得られるので便利です。そのため、try?
で色々と頑張るよりも、この方法が良いですね。エラーハンドリングを使っている以上、エラー情報が必要になる場面が多々あります。try?
だとなかなか使いどころがなくなってくるのが現実です。そのため、最初から do-try-catch
を使いがちですが、逆に try?
を使う場面を見極めることで、新しい発見があるかもしれません。
今日はこれぐらいにして、次回は do-try-catch
の色々な書き方について詳しくおさらいしていこうと思います。それでは、今日の勉強会を終わりにします。お疲れ様でした。ありがとうございました。