Typed throws
01 何が問題だったのか
Swiftのエラーハンドリングでは、throws と書いた関数から投げられるエラーはすべて any Error に型消去されます。ほとんどのコードでは、エラーは呼び出し元にそのまま伝播させるか、ログやUIでそのまま表示するだけで、個別に網羅的にハンドリングすることはまれです。そのため、型消去されたデフォルト挙動は多くの場面で妥当な選択でした。
一方で、次のような場面では型情報が失われることが問題になっていました。
Result や Task との情報量の差
標準ライブラリの Result<Success, Failure> や Task<Success, Failure> は、失敗時のエラー型を型パラメータとして保持します。それに対して throws は常に any Error 相当なので、
enum CatError: Error {
case sleeps
case sitsAtATree
}
func callCat() -> Result<Cat, CatError>
func callCatOrThrow() throws -> Cat
のように、Result 版は失敗の種類を型で表現できるのに、throws 版ではそれができません。
さらに、throws と Result を相互変換しようとすると型情報が足りずコンパイルできない、あるいは網羅性チェックが通らずに余計な catch を書く必要があるといった問題が出ます。
func callAndFeedCat() -> Result<Cat, CatError> {
do {
return .success(try callCatOrThrow())
} catch {
// error は any Error なので、そのまま Result.failure(error) にできない
return .failure(error)
}
}
モジュール内で網羅的に扱いたいエラー
モジュールやパッケージ内に閉じたAPIで、想定されるエラーの種類が完全に把握できていて、呼び出し側で網羅的に処理したい場合もあります。このような場面でも、any Error を as? などで動的にキャストする必要があり、コンパイラの静的なサポートを受けられませんでした。
rethrows の限界
rethrows は「クロージャ引数が投げたときだけ投げる」ことを表しますが、エラー型自体は常に any Error のままです。そのため、map のようにクロージャが投げたエラーをそのまま透過的に流したいだけの関数でも、呼び出し側では具体的なエラー型を取り戻せません。また、rethrows の静的チェックは保守的で、実質的にはクロージャ由来のエラーしか投げていなくても、中間で any Error? を介して再throwするようなコードはコンパイラが rethrows として認めてくれないことがありました。
any Error のコスト
any Error はexistential型なので、コードサイズ・ヒープアロケーション・実行性能のいずれにも無視できないオーバーヘッドを伴います。Embedded Swiftのように制約の厳しい環境ではexistential型そのものが使えないことがあり、そうした環境では従来の throws をそのまま使うのが難しいという問題もありました。
これらの理由から、「関数が投げるエラーの型」をピンポイントに指定できる仕組みが必要とされていました。
02 どのように解決されるのか
throws の後ろに括弧でエラー型を書くことで、その関数が投げ得るエラー型をひとつだけに絞り込めるようになりました。
func callCat() throws(CatError) -> Cat {
if Int.random(in: 0..<24) < 20 {
throw .sleeps
}
// ...
}
callCat は CatError 以外のエラーを投げられません。throw の位置では投げるエラー型が確定しているので、CatError.sleeps のようにフルネームで書かなくても .sleeps と省略できます。CatError に変換できないエラーを投げようとするとコンパイルエラーになります。
func callCatBadly() throws(CatError) -> Cat {
throw SimpleError(message: "sleeping")
// error: SimpleError を CatError に変換できない
}
投げるエラー型は Error プロトコルに適合していなければなりません。any Error & Codable のように複数要件を持つexistentialは、それ自体が Error に適合していないため、現時点では指定できません。
throws(any Error) と throws(Never)
typed throwsは、既存の throws と非throwing関数の両方を統一的に表現できます。
throws(any Error)は従来のthrows(=型消去)と等価です。throws(Never)は「絶対に投げない」=非throwing関数と等価です。
func throwsAnything() throws(any Error) { ... } // throws と同じ
func throwsNothing() throws(Never) { ... } // 非throwingと同じ
関数型のサブタイピングもこの包含関係に沿って整理され、「投げない → 具体的な型を投げる → any Error を投げる」の向きに緩める変換は常に可能です。
catch での型付き捕捉
do...catch の中で throw 箇所や try 箇所がすべて同じ具体的なエラー型(と Never)しか投げない場合、catch の暗黙の error 変数はその具体的なエラー型として扱われます。
func callAndFeedCat() -> Result<Cat, CatError> {
do {
return .success(try callCat())
} catch {
// error は CatError 型なので、そのまま渡せる
return .failure(error)
}
}
異なる具体的エラー型が混ざる場合は、これまでどおり any Error に落ちます。
func callKids() throws(KidError) -> [Kid] { ... }
do {
try callCat()
try callKids()
} catch {
// error は any Error
}
ソース互換のため、do ブロック内に直接書かれた throw 文は推論上「any Error を投げる」扱いになります。これにより、既存の do { throw SomeError() } catch { ... } のようなコードの意味は変わりません。do ブロックで投げるエラー型を明示したいときは、do 自体に throws 節を付けられます。
do throws(CatError) {
if isDaylight && foodBowl.isEmpty {
throw .sleeps
}
try callCat()
} catch let myError {
// myError は CatError
}
具体的な型が分かっているので、値パターンによる catch も自然に書けます。
do /* throws(CatError) と推論 */ {
try callCat()
} catch .sleeps {
openFoodCan()
} // .sleeps 以外の CatError はこの do...catch の外に伝播する
ただし、網羅的な catch と見なされるのは「条件なしの catch」だけです。catch let e as CatError のようにパターンで型を明示しても、それだけで網羅とは扱われず、do の外にエラーが伝播し得る扱いになります。
ジェネリックな「rethrows」としての活用
typed throwsのもうひとつの大きな用途が、ジェネリックなパススルー関数の記述です。map を rethrows ではなく、エラー型のジェネリックパラメータを持つtyped throwsで書き直すと次のようになります。
extension Collection {
func map<U, E: Error>(
_ body: (Element) throws(E) -> U
) throws(E) -> [U] {
var result: [U] = []
for element in self {
result.append(try body(element))
}
return result
}
}
- 引数のクロージャが
CatErrorを投げるなら、map自身もCatErrorを投げます。 - 引数のクロージャが何も投げない(
Never)なら、EはNeverと推論され、mapも非throwingになります。 - 引数のクロージャが
any Errorを投げるなら、mapもany Errorを投げ、従来のrethrowsと同じ振る舞いになります。
ジェネリックパラメータを投げるエラー型として使うと、そのパラメータには Error への適合が暗黙に要求されます。上記の map では E: Error と明記していますが、省略しても同じ要件が推論されます。
この書き方は、従来の rethrows では表現しづらかったパターンにも対応できます。たとえば「中間で一度 error を保持してから最後に投げ直す」ような実装は、従来の rethrows チェッカでは受け付けられませんでしたが、エラー型をジェネリックパラメータ E として持ち回ることで素直に書けます。
func countNodes<E: Error>(
in tree: Node,
matching predicate: (Node) throws(E) -> Bool
) throws(E) -> Int {
// predicate から投げられた E 型のエラーを一度保持して、最後に投げ直すような実装も、
// すべて E 型として型付きのまま扱える
// ...
}
一方で、rethrows キーワード自体は今回は変わりません。rethrows 関数にエラー型を書くことはできず、また rethrows の意味も従来どおり「クロージャ由来のエラーを any Error として投げる」ままです。rethrows のクロージャから、typed throws で書かれた map などを呼び出しても rethrows チェックが通るように、小さな互換用の拡張だけが加えられています。
opaque thrown error types
throws(some Error) のようにopaque result typeでエラー型を指定することもできます。具体的な型は関数の実装側が選び、呼び出し側からは「Error に適合する何か」としてしか見えません。複数のエラー型を束ねて扱いたいが、実装詳細を公開したくない場合に使えます。
func doSomething() throws(some Error) {
// 内部では Either<CatError, KidError> などを throw する、といった実装が可能
}
関数の引数位置に現れる場合はopaque parameterとなり、呼び出し側のクロージャが型を決めることになるので、
func map<T>(_ transform: (Element) throws(some Error) -> T) rethrows -> [T]
は実質的に
func map<T, E: Error>(_ transform: (Element) throws(E) -> T) rethrows -> [T]
と同じ意味になります。
プロトコル要件・継承との関係
プロトコル要件やオーバーライドでは、先に述べたサブタイピング規則に沿って「より狭い」エラー型を使って適合・オーバーライドできます。
protocol Throwing {
func f() throws
}
struct NotThrowing: Throwing {
func f() { } // 投げない実装でもよい
}
enum SpecificError: Error { /* ... */ }
struct ThrowingSpecific: Throwing {
func f() throws(SpecificError) { } // 具体的な型だけ投げる実装でもよい
}
プロトコルの associatedtype をエラー型として使うこともでき、適合する具体型からエラー型を推論させられます。
protocol CatFeeder {
associatedtype FeedError: Error
func feedCat() throws(FeedError) -> CatStatus
}
struct Tabby: CatFeeder {
func feedCat() throws(CatError) -> CatStatus { ... } // FeedError == CatError
}
struct Sphynx: CatFeeder {
func feedCat() throws -> CatStatus { ... } // FeedError == any Error
}
struct Ragdoll: CatFeeder {
func feedCat() -> CatStatus { ... } // FeedError == Never
}
async let との関係
async let の初期化式が投げ得るエラーは、束縛された変数にアクセスするときに実効的に再throwされます。この「再throw時のエラー型」も、do ブロックと同じ推論規則で決まります。たとえば async let answer = callCat() とすれば、try await answer の位置で投げられるエラーは CatError として扱えます。
標準ライブラリでの採用
Result のイニシャライザと get() はtyped throwsで書き直されます。
// Before
init(catching body: () throws -> Success) where Failure == any Error
func get() throws -> Success
// After
init(catching body: () throws(Failure) -> Success)
func get() throws(Failure) -> Success
これにより、Result<Success, CatError> を使う場面で get() から直接 CatError を受け取れるようになり、非throwingクロージャからは Failure == Never な Result を作れます。
そのほか、標準ライブラリの rethrows 関数(map、filter、last(where:) など)も、上記の map と同じ形のジェネリックなtyped throws版に機械的に置き換えられます。呼び出し側の挙動は従来と変わらず、投げないクロージャを渡せば非throwing、具体型を投げるクロージャを渡せばその型がそのまま伝播します。
いつtyped throwsを使うか
typed throwsは強力ですが、関数のエラー型を固定することは、その関数の将来の進化にとって制約にもなります。エラーは通常「伝播させるか表示する」だけで、呼び出し側で網羅的にハンドリングされることは多くありません。そのため、typed throwsを導入した後でも、多くのコードにとっては従来の throws(つまり throws(any Error))のほうが引き続き良いデフォルトです。
次のような場面でのみtyped throwsを検討すると良いとされています。
- モジュールやパッケージ内に閉じた実装で、すべてのエラーを自分で責任を持って処理したい場合。
mapのようにクロージャから受け取ったエラーをそのまま素通しするだけで、自分ではエラーを生成しないジェネリックなコード。- Embedded Swiftなど、existential型やヒープアロケーションを避けたい制約付きの環境で、かつ自分でしかエラーを投げないコード。
「実装の都合で現時点では1種類しかエラーを投げていない」というだけの理由でtyped throwsを選ぶのは避けるのが無難です。たとえば loadBytes(from:) throws(FileSystemError) のようなAPIは、将来ネットワークやDBからの読み込みにも対応したくなったときに、エラー型の互換性を壊すか翻訳コードを押し込むかの二択を迫られます。こうしたAPIでは従来の throws を使うほうが適切です。
今後の見通し
今回のproposalでは、Swift 6.0で実装済みの範囲に絞られています。たとえば、クロージャ本体の throw 文から具体的なエラー型を推論する FullTypedThrows 相当の拡張や、AsyncSequence など concurrency ライブラリへのtyped throws適用、分散アクターの transport エラー型のtyped化などは「Future Directions」として言及されており、将来別のproposalで扱われる可能性があります。これらは方向性の共有であって、実現を約束するものではありません。