LoginSignup
298
195

More than 5 years have passed since last update.

Swift 5 のResultに備える

Last updated at Posted at 2018-12-10

Swift の機能提案は Swift Evolution で行われるのですが、 Swift の標準ライブラリに Result 型を追加するプロポーザル SE-0235 が承認されました。その結果、 Swift 5 で Result が標準ライブラリに追加されます。

本投稿では

  • Result とは何か
  • いつ Result を使うべきか
  • Result をどのように扱うべきか
  • 今のうちに Result に備える方法

を説明します。

Result とは何か

Swift にはサードパーティ製の antitypical/Result という人気ライブラリがあり、 Result に馴染みのある人も多いと思います。本節では、 Result に馴染みのない人のために Result について簡単に説明します。

Result はエラーハンドリングに用いられる型で、次のように宣言されます。

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

Result を用いると、たとえば、ファイルの内容を Data インスタンスとして読み込む関数 readFile のシグネチャは次のように書けます。

func readFile(at path: String) -> Result<Data, IOError>

ただし、入出力に伴うエラーは次のような IOError というエラー型で表されるものとしています。

enum IOError: Error {
    case fileNotFound(path: String)   // ファイルが存在しないとき
    case permissionDenied(Permission) // パーミッションを持たないとき
    ...
}

このとき、 readFile 利用時のエラーハンドリングは次のようになります。

switch readFile(at: path) {
case .success(let data):
    ... // `data` を使うオペレーション
case .failure(.fileNotFound(path: let path)):
    ... // ファイルが存在しないときのエラーハンドリング
case .failure(.permissionDenied(let permission)):
    ... // パーミッションを持たないときのエラーハンドリング
case .failure(let error):
    ... // その他の場合のエラーハンドリング
}

throws との関係

Resultthrows でできることはよく似ています。 throws を使うと readFile の例は次のように書けます。

func readFile(at path: String) throws -> Data

エラーハンドリングの例は次の通りです。

do {
    let data = try readFile(at: path)
    ... // `data` を使うオペレーション
} catch IOError.fileNotFound(path: let path) {
    ... // ファイルが存在しないときのエラーハンドリング
} catch IOError.permissionDenied(let permission) {
    ... // パーミッションを持たないときのエラーハンドリング
} catch let error {
    ... // その他の場合のエラーハンドリング
}

構文の違いや throws ではエラー型を指定できないという違いはありますが、できることはほぼ同じです。

いつ Result を使うべきか

Resultthrows は似た機能なのでどのように使い分ければ良いのかという疑問が生まれると思います。本節では Resultthrows の使い分けについて説明します。

Manual Propagation と Automatic Propagation

Swift のエラーハンドリングを語るときに欠かすことのできない Error Handling Rationale and Proposal というドキュメントがあります。これは Swift 2.0 で throws/try が追加されるに当たって Swift Core Team のメンバーによって書かれたドキュメントで、 Swift におけるエラーハンドリングがどのように設計されたかを知ることができる貴重な資料です。

その中で Manual Propagation と Automatic Propagation という概念が示されています。エラー値を通常の値と同じように扱い(戻り値として受け取り、変数や定数に格納するなど)、通常の制御構文( if, guard, switch 文など)を用いて取り扱う場合、それは Manual Propagation です。一方、 do/try/catch のように専用の構文を用いてエラー発生箇所からハンドリング箇所へ暗黙的にジャンプするような場合は Automatic Propagation です。

現状( Swift 4.2 )の Swift の標準ライブラリや Foundation でもこれらは使い分けられています。

たとえば、文字列を整数に変換するコードは次のように書きます。

let numberOrNil: Int? = Int(string)
guard let number = numberOrNil else {
    ... // エラーハンドリング
}
... // `number` を使うオペレーション

Int(string) の戻り値の型は Int? で、 nil によってエラーを表します。この場合は Optionalguard let ( Optional Binding )という通常の言語機能を使ってエラーハンドリングを行っており、 Manual Propagation です。

一方、 Data をファイルに書き込む処理は次のようになります。

do {
    try data.write(to: URL(fileURLWithPath: path))
    ... // 何らかのオペレーション
} catch let error {
    ... // エラーハンドリング
}

この場合、 data.write の呼び出し箇所から catch までジャンプすることになり Automatic Propagation です。

Resultthrows かという視点で考えると、 Result は Manual Propagation 、 throws は Automatic Propagation ということになります。

Automatic Propagation は多くの場合 Manual Propagation よりもコードを簡潔に書くことができます。たとえば、次のコードは Automatic Propagation で書かれたものですが、これと同じオペレーションを Manual Propagation で書こうとすると大変です。

let db: Database = ...
do {
    let group: Group = try db.get(Group.self, withKey: groupID)!
    let owner: User = try db.get(User.self, withKey: group.ownerID)!
    let ownerFriends: User = try db.get([User.self], withKeys: owner.friendIDs)
    ... // `ownerFriends` を使うオペレーション
} catch DatabaseError.connectionFailure {
    ... // 接続に失敗した場合のエラーハンドリング
} catch DatabaseError.transactionError {
    ... // トランザクション関連のエラーハンドリング
} catch let error {
    ... // その他の場合のエラーハンドリング
}

これは当たり前の話で、 Manual Propagation は通常の言語機能を使うので、もし Manual Propagation で十分なのであれば言語が Automatic Propagation のための構文を用意する必要はありません。わざわざ Automatic Propagation ができるようになっているのは、 Manual Propagation だけでは何らかの不便があり、それを解決したいという意図があったからです。

しかし、それならすでに Automatic Propagation のための throws があるのに、今さら何のために Manual Propagation のための Result が追加されようとしているのでしょうか。それは、 Manual Propagation にはできて Automatic Propagation にはできないことがあるからです。

Automatic Propagation はエラーが発生したら即時ハンドリングすることが強制されます。一方、 ResultOptional はエラーハンドリングしないまま引数や戻り値として取り回したり、プロパティに保持しておいたりすることができます。実際に、そのような Manual Propagation でしか扱えないケースに対応するために、サードパーティ製の Result ライブラリが使われています。

しかし、ライブラリ A がある Result ライブラリを、ライブラリ B が別の Result ライブラリを、ライブラリ C が依存性を避けるために独自の Result 型を使っているというようなケースが頻発し、 Result 同士の互換性が問題となっていました。 Result を標準ライブラリに追加する主な目的は、標準の Result 型を導入することでそのような互換性の問題を解決することです。

Manual Propagation でしか解決できないケース

Manual Propagation でしかできない例をいくつか紹介します。

非同期処理

URL を指定してファイルをダウンロードする関数 download を考えてみましょう。当然、この関数はネットワークエラーを起こす可能性があります。単純に考えると次のようなシグネチャになりそうです。

func download(from url: URL) throws -> Data

しかし、これは望ましくありません。上記の download 関数は同期処理になってしまっています。もし UI スレッドで上記のような関数を呼ぶとダウンロードが完了するまで画面が固まってしまうでしょう。非同期処理とするために、コールバックのクロージャを指定できるようにしたいところです。

次のようにコールバックで Data を受け取るようにするとどうでしょうか?

func download(from url: URL, _ completion: (Data) -> Void) throws

なんとなくシグネチャの上ではうまくいきそうに見えるかもしれませんが、これではうまくいきません。上記の download を使うコードは次のようになります。

do {
    try download(from: url) { data in
        ... // `data` を使うオペレーション [A]
    }
} catch let error {
    ... // エラーハンドリング [B]
}

上記のコードの [B] は download を呼び出した直後に実行されることになります。しかし、その時点ではダウンロードは終わっていません。将来起こるかもしれないエラーのハンドリングを先に行うことはできません。非同期処理では、エラーハンドリングもオペレーション終了後に実行される [A] でやらなければなりません。

しかし、 [A] でエラーハンドリングさせるような download 関数を throws でうまく書くことはできません。無理やり次のように書くことができないわけではないですが非常にわかりづらいです。

func download(from url: URL, _ completion: (() throws -> Data) -> Void)

そのため、次のような設計になっていることが多いです。

func download(from url: URL, _ completion: (Data?, Error?) -> Void)

ダウンロードに成功した場合は (Data?, Error?) として (data, nil) を、失敗した場合は (nil, error) を受け取ります。

download(from: url) { data, error in
    if let data = data {
        ... // `data` を使うオペレーション
    } else {
        let error = error! // ここで ! が必要
        ... // エラーハンドリング
    }
}

残念ながらこれは型として厳密ではありません。 (Data?, Error?)(data, nil), (nil, error) の他に (data, error), (nil, nil) も許容します。それらが仕様として起こり得なくても型として表現できていないので、上記のコードのように ! が必要になってしまっています。

こんなときに Result を使うと型を厳密に書くことができます。

func download(from url: URL, _ completion: (Result<Data, DownloadError>) -> Void)
download(from: url) { result in
    switch result {
    case .value(let data):
        ... // `data` を使うオペレーション
    case .failure(let error):
        ... // エラーハンドリング
    }
}
async/await

非同期処理は Result のユースケースのかなりの割合を占めると予想されますが、現在提案されている async/await が導入されるとほとんどの非同期処理で Result は不要になります。

ここでは async/await についての詳しい解説は行いませんので、興味があれば以前に書いた次の投稿を御覧下さい。

async を使えば、前述の download 関数は throws と組み合わせて次のように書けるようになります。

func download(from url: URL) async throws -> Data

download を使う側のコードも awaittry を使って簡潔に書けます。

do {
    let data = try await download(from: url)
    ... // `data` を使うオペレーション
} catch let error {
    ... // エラーハンドリング
}

非常にシンプルですね。もし async/await が導入されれば、このようなケースであえて Result を使う必要はありません。

並行した非同期処理

しかし、 async/await で書くことのできない非同期処理もあります。 async/await では処理を順番に行うので、次のようなコードでは A をダウンロードしてから B をダウンロードすることになります。

let dataA = try await download(from: urlA)
let dataB = try await download(from: urlB)
... // `dataA` と `dataB` を使うオペレーション

A と B を並行にダウンロードしたい場合は async/await だけでは書くことができません。そのため、プロポーザルで言及されている Future のような型が必要になります。この FutureResult の提案以前に書かれたものなので内部に独自の Result 型を持っています。また、 FutureResult の機能も併せ持ったものになっています。もし Future から Result の機能を分離したとすると、 A と B を並行でダウンロードするオペレーションは次のように書けます1

let futureA: Future<Result<Data, DownloadError>> = Future { try await download(from: urlA) }
let futureB: Future<Result<Data, DownloadError>> = Future { try await download(from: urlB) }
let dataA: Data = try await futureA.get()
let dataB: Data = try await futureB.get()
... // `dataA` と `dataB` を使うオペレーション

このように、 async/await が導入されたからといってすべての非同期処理で Result が不要になるわけではありません。

Observable などの型と組み合わせる場合

RxSwiftObservable など、ジェネリックな型の型パラメータと組み合わせて使いたい場合には Manual Propagation が必要となります。 Observable<Result<Foo, BarError>> のような型を throws で書こうとすると、先のコールバックのように Observable<() throws -> Foo> のように複雑になってしまうので、このような場合は Result を用いるのが適当です。

いつ Result を使うことが想定されているか

Result をどのような場合に使うことが想定されているのでしょうか。僕が注目したのは Swift Core Team のメンバーである John McCall の次のコメントです。 John McCall は "Error Handling Rationale and Proposal" の著者でもあります。

I want to clarify that I favor adding Result only to address situations where manual propagation is necessary.
参考訳: 私は Manual Propagation が必要となるような状況に対処するため だけResult を追加することを支持するとはっきりさせたい。

I will continue to recommend against using Result as an alternative mechanism for ordinary propagation of errors
参考訳: 通常のエラーの propagation の代わりとして Result を使用しないように引き続き推奨していく
( John McCall: https://forums.swift.org/t/se-0235-add-result-to-the-standard-library/17752/129 )

このように、 Resultthrows で書くことが困難で、 Manual Propagation が必要になったときに だけ 使うのが良いでしょう。そして、 async/await が導入されると多くの非同期処理では Result が不要となるので、 Result を利用すべきケースは限定的であると言えます。 Result の使用は必要最小限に留め、本当に Result がないと( throws では)書けないようなコードを書くときにだけ利用することをオススメします。

ただ、他の Core Team メンバーのコメントなども見ているといつ Result を使うべきか、必ずしも Core Team の中でもコンセンサスが得られているわけではない印象を受けました。そのため、レビュー時にいつ Result を使うべきと意図されているのかを示してほしいというコメントを書き込んでみました。残念ながら直接の返信は得られなかったものの、全体へのアナウンスの中に次のように書かれていたので、 Result が実際に追加されるときにはいつ使うべきかもより明確に示されるものと思われます。おそらく、僕は John McCall のコメントにあるような、 Manual Propagation が必要なケースでのみ Result を利用するということで落ち着くのではないかと考えています。

The Core Team acknowledges the call from several reviewers to make a special effort to communicate how we think Result ought to be used in Swift. We absolutely agree that is an important part of adding Result to the language, and we intend to provide this guidance as part of the release.
参考訳: Swift において Result がどのように使用されるべきと Core Team が考えているのかコミュニケートしようと特別な努力を払った何人かのレビュワーからの要望を Core Team は認知している。私達は、それは Result の言語への追加の重要な一部であると完全に同意する。そして、私達はそのようなガイダンスを Result のリリースの一部とするつもりだ。
( John McCall: https://forums.swift.org/t/accepted-with-modifications-se-0235-add-result-to-the-standard-library/18603 )

エラー型の指定

Resultthrows は似た仕組みを提供しますが、 Manual Propagation か Automatic Propagation かということ以外に大きく異なるのがエラー型を指定できるかどうかです。 Swift 4.2 時点では、 Java のように throws の後ろにエラー型を指定することはできません。

// これはできない
func readFile(at path: String) throws IOError -> Data

現状で(サードパーティ製の) Result が利用されている目的の一つにエラー型を指定したいということがあります。 throws でエラー型を指定できないのは意図的な設計によるものです。僕の解釈では、それは次のような理由によります。

エラーハンドリングのほとんどはオペレーションに成功したか失敗したかがわかればよく、エラーの型は重要ではありません(特にアプリケーションの実装においてはその傾向が強いと思います)。エラーの種類によって分岐したいようなケースでも、原因ごとの網羅的な分岐が必要になることは稀です。大抵は一つ二つの原因とその他というような分け方をすることになります。

たとえば、多様な原因を持つ I/O エラーを個別に分岐することはほぼないと思います。

do {
    let data = try readFile(at: path)
    ... // `data` を使うオペレーション
} catch IOError.fileNotFound(path: let path) {
    ... // ファイルが存在しないときのエラーハンドリング
} catch IOError.permissionDenied(let permission) {
    ... // パーミッションを持たないときのエラーハンドリング
} catch let error { // ←ここを個別に分岐して対応することは少ない
    ... // その他の場合のエラーハンドリング
}

他にも、ネットワークエラーを HTTP のすべてのステータスコードに個別に対応するコードを書いたことのある人はまずいないでしょう。 404 などのいくつかのケースに対応して、後はその他としてまとめてしまうことがほとんどのはずです。

このように、エラー型に対して網羅的かつ個別に分岐が必要となるケースは稀です。それに対して、エラー型を上手く設計するのは困難です。後から case の追加がしたくなったり、エラー型の種類を追加したくなったりすることはよくあることです。原因を整理してエラー型の実装を作り変えたくなることも多いでしょう。

もし、 throws にエラー型を指定することができると、おそらく多くのプログラマはそれを丁寧に設計しようとするはずです。しかし、それによって得られるリターンとそのために費やしたコストを天秤にかけると、コストの方が大きいケースが多いのではないでしょうか。そのような現実を踏まえて、現状では throws にエラー型を指定できないようになっているのではないかと思います。

しかし、エラー型を指定したいケースが存在するのも確かです。特に、ライブラリなどではエラー型が明確な方が良いことも比較的多いでしょう。 JSON のデコードのようなオペレーションでは、考えうるエラーの原因が数個程度に限定でき、個別に分岐したいケースも現実的に起こり得ます。

そのような場合にエラー型を指定するために Result を使うべきかと言うと、僕はオススメできません。エラー型を指定したいというのは前述の Manual Propagation が必要な場合には該当しません。

また、 Typed Throws ( throws の後ろにエラー型を指定できる構文)も Swift Evolution で議論され続けています。将来的にそのような構文が追加される可能性もあり、前述の JSON のデコードのようなケースは Typed Throws で対応すべきものだと思います。現状ではエラー型を指定できない状態の throws で実装しておき、 Typed Throws が追加されたときにエラー型を指定するのが良いでしょう。

Result<T, Error>

逆に、 Result に不必要にエラー型を指定するのは避けるのが良いでしょう。特に、非同期処理で Result を使うようなケースでは、 async/await さえあれば本来エラー型の指定されない throws で十分なことが多いはずです。そういう場合には Result<Data, Error> のように型パラメータ Failure に汎用的な Error を指定し、汎用的なエラー型として扱うのが良いと思われます。

func download(from url: URL, _ completion: (Result<Data, Error>) -> Void)
download(from: url) { result in
    switch result {
    case .value(let data):
        ... // `data` を使うオペレーション
    case .failure(let error):
        ... // エラーハンドリング
    }
}

Swift ではプロトコル型はそのプロトコル自身に適合しないという制約があったため、 Result<Data, Error> のような型を作ることができませんでした。型パラメータ Failure には Failure: Error という制約がありますが、 ErrorError 自身に適合しないので Failure の制約を満たせませんでした。この制約は言語の実装上の都合によるものですが、 Result の導入に伴って問題が生じるので、応急処置的に Error に関してのみ自分自身のプロトコルに適合する仕様( self-conformance )が追加されます。積極的にこれを活用しましょう。

Result をどのように扱うべきか

いつ Result を使うべきかがわかったとして、実際に Result を返すような API があった場合にどのように Result を扱えば良いでしょうか。ここでは簡単に僕のオススメを書いておきます。

Result を受け取ったらできるだけすぐにハンドリングすると良いでしょう。特に、非同期処理の結果として受け取った場合に、それ以上 Result のまま値を取り回す必要はないはずです。成功か失敗かが知りたいだけなら、 getdo/try/catch で十分でしょう。

download(from: url) { result in
    do {
        let data: Data = try result.get()
        ... // `data` を使うオペレーション
    } catch let error {
        ... // エラーハンドリング
    }
}

もし、エラーの原因別に分岐してハンドリングが必要なケースでは、網羅的な分岐が書きやすい switch/case を使うのが良いでしょう。

map, flatMap, mapError, flatMapError の利用は、個人的には Optional 同様に最小限に留めておくのが良いと思います。それらが必要なケースが存在するのは確かですが、 switch/casedo/try/catch と適切に使い分けないと可読性を損なうでしょう。

今のうちに Result に備える方法

Result が導入されると、既存のコードベースの中でサードパーティ製 Result を使っている箇所や、コールバックで値とエラーを受け取っているケース(前述の func download(from url: URL, _ completion: (Data?, Error?) -> Void) など)を標準ライブラリの Result を使って書き換えることになると予想されます。できるだけ移行の負荷を小さくするために、今のうちから標準ライブラリの Result に備えておきたいところです。

そのために、 SE-0235 で提案されている Result 互換の Result 型ライブラリ SwiftResult を作りました。

今のうちに SwiftResultResult を使ったコードに変更しておけば、 Swift 5 で Result が導入されたときに import SwiftResult を消すだけで移行が完了するはずです。是非ご活用下さい。


  1. Future<Result<T, E: Error>> に対して FutureResult を同時にアンラップする extension メソッド get が実装されているものとします。 

298
195
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
298
195