Contents
Swift Concurrency の基礎 ― async / await と throws のシグネチャ
結論
asyncとthrowsは同時に宣言できる。- これにより 非同期処理で発生するエラー をコンパイル時に安全に扱えるようになる。
背景と理由
| 項目 | 説明 |
|---|---|
async |
関数が非同期であることを示し、呼び出し側は必ず await で待機する必要がある。実行は別スレッド(もしくは同一スレッドの協調的切り替え)で行われる。 |
throws |
エラー発生時に例外を throw でき、呼び出し側は必ず try/do‑catch によってハンドリングすることが強制される。 |
| 両方併用 | async throws と書くことで「非同期かつ失敗可能」な API を宣言でき、型システムが 成功・失敗の両方 を保証してくれる。 |
Swift 5.5 以降はこの組み合わせが標準となり、従来のコールバックや
Result型に比べてコードが圧倒的にシンプルになる。
非同期関数の基本例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import Foundation // MARK: - Model struct User: Decodable { let id: String let name: String } // MARK: - API /// ユーザー情報をサーバーから取得する非同期関数。 /// - Parameter id: 取得したいユーザーの ID /// - Returns: `User` オブジェクト /// - Throws: ネットワークエラー、デコードエラー、またはキャンセルエラー func fetchUser(id: String) async throws -> User { // URL の組み立てに失敗したら即座に例外を投げる(実装上は `guard` でチェック) guard let url = URL(string: "https://api.example.com/users/\(id)") else { throw URLError(.badURL) } // `await` が付くので、ここで非同期的にデータ取得が行われる let (data, _) = try await URLSession.shared.data(from: url) // デコード時に失敗したら `throw` される return try JSONDecoder().decode(User.self, from: data) } |
- 呼び出し側は必ず
try awaitと書く
swift
let user = try await fetchUser(id: "123")
do‑catch と try / try? / try! の使い分け
結論
| キーワード | 挙動 | 推奨シーン |
|---|---|---|
try |
エラーが発生すると 捕捉必須。コンパイルエラーになるため、失敗を必ず処理できる。 | UI にエラーメッセージを表示したいとき、リトライロジックを入れたいとき |
try? |
エラーは nil へ変換され、結果はオプショナルになる。失敗時に何もしないケースで有用。 |
キャッシュが無くても処理を続行できる場合 |
try! |
エラーが起きたら ランタイムクラッシュ。デバッグ以外では使用しないこと。 | テストコードや、絶対に失敗し得ないと確信できる内部ロジック |
実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import os func loadProfile() async { do { let user = try await fetchUser(id: "123") // UI 更新は必ずメインスレッドで行う await MainActor.run { show(user) } } catch let urlError as URLError where urlError.code == .notConnectedToInternet { // ネットワーク切断時だけ特別扱い presentAlert("ネットに接続できません。再試行してください。") } catch { // 予期しないエラーはログに残す os_log("%{public}@", log: .default, type: .error, "fetchUser error: \(error)") presentAlert("不明なエラーが発生しました。") } } // `try?` の例(失敗したら nil を返すだけで無視できるケース) func cachedUsername() async -> String? { return try? await fetchCachedName() } |
ベストプラクティス
- UI 更新は必ず
MainActor.run/DispatchQueue.main.async - エラーログは一元化(例:
os_log+ Crashlytics) do‑catchの中でエラー型を絞り込むと、適切なユーザー通知が書きやすくなる。
Task, withThrowingTaskGroup, async let におけるエラーハンドリング
結論
| 構文 | 主な用途 | エラー伝搬の特徴 |
|---|---|---|
Task {} |
単一非同期処理をバックグラウンドで走らせる | await task.value で取得。内部エラーは自動的にキャンセルされ、呼び出し側で捕捉できる |
withThrowingTaskGroup |
複数タスクを同時実行しつつ、最初の失敗で全体をキャンセルしたいケース | 各タスクは個別に throw 可能。グループ外へは 最初にスローされたエラー が伝搬 |
async let |
軽量な並列実行(数個程度) | await (a, b) の時点で全タスクの結果が集約され、いずれかが失敗すればその場で例外が発生 |
実装サンプル
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
import Foundation import os // 1️⃣ Task 内で単一処理を実行しエラーハンドリング func startBackgroundSync() { Task { do { try await syncAllData() } catch is CancellationError { // キャンセルはログに残さない(ノイズ防止) } catch { os_log("syncAllData failed: %{public}@", log: .default, type: .error, "\(error)") } } } // 2️⃣ withThrowingTaskGroup を用いた複数 API 呼び出し func fetchMultipleResources(ids: [String]) async throws -> [Resource] { var results = [Resource]() try await withThrowingTaskGroup(of: Resource.self) { group in for id in ids { group.addTask { // 個別タスクは `throws` できる return try await fetchResource(id: id) } } // 成功したものだけを順次取得 for try await resource in group { results.append(resource) } } return results } // 3️⃣ async let のエラーハンドリング例(画像二枚同時ダウンロード) func loadImages() async { async let img1 = downloadImage(url: URL(string: "https://example.com/1.png")!) async let img2 = downloadImage(url: URL(string: "https://example.com/2.png")!) do { let (image1, image2) = try await (img1, img2) await MainActor.run { display(images: [image1, image2]) } } catch { os_log("downloadImage error: %{public}@", log: .default, type: .error, "\(error)") presentAlert("画像の取得に失敗しました。") } } |
ポイントまとめ
Taskは「非同期で実行したいが結果をすぐに待ちたくない」場合に使用。withThrowingTaskGroupは「多数タスクを同時走査し、失敗時は全体を止めたい」シナリオのデファクトスタンダード。async letは数個程度の軽量並列処理に最適だが、エラーはawait時点でまとめて捕捉する必要がある。
キャンセルエラーと通常エラーの分離、Result 型+Continuation の併用例
結論
CancellationErrorは特別扱いし、ログに残すかどうかはケースバイケースで決定する(多くは無視して再スロー)。Result+withCheckedThrowingContinuationにより、コールバックベースの API でも型安全な async/await 呼び出しが実現できる。
実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import Foundation import os // MARK: - キャンセルと通常エラーを分離する関数 func performLongRunningTask() async throws -> Data { do { return try await fetchLargeFile() } catch is CancellationError { // 上位へ再スローだけで、ログは残さない throw CancellationError() } catch { os_log("Long task error: %{public}@", log: .default, type: .error, "\(error)") throw error // 必要に応じてラップしても良い } } // MARK: - Result + Continuation で URLSession のコールバック版を async に変換 func dataTaskResult(url: URL) async -> Result<Data, Error> { await withCheckedContinuation { continuation in let task = URLSession.shared.dataTask(with: url) { data, _, error in if let err = error { continuation.resume(returning: .failure(err)) } else if let d = data { continuation.resume(returning: .success(d)) } else { let unknown = NSError(domain: "com.example", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown response"]) continuation.resume(returning: .failure(unknown)) } } task.resume() } } // MARK: - 呼び出し側の例 func loadConfig() async { let result = await dataTaskResult(url: URL(string: "https://example.com/config.json")!) switch result { case .success(let jsonData): parse(jsonData) // 正常処理 case .failure(let err): os_log("Config load failed: %{public}@", log: .default, type: .error, "\(err)") presentAlert("設定の取得に失敗しました。") } } |
補足ポイント
CancellationErrorは ユーザー操作やシステムポリシー による中断が目的なので、通常ログに残すとノイズが増えることが多い。- Continuation 系 API は メモリリークの危険性 があるため、必ず
resumeを 1 回だけ呼び出すように注意する。
実務で推奨されるエラーロギング・ユーザー通知パターン
結論
- エラーは 「ロギング層」 と 「ユーザー向けフィードバック」 に分離して処理すべき。
- これによりデバッグ情報の取得が容易になると同時に、UX が損なわれない。
実装例(UIKit 前提)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import UIKit import os // MARK: - アプリ独自エラー型 enum AppError: LocalizedError { case network(URLError) case parsing(Error) case cancelled var errorDescription: String? { switch self { case .network(let urlErr): return "ネットワークエラー: \(urlErr.localizedDescription)" case .parsing(let err): return "データ解析失敗: \(err.localizedDescription)" case .cancelled: return "操作がキャンセルされました" } } } // MARK: - ロギングユーティリティ(シングルトン的に使う) func logError(_ error: Error, context: String = "") { let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "Error") logger.error("\(context) – \(error.localizedDescription)") } // MARK: - UI アラート表示ユーティリティ func presentErrorAlert(_ error: AppError, retryHandler: (() -> Void)? = nil) { guard let rootVC = UIApplication.shared.windows.first?.rootViewController else { return } let alert = UIAlertController(title: "エラー", message: error.errorDescription, preferredStyle: .alert) if let retry = retryHandler { alert.addAction(UIAlertAction(title: "リトライ", style: .default) { _ in retry() }) } alert.addAction(UIAlertAction(title: "閉じる", style: .cancel)) rootVC.present(alert, animated: true) } // MARK: - 実務での利用例 func fetchSettings() async { do { let data = try await fetchRemoteSettings() apply(settingsData: data) // 正常処理 } catch is CancellationError { // キャンセルは UI には何も表示しないが、ロギングだけは残す(必要なら) logError(CancellationError(), context: "fetchSettings") } catch let urlErr as URLError { let appErr = AppError.network(urlErr) logError(appErr, context: "fetchSettings") // ネットワークエラーはリトライボタンを提示 presentErrorAlert(appErr) { Task { await fetchSettings() } } } catch { let appErr = AppError.parsing(error) logError(appErr, context: "fetchSettings") presentErrorAlert(appErr) } } |
パターンの要点
| 項目 | 実装ポイント |
|---|---|
| ロギングは一元化 | os.Logger(iOS 14+)+外部サービス(例: Crashlytics)で全エラーを集約。 |
| エラー種別ごとの UI フロー | - CancellationError → 静かに無視 - ネットワーク障害 → 「リトライ」ボタン付きアラート - パース失敗などの致命的エラー → 「閉じる」だけのシンプルな通知 |
| 共通ユーティリティ | logError と presentErrorAlert をプロジェクト全体で共有し、コード重複を防止。 |
まとめ
async throwsが提供する安全性-
非同期処理でもコンパイル時に失敗可能性が明示されるので、例外漏れが減少。
-
エラーハンドリングの基本構造
do‑catch→ 必須try?は「結果を無視できる」ケースでのみ使用-
try!はデバッグ時以外は禁止 -
タスク並列化とエラー伝搬
-
単一処理は
Task {}、多数タスクはwithThrowingTaskGroup、軽量な同時実行はasync let。 -
キャンセルと通常エラーの分離
-
CancellationErrorは必要に応じてログを省略しつつ再スロー。 -
既存コールバック API の橋渡し
-
Result+withCheckedThrowingContinuationで安全に async/await に変換できる。 -
実務レベルのロギング・通知
- ログは一元化、ユーザー向けメッセージはエラー種別に合わせて適切な UI を提示する。
これらのベストプラクティスをプロジェクトに組み込めば、Swift Concurrency のエラーハンドリングが 安全かつ保守しやすく なり、開発者とユーザー双方にメリットをもたらします。