現在Swift Evolutionで議論されているSE-0413 Typed throwsについて、Swiftの歴史を辿りながら紹介します。
この記事ははてなエンジニア Advent Calendar 2023の9日目の記事です。昨日は id:kouki_dan のiPadだけでアプリを作ってみるでした。ファスティング中の id:kouki_dan を関モバに誘ったのは私です。お誕生日おめでとうございました。
Swiftのエラーハンドリング
Swiftのエラーハンドリングでは、2015年6月のSwift 2.0のリリース以来、エラーに型がつかない。Error
プロトコルに準拠したなんらかの型が投げられるということだけ決まっていて、それが実際にどうであるかを確認するのは(あるいは確認しないのは)、呼び出し側に任されている。do
文のcatch
句にはパターンが書けるので、必要に応じてハンドリングできる。
do {
let xmlDoc = try parse(myXMLData)
} catch let e as XMLParsingError {
print("Parsing error: \(e.kind) [\(e.line):\(e.column)]")
} catch {
print("Other error: \(error)")
}
実際にどういった型のエラーが起きるのかは、ドキュメンテーションでしか宣言できない。エラーのハンドリングが網羅的かどうかを機械的に検査することもできない。
Typed throwsに関する初期の議論
このことは度々議論の的となった。2015年12月にはすでに、当時のswift-evolutionメーリングリストで議論されている。Swiftを生み出したクリス・ラトナーは、typed throwsは良いが、Swift 3の resilience モデルまでは問題がある、と返信している。
動的にリンクされるライブラリがエラーをthrowする際に、ライブラリ側が変化してthrowするエラーが変わっても、呼び出し元からはそれを知ることができないから、なんらかの仕組みがないと型の安全性が壊れる、ということだ。
ちなみに当時 resilience モデルと言っていたものは、Swift 3では実現されない。Swift 5.0でのABIの安定化後に、Library Evolutionとして、2019年9月にリリースされたSwift 5.1から利用できるようになった。
エラーの型をパラメータに持つ型
Result
2018年11月にResult
を標準ライブラリへ追加するプロポーザルがSwift Evolutionで起案され、1ヶ月後に承認される。そして2019年3月のSwift 5.0でリリースされた。
@frozen public enum Result<Success, Failure> where Failure : Error {
case success(Success)
case failure(Failure)
}
これはSwiftのエラーシステムが提供するthrows
、try
、catch
とは全く違う方法でエラーハンドリングを行わせるもので、言語としての一貫性という意味では怪しいところがある。ただし当時の背景からすればこれは妥当で、まだSwift Concurrencyがなく、非同期処理はコールバックで表現されていたため、このようなものが求められていた。実際にサードパーティのResult型が広く使われてもいた。
C++の開発者であるビャーネ・ストラウストラップは「プログラミング言語C++ 第4版」の中で、標準ライブラリの役割のひとつに「ライブラリ間通信を実現するコンポーネントの集合」を挙げている。ライブラリ間でのやり取りに必要な汎用のコンテナ型を提供するのは、標準ライブラリの重要な役割である。したがってResult
が標準ライブラリに追加されることには必然性があった。
そしてこのResult<Success, Failure>
の型パラメータには、Error
プロトコルに制約されたFailure
がある。他のプログラミング言語におけるEither型を考えればこれも妥当であるが、既存のエラーハンドリングモデルとはギャップがある。
Result
のget
メソッドやイニシャライザによって、Swiftのエラーハンドリングシステムと相互運用できるようになっている。このときエラーの型はany Error
になる。
@frozen public enum Result<Success, Failure> where Failure : Error {
@inlinable public func get() throws -> Success
}
extension Result where Failure == any Error {
public init(catching body: () throws -> Success)
}
Swift ConcurrencyのTask
2021年9月リリースのSwift 5.5で、Swift Concurrencyとしてasync
/await
やActorなどが導入された。ここで導入されたTask
にもResult
と同様にFailure
型パラメータがある。
@frozen public struct Task<Success, Failure> : Sendable where Success : Sendable, Failure : Error {
}
これもResult
に近い。
Primary Associated TypesとAsyncSequence
2022年9月にリリースされたSwift 5.7で、Primary Associated Typeという機能が追加された。標準ライブラリの多くのプロトコルにも設定されたため、この機能でsome Sequence<String>
のように書ける。ところが、AsyncSequence
プロトコルにはPrimary Associated Typeが設定されなかった。
AsyncSequence
and AsyncIteratorProtocol
logically ought to have Element as their primary associated type. However, we have ongoing evolution discussions about adding a precise error type to these. If those discussions bear fruit, then it's possible we may want to also mark the potential new Error
associated type as primary. To prevent source compatibility complications, adding primary associated types to these two protocols is deferred to a future proposal.
— Primary Associated Types in the Standard Library
Swift Evolutionでは、エラーの型に関する議論が続いているから、とされた。このことでany AsyncSequence<String, any Error>
とは書けない。
Typed throws
そして2023年8月に、Status check: Typed throwsが投稿される。9月にはSwift Language Steering GroupのDoug GregorがPitchを投稿し、11月、ついに正式なプロポーザルSE-0413 Typed throwsができた。
実際に試す
ここで実際に動作を試してみる。
最新のdo throws(ErrorType)
の構文を試したいので、Swift Forumに投稿された最新のツールチェーンをダウンロードし、~/Library/Developer/Toolchains/
に展開する。
Xcodeなら「Xcode > Toolchains」からこれを選択。
あるいは「Manage Toolchains…」でもいい。
Terminalの場合
シェルではツールチェーンのBundle IDを調べてTOOLCHAINS
環境変数に設定する。
$ ls ~/Library/Developer/Toolchains/
swift-PR-70182-969.xctoolchain
$ /usr/libexec/PlistBuddy -c "Print CFBundleIdentifier:" ~/Library/Developer/Toolchains/swift-PR-70182-969.xctoolchain/Info.plist
org.swift.pr.70182.969
$ export TOOLCHAINS=org.swift.pr.70182.969
$ swift --version
Apple Swift version 5.11-dev (LLVM e131e99f323910c, Swift 4d62b1f4e64aa28)
Target: arm64-apple-macosx14.0
実験的フラグの設定
また実験的フラグTypedThrows
を有効にする必要がある。Swift Packageなら、.enableExperimentalFeature("TypedThrows")
とするのが簡単だ。
import PackageDescription
let package = Package(
name: "TypedThrows",
targets: [
.executableTarget(
name: "TypedThrows",
swiftSettings: [
.enableExperimentalFeature("TypedThrows"),
]
),
]
)
Typed throwsを見ていく
エラーの型を指定するにはthrows
の代わりにthrows(ErrorType)
を書く。
enum CatError: Error {
case sleeps
case sitsAtATree
}
func callCat() throws(CatError) -> Cat {
if Int.random(in: 0..<24) < 20 {
throw .sleeps
}
return Cat(name: "Neko")
}
もし宣言したのと違う型のエラーをthrowしようとすれば、そこでコンパイルエラーになる。
func callCatBadly() throws(CatError) -> Cat {
throw SimpleError(message: "sleeping")
}
catch
句では型推論される。
do {
_ = try callCat()
} catch {
print(error)
}
ただし、do
の中で複数の型のエラーが起きる場合は、any Error
に落ちる。
do throws(ErrorType)
で明示的に発生していいエラーを限定できる。もしもそれ以外のエラーが発生するようであれば、そこでコンパイルエラーになる。
do throws(SimpleError) {
_ = try callCat()
} catch {
print(error)
}
throws(any Error)
はthrows
と同じ意味で、throws(Never)
はthrows
じゃないのと同じ意味になる。
またエラー型を型パラメータにすることで、rethrows
を置き換えられる。
ということで、全体の規則は難しくない。
Typed throwsを使うべき場面は限定されている
プロポーザルに、Typed throwsを使っていいケースが紹介されている。普通はエラーを網羅的に場合分けしないので、any Error
である方がむしろいい。型があってもいい場面は次の通り。
- モジュールやパッケージ内に閉じていて、常にエラー処理したい場合は、純粋に実装の詳細であり、もっともらしい
- ジェネリックなコードで自分自身がエラーを発生させず、利用者が発生させたエラーをそのまま伝える場合
- 制限された環境下で動作するか、あるいはメモリを割り当てできない場合で、かつ自分自身でしかエラーを作らないとき
1つ目のケースは、つまり外部との境界に表れないなら問題ないということだ。あとからエラーの種類が増えてもモジュール内に閉じているので、特に問題が起きない。
2つ目のケースは、rethrows
と同等の条件だ。これも型は外から与えられるので、実質的にモジュールに閉じる。
3つ目のケースは少し特殊で、組み込み環境のようなものが想定されている。
要するに、モジュールの境界ではまず型をつけない方がいい、ということが書かれている。Typed throwsが利用されすぎることが懸念されている。
Typed throwsの今後
現在のプロポーザルについて、おおよそ全体には好意的に受け止められている。このまま受理されれば、遅くとも来年秋のSwift 5.11頃にリリースされるのではないか。(互換性のためにSwift 6になると少し動作が変化する予定とされている。)
Typed throwsによってResult
やTask
などとインピーダンスが揃い、使いやすくなる面が多いだろう。ただしAsyncSequence
にPrimary Associated Typesを設定するのはFuture directionsに示されている通り、for..in
の調整も含めて別のプロポーザルを待つ必要がある。
またかねてから議論されていた、throws(FileSystemError | NetworkError)
のように複数のエラー型を扱えるようにする話はいったん見送られ、Alternatives consideredに記載された。実質的に匿名enum(直和型と呼ばれることも)を追加することになるため、このプロポーザルのスコープから外されている。
ということで、関西モバイルアプリ研究会A #1で話したTyped throwsでした。
明日は id:papix です。