Swiftのエラーハンドリングについての三つの話
世界で最も優れたSwiftカンファレンスの一つであるtry! Swiftに参加できて光栄です。実を言うと、私はSwiftのカンファレンスに参加したことはなく、これが初めてのSwiftに関するプレゼンテーションです。今日は、エラーハンドリングに関する3つの話をしたいと思います。それだけです。大したことではありません。ただの3つの話です。
1. Optionalとの出会い (Meeting the Optionals)
最初の話は Optional との出会いについてです。
私は、 Optional はSwiftの最高の機能の一つだと考えています。では、なぜ私はそう考えるようになったのでしょうか。それは、Swiftが生まれる前に始まりました。
Cにおけるエラーハンドリング
私は子供の頃にBASICで遊んだことはありましたが、本格的に最初に使ったプログラミング言語はCでした。Cでのエラーハンドリングは、このようなものでした。
// [ C ]
int *numbers = (int *)malloc(sizeof(int) * 42);
if (numbers == NULL) {
// ここでエラーハンドリング
}
これは簡単に忘れてしまいがちです。
Cコンパイラは、エラーハンドリングを忘れても警告やエラーを出しません。これは安全ではありません。
Javaにおけるエラーハンドリング
その後、私はJavaの検査例外を学びました。これは、プログラマにエラーハンドリングを強制するものです。
例えば、文字列から整数をパースする関数を考えてみましょう。この関数は、文字列が正しくパースできない場合に FormatException を throw します。
// [ Java ]
static int toInt(String string) throws FormatException {
...
}
// [ Java ]
toInt("42"); // 成功
toInt("Swift"); // 失敗
エラーハンドリングをしないと、コンパイルエラーになります。
// [ Java ]
String string = ...;
int number = toInt(string); // コンパイルエラー
try と catch を使う必要があります。
// [ Java ]
String string = ...;
try {
int number = toInt(string);
...
} catch (FormatException e) {
// エラーハンドリング
...
}
しかし、時にはエラーを無視したいこともあります。たとえば、テキストフィールドにユーザーが数字だけを入力できるようにしている場合などです。そのような場合でも、エラーを無視するために、意味のないエラーハンドリングを書かなければなりません。
// [ Java ]
String string = ...;
try {
int number = toInt(string);
...
} catch (FormatException e) {
// エラーハンドリング
throw new Error("ここには到達しない。");
}
私は、同僚とこの問題について議論し、エラーを無視するための、明示的かつ簡単な方法が必要だという結論に達しました。
メソッド呼び出しの後に!をつけるのは良い候補でした。1つのキーを打つだけで済み、明示的で、危険そうに見えます。
// [ Java ]
String string = ...;
int number = toInt(string)!; // 例外を無視する
// この`!`が私たちが求めていたものでした。
エラーハンドリングのための Optional
数年後、私はSwiftの Optional に出会いました。
Swiftは主に NullPointerException を排除するために Optional を提供しました。しかし、 Optional はエラーハンドリングにも使われていました。
toInt は、Optional を使ってこのように書かれます。
// [ Swift ]
func toInt(string: String) -> Int? {
...
}
エラーハンドリングをしないと、コンパイルエラーになります。
// [ Swift ]
let string: String = ...
let number: Int = toInt(string) // コンパイルエラー
Optional Bindingを使ってエラーをハンドリングすることができます。
// [ Swift ]
let string: String = ...
if let number = toInt(string) {
...
} else {
// エラーハンドリング
...
}
エラーを無視するにはどうすればよいでしょうか。
Forced Unwrappingを知ったとき、私はとても驚きました。それは、まさに私たちが求めていたもの( ! によるエラーの無視)だったからです。
// [ Swift ]
let string: String = ...
let number: Int = toInt(string)! // エラーを無視する
検査例外とは異なり、 Optional は副作用のある関数、特に戻り値のない関数ではうまく機能しません。しかし、 @warn_unused_result 属性がその解決策になると思います。
// [ Swift ]
@warn_unused_result
func updateBar(bar: Bar) -> ()? {
...
}
// [ Swift ]
foo.updateBar(bar) // 戻り値を使わないと警告
エラーのハンドリングと無視は、次のように行うことができます。
// [ Swift ]
if let _ = foo.updateBar(bar) {
...
} else {
// エラーハンドリング
...
}
// [ Swift ]
foo.updateBar(bar)! // エラーを無視する
もし@error_unused_resultのような属性があれば、(エラーハンドリングするか、明示的に無視するかを強制できて)さらに良くなるでしょう。
( Optional によるエラーハンドリングを検査例外と比較すると) Optional はエラーをハンドリングするためのより柔軟な方法を提供します。例外は throw された直後にハンドリングしなければなりませんが、 Optional は遅延してハンドリングすることができます。
Optional を変数に代入したり、関数に渡したり、プロパティに格納したりすることができます。
// [ Swift ]
let string: String = ...
let number: Int? = toInt(string)
...
// エラーは遅延してハンドリングできる
if let number = number {
...
} else {
// エラーハンドリング
...
}
Optional を使う難しさ
すべてがロマンチックだったわけではありません。私のコードはすぐに Optional だらけになりました。
Optional では、数値を2乗するのでさえ簡単ではありません。
// [ Swift ]
let a: Int? = ...
let square = a * a // コンパイルエラー
和を計算するのも同様です。
// [ Swift ]
let a: Int? = ...
let b: Int? = ...
let sum = a + b // コンパイルエラー
Optional Bindingを使うと、それぞれ5行になります。ひどいですね。
// [ Swift ]
let a: Int? = ...
let square: Int?
if let a = a {
square = a * a
} else {
square = nil
}
// [ Swift ]
let a: Int? = ...
let b: Int? = ...
let sum: Int?
if let a = a, b = b {
sum = a + b
} else {
sum = nil
}
Optional のための関数型言語的な操作
幸いなことに、Swiftはそのようなケースのための関数型言語的な方法を提供しています。
square には map が便利で、
// [ Swift ]
let a: Int? = ...
let square: Int? = a.map { $0 * $0 }
sum には、ネストした Optional をフラット化するために flatMap が使えます。
// [ Swift ]
let a: Int? = ...
let b: Int? = ...
let sum: Int? = a.flatMap { a in b.map { b in a + b } }
Optional 値が増えると、複雑になります。典型的なケースは、JSONからモデルをデコードする場合です。
SwiftyJSON[2]のようなAPIがあるとします。
// [ Swift ]
let id: String? = json["id"].string
デコードのどのステップでも失敗する可能性があります。
// [ JSON ]
// `json`が`Object`ではないかもしれない
[ "abc" ]
// `"id"`というキーがないかもしれない
{ "foo": "abc" }
// 値が`String`ではないかもしれない
{ "id": 42 }
そのため、すべての戻り値が Optional になります。これらの Optional 値を使って、この Person をどのように初期化すればよいでしょうか。
// [ Swift ]
struct Person {
let id: String
let firstName: String
let lastName: String
let age: Int
let isAdmin: Bool
}
let id: String? = json["id"].string
let firstName: String? = json["firstName"].string
let lastName: String? = json["lastName"].string
let age: Int? = json["age"].int
let isAdmin: Bool? = json["isAdmin"].bool
flatMapを使うと、このひどいピラミッドができあがります。
// [ Swift ]
let person: Person? = id.flatMap { id in
firstName.flatMap { firstName in
lastName.flatMap { lastName in
age.flatMap { age in
isAdmin.flatMap { isAdmin in
Person(
id: id,
firstName: firstName,
lastName: lastName,
age: age,
isAdmin: isAdmin
)
}
}
}
}
}
Optional Bindingの方がましに見えます。
// [ Swift ]
let person: Person?
if
let id = id,
let firstName = firstName,
let lastName = lastName,
let age = age,
let isAdmin = isAdmin
{
person = Person(
id: id,
firstName: firstName,
lastName: lastName,
age: age,
isAdmin: isAdmin
)
} else {
person = nil
}
しかし、パラメータ名を何度も繰り返さなければなりません。
Haskellでよく使われるアプリカティブスタイルでは、もっとシンプルになります。
// [ Swift ]
let person: Person? = curry(Person.init)
<^> id
<*> firstName
<*> lastName
<*> age
<*> isAdmin
アプリカティブスタイルは、サードパーティのライブラリthoughtbot/Runes[3]を使うことで、Swiftでも利用可能です。
Optionalのためのシンタックシュガーと演算子
さらに、Swiftは次のようなのシンタックスシュガーと演算子を提供し、 Optional を使いやすくしています。
// [ Swift ]
//let foo: Optional<Foo> = ...
let foo: Foo? = ...
// let baz: Baz? = foo.flatMap { $0.bar }.flatMap { $0.baz }
let baz: Baz? = foo?.bar?.baz
// let quxOrNil: Qux? = ...
// let qux: Qux
// if let q = quxOrNil {
// qux = q
// } else {
// qux = Qux()
// }
let quxOrNil: Qux? = ...
let qux: Qux = quxOrNil ?? Qux()
Swiftにおける Optional
Foo? == Optional<Foo>-
Forced Unwrapping:
! -
map,flatMap - アプリカティブスタイル:
<^>,<*> -
Optional Chaining:
foo?.bar?.baz -
Nil-Coalescing Operator:
??
一部の言語にはそれらのいくつかがありました。しかし、それらすべての組み合わせによって、Swiftは他の言語には実現できていない安全で実用的、理論的に洗練された言語になりました。私はそれに魅了されました。
null参照の発明者であるTony Hoareはこう言いました。
実装が非常に簡単だったので、null参照を入れたいという誘惑に抗えませんでした。
これはプログラミングのダークサイドだと思います。ダークサイドに落ちるのは簡単です。少しの安全性を犠牲にすれば、型の複雑さから解放されます。しかし、私はジェダイでいたいのです。それが進化につながると信じています。
Optional は進化でした。型安全でありながら実用的です。それが、私がSwiftの Optional が素晴らしいと考える理由です。
2. 成功か失敗か (Success or Failure)
私の二つ目の話は、成功か失敗かについてです。
Optional の問題点
Optional は素晴らしかったのですが、エラーの原因を報告する方法がありませんでした。
それは主に二つの問題につながります。
- デバッグが難しい
- エラーの原因によって処理を分岐できない
たとえば、
// [ Swift ]
let a: Int? = toInt(aString)
let b: Int? = toInt(bString)
let sum: Int? = a.flatMap { a in b.map { b in a + b } }
guard let sum = sum else {
// `a`と`b`のどちらがパースに失敗したのか?
// 入力された文字列は何だったのか?
...
}
このような単純な処理でさえ、 a と b のどちらのパースに失敗したのか、入力は何だったのかを知りたいところです。
もう一つはJSONの例です。JSONでキー "isAdmin" が省略されている場合に false としたい場合、 Optional ではどのようにすればよいでしょうか。
// [ Swift ]
let isAdmin: Bool
if let admin = json["isAdmin"].bool {
// JSONが {"isAdmin": true} だった場合はここに入る
isAdmin = admin
} else {
// JSONが
// 1. [true]
// 2. {}
// 3. {"isAdmin": 42}
// のどれであってもここに入る
isAdmin = ...
}
示したように、3通りの失敗の仕方があります。2番目のケースからのみ回復させたい( "isAdmin" を false としてデコードしたい)のですが、他の二つのケースではエラーにしたいです。
-
[ true ]: error -
{}:false -
{ "isAdmin": 42 }: error
nil ではその違いを表現できません。
Optional の代替案
私はそれらの問題に対して3つの解決策を見つけました。
- タプル
- Union型
- Result型
それらはSwift Evolutionメーリングリストでも議論されています。
タプル
タプルを使うと、toIntは次のように書けます。
// [ Swift ]
func toInt(string: String) -> (Int?, FormatError?) {
...
}
Int 値に加えて FormatError を返します。Goのライブラリではこのスタイルを採用しているものがあります。
しかし、これでは結果が次の4通りになってしまいます。
-
(value, nil ): 成功 -
(nil , error): 失敗 -
(value, error): ??? -
(nil , nil ): ???
最後の二つは不要です(型の上で起こらないものを含んでいて冗長です)。
Union型
Union型はCeylon、TypeScript、型ヒント付きのPythonなどで提供されています。
Union型では、 Int|String は Int または String 型であることを意味します。そのため、 Int|FormatError で Int か FormatError を(戻り値として)直接返すことができます。
// [ Swift ]
func toInt(string: String) -> Int|FormatError {
...
}
// [ Swift ]
switch toInt(...) {
case let value as Int:
...
case let error as FormatError:
// エラーハンドリング
...
}
さらに興味深いのは、CeylonとPythonの Optional がUnion型のシンタックスシュガーであることです。
// [ Ceylon ]
Integer? a = 42;
Integer|Null a = 42;
# [ Python ]
def foo() -> Optional[Foo]: ...
def foo() -> Union[Foo, None]: ...
Union型は、それらの言語では Optional を一般化する自然な方法です。
しかし、Swiftの Optional は enum です。
// [ Swift ]
enum Optional<T> {
case Some(T)
case None
}
私はUnion型で Optional を表すのはSwift的ではないと思いました。
Result型
Result型はRustから来ました。
Result はこのように宣言できます。
// [ Swift ]
enum Result<T, E> {
case Success(T)
case Failure(E)
}
これはSwift的で、Optionalの自然な拡張です。
// [ Swift ]
enum Optional<T> {
case Some(T)
case None
}
Result型を使えば、エラー情報を取得でき、
// [ Swift ]
let a: Result<Int, FormatError> = toInt(aString)
let b: Result<Int, FormatError> = toInt(bString)
let sum: Result<Int, FormatError> = a.flatMap { a in b.map { b in a + b } }
switch sum {
case let .Success(sum):
...
case let .Failure(error):
// `error`から詳細なエラー情報を取得する
...
}
エラーの原因によって処理を分岐できます。
// [ Swift ]
let isAdmin: Bool
switch json["isAdmin"].bool {
case let .Success(admin):
isAdmin = admin
case .Failure(.MissingKey):
// {} => false
isAdmin = false
case .Failure(.TypeMismatch, .NotObject):
// [ true ] => error
// { "isAdmin": 42 } => error
...
}
Result は Optional のように map や flatMap することもできます。
antitypical/Result[4]は、そのような Result をSwiftに提供しています。
Result に Optional のようなシンタックスシュガーがあれば便利でしょう。
Unition型は導入しないにしても、その | による表記は直感的で書きやすそうです。また、 flatMap のチェーンはOptional Chainingのように書けるべきです。
// [ Swift ]
let foo: Result<Foo, Error> = ...
let baz: Result<Foo, Error> = foo.flatMap { $0.bar }.flatMap { $0.baz }
// [ Swift ]
let foo: Foo|Error = ...
let baz: Baz|Error = foo?.bar?.baz
それらは Result をより強力にするでしょう。
Result を使う難しさ
Result は良さそうでした。しかし、それらがうまく機能しないケースがすぐに見つかりました。これがその例です。
// [ Swift ]
let a: Result<Int, ErrorA> = ...
let b: Result<Int, ErrorB> = ...
let sum: Result<Int, ???> = a.flatMap { a in b.map { b in a + b } }
2番目の型パラメータは何にすべきでしょうか。ErrorAとErrorBの両方になり得ます。
1つの簡単な答えは、( ErrorA|ErrorB の)Union型を使うことでした。しかし、Union型は(Swiftには適さないと)導入を見送りました。
// [ Swift ]
// ⛔️ Union型の導入を見送ったのでErrorA|ErrorBは使えない
let sum: Result<Int, ErrorA|ErrorB> = a.flatMap { a in b.map { b in a + b } }
次のアイデアは、ネストした Result でした。
// [ Swift ]
let sum: Result<Int, Result<ErrorA, ErrorB>> = a.flatMap { a in b.map { b in a + b } }
しかし、これはひどく見えます。
| 表記を使うと、少しマシになりました。
// [ Swift ]
let sum: Int|ErrorA|ErrorB = a.flatMap { a in b.map { b in a + b } }
しかし、 Result が増えるとまだひどい状態でした。ネストが深すぎて直感的ではありません。
// [ Swift ]
let id: String|ErrorA = ...
let firstName: String|ErrorB = ...
let lastName: String|ErrorC = ...
let age: Int|ErrorD = ...
let isAdmin: Bool| ErrorE = ...
let person: Person|(((ErrorA|ErrorB)|ErrorC)|ErrorD)|ErrorE
= curry(Person.init) <^> id <*> firstName
<*> lastName <*> age <*> isAdmin
このとき、エラーハンドリングはこのように行われます。
// [ Swift ]
switch person {
case let .Success(person):
...
case let .Failure(.Success(.Success(.Success(.Success(.Failure(errorA)))))):
...
case let .Failure(.Success(.Success(.Success(.Failure(errorB))))):
...
case let .Failure(.Success(.Success(.Failure(errorC)))):
...
case let .Failure(.Success(.Failure(errorD))):
...
case let .Failure(.Failure(errorD)):
...
}
あまりにも煩雑です。
エラー型のないResult型
私はこの問題について長い間考えました。そして最終的に、 Result の2番目の型パラメータは実際には重要ではないと結論付けました。
もし Result が次のように宣言されていれば、エラーの型を失い、安全ではないように見えます。
// [ Swift ]
enum Result<T> {
case Success(T)
case Failure(ErrorType)
}
しかし、ほとんどの場合、すべての可能なエラーに対して処理を分岐する必要はありません。気にする必要があるのは、一つか二つの例外的なエラーだけです。
ネットワーク処理について考えてみましょう。それらはさまざまな方法で失敗します。タイムアウトになったときは処理をリトライしたいですが、ForbiddenやNot foundなどの場合はリトライしたくありません。
そこで、処理を成功・タイムアウト・その他のケースに分岐します。起こり得るすべてのエラーを列挙する必要はありません。
// [ Swift ]
downloadJson(url) { json: Result<Json> in
switch json {
case let .Success(json): // 成功
...
case let .Failure(.Timeout): // タイムアウト
// リトライ
...
case let .Failure(error): // その他
// エラー
...
}
}
JSONの例でも同じことが言えます。MissingKeyからのみ回復させ、その他のエラーではエラーとしてハンドリングしたいです。
// [ Swift ]
let isAdmin: Bool
switch json["isAdmin"].bool {
case let .Success(admin): // 成功
isAdmin = admin
case .Failure(.MissingKey): // キーがない
// {} => false
isAdmin = false
case let .Failure(error): // その他
// [ true ] => error
// { "isAdmin": 42 } => error
...
}
起こり得るすべてのエラーに対して処理を分岐する必要があることは非常にまれです。そして、実際にそれが必要な場合は、Swiftがすでに提供しているAssociated Value付きの enum で行うことができます。
// [ Swift ]
enum Foo {
case Bar(A)
case Baz
case Qux(B)
}
func foo() -> Foo { ... }
switch foo() {
case let Bar(a):
...
case let Baz:
...
case let Qux(b):
...
}
私は、今述べたようなエラー型を指定しない Result を提供するライブラリResultK[5]を実装しました。さまざまな型のエラーが混在していても、うまく機能します。
// [ Swift ]
let a: Result<Int> = ... // ErrorA
let b: Result<Int> = ... // ErrorB
let sum: Result<Int>= a.flatMap { a in b.map { b in a + b } } // ErrorAまたはErrorB
エラー型を指定しない場合、( Int|FormatError のような)シンタックスシュガーはどうすれば良いでしょうか。 Result<Int> の代わりに Int| とするのが良いかもしれません。
// [ Swift ]
let a: Int| = ...
let b: Int| = ...
let sum: Int| = a.flatMap { a in b.map { b in a + b } }
そのような、エラー型を指定しない Result は(型安全性を失ってしまったという意味で)ダークサイドかもしれません。しかし、私が考えた限りでは、今のところこれが最良の方法でした。
3. Try
私の三つ目の話は try についてです。
エラーのAutomatic Propagation
Swift 2.0では、Javaの try/catch と似たような構文が導入されました。
// [ Swift ]
func toInt(string: String) throws -> Int {
...
}
do {
let number = try toInt(string)
...
} catch let error {
// ここでエラーハンドリング
...
}
私の第一印象は良くありませんでした。Java時代に逆戻りしたくはありませんでした。しかし、それを学ぶにつれ、かなり良いものだと思うようになりました。
SwiftのCore Teamは、"Error Handling Rationale and Proposal"[6]という文書で、なぜ try/catch 構文を採用したのかを説明しています。
その文書の中で、Core TeamはエラーのManual PropagationとAutomatic Propagationを定義しています。Manual Propagationでは、エラーは制御構文を用いてで手動でハンドリングされますが、Automatic Propagationでは、エラーが発生すると自動的にハンドラにジャンプします。
// [ Swift ]
// Manual Propagation
switch(toInt(string)) {
case let .Success(number):
...
case let .Failure(error): // エラーを手動でハンドリング
...
}
// Automatic Propagation
do {
let number = try toInt(string) // 自動的に`catch`にジャンプ
...
} catch let error {
...
}
Automatic Propagationは、特に複数のエラーをまとめてハンドリングしたい場合に便利です。Manual Propagationでも、 map や flatMap を使って関数型言語的にエラーハンドリングすることはできます。しかし、構文的に複雑で理論的に難しいです。
// [ Swift ]
// Manual Propagation
let a: Result<Int> = toInt(aString)
let b: Result<Int> = toInt(bString)
switch a.flatMap { a in b.map { b in a + b } } {
case let .Success(sum):
...
case let .Failure(error):
...
}
// Automatic Propagation
do {
let a: Int = try toInt(aString)
let b: Int = try toInt(bString)
let sum: Int = a + b
...
} catch let error {
...
}
そのドキュメントの中で、Core TeamはHaskellの do 記法に関する興味深いトピックに言及しています。それ( do 記法)は flatMap のチェーンとネストした flatMap を簡略化するための記法です。
// [ Swift ]
let sum = toInt(aString).flatMap { a in
toInt(bString).flatMap { b in
.Some(a + b)
}
}
-- [ Haskell ]
sum = do
a <- toInt aString
b <- toInt bString
Just (a + b)
Core Teamは、これは一種のAutomatic Propagationだと述べています。
つまり、(結局Haskellのような関数型言語であったとしても、 map や flatMap だけで記述するのはわずらわしいので)関数型でも関数型でなくてもエラーハンドリングを簡潔な表記で書くためには、Automatic Propagationが必要になるということです。そのため、(当初Javaの try/catch のような構文をSwiftに導入するのは不安に思いましたが、最終的に)私はAutomatic PropagationをSwiftに導入するのは良いことだと理解しました。
Marked Propagation
私は、Untyped Throws(型付けされていない throws )についても心配していました。
(Swiftでは)これまでのところ、 throws にエラーの型を指定できません。
// [ Swift ]
func toInt(string: String) throws FormatError -> Int { // コンパイルエラー
...
}
型安全ではないように見えますが、 Result の場合と同じ理由でそれ(エラーの型を型付けしないこと)は妥当だと思います。私が心配していたのは別のことでした。
Javaには非検査例外があります。それらをハンドリングしなくても、コンパイラは何も報告しません。C#やさまざまな動的型付け言語にも同様のメカニズムがあります。
// [ Java ]
class FormatException extends RuntimeException { // 非検査例外
...
}
// [ Java ]
static int toInt(String string) throws FormatException {
...
}
// [ Java ]
String string = ...;
int number = toInt(string); // 非検査例外なのでコンパイルエラーにならない
それらの言語では、コードのすべての行で予期しないエラーがスローされる可能性があります。
// [ Java ]
void foo() { // `foo`は何をthrowする可能性がある?
a(); // 非検査例外をthrowするかもしれない
b(); // 非検査例外をthrowするかもしれない
c(); // 非検査例外をthrowするかもしれない
d(); // 非検査例外をthrowするかもしれない
e(); // 非検査例外をthrowするかもしれない
f(); // 非検査例外をthrowするかもしれない
g(); // 非検査例外をthrowするかもしれない
}
そうなると、自分自身で実装した関数でさえ、どのような種類のエラーが実際に throw され得るのかわからなくなります。これは非常にまずいことです。エラーハンドリングを完璧にすることは不可能で、ケアレスミス(必要なエラーハンドリングを忘れる事態)を引き起こしかねません。
私は、それがSwiftでも起こり得ると思いました。Swiftには非検査例外はありません。しかし、一度関数に throws を追加すると、その関数のどの行がエラーをスローする可能性があるのかを知るのは難しくなります。そして、エラーの型を指定する必要がないため、その関数が throws するエラーの種類について不注意になります。
// [ Swift ]
func foo() throws { // `foo`は何をスローする可能性がある?
a() // エラーをthrowする可能性がある?
b() // エラーをthrowする可能性がある?
c() // エラーをthrowする可能性がある?
d() // エラーをthrowする可能性がある?
e() // エラーをthrowする可能性がある?
f() // エラーをthrowする可能性がある?
g() // エラーをthrowする可能性がある?
}
しかし、Swiftでは throws を伴う関数を呼び出すときに、 try キーワードを付与することが強制されています。Core TeamはこれをMarked Propagationと呼んでいます。
// [ Swift ]
func foo() throws {
a()
try b() // エラーをthrowする可能性がある
c()
d()
try e() // エラーをthrowする可能性がある
f()
g()
}
try があれば、どの行がエラーを throw する可能性があるのかが明確になります。そして、関数内でどのような種類のエラーがスローされる可能性があるのかを確認するのがずっと簡単になります( try が付与された関数を確認すれば良いので)。
もし throws に型があれば、より安全になるでしょう。しかし、Marked Propagationは(前述のように)型付けされていない throws の最悪の部分を取り除きます。これは、型安全性とシンプルさのバランスを取った妥当なトレードオフだと思います。
Marked Propagationはコードを読むのにも役立ちます。Automatic Propagationでは、(エラー発生時に)どの行から catch 節にジャンプし得るのかを理解するのが難しいです。これは"Error Handling Rationale and Proposal"の中で Implicit Control Flow Problem(暗黙的な制御フロー問題) として言及されています。Marked Propagationは、その暗黙的な部分をより明確にします。
// [ Java ]
try {
foo();
bar();
baz();
} catch (QuxException e) {
// どこから来るかわからない
}
// [ Swift ]
do {
foo()
try bar()
baz()
} catch let error {
// bar()から来たのが明確
}
つまり、Marked Propagationは
- どのようなエラーが
throwされ得るのかわからなくなる - Implicit Control Flow Problem(暗黙的な制御フロー問題)
という二つの問題の解決策なのです。私は、それは(言語の)進化だと思いました。
Optional のためのMarked Propagation
さて、Marked Propagationが良いものなら、なぜそれを Optional に使わないのかという疑問が生まれます。
"Error Handling Rationale and Proposal"の中でCore Teamは、 Optional はSimple Domain Errorに用いられるべきで、それにはManual Propagationが適していると述べています。 toInt はその例として挙げられていました。
// [ Swift ]
// Simple Domain ErrorのためのOptional
func toInt(string: String) -> Int? {
...
}
// Manial Propagation
guard let number = toInt(string) {
// ここでエラーハンドリング
...
}
しかし、私は Optional にもAutomatic Propagationが役立つと考えています。エラーとしてだけでなく、単に空の値として nil を取得することもあります。私たちのコードは Optional だらけです。それらを手動でハンドリングするのはコストがかかります。
私は Optional のためのAutomatic Propagationをこのように提案します。
// [ Swift ]
// Manual Propagation
let a: Int? = toInt(aString)
let b: Int? = toInt(bString)
if let sum = (a.flatMap { a in b.map { b in a + b } }) {
...
} else {
...
}
// Automatic Propagation
do {
let a: Int = try toInt(aString)
let b: Int = try toInt(bString)
let sum: Int = a + b
...
} catch {
...
}
この構文では、 try は一種のアンラップです。それを catch するか、戻り値の型を Optional にしなければいけません。私は、この構文には一貫性があると思います。
Result と try
この考え方は Result にも拡張できます。
Result とthrowsは理論的に交換可能です。もし throws が Result を返すことのシンタックスシュガーであれば、 Result と throws の両方の世界をシームレスに接続できるでしょう。
// [ Swift ]
// throwsの場合
func toInt(string: String) throws -> Int { // このように書けば
...
}
// [ Swift ]
// Resultの場合
func toInt(string: String) -> Result<Int> { // こう書いたのと同じ意味とする
...
}
// [ Swift ]
// throwsの場合
do {
let a: Int = try toInt(aString) // tryとcatchでハンドリングしても良いし
let b: Int = try toInt(bString)
let sum: Int = a + b
...
} catch {
...
}
// [ Swift ]
// Resultの場合
let a: Result<Int> = toInt(aString) // Resultとして受け取って後でハンドリングしても良い
let b: Result<Int> = toInt(bString)
switch a.flatMap { a in b.map { b in a + b } } {
case let .Success(sum):
...
case let .Failure(error):
...
}
Result は、 Optional のように(変数に格納して取り回すことで)後からハンドリングするなど、エラーをより柔軟にハンドリングする方法を提供します。そのため、 throws と Result を自由に行き来して、そのときに取り扱いたい方法で取り扱えることが重要です。
Result と throws を行き来できることが重要な例
例を示しましょう。
私は遅延評価される List を提供するライブラリListK[7]を実装しました。これにより、無限リストを作成することができます。
無限であるにもかかわらず、処理が遅延評価されるため、 map することができます。
// [ Swift ]
let infinite: List<Int> = List { $0 } // [0, 1, 2, 3, 4, ...]
let square: List<Int> = infinite.map { $0 * $0 } // [0, 1, 4, 9, 16, ...]
しかし、throwsのある関数ではうまく機能しません。
// [ Swift ]
func toInt(string: String) throws -> Int {
...
}
let strings: List<String> = ... // ["0", "1", "2", ...]
do {
// 決して終わらない
let numbers: List<Int> = try strings.map(transform: toInt)
} catch let error {
...
}
この map の処理は決して終わりません。なぜでしょうか。
throws のある map は、 Result を返す型で書くと次のように書けます。
// [ Swift ]
// throwsを使って
func map<U>(transform: T throws -> U) throws -> List<U>
// Resultを使って
func map<U>(transform: T -> Result<U>) -> Result<List<U>>
Result を返すにはその値が .Success か .Failure かを決定しなければいけません。そのためには、無限に続く要素すべてについて transform を評価しなければならず、決して終わりません。
私が List に求めているのは、次のようなものです。 Result と List が入れ替わっています。これなら遅延評価することができます。
// [ Swift ]
// throwsを使って
func map<U>(transform: T throws -> U) -> List<Result<U>>
// Resultを使って
func map<U>(transform: T -> Result<U>) -> List<Result<U>>
これにより、 throws のある transform で無限リストを map することができるようになります。
// [ Swift ]
func toInt(string: String) throws -> Int {
...
}
let a: List<String> = ... // ["0", "1", "2", ...]
let b: List<Result<Int>> = strings.map(transform: toInt)
// [Result(0), Result(1), Result(2), ...]
let c: List<Result<Int>> = b.take(10)
// [Result(0), Result(1), Result(2), ..., Result(9)]
let d: Result<List<Int>> = sequence(c)
// Result([0, 1, 2, ..., 9])
do {
let e: List<Int> = try d // [0, 1, 2, ..., 9]
...
} catch let error {
// `FormatError`のハンドリング
...
}
throws と Result の世界を行き来し、必要に応じて throws ではなく Result でエラーを扱えることで、無限リストの map が可能となりました。
throws を Result を返すことのシンタックスシュガーとすることの欠点
しかし、 throws を Result を返すことのシンタックスシュガーとすることの欠点もあります。
Swift 2.xでは、 try キーワードを書き忘れると、その場所でコンパイルエラーが発生します。(これは、Marked Propagationとして前述したように望ましいことです。)
// Swift 2.x
let a = toInt(aString) // ここでコンパイルエラー
let b = toInt(bString)
let sum = a + b
しかし、 throws を Reuslt を返すことのシンタックスシュガーにしてしまうと、これを実現することができません( try を忘れてもエラーにはならず、 Result を返すという意味になるため)。 try を忘れた箇所でエラーになる代わりに、 Result の値を使おうとしたところでコンパイルエラーが発生します。
// 私の提案によるSwift
let a = toInt(aString) // aの型はResult<Int>
let b = toInt(bString) // bの型はResult<Int>
let sum = a + b // ここでコンパイルエラー(Result<Int>同士を足すことはできない)
直観的ではなく、わかりづらいです。しかし、これまで見てきたように、全体としては throws を Result を返すことのシンタックスシュガーにするのは良い案だと思います。
非同期処理と try
さらに、私は try がエラーハンドリング以外の目的にも使えると考えています。一つの例は非同期処理です。
JavaScriptは非同期処理のために Promise をネイティブにサポートしています。その then メソッドは理論的には map と flatMap に相当します。私はSwiftで map と flatMap を使って Promise ライブラリ[8]を実装しました。
// [ Swift ]
let a: Promise<Int> = asyncGetInt(...)
let b: Promise<Int> = asyncGetInt(...)
let sum: Promise<Int> = a.flatMap { a in b.map { b in a + b } }
これはResultとまったく同じように見えます。
// [ Swift ]
let a: Result<Int> = failableGetInt(...)
let b: Result<Int> = failableGetInt(...)
let sum: Result<Int> = a.flatMap { a in b.map { b in a + b } }
唯一の違いは、非同期処理を表すか、失敗し得る処理を表すかということです。
将来のJavaScriptは、C#で生まれた async/await 構文をサポートする予定です。この構文は Promise の上に成り立っており、 then のチェーンを簡単に書くことができます。
非同期処理は今日のプログラミングで最もホットなトピックの一つです。そのため、Swiftでも async/await 構文について議論する必要があると思います。
// [ C# ]
async Task<int> AsyncGetInt(...) {
...
}
async void PrintSum() {
int a = await AsyncGetInt(...);
int b = await AsyncGetInt(...);
Console.WriteLine(a + b);
}
C#の async/await 構文は次のように使われます。C#のこの Task クラスは Promise に相当します。
もしSwiftにこの構文があったら、次のようになるでしょう。
// [ Swift ]
func asyncGetInt(...) async -> Promise<Int> {
...
}
func printSum() async {
let a: Int = await asyncGetInt(...)
let b: Int = await asyncGetInt(...)
print(a + b)
}
私は、Swiftではこの構文を変更して、戻り値を暗黙的に Promise にラップすべきだと思います。
// [ Swift ]
func asyncGetInt(...) async -> Int { // <- ここを変更
...
}
これで、async/awaitとthrows/tryの共通の関係が見えてきました。
// [ Swift ]
func asyncGetInt(...) async -> Int { // async
...
}
func printSum() async { // async
let a: Int = await asyncGetInt(...) // await
let b: Int = await asyncGetInt(...) // await
print(a + b)
}
// [ Swift ]
func failableGetInt(...) throws -> Int { // throws
...
}
func printSum() throws { // throws
let a: Int = try failableGetInt(...) // try
let b: Int = try failableGetInt(...) // try
print(a + b)
}
async/await 構文は Promise を返すことと等価で、 throws/try 構文は Result を返すことと等価です。これは完全に意味を成します。 async/await/Promise と throws/try/Result は、非同期処理についてのものか、失敗し得る処理についてのものかという1点だけが異なるだけで、共通の概念を表しています。
ここで、 try を await の代わりとして使い、( async や throws はやめて)単に Promise (や Result )を返すようにすることで、非同期処理と失敗し得る処理を統合することができます。
// [ Swift ]
func asyncGetInt(...) -> Promise<Int> { // Promise
...
}
do {
let a: Int = try asyncGetInt(...) // try
let b: Int = try asyncGetInt(...) // try
print(a + b)
}
async, await, reasync のような独立したキーワードがあると、コードを読みやすくなる点は良いです。しかし、新しい機能を追加するたびに新しいキーワードが際限なく必要になります。
try を await の代わりにするのが良いと確信しているわけではありません。 await の代わりに try を使うのが良いか、それとも await を導入するのが良いか、どちらが良いのか迷っています。ここでそれについて述べたのは、 try の可能性(すべてのモナドを扱うキーワードになれるかもしれないということ)を示したかったからです。
まとめ
私はいくつかのアイデアを紹介しました。
-
Result<T, E>の代わりにResult<T>にする -
OptionalのAutomatic Propagation -
throwsをResultを返すことのシンタックスシュガーにする -
async/awaitと非同期処理のためのtry
それらがすべて良いものであるかはわかりません。ですから、意見を聞かせてください。このカンファレンスを通じて、Swiftのエラーハンドリングについて議論したいと思います。そして、私はswift-evolutionメーリングリストに参加するつもりです。
私は誰もがプログラミングの教育を受けている世界を夢見ています。教育に適したプログラミング言語を自分で設計しようともしました。
ある朝、私はSwiftに出会いました。Swiftは私の目的に適していると思いました。今、私は誰もが、"Hello, world!!"からモナドまで、幅広いプログラミングの概念を学べるように、Swiftを題材に、無料で読めるオンラインブックを書く計画を立てています。
プログラミング言語を設計した経験から、私は、それは安全性と複雑さとの戦いだと言うことができます。別の言い方をすれば、それはこのように言えます。 "Stay Typed. Stay Practical."(「型付けを保ちながら、実用的でもあり続けよう。」) このことは、この発表を通してお話ししてきたように、(言語の)進化を生むと思います。 Stay Typed. Stay Practical. 私は、Swiftの設計者に常にそれを願ってきました。そして今、Swiftがオープンソースになったので、私たちに(も)それを願っています。
Stay Typed. Stay Practical.
皆さん、どうもありがとうございました。
-
"‘You’ve got to find what you love,’ Jobs says" https://news.stanford.edu/2005/06/12/youve-got-find-love-jobs-says/ ↩︎
-
"SwiftyJSON", https://github.com/SwiftyJSON/SwiftyJSON ↩︎
-
"thoughtbot/Runes", https://github.com/thoughtbot/Runes ↩︎
-
"antitypical/Result", https://github.com/antitypical/Result ↩︎
-
"ResultK", https://github.com/koher/ResultK ↩︎
-
"Error Handling Rationale and Proposal", https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst ↩︎
-
"ListK" https://github.com/koher/ListK ↩︎
-
"PromiseK" https://github.com/koher/PromiseK ↩︎
Discussion