https://youtu.be/uD57QvbQ2H4
今回は、前回の話を簡単におさらいしてから、これまでの A Swift Tour
に戻って続きの Error Handling
を見ていきます。Optional
と同様に Swift 言語による手厚いサポートが魅力な機能のひとつで、これまでにもたびたび話題に登ったものでもありますけれど、今回はそれに真っ直ぐフォーカスして基本的なところをしっかりと眺めてみようと思います。どうぞよろしくお願いしますね。
———————————————————————————— 熊谷さんのやさしい Swift 勉強会 #70
00:00 開始 01:02 クラスのオーバーライドにおける特色 01:24 失敗可能イニシャライザーにおける特色 04:10 メソッドにおける特色 11:38 汎化のときと特化のときとで API を最適化 14:58 プロトコルを用いて表現する際の注意 15:58 Error Handling 16:22 例外処理とエラー処理 19:26 エラー処理とスコープ 20:11 エラー型でエラーを表現 20:44 文字列をエラー型にするアイデア 22:19 エラーに情報を埋め込む 24:28 列挙型によるエラー型の定義 26:14 Printer on Fire 30:52 構造体やクラスによるエラー型の定義 33:08 エラーの送出 34:36 エラーには呼び出し元が対応する 36:12 サーマルスロットリング 38:10 エラーが送出されると呼び出し元に制御を戻す 38:39 戻り値の型が Never の関数 40:05 throw で Never から復帰する 43:42 正常系と異常系 45:45 次回の展望 ————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #70
はい、じゃあ始めていきますね。今日はエラーハンドリングの話をしていこうと思うんですけど、その前にさすがに前回の動画で遅延が多すぎたので話がめちゃくちゃな感じになってしまいました。まずそこをざっくり振り返ってからエラーハンドリングに入っていこうと思います。
前回どんな話をしたかというと、教科と反転ということでやっていましたが、最終的にはそういう難しい話は抜きにして、具体的にどういうことをやっていたのかという話を紹介します。
例えば、class A
があって、その class B
が A
を継承しているような場合です。このときに、オーバーライド制で面白い特徴を示すという話をしていました。
まず一つの例として、親クラス A
のイニシャライザーが失敗する可能性があるときに、サブクラス B
のイニシャライザーをオーバーライドする場合です。一般的には同じインターフェースをオーバーライドしていく書き方になります。たとえば、A
型に対して B
を入れ込むとき、B
のイニシャライザーは失敗する可能性があります。しかし、B
にとって絶対にインスタンス化が成功するような場合には、一般的な書き方だとビックリマークをつけて「失敗しないよ」ということを示します。
しかし、親クラス A
では成功するか分からないけれど、クラス B
では絶対に成功するという設計の場合、オーバーライドで init?
を上書きすることができます。こうすることで、クラス B
を使う側は、外側でビックリマークを入れる必要がなく実行できます。ただし、デフォルトイニシャライザーが失敗可能なのにここでは失敗しないという状況になるため、単純にデフォルトイニシャライザーにパスすることができなくなります。これは当然のことですね。例えば、super.init()
を呼び出すときも同様ですが、カスタムなイニシャライザーで必ず成功させるコードを書けばコンパイルが通ります。
続いて、メソッドについても同じようなことが言えます。例えば、protocol P
があって、struct S
が P
に準拠しているとします。このとき、class A
が func someMethod() -> P
というメソッドを持っているとします。これをサブクラス B
で P
の代わりに B
型を返すようにオーバーライドしようとすると、通常は互換性のない型を返すことができません。
具体的な例として、以下のようなコードです:
class A {
func someMethod() -> A {
return A()
}
}
class B: A {
override func someMethod() -> B {
return B()
}
}
このコードでは、B
が someMethod
をオーバーライドして B
型を返すことができるかどうかですね。
このように、失敗する可能性のあるイニシャライザーやメソッドのオーバーライドについて、親クラスとサブクラスでの処理方法の違いとその実装について話していました。
さて、これを踏まえた上で、次はエラーハンドリングについて入っていきたいと思います。 イニシャライザーに関して、ちゃんとできている場合は自然なコードが書けます。この前提を踏まえてコードを書くといい感じになります。さっきのコードの違いは、イニシャライザーがあったからでしょうか、それともプロトコルとストラクトだからでしょうか。
さて、サムシングに対して A
を返す API があって、クラスの方では B
を返すというコードを書くこともできます。同様に、サムシングメソッド1とメソッド2にしましょう。オーバーロードするとわかりにくくなりますが、できないことはないです。
例えば、メソッド2でパラメーターとして B
を取るとします。普通は B
を取らないかもしれませんが、ここでは例として用います。X
と Y
でもいいのですが、B
を取る場合、ファンクションメソッド2としてオーバーライドが成立します。ここで A
を取るとしてもオーバーライドが成立します。ちょっと変な感じがしますが、このようにクラス X
を継承したクラス Y
を定義し、X
を返してパラメーター Y
を取るといった感じにできます。
これでコードがちゃんと動き、実装できました。よかったです。イニシャライザーに関しても、init?
という失敗可能なイニシャライザーを init
でオーバーライドできる件についても話しました。このようなプロパティに関する器用なことはできませんが、イニシャライザーとメソッドについてはちゃんとできたと思われます。
クラス継承の醍醐味は、汎用的なクラスをより専門的なクラスに特化していくことです。特化することで汎用的には失敗しても、特化した場合には失敗しないこともあります。また、規定クラスを返すメソッドが特化することで別の導出クラスを返すことが可能になります。パラメーターも特定の型 Y
を取るメソッドが、特定の型 X
を取るように変更できます。
例えば、クラス A
型として使う場合、メソッド1は X
型を返すけれども、メソッド2は Y
型を取るインターフェースになります。同様に B
型として扱うときには、戻り値は Y
になり、パラメーターは X
になるなど、より適切な制限が狭くなっていく特徴があります。
この特徴は、最近の Swift 言語におけるオブジェクト指向設計では少なくなってきている気もしますが、あらかじめ規定クラスを作る際に、継承先を見越して設計していくことが重要です。失敗可能なイニシャライザーを置いておくと適切な設計になると思います。
このような特徴を知っておくことで、プロトコルにおいても似たような感じが適用されます。ただし、プロトコルの場合は準拠しなくなり、クラスに限定されることがあるため、注意が必要です。
これらの知識をしっかりと意識しながらコードを設計すると、良い感じのコードが書けるようになるでしょう。 前回はこのようなお話をしていました。じゃあ今日は続いて、エラーハンドリングの話を見ていくことにしましょう。Swiftプログラミング言語に戻って、「A Swift Tour」セクションの「エラーハンドリング」に入ります。
エラーハンドリングという言葉は、要はエラー処理のことです。世間一般のプログラミング言語では「例外処理」と言われたりしますね。"Exception" という言葉が使われますが、これはエラー処理の一部と捉えられます。Swiftもそうと捉えればそうですが、もっと広範にエラー処理を捉えており、言語サポートとしてさまざまな機能が用意されている点が注目すべきところです。
エラーハンドリングで思い出しましたが、例外処理のほかに「割り込み」という言葉もありますね。マシン語を扱っていると耳にすることがあるかもしれません。たとえば、ゼロで割ると割り込みが発生するというのがあります。ただし、最近はマシン語を使っていないので詳しくは覚えていません。タイマー割り込みとは違って、60分の1秒ごとに特別な処理を挟むとか、そういうやり方でした。割り込みというのはハードウェアが突然介入して処理を促すものです。インターバル割り込みはエラーとは異なるのでまた別の話ですが、メモリのアクセス違反なども含まれます。
ただ、Swiftのエラーハンドリングはこれとは少し雰囲気が違います。Swiftのエラーハンドリングはスコープ内でしっかり制御されていて、スコープを飛ばして先へ行くようなことは基本的にありません。視点によって異なるかもしれませんが、割り込みや例外処理とは異なった感覚ですね。
とりあえず、割り込みや例外処理とは違った雰囲気で進めていくと面白いかなと思います。
まず、Swiftでエラー処理をする上で必要なもの、これは「エラー型」です。Swiftではエラーを型で表現します。C++も例外処理をするときにはエラーをクラスで表現します。他の言語によっては最終的には型ですが、JavaScriptの例外の場合、クラスをわざわざ用意せずに文字列としてエラーを生成することもできます。
コメントで面白い意見をいただきました。「割とよくやるのがストリングエラーにしてスローする」とのこと。JavaScriptで文字列をそのままエラーとしてスローできるのは便利ですね。なるほど、Exception String Error。
エラー型を作るときの根本的な目的は、エラーの詳細を伝えたいというのがまず第一です。文字列としてエラーメッセージを投げれば、それでエラーの情報が伝わります。ただ、もしエラーが発生したときに復旧を行いたい場合、その状況をエラーに含める必要があります。そうなると、単純な文字列型では適切ではありません。
純粋にエラーを通知するだけなら、文字列型で全然問題ありません。それよりもストリング型でエラーを表現した方が、プログラムを書く際にも直感的でわかりやすいかもしれません。例えば、キャッチ文で文字列型のエラーをキャッチした場合、それが表示するためのものだとすぐに分かりますね。
ということで、Swiftのエラーハンドリングでは、エラー型を使って必要な情報を適切に表現することが重要です。 ``` なので、実際にやっていこうと思います。エラー型についてですが、一般に列挙型を使う人が多いかと思います。Swiftのプログラミングランゲージの本でも、一般的に列挙型がエラーを表現するのに向いていると紹介されています。列挙型とエラー型は相性がいいのです。
なぜ相性がいいかというと、スライドに表示されていたエラー型のコードを書いていきますけれども、まず PrinterError
というエラー型を宣言します。これでSwiftのエラープロトコル(標準ライブラリにあるエラープロトコル)に準拠させることで、エラーとして使える型になります。Swift言語における決まりです。
例として、プリンターに関するエラーの型 PrinterError
を作成します。この型では以下のようなエラーが定義できます。
enum PrinterError: Error {
case outOfPaper
case noToner
case onFire
}
このように、どんな場合にエラーになるかを列挙型を使って容易に表現できます。例えば、outOfPaper
や noToner
のように、どの場面がエラーとして考えられるかを具体的に定義できます。
これが理由で、列挙型とエラー型の相性が非常に良いのです。ちなみに、onFire
というエラーケースがありましたが、これについて知っている人はいますか? 私も最初は「オンファイヤーって何だ?」と思いましたが、調べてみると面白い話でした。
実は、これは文字通りの意味で「火災発生中です」というエラーです。コメントをいただいた通りです。昔のプリンターでは火災が発生しうる状況がありました。プリンター内部でトナーを焼き付けて印刷する際に、印刷が止まると定着オーブンという装置によって詰まった紙が過熱され、それが原因で火災になることがありました。
こうした状況で「オンファイヤー」というエラーステータスが存在するのです。本当に「今火事ですよ」という通知ですね。今の時代では考えられない話ですが、当時はかなり深刻な問題だったようです。家庭用プリンターがこのような状況になっていなくて本当に良かったと思います。工場や印刷所などではどうなっているか分かりませんが。
確かに、プリンター以外にももっと厄介なエラーはありますが、プリンターがエラーを報告すること自体は重要な機能です。
とりあえず、エラープロトコルに準拠させた型っていうのはエラーとして扱えるようになります。それで、今回の例ではあまり情報を追加しても意味がないですが、列挙型にはアソシエイティブバリューっていうのを持たせて、他の情報も持たせることができるんです。今はエラー型の話なので、エラーの情報をさらにたくさん持たせることもできます。
こうやって、元々の列挙型の表現力の高さを活かして、エラー型として単純にどんな場合のエラーがあるかだけでなく、そのときのより詳しい情報も添えてエラーとして使っていけるという、こういった性格を列挙型で作り出すことができるのです。エラー型は別に列挙型である必要はなく、他の名前付き型(ネームズタイプ)なら何でも対応できます。例えば、`struct`にしてエラー型を作り、普通に構造体を定義するようにプロパティを持たせることで、問題なくエラーを定義できるのです。
もちろん、クラスもエラー型にすることができます。これも構造体とほぼ同じ感じですが、クラスの場合はオーバーライドすることができるため、オーバーライドという形ではあまり良くないかもしれませんけど、そういう風にオーバーライドすることもできるのです。現在、エラーが出ているのはイニシャライザーがないだけなので、イニシャライザーをちゃんと定義してあげればコンパイルが通って、クラスで継承関係を持ったエラーというものも表現できるようになります。しかし、ちょっと無理がある感じもします。
JavaやC++のエラー表現について言うと、クラスを作ってクラス継承で表現していきます。このように、列挙型、構造体、クラスといった三つの方法で自由度の高いエラー表現ができるというのがまずSwiftの特徴の一つです。
その作ったエラーを投げる(throw)ときには、`throw`というキーワードを使います。このキーワードを使うことでエラーのインスタンスを投げることができ、エラーを投げると関数はその時点で終了し、呼び出し元に制御が戻ります。しかし、Swiftの場合、エラーを投げる可能性があることをAPIにあらかじめ示しておく必要があります。これが大事なポイントです。
`throw`が付くことで、メソッドがエラーを返す可能性があることを宣言できます。すると、中で`throw`を使って、先ほど作ったエラーのインスタンスを投げることができるという動きになります。エラーを送出した際には、そのエラーを投げたタイミングで関数が終了し、呼び出し元にリターンします。
呼び出し元は必ずエラーに対処しなければならないというのがSwiftの大きなポイントです。例外処理に何も対処しなければ、さらにその外へ投げられていくという特徴がありますが、それにより、どこから発生したエラーなのかが分からなくなります。しかし、Swiftの場合は呼び出し元が必ず対応するという理由から、どこでエラーが発生したかが必ず分かるようになっています。 よくわからないところからエラーが発生しているという状況には、基本的にはならないのです。これがSwiftのエラーハンドリングの嬉しい特徴の一つです。
少しコメントで面白い話が出ていましたね。CPUが「オンファイヤー」かもしれないという表現がありましたが、確かにそのようなエラーも存在します。今のCPUは、ある程度の温度の閾値を超えると処理性能を落として冷却を図る、サーマルスロットリングという機能を持っています。この機能のおかげで、今のCPUは燃えません。しかし、昔のCPUは発熱量が増大すると煙を吐くという状況もありました。インテル486あたりから温度センサーが搭載されましたが、それ以前のCPUは温度管理が不十分でした。特に、AMDのK4やK5などはそのようなリスクが高かったです。
そのような背景を考えると、エラーが面白いですよね。さて、話を戻しますが、Swiftの`throw`について面白い疑問が湧いてきませんか?`throw`を使うと関数は直ちに制御を呼び出し元に戻します。Swiftの関数は基本的に`Void`を返すルールになっています。つまり、スコープを抜けた時点で制御を戻すのですが、Swiftには唯一、制御を戻さないメソッドがあります。それは、戻り値が`Never`の関数です。
例えば、以下のような関数があるとします。
```swift
func someFunction() -> Never {
// 何らかの処理
}
このsomeFunction
を呼び出すと、例えばその後にprint("Done")
と書いても、その行まで制御が戻りません。コンパイルエラーが出ます。Never
を返さなければならないルールがあるからです。そして、スローを使ってエラーを返した場合に直ちに制御を戻すかどうかについても興味深いです。これを試してみたところ面白いことが起こりました。
以下のようなエラーハンドリングを設計し、実行してみます。
func someFunction() throws {
throw SomeError()
}
do {
try someFunction()
} catch {
print("Error occurred")
}
実行すると、エラーが発生した場所で即座にキャッチされ、制御が戻ります。スローを使った場合には、制御が戻ることが確認できます。
以上、Swiftのエラーハンドリングについて簡単に説明しました。他にも興味深いポイントがあれば、ぜひ探求してみてください。 スローするから、この7行目にはいかないという警告になっています。ここ、Something
はNever
を返す関数なので、制御を戻さないということになっています。よって、10行目より下にある12行目は呼ばれないという警告になっている。ただし、これで終わっているコンソールにはエラーがスローされて、SomeError
のSomething
が受け取れたという報告になっています。
つまり、どういうことかというと、エラーが投げられたということです。do
で、これでキャッチでprint(Error)
とかやると、これを実行するとどうなるか想像してもらったら良いんですけど、実行するとこんな感じで、Something
はNever
を返すんですけど、スローによって直ちに制御が戻されて、この11行目はエラーなので、キャッチブロックに飛んでprint
文が実行されて、キャッチ文が終わって17行目が実行される。これを強引に解釈すると、Never
エラーを返さない、要はエラーを返さないどころか、強制終了するはずの関数からエラーを使うことによって復帰できるという、なかなか面白い動きだなと思ったりします。突き詰めていくと普通といえば普通かなと思ったり、複雑ですけど、なかなか面白い動きを見せるなと思って。これを知ったからって、これを使っていくのはちょっとお勧めはできないですけど、面白い特徴だなっていうのと、直ちにスローが制御を戻しているというところが伺える面白い例です。
今までの話を総合すると、コンソールとかに標準入力と標準出力と標準エラー出力ってありますよね。スタンダードSTDOUT
とSTDERR
。何で使われる言葉だっけ、ファイルハンドルで良く言われるやつ。普通のリダイレクションで使うのか、シェルのね。とりあえず正常系のリターン先と異常系のリターン先、2つの出力が用意されているようなイメージっていうのかな。Swiftのエラー型というのは、だからNever
だとちょっと分かりにくくなっちゃうのでVoid
に変えますけど、正常系の戻り値、異常系の戻り値、スローズがついていないときには異常系の戻り値はない。戻り値がVoid
の普通の戻り値がVoid
のときには正常としか言えないのか。だからNever
のときもやっぱり、Never
のときには正常系の戻り値はないみたいな。そんな感覚でアウトプットが2チャンネル用意されているみたいな。そういったイメージでSwiftのエラーハンドリングはつかんでいけるかなというような感じがする。ちょっとややこしい例を挙げちゃったけど、そういったところも面白いところかなと。それでは次回、基本的なエラーハンドリングの説明をしていこうと思います。
概要としては、Swiftはdo-catch
という、今ちょっとサンプルコードを書いて紹介した方法と、あとtry?
という方法。これを使ってエラー処理をしていく。あと、そのままエラーを次につないでいく、そのままスローするみたいな。もう1つのケースがありますけど、基本的にはエラーに対応するのはこのdo-catch
とtry?
みたいな。この辺りを押さえておくのが大事になってくるのかな。それではこの辺りは次回、またじっくりと見ていこうかなと思います。
何か質問がありますかね、今日の話の中とか、前回も含めて大丈夫ですけど。
大丈夫かな。基本的なところをおさらいしたような感じなのでね。じゃあ、そろそろ時間の感じなので、今日はこれぐらいで終わりにしましょう。お疲れさまでした。ありがとうございました。