https://www.youtube.com/watch?v=QO3rl0SuQ78
今回も引き続き Swift.org の About Swift
を眺めていきます。Objective-C からみた追加機能、そんな話題の中から 強力なエラー処理機構
から見ていく感じになりそうです。よろしくお願いいたしますね。
—————————————————————— 熊谷さんのやさしい Swift 勉強会 #6
00:00 開始 01:16 Objective-C 04:14 Pascal 05:23 パスカルケース 07:21 強力なエラー処理機構 07:49 do-try-catch 10:28 エラーハンドリング 13:59 エラー対応 19:35 エラーが発生する箇所の明記 22:35 列挙型以外でのエラー表現 25:35 状況に応じたエラー対応 27:16 関数でのエラー対応 29:50 エラーの再送出 32:50 rethrows 38:40 do ブロックの省略 39:46 エラーハンドリングの感動ポイント 44:04 C++ 46:14 高度な制御構文 46:43 do によるローカルスコープ 50:31 do によるエラー対応 54:19 次回の展望 ——————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #6
今回は前回からの続きです。Swiftについて学んでいるシリーズの中で、現在は「追加機能」というところまで来ています。前回も言いましたが、この「追加機能」という表現に違和感を覚える方もいるかもしれません。新しい言語であるはずのSwiftに「追加機能」とは何なのか、ということですね。
これは、SwiftがObjective-Cの後継として登場したという背景から来ています。Objective-Cと違って、新しく強化された部分が記載されているのが「追加機能」として紹介されているのです。つまり、これを読むことでSwiftにはどんな新しい機能が追加されたのか、Swiftらしさがどこにあるのかが掴みやすくなります。個人的には、これは非常に良い題材だと思って話しています。
おだしょーさんが、「Objective-Cの開発にはAppleが関わっていた」という話をしていました。確かに、新機能と聞くとAppleが作っていたような印象がありますね。Objective-CにもAppleが関わっていたと記憶していますが、どうでしたっけ?
おだしょーさん曰く、積極的かつ愛情を込めて関わっていた感じがあるようですね。Steve Jobsが特に力を入れていたという話も聞いたことがあります。また、Objective-C 2.0ぐらいからエラーメッセージや警告メッセージが非常に丁寧になり、Xcodeとの親和性が高まったように感じます。Xcode 4の頃くらいからでしょうか。
その頃からSwiftの雰囲気が漂い始め、最終的にはSwiftが完成形として登場したような印象です。特にiOSやmacOSといったAppleのプラットフォームに非常に親和性の高いものに変わっていくような印象があります。ですから、AppleがObjective-Cに関わっていたことは間違いないでしょうし、それがSwiftの開発にも繋がったという感じですね。
そのあたりの話も調べてみると面白いかもしれません。確かにAppleはObjective-Cを非常に愛していたようですね。同時に、macOSアプリも以前はJavaやPascalが使われていた時代がありましたが、最終的にはObjective-C一本に統一され、そこからSwiftへと変わっていきました。
Pascalの文字列や文化も昔のmacOSにはありました。具体的にPascalが使われていたかどうかは経験がないのでわかりませんが、その時代を知っている人もいるでしょう。OS 8の頃の話ですね。
じゃじけさんからも何か知識が出てくるかもしれませんが、PascalはPascalCaseの名称の由来にもなっています。Pascal言語自体がアッパーキャメルケースを導入していたためです。Pascalで書かれたコードも少し残っているようですが、基本的にはC言語が中心だったようです。Pascalの文字列は先頭にバイト数が入るという構造があり、そういった文化が使われていました。
文字列の扱いには色々と苦労があったようで、パスカル文字列は先頭を見ることで文字列の長さが分かるため、当時はこういった形式が使われていたとのことです。これにより、文字列の終端を見誤ると大変な問題が発生してしまうこともありました。懐かしい話ですが、文字列の扱いは非常に興味深いトピックです。
さて、本題に戻りましょう。前回は「関数型プログラミングパターン」の話を終えたところまで進めました。次に、「強力なエラー処理構文が内蔵された」という話に入っていきます。これは以前の勉強会で話題にしたトライキャッチの話にも関連します。何度も復習することで身につきやすくなるので、ゆっくりと見ていきましょう。
エラー処理機構ですが、具体的にはどのようなものがあるか見ていきましょう。日本語訳は正確でないこともありますが、「強力なエラー処理」という表現で良いでしょうか。 とりあえず、エラー処理機構というのがありまして、Objective-Cの頃にも@try
、@catch
みたいな例外処理がありました。Objective-Cでは使う機会はあまり多くなかったですけど、というか好みが分かれていたのかなと感じます。私自身はよく使っていましたが、これがありました。
C++も例外処理がありますし、Javaも例外処理が主流です。基本的に例外処理は現代のプログラムにおいて主流で、実行順序が左から右、上から下という基本を無視して、いきなり異常が発生した部分に飛んでいくといったイメージがあります。
その例外処理の最もクリティカルなところは、ランタイムエラーです。ランタイムエラーで何か不正アクセス等が起きると、そのタイミングでアプリが強制終了されます。そのタイミングをキャッチして復帰を目指したり、安全に処理をして終了させるのが例外処理の特徴でしょう。
Swiftではこれを「エラーハンドリング」と呼び、例外(エクセプション)とは違う名前を付けています。このあたりが他の言語と異なる点で、興味深いところです。
エラーハンドリングの基本的な道具としては、まずエラーを自分で定義することです。オブジェクト指向言語の場合、特別な例外処理用のクラスを継承して、独自のクラスを作成することが一般的です。C++でも同様です。しかし、Swiftではクラスにこだわらず、「型」であればエラーとして表現することができます。よく使われるのは列挙型です。
例えば次のようにしてディスクエラーのエラー型を作成することができます。
enum DiskError: Error {
case ioError
case noPermission
}
これを使ってエラーを投げる際にはthrow
構文を使います。ただし、その関数がエラーを投げるためには、関数定義にthrows
を明記する必要があります。
func performDiskOperation() throws {
throw DiskError.ioError
}
そして、このメソッドを実際に使う場合にはtry
を使って実行します。try
を書かないとコンパイルエラーになります。
do {
try performDiskOperation()
} catch {
print("An error occurred: \\(error)")
}
また、try?
を使うことで、エラー発生時にはnil
を返す書き方もできます。
let result: Int? = try? performDiskOperation()
この場合、result
はOptional<Int>
型になります。エラーが発生した場合にはnil
が代入され、正常に処理が行われた場合には結果が代入されます。
次に、エラー発生時に強制終了させたい場合にはtry!
を使います。これは非常に限定的なシナリオでの使用に留めるべきです。例えば、スクリプト風に簡素なコードを記述したい場合などです。
let result: Int = try! performDiskOperation()
以上が、Swiftにおけるエラーハンドリングの基本的な手法です。それでは、このあたりでもう少し複雑なエラー処理についても見ていきましょう。 「なので、この性質を使うと、オプショナルバインディングを使って if let result = try? action
みたいに書くことができます。値が取れたときにはそれを使って何かをし、値が取れなかったときには別の処理をするというふうに書けます。
さらに、Swiftではエラーハンドリングの方法として try?
を使うこともできるんですが、他にも try-catch
と同様に do-catch
ブロックを使ってエラー処理を書くこともできます。これは多くのプログラミング言語で見られる try
キーワードとキャッチブロックを組み合わせたものに似ていますが、Swiftでは do-catch
という書き方になります。
例えば、if let
を使った方法と比べると、do-catch
を使うことで条件処理ブロックを書かなくてよくなります。結果的にエラーハンドリングのコーディングがシンプルで見やすくなり、エラーが発生する可能性のある箇所が明示されるため、可読性が高まります。特に、Objective-Cのようにどこでエラーが発生するかが分かりにくいコードとは違い、try
キーワードのある箇所でエラーが発生する可能性があることがコードから一目で分かるという利点があります。
このようにSwiftの新しいエラー処理機構は、予期しないアプリの終了を防ぎ、コードの可読性と保守性を高めることができます。さらに、try?
を使わずに do-catch
を使うと、エラー情報を具体的に取得することが可能です。例えば、エラー変数が暗黙的に定義され、エラーの内容を取得することができます。
次に、実際に例を見てみましょう。アクション内で DiskIOError
が発生すると、キャッチブロックでこのエラーを拾い、エラーメッセージが出力されます。これにより、エラーの内容を見て適切なリカバリ処理を書くことができます。
他のエラーの出る可能性も紹介します。エラーは列挙型だけでなく、構造体やクラスでも定義できます。例えば、エラープロトコルに準拠することで、構造体を使ってエラー情報を定義することも可能です。
具体的なコード例を挙げると、値が 1
だったら OperationError
が発生し、キャッチブロックでこのエラーを処理します。2
を渡すとディスクI/Oエラーが発生し、それ以外の場合は正常に処理が行われます。エラーに応じて適切な処理を行うために、キャッチブロックを複数書くことも可能です。
例えば、ディスクI/Oエラーが発生した場合には特定の処理を行い、OperationErrorの場合には別の処理を行うといった具合です。以下のコードのように書けます:
do {
// アクションの実行
} catch DiskIOError {
// ディスクI/Oエラーの処理
} catch OperationError {
// オペレーションエラーの処理
}
このようにして、状況に応じてエラーが変わった場合でも、それぞれのエラーに対して適切な処理を行うことができます。」 とりあえず別の処理を書いてあげると、そうするとこれで動きました。ディスクI/Oエラーの時にはそれを無視して突っ切るコードになっています。これがもし別のオペレーションエラーが発生した時には、全てのエラーを処理するキャッチ文の方に移って処理されるという感じです。
エラーハンドリングに関して、オプショナルをうまく使ってエラーとは言わなくなってくる気がしますが、値が取れたかどうかという観点から、エラーハンドリングが処理できるようになっています。エラーハンドリングにはいろんな高度な機能があり、これを全部把握するのは意外と大変です。
例えば、あるメソッドがあってその中でエラーが発生する可能性があるとします。これを関数で作ってしまえば良いのですが、具体的には do something
の中でエラーを発生する可能性があるアクションが呼ばれるといった場合があります。このような時に、さっき説明した通りのエラー想定だったら、例えばディスクI/Oエラーだったら正常に終わらせて良い、一応ログは残しておこうという感じです。それ以外のエラーだった時には復帰しようがないから強制終了させるというコードを書いて、全体としてはエラー処理が完全に終わった状態で処理関数が終わるというような状況です。
つまり do something
を呼ぶ側にとってはエラーは起こり得ない、ちょっとしたランタイムエラーはありますけどね。そのような場面で、例えばランタイムエラーでは都合がよろしくない場合、この関数がエラーを返すという仕様に変えたい時には、先ほどと同じような感覚で throws
を付けてあげる。このコードのままだと全てのエラーが解決されてしまうので、例えばこのオペレーションエラーを外に中継したい時には、いくつか書き方があります。
まず複雑な書き方から説明すると、キャッチした時に再びスローしてエラーを投げ直すという方法があります。確かできたはずです。こういう書き方を自分はしないので忘れていましたが、この方法でパフォーマンスが確保できます。例えば、do something
がエラーを返す可能性があるよと書いてあるおかげで、実際にエラーを返せるという感じになります。
このプレイグラウンドが動いてくれない場合もありますが、大丈夫なはずです。もし違ったらまたSlackに書いておきます。確かにこういう書き方ができる言語も他にあったと思いますが、こちらでは違いますね。最短でも最長でも、このくらいの長さになります。最後のキャッチ文を書かなくても問題はありません。 そうなんです。こうやってスローズがあるおかげで、エラーを全くキャッチしなかった場合には、それをスルーして投げてくれるという大きな特徴があります。こういう書き方のほうが普通ですよね。実際にこういう書き方をすると、アクションが今回の場合、2つのパターンのエラーを返すわけですが、キャッチしなかったものに限って外に投げるという書き方になっています。
実際にこのスローズがないとどういうことになるかというと、エラーを取り切れていないため、「ちゃんと書いてね」といった感じでトライのところでエラーになります。つまり、スローズがあるかないかで、内側のエラーを全部解消すべきか、それとも外に渡していけるかの大きな違いが出てきます。
あともう1つ、このタイミングで話してしまうか、また多分将来エラーハンドリングの話になったときに話すとも思いますが、せっかくですから軽く話しておきます。他にもリスローズという書き方があり、これは関数で何らかの関数型を受け取るときの話なんです。例えば、マップ関数についてです。今回実際にコードを書くところまではやめて、定義を見るだけにしておきます。今後の楽しみにしておきましょう。
配列に map
関数がありますよね。この map
関数に面白いキーワードがあって、それがリスローズです。スローズとリスローズがあり、リスローズが付いているものは、引数で受け取った関数型のインスタンスがエラーハンドリング、つまりエラーを投げる関数が渡されたときに、この map
関数全体がエラーを返すかもしれないものとして扱われます。しかし、トランスフォーム変数にエラーを返す可能性のない関数が渡されたときには、map
全体は当然エラーを返さない関数に変わります。
口で言っても難しいかもしれませんが、要はこのコード。配列を map
しているわけですが、何もエラーを返さないときにはこれで処理が通ります。
let numbers = [1, 2, 3]
let result = numbers.map { $0 * 2 }
こういったコードが書けます。しかし、もしこの中でエラーを返す場合、たとえば以下のように書くとします。
let result = numbers.map { value in
guard value != 0 else {
throw MyError.operationError
}
return value * 2
}
このように書くと、この map
の中に渡されている関数はエラーを返す可能性があるため、リスローズが指定されている map
はエラーを返す可能性があることになります。従って try
が求められます。
do {
let result = try numbers.map { value in
guard value != 0 else {
throw MyError.operationError
}
return value * 2
}
} catch {
print("Error occurred: \\(error)")
}
このリスローズがあることで、中でエラーを返す関数が使われている場合に、map
全体がエラーを返す可能性があるものとして扱われます。自分はこのリスローズをとてもかっこよいと思うので、ぜひ試してみてください。エラーハンドリングを学ぶついでに、このリスローズを使いこなす練習をすると、全体が見渡せるようになると思います。
最初のうちはリスローズの意味がよくわからないかもしれませんが、やっていくうちにエラーハンドリング全体が見えてくると思います。これは自分で学習してみるのをおすすめします。勉強会などで発表してみるのも面白いですね。
また、コメントで do
ブロックを省略できるという話があるので、そこも紹介しておきたいと思います。 今回のこのコードの例について、なんらかのエラーを想定して、この DoSomething
メソッドの中でリカバリ処理や継続処理を行う場合、do
ブロックを使って必要なエラーをキャッチすることが一般的です。しかし、ディスクI/Oエラーも含めてエラーを外に投げれば済む場合には、キャッチ文が不要になります。そうすると必然的に do
ブロックも不要になり、シンプルな記述が可能になります。このようなコードのシンプルさは確かに面白いところです。
このように非常にシンプルなコードになったとしても、この実装を読める人にとっては、このアクションでエラーが発生する可能性があることや、それが外部に投げられることが非常に明確に伝わります。これは素晴らしいことです。
話が明確なところに戻りますが、スルートのほうが async/await
の場合、ロー・ストライドに似たような思想で設計されており、ちゃんと await
を書かなければならないところが結構嬉しいところです。少しプログラマーの手を煩わせるだけで、可読性や安全性、安定性が飛躍的に向上するのは本当に嬉しい機能です。
これが、もし以前のObjective-C++のようなコードになってしまったら、Swiftに慣れている身としては絶望感があります。エラーがどこで発生するかを読み解くには、第六感を求められているようなものです。エラー処理の有無が大きな違いを生むので、その素晴らしさを話している限りは、多くの人がその価値を認識している印象です。
エラー処理の楽しさを感じるようになると、特に汎用ライブラリを作る人には rethrows
を覚えてもらうと、非常に使い勝手が良く安定性の高いコードが提供できるのでおすすめです。常に使っていて感動を覚えないかもしれませんが、それでも素晴らしい機能です。
不便な方に走る必要はないですが、マニアックな人にはC++をおすすめします。C++は非常に勉強になる言語で、自分でいろんなことをしないといけません。例えば auto instance = std::move(other);
というように other
を渡すときに後始末を考慮しなくて良いなど、自分で意識して考えないといけません。
こういった様々な考慮が必要なので勉強にはなりますが、Swiftの世代から見ると遠回りに思えます。スマートポインターなどの自動メモリ管理についても、書き方が変わることがありますが、C++はわざわざ書きたいとは思わないかもしれません。しかしながら、C++について知っている方も多い印象です。
エラー処理機構については一通り話せたので、次に行きましょう。あと10分ほどですが、新機能として高度な制御構文が搭載されている箇所があります。特に do
, guard
, defer
, repeat
などがあり、do
と guard
については10分あれば話せるでしょう。この辺りをまず見ていきましょう。
do
が高度な構文かどうかは議論の余地がありますが、エラーハンドリングのためであれば高度かもしれません。do
は非常にシンプルな構文で、ローカルスコープを作るものだと覚えておけば大丈夫です。 例えば、変数としてデータを largeData
にしましょうか。big
か large
にするか迷いましたが、英語があまり得意ではないので合っているか分かりません。とにかく largeData
という名前のデータがあったとしてください。空っぽだとかいうツッコミは控えてもらって、大きいデータがあるという前提で話を進めます。
これで処理をしていくわけですが、例えば関数があって、その中で largeData
を一時的に使って処理をする場合を考えます。その後、もう計算が終わって、value
に largeData
を使い終わった後で String
に変換するようなコードがあったとします。
var value = String(largeData)
このようにして使い終わったとしましょう。そうすると、スコープを抜けるまで largeData
が残ってしまうわけです。最適化がどこまで働くかは分かりませんが、基本的にはソースコードを素直に捉える限りでは largeData
が残ってしまいます。
このような場合に、largeData
は value
が作られた時まで生存していれば十分です。そこで、ローカルスコープを作ってあげて、この中で閉じ込めてあげます。
do {
let temporaryData = largeData
// ここで temporaryData を使った処理
value = String(temporaryData)
}
これだと、value
がスコープと一緒に消えてしまうので、do
ブロックの外に value
を定義しておきます。
var value: String?
do {
let temporaryData = largeData
value = String(temporaryData)
}
こうすることで、largeData
は do
スコープの中に閉じ込められるので、変換し終わった後に破棄され、後の処理が実行されるという形になります。プログラマーが明示的にこのように書いてあげる使い方ができます。
また、エラーが発生する可能性がある場合には catch
文を添えることで、この do
ブロックのスコープ内で発生したエラーに限って catch
文で受け取って処理することができます。これが do
構文の便利な使い方です。
質問があるとのことですが、どうぞ。
質問者:「例えば、エラーハンドリングで do-try
を書くときに、どこがエラーを発生するか分かりやすくするために、なるべく do
の中を簡潔にしようとしていました。例えば、value
が外に出せるなら出す感じで、必要なデータを外のスコープで準備していました。でも必要ではないなら、逆にスコープを狭めるために do
の中に書いたほうがいいのでしょうか。」
回答者:「多分そうだと思います。エラーハンドリングに限らず、スコープを見ていると自然とそういう視点が出てきますよね。do
がスコープを作っていると聞いて、納得できましたね。せっかくローカルスコープができたんだから、この中でしか使わないものは詰め込んでいって全く問題ないです。」
実際、仮に do
の中が少し膨大になってきても、エラーが発生する部分には try
がついてくるので、ごちゃごちゃして読み手が誤解することや、読みづらさは発生しにくいと思います。このスコープ内でそこそこの長さまでなら十分自然に対応できるので、do
の中に積極的に必要なものを入れていくのは良い考えだと思います。
そういう視点でコードを書いてみると、色々な発見があるかもしれません。これで do
構文は強力な制御構文の一つかもしれないと見直せました。スコープを作る do
構文についてはこれぐらいにしておきます。次回は guard
について見ていきます。それではお疲れ様でした。