Swift Digest
SE-0235 | Swift Evolution

Add Result to the Standard Library

Proposal
SE-0235
Authors
Jon Shier
Review Manager
Chris Lattner
Status
Implemented (Swift 5.0)

01 何が問題だったのか

Swift のエラーハンドリングは、throws / try / catch による「型付き・自動伝播」方式を基本としています。この方式は同期的な処理では十分にうまく働きますが、言語が扱う「エラー伝播」のすべてをカバーできるわけではありません。特に次のような場面で扱いにくさが目立ちます。

非同期 API の完了ハンドラ

Apple や Foundation の非同期 API には、結果と失敗を別々の optional で受け取る「三つ組」パターンが多く見られます。たとえば URLSession のコールバックは次のようなシグネチャです。

func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask

このとき呼び出し側は、本来「成功なら dataresponse、失敗なら error」という排他的な結果を受け取りたいにもかかわらず、三つの optional を自分で組み合わせて解釈する必要があります。

URLSession.shared.dataTask(with: url) { (data, response, error) in
    guard error == nil else { return self.handleError(error!) }

    guard let data = data, let response = response else { return // Impossible?
    }

    handleResponse(response, data: data)
}

errornil でなければ dataresponse が揃っているはずですが、型システムはそれを保証してくれません。! による強制アンラップや、本来起こり得ないはずの else 分岐が必要になり、コードから意図が読み取りにくくなります。非同期境界をまたぐ以上、throws による自動伝播は使えないため、こうした冗長な分解が避けられませんでした。

スローする処理の結果を「保留」したい場面

スローする関数を今すぐ呼びたいけれど、その成否は後でまとめて扱いたい、というケースでも同様の面倒が生じます。値とエラーを別々のプロパティに分解して保持する必要があり、関数が複数あれば分解のコードも倍々に増えていきます。

var configurationString: String?
var configurationReadError: Error?

do {
    configurationString = try String(contentsOfFile: configuration)
} catch {
    configurationReadError = error
}

// あとで利用する
func doSomethingWithConfiguration() {
    guard let configurationString else { return handle(configurationReadError!) }
    // ...
}

optional のタプル (string: String?, error: Error?) にまとめても、やはり「成功と失敗のどちらか」という排他性を型で表せず、URLSession のコールバックと同じ問題が残ります。

複数の失敗要因を区別したい場面

複数のスローする処理を走らせ、それぞれのエラーを別々に扱いたいこともあります。do / catch で書くと、処理のたびにブロックを分けて書く必要があり、本来の流れがノイズに埋もれてしまいます。

do {
    handleOne(try String(contentsOfFile: oneFile))
} catch {
    handleOneError(error)
}

do {
    handleTwo(try String(contentsOfFile: twoFile))
} catch {
    handleTwoError(error)
}

do {
    handleThree(try String(contentsOfFile: threeFile))
} catch {
    handleThreeError(error)
}

共通する本質

いずれの場面にも共通するのは、「成功値」と「失敗値」のどちらか一方だけを表す、第一級の値としてのエラー結果 が標準ライブラリに存在しない、という点です。Kotlin の Result<T>、Scala の Try[T]、Rust の Result<T, E> のように、多くの言語ではこの役割を持つ型が標準で用意されており、Swift コミュニティでも独自に Result 型を定義するライブラリが数多く作られてきました。標準ライブラリに入っていないためにライブラリごとに型が分裂し、相互運用のたびに詰め替えが必要になる、という状況も問題でした。

02 どのように解決されるのか

標準ライブラリに Result 型を追加します。成功値の型と失敗値の型の両方をジェネリックパラメータに取り、失敗値の型は Error に適合することを要求します。

public enum Result<Success, Failure: Error> {
    case success(Success)
    case failure(Failure)
}

throws による自動伝播と共存させるために、Result手動で エラーを伝播・保持するための型という位置づけです。非同期のコールバック、処理結果の保留、複数エラーの区別など、「スローできない境界を越えて成否を値として持ち運びたい」場面で使います。

基本的な使い方

非同期 API では、三つ組の optional の代わりに Result を受け取る形に設計することで、「成功値か失敗値のどちらか」という排他性を型で表現できるようになります。

URLSession.shared.dataTask(with: url) { (result: Result<(response: URLResponse, data: Data), Error>) in
    switch result {
    case .success(let success):
        handleResponse(success.response, data: success.data)
    case .failure(let error):
        handleError(error)
    }
}

スローするクロージャからの生成

FailureSwift.Error のときに使える専用のイニシャライザが用意されていて、スローするクロージャの実行結果をその場で Result に包めます。値とエラーを別変数に分解する必要がなくなります。

let configuration = Result { try String(contentsOfFile: configurationPath) }

// あとで利用する
func doSomethingWithConfiguration() {
    switch configuration {
    case .success(let string):
        // 成功時の処理
        _ = string
    case .failure(let error):
        handle(error)
    }
}

複数のスローする処理の結果を別々に保持したいときも、do / catch のブロックを並べる必要がなくなります。

let one = Result { try String(contentsOfFile: oneFile) }
let two = Result { try String(contentsOfFile: twoFile) }
let three = Result { try String(contentsOfFile: threeFile) }

handleOne(one)
handleTwo(two)
handleThree(three)

throws との橋渡し

Resultthrows は対立するものではなく、get() メソッドで相互に行き来できます。get() は成功なら値を返し、失敗ならその失敗値を throw します。

let integerResult: Result<Int, Error> = .success(5)
do {
    let value = try integerResult.get()
    print("The value is \(value).")
} catch {
    print("Error retrieving the value: \(error)")
}

これにより、Result として保持していたものを do / catch のフローに合流させたり、逆に Result(catching:)throws の世界から Result の世界に持ち出したりできます。

変換メソッド

値や失敗の型を変換するための高階メソッドも用意されています。

  • map(_:): 成功値を別の型に写す。
  • mapError(_:): 失敗値を別のエラー型に写す(エラーを独自のラッパー型に包み直すときに便利)。
  • flatMap(_:): 成功値から再び Result を作る。チェーン可能。
  • flatMapError(_:): 失敗値から再び Result を作る。
func getNextInteger() -> Result<Int, Error> { /* ... */ }

let integerResult = getNextInteger()
let stringResult = integerResult.map { String($0) }
// 成功値だけが Int から String に置き換わる

struct DatedError: Error {
    var error: Error
    var date: Date
}

let result: Result<Int, Error> = // ...
let dated = result.mapError { DatedError(error: $0, date: Date()) }

Equatable / Hashable への条件付き適合

SuccessFailure がそれぞれ Equatable または Hashable のとき、Result 自体も同じプロトコルに適合します。これはジェネリック型の conditional conformance として宣言されています。

extension Result: Equatable where Success: Equatable, Failure: Equatable {}
extension Result: Hashable where Success: Hashable, Failure: Hashable {}

Error の自己適合

Result<Success, Error> のように失敗値の型をプロトコル Error そのものにしたいことがよくあります。これを許すために、Swift 5 からは Error が自分自身に適合するようになりました(いわゆる self-conformance)。

この自己適合は、あくまで 正確に Error という型 に対してのみ認められます。Error & CustomStringConvertible のような合成型にはまだ拡張されていませんが、将来拡張する余地は残されています。

FailureError に制約している理由

Failure が任意の型ではなく Error 適合型に制約されているのは、主に次の理由からです。

  • throws と常に橋渡しできる(get()Result(catching:) が無条件に使える)。
  • 失敗値に IntString をそのまま入れるのではなく、意味のあるエラー型を定義する動機になる。
  • Result<Error, Value> のように成功と失敗を書き間違える古典的なミスを、コンパイル時に検出できる。