Swiftのエラー処理とジェネリクス:throws・型付きthrows・Result・where
Swift 6.4.2で実行した中級講座。do/try/catch、try!の実クラッシュと消える出力、型付きthrows、Result、ジェネリクスの制約違反の実エラー、ジェネリクス対anyの-O実測と判断表。
シリーズ:Swift講座 4/6(中級)。 struct・enum・protocolで型を設計できるようになり(第3回)、次は失敗しうるコードや多くの型で動くコードを書く段階の方向けです。読み終えると、throws・型付きthrows・Result・Optional・try! を場面で使い分けられ、「requires that X conform to Y」というコンパイラのエラーを当て推量なしで読めるようになります。
社内の小さな共通ライブラリ、たとえば日付文字列のパーサーやAPIクライアントを任されると、Swiftの2つの壁にぶつかります。1つ目は、エラー処理の書き方が5通り(throws、try?、try!、Result、型付き throws(E))あってどれを選べばよいか分からないこと。2つ目は、ジェネリクスのエラーメッセージが見当違いの行を指しているように見えることです。この記事は、そのどちらかで手が止まった人のためのものです。すべて Swift 6.4.2(swiftc -swift-version 6、Swift 6言語モード+厳格な並行性チェック)を使い、Apple Silicon Mac (M1 Pro) でコンパイル・実行しました。出力・クラッシュメッセージ・コンパイルエラーはその結果をそのまま載せています。
第3回と同じく、このツールチェーンでは swift file.swift だけだとSwift 5言語モードで動きます。-swift-version 6 を付けてください。
throws・do/try/catch・try?・try!
throws を付けた関数は、値を返す代わりに処理を止めてエラーを呼び出し側に渡せます。呼び出し側は必ず try を書きます。ここで制御が抜けるかもしれない、という目印です。エラーは Error プロトコルに準拠した任意の型で、enumで作るのが一般的です。
enum ParseError: Error, CustomStringConvertible {
case empty
case notANumber(String)
case outOfRange(Int)
var description: String {
switch self {
case .empty: "empty input"
case .notANumber(let s): "not a number: \"\(s)\""
case .outOfRange(let v): "out of range: \(v)"
}
}
}
func parseAge(_ s: String) throws -> Int {
guard !s.isEmpty else { throw ParseError.empty }
guard let v = Int(s) else { throw ParseError.notANumber(s) }
guard (0...150).contains(v) else { throw ParseError.outOfRange(v) }
return v
}
for input in ["42", "", "abc", "200"] {
do {
let age = try parseAge(input)
print("ok:", age)
} catch ParseError.outOfRange(let v) where v > 150 {
print("too old:", v)
} catch {
print("failed:", error, "| type:", type(of: error))
}
}
print("try? :", (try? parseAge("abc")) as Any, (try? parseAge("7")) as Any)
let forced = try! parseAge("x1")
print("unreachable", forced)
ok: 42
failed: empty input | type: ParseError
failed: not a number: "abc" | type: ParseError
too old: 200
try? : nil Optional(7)
errors/errors.swift:30: Fatal error: 'try!' expression unexpectedly raised an error: not a number: "x1"
プロセスの終了コードは 133、つまり128 + 5(SIGTRAP)でした。用語を整理します。
catch節は上から順に照合され、switchと同じくパターンやwhereが使えます。最後の素のcatchでは、エラーがerrorという名前の定数で渡されます。try?は「throwsするかIntを返す」をInt?に変えます。エラー自体は捨てられるので、なぜ失敗したかは分からなくなります。try!は「絶対に失敗しない、失敗したらクラッシュしてよい」という宣言です。本番環境では捕まえられる例外ではなく、クラッシュレポートになります。
よくある失敗 → 実際に起きること → 直し方。 このファイルを最初に実行したときは、標準出力をファイルにリダイレクトしていました。出力ファイルは 0行 で、標準エラーにfatal errorだけが残りました。ファイルへの標準出力はバッファリングされ、トラップで落ちるとフラッシュされないので、クラッシュ前の print がすべて消えたのです。CIのログやサーバープロセスでは「何もせずに落ちた」ように見えます。デバッグ時の対処は、import Foundation して危ない行の前で fflush(stdout) を呼ぶことです(上の出力はそうして取りました)。根本的な対処は、自分で制御できない入力に try! を使わないことです。try! は、正規表現リテラルやアプリ同梱のリソースのように、失敗すればプログラマのミスと言い切れるものに限りましょう。
型付きthrows(Swift 6):コンパイルを確認
素の throws は「any Error を投げる」という意味なので、呼び出し側の catch には存在型が届き、最後の受け皿が必要です。型付きthrows(SE-0413、Swift 6.0コンパイラ)を使うと、関数が投げるエラーの型を1つに決められます。
enum ParseError: Error { case empty, notANumber(String) }
struct NetworkError: Error {}
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty } // type known: .empty works
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
do {
_ = try parse("x")
} catch {
// error is ParseError here, not any Error: the switch needs no default
switch error {
case .empty: print("empty")
case .notANumber(let s): print("typed catch:", s)
}
}
let r = Result { try parse("12") }
print(r, type(of: r))
func never() throws(Never) -> Int { 1 } // same as non-throwing
print(never())
typed catch: x
success(12) Result<Int, Error>
1
-swift-version 6 でも -swift-version 5 でも、診断メッセージなしでコンパイルできました。型付きthrowsはSwift 6言語モードの機能ではなく、コンパイラの機能です。違う型を投げるとコンパイルエラーになります。
typed_err.swift:4:27: error: thrown expression type 'NetworkError' cannot be converted to error type 'ParseError'
出力の2行目に注目してください。Result { try parse("12") } は Result<Int, ParseError> ではなく Result<Int, Error> になりました。クロージャが投げる型は、今も any Error と推論されます。Result<Int, ParseError> { try parse("x") } と書くと、swiftcは error: invalid conversion of thrown error type 'any Error' to 'ParseError' を出しました。次の節のように、クロージャ自体に型を書く必要があります。
型付きthrowsが向くのは、モジュール内部のコードや、パーサーやバリデータのように呼び出し側が全ケースを本当に switch する小さな閉じたエラー集合です。将来失敗理由が増えるかもしれない公開APIには、柔軟な素の throws が今も無難です。型付きエラーに後からケースを足すと、網羅的に書いた呼び出し側がすべて壊れます。
Result:保存できるエラー
Result<Success, Failure> は .success(値) と .failure(エラー) を持つenumです。結果を保存する、受け渡す、後で届ける必要があるときに使います。完了コールバック、要素ごとの結果を集めた配列、throwsできない境界などです。
enum ParseError: Error { case empty, notANumber(String) }
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty }
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
func attempt(_ s: String) -> Result<Int, ParseError> {
Result { () throws(ParseError) -> Int in try parse(s) } // closure must spell out its error type
}
let loose = Result { try parse("12") }
print(type(of: loose), type(of: attempt("x")))
switch attempt("x").map({ $0 * 2 }) {
case .success(let v): print("value", v)
case .failure(.notANumber(let s)): print("bad input", s)
case .failure(.empty): print("empty")
}
print(["1", "x", "3"].map(attempt).compactMap { try? $0.get() })
func load(_ s: String, completion: (Result<Int, ParseError>) -> Void) { completion(attempt(s)) }
load("") { print("callback:", $0) }
Result<Int, Error> Result<Int, ParseError>
bad input x
[1, 3]
callback: failure(result.ParseError.empty)
map は成功値だけを変換し、get() は Result をthrowsする呼び出しに戻します。失敗型が具体型なので、.failure(.notANumber(let s)) での switch は網羅的になります。async/await(第5回)を使えば完了コールバックでの Result はほとんど不要になりますが、結果を集める用途では今も便利です。
ジェネリクス:制約・関連型・where句
ジェネリックな関数や型は型パラメータ(T)を持ち、1つの実装で多くの型に対応します。制約(T: Comparable)で、本体が必要とする操作をサポートする型に T を絞ります。プロトコルは関連型(associated type)を宣言できます。これは準拠する型がそれぞれ埋める型の穴です。where句は、その穴にさらに制約を加えます。
// 1. constraint: T must be Comparable
func largest<T: Comparable>(_ xs: [T]) -> T? {
guard var best = xs.first else { return nil }
for x in xs.dropFirst() where x > best { best = x }
return best
}
print(largest([3, 9, 2]) as Any, largest(["pear", "apple"]) as Any, largest([Double]()) as Any)
// 2. protocol with an associated type
protocol Store {
associatedtype Item
mutating func add(_ item: Item)
var all: [Item] { get }
}
struct MemoryStore<Element>: Store { // Item is inferred as Element
private var items: [Element] = []
mutating func add(_ item: Element) { items.append(item) }
var all: [Element] { items }
}
// 3. where clause on the associated type
func summary<S: Store>(_ s: S) -> String where S.Item: Numeric & CustomStringConvertible {
let total = s.all.reduce(0, +)
return "\(s.all.count) items, total \(total)"
}
var ints = MemoryStore<Int>(); ints.add(5); ints.add(7)
print(summary(ints))
// 4. conditional extension: only when Element is Equatable
extension MemoryStore where Element: Equatable {
func contains(_ x: Element) -> Bool { all.contains(x) }
}
print(ints.contains(7))
// 5. primary associated type lets you write `some Collection<Int>`
func sum(_ xs: some Collection<Int>) -> Int { xs.reduce(0, +) }
print(sum([1, 2, 3]), sum(Set([10, 20])))
Optional(9) Optional("pear") nil
2 items, total 12
true
6 30
見落とされがちなのは4番です。User が Equatable でなければ、MemoryStore<User> には contains が存在しません。条件付き拡張は、メソッドを支えられる特殊化にだけメソッドを足します。
制約を満たさないときの実際のエラー
上の制約を、1ファイルの中でわざと全部破ってみました。各エラーの先頭行は次のとおりです。
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
struct User { let name: String }
_ = largest([User(name: "a")])
// … Store / MemoryStore as above, then:
_ = summary(MemoryStore<String>())
func addAll<T>(_ xs: [T]) -> T { xs.reduce(0, +) }
_ = MemoryStore<User>().has(User(name: "b")) // has() is in `extension … where E: Equatable`
let s: Store = MemoryStore<Int>()
error: global function 'largest' requires that 'User' conform to 'Comparable'
| `- note: where 'T' = 'User'
error: global function 'summary' requires that 'String' conform to 'Numeric'
| `- note: where 'S.Item' = 'String'
error: referencing instance method 'has' on 'MemoryStore' requires that 'User' conform to 'Equatable'
| `- note: where 'E' = 'User'
error: cannot convert value of type '(Int) -> Int' to expected argument type '(Int, T) throws -> Int'
error: cannot convert return expression of type 'Int' to return type 'T'
warning: use of protocol 'Store' as a type must be written 'any Store'; this will be an error
in a future Swift language mode [#ExistentialAny]
読み方は次のとおりです。
- 「requires that 'X' conform to 'Y' … note: where 'T' = 'X'」は呼び出し側に出ます。noteが、どの型パラメータに何が入ったかを教えてくれます。直し方は、型を準拠させる(
struct User: Comparable、演習1参照)か、別の関数を呼ぶかです。 - 分かりにくいのは
addAllです。制約のないTでは、Tに0や+があるかをコンパイラが知らないので、reduceの奥で型の不一致として報告されます。「制約を足して」とは言ってくれません。「cannot convert … to 'T'」を見たら、本体がどの操作を使っているかを考え、そのプロトコル(T: Numeric)を足します。 - 最後の行は、
ExistentialAnyフラグがなくても出る警告です。第3回ではlet s: Shapeが黙って通りましたが、Storeは関連型を持つので、Swift 6.4.2はすでにany Storeを求めてきます。
実測:ジェネリクス 対 具体型 対 any
ジェネリック関数にコストはあるのでしょうか。100万個の Square struct(シード42固定のLCG)の area() を3通りの関数で合計しました。関数が分離されたままになるよう @inline(never) を付け、swiftc -O -swift-version 6 でビルドしています。1回の実行で10回測った最速値を3回分、Apple Silicon Mac (M1 Pro) で取りました。
@inline(never) func totalConcrete(_ xs: [Square]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalGeneric<T: Shape>(_ xs: [T]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalAny(_ xs: [any Shape]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
| 100万要素 | -O(3回) |
-Onone |
要素サイズ |
|---|---|---|---|
具体型 [Square] |
0.93–0.94 ms | 72.40 ms | 8 B |
ジェネリック <T: Shape> |
0.93 ms | 99.66 ms | 8 B |
存在型 [any Shape] |
1.87–1.88 ms | 87.48 ms | 40 B |
-O では、ジェネリック版は手書きの具体型版とまったく同じ速さでした。理由はアセンブリ(swiftc -O -emit-assembly)で確かめました。ジェネリック本体とは別に、swift demangle で generic specialization <bg.Square> of bg.totalGeneric<A where A: bg.Shape>([A]) -> Swift.Double と読めるシンボルがあります。オプティマイザが Square 専用のコピーを作ったのです。any 版は2倍かかりました。要素ごとに40バイトの箱があり、area() を呼ぶたびにwitness tableを経由するからです。
Debugビルドでは順位が逆転し、ジェネリック版がいちばん遅くなりました(99.66 ms)。特殊化されないと、要素ごとに型メタデータを渡してwitness table経由で呼び出すからです。Debugでベンチマークしてはいけない理由がまた1つ増えました。特殊化には限界もあります。コンパイラがジェネリック本体を見られることが前提なので、@inlinable のない別モジュールのジェネリック関数は、特殊化されない経路で実行されます。
判断表と判断図
| 状況 | 選ぶもの | 実行から分かった注意点 |
|---|---|---|
| 起きたらバグ(壊れたリテラル、同梱ファイルの欠落) | try! / precondition |
クラッシュして終了コード133。バッファ中の標準出力が消えることも |
| 呼び出し側は成否だけ知れればよい | try? → Optional |
失敗理由は捨てられる |
| 理由に応じて対応する、エラーが増えうる(公開API) | throws |
catch に最後の受け皿が必要 |
小さな閉じた集合で、全ケースを switch する |
throws(E) |
default なしで網羅。クロージャには throws(E) の明示が必要 |
| 結果を保存・収集・後で配送する | Result<T, E> |
map、get()。E を保つにはクロージャに型を書く |
| 1つのアルゴリズムを多くの要素型で使う | ジェネリクス <T: P> |
-O で特殊化され、具体型と同じ速さ |
| 1つのコレクションに本当に異なる型が混ざる | [any P] |
今回のループで2倍遅く、要素あたり40 B |
演習
演習1. largest は変えずに、largest([User(name: "a", score: 3), User(name: "b", score: 8)]) がコンパイルされ、スコアの高いユーザーの名前を出力するようにしてください。
演習2. 上の型付き parse を使って、func parseAll(_ xs: [String]) -> Result<[Int], ParseError> を書いてください。最初の不正な文字列で止まるようにします。
解答
Userに<演算子を書いてComparableに準拠させます。格納プロパティがすべてEquatableなので、==は自動合成されます。
struct User: Comparable {
let name: String
let score: Int
static func < (l: User, r: User) -> Bool { l.score < r.score }
}
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
print(largest([User(name: "a", score: 3), User(name: "b", score: 8)])!.name) // b
- 型付きの
doブロックと型付きクロージャを使い、失敗型をParseErrorのまま保ちます。
func parseAll(_ xs: [String]) -> Result<[Int], ParseError> {
do throws(ParseError) {
return .success(try xs.map { s throws(ParseError) in try parse(s) })
} catch {
return .failure(error)
}
}
print(parseAll(["1", "2", "3"])) // success([1, 2, 3])
print(parseAll(["1", "oops", ""])) // failure(ex4.ParseError.notANumber("oops"))
map は "oops" で止まり、空文字列までは進みませんでした。このツールチェーンでは Array.map がクロージャの型付きエラーをそのまま再送出するので、do throws(ParseError) ブロックがコンパイルできます。
正直な限界とバージョンの注記
- 実行したのは Swift 6.4.2(swiftlang-6.4.2.1.9)だけです。型付きthrowsはSwift 6.0(SE-0413)で入ったとされていますが、6.0を入れて確かめてはいません。6.4.2では
-swift-version 5と-swift-version 6の両方で動きました。プライマリ関連型(some Collection<Int>)はSwift 5.7からですが、ここで確認しただけです。 Result { try … }のクロージャがany Errorと推論されるのは、6.4.2で見た挙動です。将来のコンパイラでは型付きエラーが推論されるかもしれません。- クラッシュメッセージの
errors/errors.swift:30はモジュール名/ファイル名で、掲載コードのtry!の行を指します。標準出力の行は、その行の前にimport Foundationとfflush(stdout)を足した2回目のビルドから、標準エラーの行は1回目のビルドから取りました。 @inlinableのない別モジュールのジェネリクスが特殊化されないことは、ドキュメント上の挙動です。2モジュール構成のパッケージを作っての計測はしていません。- ベンチマークは単一ファイル内で
ContinuousClockを使って10回測った最速値で、オプティマイザがすべてを見られる条件です。
前回: Swift講座 3/6:struct・class・protocol。次回: Swift講座 5/6:並行処理・async/await・actor。関連:Dart講座 4:レコード・パターン・sealed、Java講座:Stream・ラムダ・Optional、C#の ジェネリック と 例外処理。
よくある質問
Swiftの型付きthrowsにはSwift 6が必要ですか?
throws(ParseError) と書く型付きthrowsはSwift 6.0コンパイラで入りました(SE-0413)。確認したのはSwift 6.4.2だけで、-swift-version 6 でも -swift-version 5 でもコンパイルできました。catch の中で error が具体型になるので、switch に default は要りません。ただし Result { try parse(s) } は Result<Int, Error> と推論されたので、クロージャを () throws(ParseError) -> Int と明示する必要がありました。
ジェネリック関数は具体型の関数より遅いですか?
呼び出しがコンパイラから見える最適化ビルドでは遅くなりません。swiftc -O で100万個のstructを合計すると、具体型の関数で0.93ms、T: Shape のジェネリック関数でも0.93msでした。コンパイラがSquare用の特殊化版を生成していたためです。同じループを [any Shape] で回すと1.87msで、約2倍かかりました。
try! が失敗すると何が起きますか?
Fatal error: 'try!' expression unexpectedly raised an error とエラーの説明が出てプログラムが止まり、macOSでは終了コード133(SIGTRAP)になります。実行例では、標準出力をファイルにリダイレクトしていると、クラッシュ前に print した内容がバッファに残ったまま失われました。