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()
Optional
Swiftにおける 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