今回は、前回に時間の都合で先送りした Error Handling
の エラーからの復帰
についてみていきます。The Swift Programming Language に紹介されている例題がエラーからの復帰を想定していそうなものになっていながら具体的なコードには触れられていなかったので、良い機会なので例題をどんなふうに味付けすると良い感じに処理を復帰できるのかみたいにあれこれ試してみようと思ってます。どうぞよろしくお願いしますね。
今回は参加者の一般公募はなかったので、ゆめみ社内の人たちのみでの参加になる見込みです。
—————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #183
00:00 開始 00:21 前回のおさらい 01:11 エラーからの復帰が必要なのでは? 03:35 エラーハンドリングはどうやる? 05:45 どのようにエラーを処理するか決めていく 07:56 ベース名の名前付けについて 10:51 前置詞句はバランスを考慮して命名 12:40 成功するまで繰り返してみる 14:52 どうやってハンドリングしていこう? 15:15 再起呼出で整えてみる 18:35 エラーをどこまで想定していくか 21:07 fatalError を現実に例えると? 23:00 エラーハンドリングを添えていってみる 24:30 中止したときの呼び出し元への伝え方 26:27 今どきは成否を真偽値では返さないかもしれない 27:40 Result 型を使った表現 30:39 Result を返すか、スローイング関数にするか 32:06 Result とエラーハンドリングとの相互変換 ——————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #183
では始めていきましょう。今日は、エラーハンドリングの例です。The Swift Programming Languageの中に書かれていたコードで、エラーハンドリングを使ってさまざまなエラーに対処する例がスライドに書かれていますが、それ自体は間違っていません。ただ、対処する例としては微妙な部分があります。
前回もお話ししましたが、例えばサンドイッチを作って売れたら食べる、売れなかったら食べるに関するエラー処理として、お皿が綺麗なのがないときには皿を洗う、食材が足りないときには食材を買う、というのがあります。しかし、このプログラムをそのまま完成品として使うと、サンドイッチができなかったときに皿を洗って終わる場合や、食材を買って終わる場合があるのです。これは問題です。まだ皿を洗って終わるぐらいならお腹が空く程度で済むかもしれませんが、食材を買って終わるのはまずいですよね。買った食材をどうするのかという問題が残ります。
やはり正常系に復帰させる必要があります。これがエラーハンドリングで重要なポイントです。この点がこのコードでは少し欠けているので、このあたりを考えながら、どんな復帰のコードが書けるかを考えると面白いと思います。エラーハンドリングに関する知見が独学メインのせいか、資料を読んでもエラーハンドリングに関する解説はあまり聞いたことがない気がします。少なくとも自分は。
業務でちゃんと知見の共有がされているのか、それとも感覚で作っているのか、気になります。自分は感覚で作っている方だと思います。エラーハンドリングの際は状況によって前提が変わるので一概には言えませんが、正しいやり方があるかもしれません。今までお話ししてきたエラーハンドリングの話でも、正常系に戻すといった話はあまりしていませんでした。
例えば、リクエストに失敗した際にどのように戻るか、無限リトライを避けるためには仕様を設計する必要があります。仕様が決まっている場合にはその通りに作るしかないですが、定まっていない場合には適当になりがちです。ログに記録した後で復帰させることもありますが、それが物理的に可能か否かも考える必要があります。
リクエストに失敗しましたという場合には、一括で処理が終わってしまって、その後続けるかは限りません。しかし、設計をしっかりすれば無限ループになることも避けられるので、仕様から設計していくことが重要です。
エラーハンドリングが利用の都合に合わせて設計されていれば、例外のサンドイッチのエラー処理も適切に行えます。エラーハンドリングを特別視せずに、利用に応じて適切に行うことが大切です。
では、さっきのコードをまずはプレイグラウンドで動くように整えていきます。最初にエラー型を定義します。たとえば、以下のように書きます:
enum SandwichError: Error {
case outOfCleanDishes
case outOfIngredients
}
続いて他のケースも定義して、エラーハンドリングのコードを書いていきましょう。上述のコードはエラーハンドリングを適切に使うことができるので、それに沿って進めていきます。 まずはエラーを定義します。エラーケースとして「アウトオブクリーム」を追加し、もう一つ「ミッシングイングレディエンツ」をケースとして定義します。ここでアソシエーティブバリュを渡しますが、今回はストリングにします。
enum SandwichError: Error {
case outOfCream
case missingIngredients(String)
}
次に、makeSandwich
関数を定義します。この関数は throws
を使ってエラーを投げる可能性があります。
func makeSandwich() throws {
// ここにサンドイッチを作るロジックを入れます
}
そして、この makeSandwich
関数を呼び出すコードを書きます。
do {
try makeSandwich()
// サンドイッチを作ることが成功した場合の処理
} catch SandwichError.outOfCream {
// クリームが足りない場合の処理
} catch SandwichError.missingIngredients(let ingredient) {
// 具材が足りない場合の処理
print("\\(ingredient) が足りません。")
} catch {
// その他のエラー
}
次に eatSandwich
関数も定義します。
func eatSandwich() {
// ここにサンドイッチを食べるロジックを入れます
}
これで基本的なサンドイッチを作るエラーハンドリングのセットアップは完了です。次に、Playground
でコードを試してみます。まず、全体の動作を確認するためにコンパイルエラーがないか見てみましょう。
do {
try makeSandwich()
eatSandwich()
} catch SandwichError.outOfCream {
print("クリームが足りません。")
} catch SandwichError.missingIngredients(let ingredient) {
print("\\(ingredient) が足りません。")
} catch {
print("その他のエラー")
}
この部分に関しては少し説明が必要かもしれません。APIデザインガイドラインによれば、「一つだけのパラメータがある場合は外に出す」と記載があるので、場合によってはラベルを使わない場合もあります。
例えば、以下のようにラベルを使うことが推奨されている場合があります。
func moveTo(x: Int, y: Int) {
// ここで移動ロジックを実装
}
このようなラベルを明確に使うことでコードの可読性が向上します。
次に、例えばポイントやムーブなどが1次元の場合、ラベル名を省略せず以下のように記述します。
func move(to point: Point) {
// 移動ロジック
}
もう一つ例として、インデックスの操作がある場合、インデックスを直接内部で使用するかどうかを検討します。
func indexOf(_ element: Element) -> Int? {
// インデックス検索ロジック
}
次に、繰り返し処理の例を見てみましょう。例えば、サンドイッチを食べるまで繰り返すロジックです。
var success = false
while !success {
do {
try makeSandwich()
eatSandwich()
success = true
} catch SandwichError.outOfCream {
print("クリームが足りないので、洗って再試行します。")
} catch SandwichError.missingIngredients(let ingredient) {
print("\\(ingredient) が足りないので、洗って再試行します。")
} catch {
print("その他のエラーが発生しました。")
}
}
このコードでは、サンドイッチを作るのに失敗した場合でも、エラーハンドリングを通じてサンドイッチを作る試みを繰り返します。while
ループと do-catch
ブロックを使って繰り返し処理を実装しています。
これで一通りのエラーハンドリングと繰り返し処理の流れが完成しました。全体のコードの可読性と実行漏れがないか再度確認して完成です。 通常、材料を揃える場合やユーザーにアラートを出す形で完了するのが自然です。ただし、特定のイベントが発生するアプリの場合、少し異なる対応が必要です。私なら、再帰的な処理(リクルーシブな処理)をしますね。再帰的ですから、たとえば「お皿を洗う」という作業が最低限必要になってくるわけです。
この再帰的なプロセスの一環として、「サンドイッチを作る」という関数をひとつにまとめるのも有効です。具体的に見てみましょう。例えば、makeSandwich
という関数があれば、その中でサンドイッチ作りに必要なすべての工程を含めるわけです。
func makeSandwich() {
// ここにサンドイッチ作りの手順を書く
gatherIngredients()
prepareIngredients()
assembleSandwich()
serveSandwich()
}
このようにやることで、一連の流れが明確になります。次に、例えば関連するイベントが発生した場合にどのように対応するかも考慮する必要があります。
食材を買う場合の例を考えましょう。食材が揃わなかった場合、どのように復旧するかを設計しておく必要があります。もし食材を買いに行って、目当ての食材がない場合の処理も含めなければなりません。
もう少し具体的に見ると、例えば皿を洗う過程で失敗した場合を考えましょう。2回目の皿洗いが失敗したら無限ループになり得ます。これを防ぐためには適切なエラーハンドリングが必要です。
func washDish() throws {
// 皿を洗う処理
if failToWashDish {
throw WashingError.failed
}
}
そして、仮に食材の入手がうまくいかなかった場合の処理も考えます。ここには例外処理を活用することが考えられます。
func buyIngredients() throws {
// 食材を買う処理
if noIngredientsAvailable {
throw BuyingError.outOfStock
}
}
これをうまく組み合わせることで、プロセス全体の頑健性を高めることができます。無限ループやスタックオーバーフローを防ぐには、早めに問題を検知し、ユーザーに対する適切なエラーメッセージを表示することが重要です。特に、食材が必ず揃うとは限らない場合のように、復旧が難しいシナリオに備えることが肝要です。
こういった形で、各プロセスのエラーハンドリングをしっかりと設計していく必要があります。また、皿を割るなどの予期せぬ事態にも対応できるように、ストックの確認やリカバリープロセスの実装も重要です。 利用状況によるね。随分とね。じゃあ、割れる可能性を考慮する方が自然なのかな。割れたとき、まあ、皿なしで食べられればいいのかな。うん。ちょっと、もうね。でも、この辺りをどこまで考えて、どこまで折り合いをつけていくのか、それ以上はハードエラーにするのかが、大事なところではありますね。
そうか、皿洗いもどこまで考えたいかな。ちょっといいアイデアが浮かびました。これが身内の筋肉だったとしたら、皿洗いを一応試みるけど、洗えなかったらそのまま使うとかね。そういった程度で復帰しちゃってもいいのかなとか。よほどのものを除いて、仮によほどのものだったらフェイタルエラーで落とすとかね。
現実と当てはめたときにフェイタルエラーってどういう状況になるんだろうね。共生終了って現実にはあまりないからね。そう、フェイタルエラーね。実行中じゃなくても、道が凍ったとかありますよね。時々、車でどこかに行ったときに、例えば台風で通れなくなったとか迂回路が狭いとか、まぁそういう状況ですね。
愛媛に行ったときも、電車が台風で止まってた場合、タクシーとか選べるけど、そこまでの復帰は求めていなかった。宿も予約してなかったのでね。そういうときはフェイタルエラーな気がしますね。こうやってスローズを受けといて、なんか買いに行ったんだけど材料がないとかいう連絡を受けるようにしておく。買い出しに行った人が判断に迷うような状況になったら連絡するようにして、じゃあテクニックやめるかみたいな。
これで十分じゃないかな。宿題購入は、でもこれやって、ここにあるけど、それ以外の状況が起こったときにはフェイタルエラーで落とすとかね。最終的にタッチを実行したらテクニック自体が終わるのが中心になる。確かに、フェイタルエラー待てしなくて、もしテクニックが中断したときにブルーを返せばいいんですね。
例えば、割れた場合はオッケーとか、リターンで eatSandwich
。このほうがいいですね。これで大体いいんじゃない?ここに書いてあるけど、ブルー返さずにスローズにすればいいと。ここで動かして、ブルー返さずに最後にタッチを残すとか。たしかに、プログを残しといて、ポイントを休止してスローズすればいいと。
API設定でブルーで成功したか失敗したかを示す方法、確かにわかりにくいですよね。情報の少なさも問題で、ブルーが正常な審議値なのかエラーなのかがわかりにくい。たしかに、正常系だったらストリームにエラーが流れるとか、呼び立てが成功すればチェックアウトが終了していいとかね。
たとえば Result
で成功が void
、失敗がエラーとかね。エラー処理が面倒になりますが、アツリーですね。 そうすると、たくさんキッチンエラーではなくノーキッチンエラーですね。まあ、それであれば3種類のアウトブックインディシーズとミシングインディシーズが同じエラーがあったというのは気持ち悪いなとも思いますね。この中の一つだけクリーンリシーズなら武器できる、という意味合いで考えれば、大事ではありません。でも確かに、エラー処理の方法がもっとたくさんあればイメージが変わってくるかもしれないですね。
あとは、こうやって他の起こるはずのないエラーのところも、起こることがないならば拠点終了で良い気もします。しかし、その後は映画にまで行かないけど、どっちを取るかですね。すべて移動するか、リタルト型でエラー範囲を限定してあげるか。限定しても、ストリームだと結局丸められちゃうのかな、普通のエラーか。リタルトでチェインしていく分には大丈夫か。そのエラーの範囲を制限するか、全体的に投げるかによって、隣の加減とかいろいろと変わってきますね。
今回のような場合、どっちが良いのかは状況によりますね。リタルトが良いのか、throws
が良いのか。まあ、Swiftの場合はthrows
かな。リタルトはそんなに使わない、ストリームしていれば使うのか、どうだろうね。throws
でリタルト型は受け取る側が処理しやすいですが、書く側が難しいです。そもそも書く側はバックエンドですが、受け取る側が難しいですよね。どのエラーがスローされるか分からないといけないですからね。呼び出し元にエラー内容が分かる方が良いかどうかで、API設計者が選ぶ感じですかね。
エラーが発生したときには、結局どっちも同じ結果になることが多いかもしれませんね。仮にここがエラーだったら14行目も16行目も同じと言い過ぎかもしれませんが、呼び出し元が苦労しますね。今回はなかなか面白いですね、リタルトをGETに変換できるので、個人的にはリタルト優先でやってしまおうかなと考えています。
リタルトに対してGETするのは普通にエラーハンドリングに変換できるので便利ですね。使い勝手はそこまで変わらないし、融通が利くから良いのかも。例えばリタルトをGETに変換する方法も考えられるかもしれません。イニシャライザーにキャッチング、ありますね。だからまあ、何とかなるのかもしれませんね。
でもポイントとなるのはイントのときなどですからね。リタルトをキャッチング、ドゥーピクニックと描き方もできなくはないんです。リタルトとクロージャにする場合、トライが必要かどうか。実際、どっちも要領があると思いますので、ここでは好みの問題に落ち着きそうです。
GETを使うバージョン、要はAPIをどっちに用意するかですが、汎用エラーであれば適切に対応できますね。14行目も18行目も同じノリで処理しても良いかもしれませんが、実際に試してみないと何とも言えないところがあります。
では時間になったので、今日はこれくらいにしますかね。次回、次の話に移っていくと思いますが、この辺りの描き方についてもう少し話してから次に移る感じにしましょう。他の方も、もしエラーハンドリングやドゥーピクニック案以外に面白い描き方があれば、次回持ってきてもらえると面白い話ができるかなと思います。
それでは、これで終わりにしますね。お疲れ様でした。ありがとうございました。