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
との関係
Result
と throws
でできることはよく似ています。 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
を使うべきか
Result
と throws
は似た機能なのでどのように使い分ければ良いのかという疑問が生まれると思います。本節では Result
と throws
の使い分けについて説明します。
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
によってエラーを表します。この場合は Optional
や guard let
( Optional Binding )という通常の言語機能を使ってエラーハンドリングを行っており、 Manual Propagation です。
一方、 Data
をファイルに書き込む処理は次のようになります。
do {
try data.write(to: URL(fileURLWithPath: path))
... // 何らかのオペレーション
} catch let error {
... // エラーハンドリング
}
この場合、 data.write
の呼び出し箇所から catch
までジャンプすることになり Automatic Propagation です。
Result
か throws
かという視点で考えると、 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 はエラーが発生したら即時ハンドリングすることが強制されます。一方、 Result
や Optional
はエラーハンドリングしないまま引数や戻り値として取り回したり、プロパティに保持しておいたりすることができます。実際に、そのような 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
についての詳しい解説は行いませんので、興味があれば以前に書いた次の投稿を御覧下さい。
-
"Proposalには載っていないSwift 5のasync/awaitが素晴らしいと思う理論的背景"
- タイトルに「 Swift 5 の」と入っていますが、 Swift 5.0 で導入されないことは決定で、おそらく現状では Swift 6 以降になると思われます。
async
を使えば、前述の download
関数は throws
と組み合わせて次のように書けるようになります。
func download(from url: URL) async throws -> Data
download
を使う側のコードも await
と try
を使って簡潔に書けます。
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
のような型が必要になります。この Future
は Result
の提案以前に書かれたものなので内部に独自の Result
型を持っています。また、 Future
が Result
の機能も併せ持ったものになっています。もし 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
などの型と組み合わせる場合
RxSwift の Observable
など、ジェネリックな型の型パラメータと組み合わせて使いたい場合には 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 )
このように、 Result
は throws
で書くことが困難で、 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 addingResult
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 )
エラー型の指定
Result
と throws
は似た仕組みを提供しますが、 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
という制約がありますが、 Error
は Error
自身に適合しないので Failure
の制約を満たせませんでした。この制約は言語の実装上の都合によるものですが、 Result
の導入に伴って問題が生じるので、応急処置的に Error
に関してのみ自分自身のプロトコルに適合する仕様( self-conformance )が追加されます。積極的にこれを活用しましょう。
Result
をどのように扱うべきか
いつ Result
を使うべきかがわかったとして、実際に Result
を返すような API があった場合にどのように Result
を扱えば良いでしょうか。ここでは簡単に僕のオススメを書いておきます。
Result
を受け取ったらできるだけすぐにハンドリングすると良いでしょう。特に、非同期処理の結果として受け取った場合に、それ以上 Result
のまま値を取り回す必要はないはずです。成功か失敗かが知りたいだけなら、 get
と do/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/case
や do/try/catch
と適切に使い分けないと可読性を損なうでしょう。
今のうちに Result
に備える方法
Result
が導入されると、既存のコードベースの中でサードパーティ製 Result
を使っている箇所や、コールバックで値とエラーを受け取っているケース(前述の func download(from url: URL, _ completion: (Data?, Error?) -> Void)
など)を標準ライブラリの Result
を使って書き換えることになると予想されます。できるだけ移行の負荷を小さくするために、今のうちから標準ライブラリの Result
に備えておきたいところです。
そのために、 SE-0235 で提案されている Result
互換の Result
型ライブラリ SwiftResult を作りました。
- SwiftResult: https://github.com/koher/SwiftResult
今のうちに SwiftResult の Result
を使ったコードに変更しておけば、 Swift 5 で Result
が導入されたときに import SwiftResult
を消すだけで移行が完了するはずです。是非ご活用下さい。
-
Future<Result<T, E: Error>>
に対してFuture
とResult
を同時にアンラップするextension
メソッドget
が実装されているものとします。 ↩