cockscomblog?

cockscomb on hatena blog

SwiftにおけるTyped throwsの現在

現在Swift Evolutionで議論されているSE-0413 Typed throwsについて、Swiftの歴史を辿りながら紹介します。

この記事ははてなエンジニア Advent Calendar 2023の9日目の記事です。昨日は id:kouki_daniPadだけでアプリを作ってみるでした。ファスティング中の 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のエラーシステムが提供するthrowstrycatchとは全く違う方法でエラーハンドリングを行わせるもので、言語としての一貫性という意味では怪しいところがある。ただし当時の背景からすればこれは妥当で、まだSwift Concurrencyがなく、非同期処理はコールバックで表現されていたため、このようなものが求められていた。実際にサードパーティのResult型が広く使われてもいた。

C++の開発者であるビャーネ・ストラウストラップは「プログラミング言語C++ 第4版」の中で、標準ライブラリの役割のひとつに「ライブラリ間通信を実現するコンポーネントの集合」を挙げている。ライブラリ間でのやり取りに必要な汎用のコンテナ型を提供するのは、標準ライブラリの重要な役割である。したがってResultが標準ライブラリに追加されることには必然性があった。

そしてこのResult<Success, Failure>の型パラメータには、Errorプロトコルに制約されたFailureがある。他のプログラミング言語におけるEither型を考えればこれも妥当であるが、既存のエラーハンドリングモデルとはギャップがある。

Resultgetメソッドやイニシャライザによって、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なら「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")とするのが簡単だ。

// swift-tools-version: 5.9

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") // error: Thrown expression type 'SimpleError' cannot be converted to error type 'CatError'
}

catch句では型推論される。

do {
    _ = try callCat()
} catch {
    print(error) // このerrorはCatError
}

ただし、doの中で複数の型のエラーが起きる場合は、any Errorに落ちる。

do throws(ErrorType)で明示的に発生していいエラーを限定できる。もしもそれ以外のエラーが発生するようであれば、そこでコンパイルエラーになる。

do throws(SimpleError) {
    _ = try callCat() // error: Thrown expression type 'CatError' cannot be converted to error type 'SimpleError'
} 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によってResultTaskなどとインピーダンスが揃い、使いやすくなる面が多いだろう。ただしAsyncSequenceにPrimary Associated Typesを設定するのはFuture directionsに示されている通り、for..inの調整も含めて別のプロポーザルを待つ必要がある。

またかねてから議論されていた、throws(FileSystemError | NetworkError)のように複数のエラー型を扱えるようにする話はいったん見送られ、Alternatives consideredに記載された。実質的に匿名enum(直和型と呼ばれることも)を追加することになるため、このプロポーザルのスコープから外されている。


ということで、関西モバイルアプリ研究会A #1で話したTyped throwsでした。

明日は id:papix です。