1. 「とりあえず runCatching」が現場で横行する背景
Java 時代の煩雑な try-catch 構文に疲弊したエンジニアにとって、Kotlin 1.3 で導入された runCatching はまさに救世主のように見えました。ネストを深くすることなく、関数型言語のような美しいメソッドチェーンで後続処理を記述できるからです。
// 一見すると非常に綺麗でモダンに見えるコード
val userResult = runCatching {
userApi.fetchUserProfile(userId)
}.onSuccess { user ->
updateUi(user)
}.onFailure { error ->
showErrorMessage(error.localizedMessage)
}
コンパイラによる検査例外(Checked Exception)が存在しない Kotlin において、例外のスローは呼び出し側に意識されにくく、予期せぬクラッシュの温床になりがちです。そのため、「すべての失敗を Result<T> という値として閉じ込める」というアプローチ自体は極めて健全であり、推奨されるべき設計原則です。
しかし、熟練したシニアエンジニアやテックリードが参加するコードレビューでは、この runCatching が容赦なく Change Request(差し戻し) の対象になります。
「ここ、Coroutines の suspend 関数の中で runCatching 使ってますよね? キャンセル例外が死ぬので修正してください。」
一見なんの問題もないように思えるこのスニペットの裏で、一体何が起きているのでしょうか?
2. なぜ Coroutines と runCatching は最悪の相性なのか?
コルーチンを司る「協調的キャンセル(Cooperative Cancellation)」
Kotlin Coroutines の最大の特徴は、軽量スレッドとして動作しながらライフサイクルに応じた協調的キャンセルを実現している点にあります。Android の viewModelScope や Ktor などのサーバー環境において、ユーザーが画面を離脱したり HTTP リクエストが切断された場合、無駄なリソース消費を防ぐために親スコープからすべての子コルーチンへキャンセル信号が送られます。
重要なのは、コルーチンのキャンセルは OS レベルの強制スレッド終了シグナルではなく、内部的に特別な例外 CancellationException をスローすることで実現されている という事実です。
suspend 関数(例えば delay() やネットワークI/O、非同期待機)は、キャンセルを検知すると CancellationException をスローし、コールスタックを駆け上がって親ジョブに「正常に停止した」ことを通知します。これが Kotlin Coroutines の根本原則である「協調的(Cooperative)」な仕組みです。
runCatching の内部実装: すべてを飲み込むブラックホール
ここで、Kotlin 標準ライブラリにおける runCatching の実際の定義を見てみましょう。
// Kotlin 標準ライブラリ kotlin-stdlib の実装抜粋
@InlineOnly
public inline fun <R> runCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
問題は catch (e: Throwable) の1行にあります。この無邪気な実装が、以下の2つの深刻な破壊を引き起こします。
- CancellationException の握りつぶし: コルーチンが「停止せよ」と叫んで投げた
CancellationExceptionまで一般のエラーとしてキャッチし、Result.failure(CancellationException)に変換してしまいます。その結果、例外の上位伝播が遮断され、コルーチンのキャンセル状態が外部へ伝わりません。 - VMレベルの致命的エラーの誤捕獲:
OutOfMemoryErrorやStackOverflowError、さらにはAssertionErrorといった「アプリケーションを速やかに停止させなければ状態破壊を招く深刻なエラー」まで、何事もなかったかのようにキャッチして握りつぶしてしまいます。
本番で実際に起きる「ゾンビ・コルーチン」事故のシナリオ
典型的な事故例として、定期ポーリングや大きなループ処理を行う ViewModel のコードを考えてみましょう。
// 🚨 危険なアンチパターン: 画面を破棄しても通信とループが止まらない
class MonitoringViewModel(private val api: SensorApi) : ViewModel() {
init {
viewModelScope.launch {
while (isActive) {
// 内部で runCatching を使ってしまう
val result = runCatching {
api.fetchLatestMetrics() // suspend 関数
}
result.fold(
onSuccess = { renderMetrics(it) },
onFailure = {
// キャンセル例外がここに来てしまい「通信エラー」と誤認される!
logError("Failed to fetch metrics", it)
}
)
delay(3000)
}
}
}
}
ユーザーが画面を閉じた瞬間、viewModelScope はキャンセルされます。直後の api.fetchLatestMetrics() または delay() は正しく CancellationException を投げます。しかし、runCatching がそれを捕獲して onFailure を実行してしまいます。
結果として、コンソールには「Failed to fetch metrics」という不気味なログが吐かれ、次のループへ進み、不要な通信を試み、メモリリークを起こしたままバックグラウンドで生き続ける「ゾンビ・コルーチン」が完成するのです。
3. 現場の処方箋①: キャンセル例外をリスローする safeRunCatching
この問題に対するもっとも軽量で実践的なアプローチは、「CancellationException は絶対に再スローする」 というルールを組み込んだラッパー関数を用意することです。
サードパーティ製の巨大なライブラリを追加する必要はありません。標準の Kotlin コードとしてプロジェクトの共通モジュール(ユーティリティ層)に以下の拡張関数を数行定義するだけで解決します。
import kotlin.coroutines.cancellation.CancellationException
/**
* Kotlin Coroutines の協調的キャンセルを阻害しない安全な runCatching。
*
* [CancellationException] はキャッチせずに直ちにリスローし、親スコープへ停止通知を伝播させる。
* それ以外のドメイン/ネットワーク例外のみを [Result.failure] として安全に捕捉する。
*/
public inline fun <R> safeRunCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
// コルーチンキャンセルのための重要シグナルなので即座に再スローする
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}
/**
* レシーバ付きの safeRunCatching 版
*/
public inline fun <T, R> T.safeRunCatching(block: T.() -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}
inline 修飾子が付与されているため、コンパイル時に呼び出し元へインライン展開され、ラムダオブジェクトの生成コストや関数呼び出しのオーバーヘッドは完全にゼロ(ゼロコスト抽象化)です。
4. 現場の処方箋②: 例外に依存しない「型安全なドメインエラー」設計
safeRunCatching によってキャンセルの握りつぶしは防止できますが、一歩進んだ堅牢なシステム設計を目指すなら、もう一つの根本的な問題に向き合う必要があります。
「Result<T>の失敗型であるThrowableは、広大すぎて何が起きたのか型情報から判別できない」
本番アプリケーションにおける「失敗」には、明確に異なる2つの性質が存在します:
- システム例外(Bug / Infrastructure Failure): ヌルポインタ、データベースファイルの破損、メモリ不足など、プログラマのバグや環境障害に起因するもの。これらはクラッシュログ(Crashlytics 等)に記録し、開発者が直すべきもの。
- ドメインエラー(Expected Business Failure): パスワード不一致、トークン期限切れ、アカウント残高不足、ネットワーク未接続など、業務仕様上「日常的に起こり得る」正常な失敗。これらはUI上で適切なメッセージや再試行ボタンへ誘導すべきもの。
この「ドメインエラー」を Throwable で表現して画面層まで引き回すと、if (e is SocketTimeoutException) ... else if (e is HttpException && e.code() == 401) ... といった脆弱な型チェックが散乱します。
そこで真価を発揮するのが、Kotlin の sealed interface を活用したドメインエラーモデリングです。
ドメインエラーを型として定義する
/**
* 認証フローにおける業務的な失敗を表現する閉じた型階層
*/
sealed interface AuthError {
data object NetworkUnavailable : AuthError
data object InvalidCredentials : AuthError
data class AccountLocked(val unlockMinutesRemaining: Int) : AuthError
data class Unexpected(val cause: Throwable) : AuthError
}
/**
* 成功値または業務エラーを表現する明示的な Result 型
*/
sealed interface DomainResult<out T, out E> {
data class Success<T>(val data: T) : DomainResult<T, Nothing>
data class Failure<E>(val error: E) : DomainResult<Nothing, E>
}
リポジトリ層での実践的ハンドリング
リポジトリ層で低レベルな通信例外をドメインエラーへと変換し、呼び出し元には「型付けされた失敗」のみを返します。
class UserRepository(
private val authApi: AuthRemoteDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun signIn(email: String, pass: String): DomainResult<User, AuthError> =
withContext(ioDispatcher) {
safeRunCatching {
authApi.login(email, pass)
}.fold(
onSuccess = { response ->
DomainResult.Success(response.toDomainUser())
},
onFailure = { throwable ->
// 低レベルの通信例外をビジネスドメインエラーへと安全に射影する
val domainError = when (throwable) {
is UnknownHostException, is SocketTimeoutException ->
AuthError.NetworkUnavailable
is HttpException if throwable.code == 401 ->
AuthError.InvalidCredentials
is HttpException if throwable.code == 423 ->
AuthError.AccountLocked(unlockMinutesRemaining = 15)
else ->
AuthError.Unexpected(throwable)
}
DomainResult.Failure(domainError)
}
)
}
}
この設計の圧倒的な利点は、UI(ViewModel / Compose)側で when 式を書いた際、コンパイラがすべてのエラーケースのハンドリングを強制してくれる(Exhaustive Check) 点にあります。将来新しいエラーケースが追加された場合も、未対応の画面があればビルドエラーとなり、サイレントな不具合をリリース前に100%遮断できます。
5. まとめ: 「短く書けるコード」が「安全なコード」とは限らない
Kotlin は非常に表現力が高く、コードを簡潔に書くためのシンタックスシュガーが豊富に用意されています。しかし、ライブラリや言語機能が裏側で何を行っているのか(ランタイムの前提条件やライフサイクルの協調メカニズム)を理解しないまま構文の便利さに甘えると、思わぬ落とし穴にはまります。
今回の要点を振り返りましょう:
- 標準の
runCatchingはCancellationExceptionを握りつぶす: コルーチン協調キャンセルのチェーンを断ち切り、ゾンビプロセスやリークを引き起こす。 - Coroutines 内では
safeRunCatchingを使う:CancellationExceptionを即座に再スローする薄い拡張関数を定義し、チームの標準とする。 - 業務的な失敗は
sealed interfaceでモデリングする:Throwableを上位レイヤに垂れ流さず、コンパイル時安全なドメインエラーとして表現する。
個人開発や少数精鋭の開発チームほど、「あとから原因を究明するのが極めて困難なバグ(非同期の停止不能やサイレントなメモリリーク)」を事前に防ぐ規約と設計が、息の長い開発スピードを担保する最大の武器になります。
あなたのプロジェクトのコードベースにある runCatching、いま一度検索して見直してみてはいかがでしょうか。