追記 (2020-08-05)
投稿時には async/await
が Swift 5 で導入されそうでしたが、 2020 年 8 月( Swift 5.2 )現在ではまだ async/await
は導入されておらず、 Swift 6 での導入が有力になっています。
-
async/await
は "Swift Concurrency Manifesto" の最初の Part に挙げられていますが、公式にアナウンスされた "On the road to Swift 6" の中で Concurrency が挙げられています。 - 先日、
async
に関する PR が Swift リポジトリの master ブランチにマージされました。
2ヶ月ほど前、 Chris Lattner から swift-evolution に "async/await + actors" というタイトルで驚きのメールが流れました。
これは Swift における並行処理関連の提案で、主に async/await
と actor
についてのものです。本投稿では、そのうち async/await
に着目します。 async/await
といえば C# や JavaScript が思い浮かびますが、 Swift の async/await
は Swift にフィットするように独自の変更が加えられています。
understanding async/await in 7 seconds pic.twitter.com/IJOQJ2DR35
— Wassim Chegham シ (@manekinekko) April 22, 2017
( JavaScript の Promise
と async/await
)
まだ正式な Proposal になっていませんが、こちら から Proposal 全文を閲覧できます。これを読めば Swift の async/await
がどのようなもので、何のために提案されているのかがわかります。この Proposal は平易な英語で書かれていて読みやすいので、是非一読をオススメします。 Proposal に書かれていることをそのまま書いてもおもしろくないので、 この投稿では Proposal では詳しく述べられていない背景思想について説明します。とはいえ、予備知識がなくても、基本的な Swift の構文さえ知っていれば async/await
について一通り理解できるように説明するので安心して下さい。
実は、僕はこの Proposal の async/await
とほぼ同じ内容のものを提案しようと考えていました。 GW 頃にまとめ、 Ray Fix さんに英文のネイティブチェックをしてもらったものがこちらです。ただ、当時すでに Swift 4 のまとめの時期に入っており新機能を提案できる感じではありませんでした。 Swift 5 の議論が始まったら投稿しようと思ってそのまま寝かしていました。そして、 8 月にようやく Swift 5 の議論が始まったのでそろそろ投稿しようかと思ってたところに冒頭の Chris Lattner のメールが投稿されました。中身を確認してみると自分が考えていたのとほぼ同じ内容だったのでびっくりというわけです。
(原型をとどめないネイティブチェックの図)
しかし、 Proposal の説明は自体はとてもわかりやすいのですが、その説明は僕がほぼ同じ async/await
に思い至った経緯とはずいぶん異なっていました。この投稿では、僕がそのような async/await
に至った過程を説明することで、なぜこの async/await
が Swift に良くフィットするのかを説明します。
コールバック地獄
Swift の非同期処理にはコールバックが用いられることが多いです。 Delegate
もありますが、 Obj-C 由来なのでここでは触れません。
コールバックの問題としてよく知られるものに コールバック地獄 があります。↓のように、複数の非同期処理を連ねるとピラミッド状のネストが生まれてしまいます。可読性もメンテナンス性も良くありません。
func downloadFooImage(callback: @escaping (Image) -> ()) {
downloadServerURL() { url in
downloadJSON(from: url) { json in
download(from: json["foo"]["image"]["url"].string! ) { imageData in
callback(Image(data: imageData)!)
}
}
}
}
Promise
コールバック地獄の対策の一つとして、 JavaScript で有名になった Promise
があります。
Promise<Foo>
は今はまだ手に入っていないけど、将来のいつか手に入る Foo
型の値を意味します。たとえば、 url
を渡して Data
をダウンロードする関数 download
を考えてみます。コールバック版と Promise
版のシグネチャはそれぞれ次のようになります。
// コールバック
func download(from url: URL, completion: @escaping (Data) -> ()) { ... }
// Promise
func download(from url: URL) -> Promise<Data> { ... }
Promise<Data>
を将来のいつか手に入る Data
型の値と読めれば、 url
を渡して Promise<Data>
を返してくれる後者の方が(少なくとも僕は)わかりやすいと思います。
では、 Promise
を使ってどのようにコールバック地獄を解消するのでしょうか?そのためにはもう少し Promise
について説明しなければなりません。
PromiseK
僕の勤務先である Qoncept では、 Swift 1.0 のリリースと同時に Swift を使い始めたんですが、 2014 年の年末頃にはコールバック地獄の問題が顕在化していました。そんなときに、僕の同僚の @omochimetaru が「 Swift にも JavaScript の Promise
がほしい。」と言って Swift で再現したものをサクッと実装し、使い始めました。
僕はそのとき初めて Promise
を知ったんですが、使ってみてなかなかいいなと思いました。しかし、使っている内に僕はいくつかの不満を抱き始めました。
たとえば、 Swift 1.0 の頃には Optional
の nil
でエラーを表すことが一般的でした。しかし、 JavaScript の Promise
は非同期だけでなくエラーも扱っていました。非同期処理はエラー処理を伴うことが多いとはいえ、本質的には二つは直交した概念です。 Swift にはせっかく Optional
があるのに、 Promise
を使うときは Promise
でエラー処理をするというのが気に入りませんでした。
そこで、 JavaScript の Promise
からエラー処理に関する機能を取り除いて非同期処理に特化した Promise
ライブラリ PromiseK を作りました。 PromiseK の Promise
は純粋に非同期であることだけを表します。エラーが起こり得ることは Promise<Optional<Foo>>
のように Optional
等と組み合わせて表現します。
提案されている async/await
と、このエラー処理を取り除いた Promise
は強く関連しているので、 PromiseK をベースに Promise
について説明します。
結果のハンドリング
コールバックだろうが Promise
だろうが、非同期処理の結果を受け取ってハンドリングすることは必須です。先程の download
関数を利用するコードはそれぞれ次のようになります。
// コールバック
download(from: url) { (data: Data) in // 非同期処理完了時に実行
// `data` を使う処理
}
// Promise
let data: Promise<Data> = download(from: url)
data.get { (data: Data) in // 非同期処理完了時に実行
// `data` を使う処理
}
このコードを見ただけでは、 Promise
を使って何がうれしいのかわからないと思います。重要なのは、 Promise
版の場合は結果が値として返されるということです。 Promise<Data>
という形で非同期処理そのものが値として返されるので、それを取り回して複数の非同期処理を結合して順番に実行したり、並列に走らせてから最後に結合したりという操作が可能になります。
具体的には後で詳しく説明しますが、そのように複数の非同期処理に対して結合などの操作を実行できることがコールバック地獄のネストを取り除くことを可能にします。なので、先程のコードのように単発で非同期処理の結果を受け取るだけでは Promise
のうまみがわかりませんが、値として取り回せるということが重要なポイントです。
map
では、どのように非同期処理を操作するのでしょう? JavaScript の Promise
では then
メソッド https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then が肝となります。しかし、 僕が JavaScript の Promise
について気に入らなかったもう一つの点がその then
メソッドでした。
当時モナドについて知りたいと思って "すごいHaskell" を読んでいた僕は、 Promise
はモナドで then
メソッドは map
と flatMap
をごちゃ混ぜにしたものだと気付きました。モナドというのは、 Array
や Optional
のように map
と flatMap
を持っている型のことです。そこで、僕は PromiseK を実装するに当たって then
メソッドを廃止し、 map
メソッドと flatMap
メソッドに分解することにしました。
map
メソッドは Swift を書いていると馴染み深いんじゃないかと思います。たとえば、 Array<Int>
( [Int]
)の各要素を 2 乗するコードは次のように書けます。
let numbers: Array<Int> = [2, 3, 5]
let squares: Array<Int> = numbers.map { $0 * $0 } // [4, 9, 25]
Optional
でも同じです。 map
メソッドを使って、値がある場合は 2 乗し、 nil
の場合は nil
のままとすることができます。
let number: Optional<Int> = 3
let square: Optional<Int> = number.map { $0 * $0 } // Optional(9)
Optional
は値が入っているかもしれないし、入っていないかもしれない箱だと考えられます。 Optional<Int>
( Int?
) なら Int
が入っているかもしれないし、入っていない( nil
)かもしれないわけです。
Promise
も同じように箱だと考えられます。 Promise
はどんな箱かというと、今はまだ手に入っていないけれども将来値が手に入る箱です。それなら、同じように map
を使って将来手に入る値を 2 乗することができてもおかしくありません。
Promise
を使って将来手に入る値を 2 乗するコードは次のようになります。 Array
や Optional
とまったく同じなのがわかると思います。
let number: Promise<Int> = downloadInt(from: url)
let square: Promise<Int> = number.map { $0 * $0 }
この場合、 number
に get
して将来得られる値が 3
だとすると、 square
に get
して将来得られる値は 9
となります。
このようにして、 Promise
の map
メソッドを使えば 今はまだ手に入っていない将来の値に対して操作を加えることができます。
flatMap
Promise
にとってより重要なのが flatMap
です。
Promise
の flatMap
を考える前に、まず Optional
の flatMap
(API Reference) についておさらいしましょう。
たとえば、 [String: String]
から値を取り出し、得られた String
を整数に変換したいとします。 Web アプリで GET パラメータを処理する場合などにありがちです。この場合、 parameters["id"]
等で得られる値は String
ではなく String?
です。また、 String
を Int
に変換する処理も、パースに失敗すると nil
となるので Int?
が返されます。
これを単純に結合すると Optional
がネストして Optional<Optional<Int>
( Int??
) になってしまします。
let idString: Optional<String> = parameters["id"]
let id = idString.map { Int($0) } // `id` の型は `Optional<Optional<Int>>`
今求めているのは、 parameters["id"]
か Int($0)
のどちらかに失敗すると nil
になり、両方に成功した場合には Int
の id
が得られることです。つまり、結果としてほしい値の型は Optional<Int>
です。しかし、先程のコードでは Optional<Optional<Int>>
のようにネストしてしまいました。そんなときに役に立つのが flatMap
です。
map
の代わりに flatMap
を使うと、ネストした Optional
をつぶしてフラット(一重)にしてくれます。
let idString: Optional<String> = parameters["id"]
let id: Optional<Int> = idString.flatMap { Int($0) }
これは parameters["id"]
と Int($0)
という二つの失敗し得る処理を結合したとも考えられます。失敗しうる処理が結合されると、どちらか一方でも失敗したら nil
になり、両方に成功した場合だけ結果が得られるわけです。
同じことが Promise
でも言えます。まずダウンロード先の URL
をダウンロードし、それを使って id
をダウンロードする二つの非同期処理を次のように flatMap
で結合できます。
let idURL: Promise<URL> = downloadURL(from: url)
let id: Promise<Int> = idURL.flatMap { downloadInt(from: $0) }
Optional
を flatMap
で結合すると両方に成功した場合だけ値が得られました。 Promise
では両方の非同期処理を順番に実行し、両方とも完了したときに初めて値が得られる Promise
が得られます。
次のようにして flatMap
を使うことで非同期処理を結合することができます。
let idURL: Promise<URL> = downloadURL(from: url)
let id: Promise<Int> = idURL.flatMap { downloadInt(from: $0) }
id.get { (id: Int) in
// `downloadURL` がまず実行され、次に `downloadInt` が実行され、
// 両方の非同期処理が完了したときに初めてこのクロージャが実行される。
}
では、これを使って先程のコールバック地獄を解消してみましょう。先程挙げた↓のコールバック地獄ですが、
func downloadFooImage(callback: @escaping (Image) -> ()) {
downloadServerURL() { url in
downloadJSON(from: url) { json in
download(from: json["foo"]["image"]["url"].string! ) { imageData in
callback(Image(data: imageData)!)
}
}
}
}
コールバックの部分がすべて Promise
なら↓のように flatMap
の連結で書けます。
func downloadFooImage() -> Promise<Image> {
return downloadServerURL().flatMap { url in
downloadJSON(from: url)
}.flatMap { json in
download(from: json["foo"]["image"]["url"].string! )
}.map { imageData in
Image(data: imageData)!
}
}
これがわかりやすいかどうかは人によると思います。ネストが深くてもコールバックの方が読みやすいという人も当然いると思います。
それでは、なぜこんなにも延々と Promise
と flatMap
の話をしているのかと言うと、 async
は Promise
を return
することに、 await
は flatMap
に対応するからです。
Promise
を知っていると async/await
がどういうものなのか格段に理解しやすくなります。
コールバックの Promise
化
さて、ここまでの話は Promise
のレベル 1 で、ここからがレベル 2 です。これまでは Promise
化された API をどのように使うかについてで、ここからは自分で Promise
化された API を作る話です。
ちなみに、レベル 3 は自分で Promise
を作る話です。今日のテーマには含まれませんが、すごく勉強になるので Promise
を実装してみることをオススメします。
Promise
がおもしろいのは、並行処理の仕組みそのものとは独立していることです。 iOS アプリ開発であれば、 GCD や Foundation の Thread
など様々な方法で並行処理を実現できます。 Promise
はそれらと組み合わせて利用することができます。また、コールバックで実装された既存のメソッド( UIView.animate(...)
等)を Promise
化することもできます。
たとえば、 func foo(_ completion: (Int) -> ())
というコールバックで結果を受け取る関数があったとして、これを Promise
化したものは次のように実装できます。
func promisedFoo() -> Promise<Int> {
return Promise<Int> { fulfill in
foo { fulfill($0) }
}
}
ちょっと複雑ですが何をしているのが順に見ていきましょう。まず、 Promise
のイニシャライザのシグネチャは次のようになっています。
init(_ executor: (_ fulfill: @escaping (Value) -> ()) -> ())
Promise
は生まれた直後には値を持っていませんが、いつかは非同期処理が終わって値を手に入れなければなりません。 Promise
に値を通知する手段を提供するのが fulfill
です。 Promise<Foo>
のとき、 fulfill
は (Foo) -> ()
という型の関数です。 fulfill
の引数に Foo
型を与えて呼び出せば Promise
の非同期処理は完了し Foo
が得られたことを意味します。
極端な話、↓のようにすれば即時に値が手に入る Promise
を作ることもできます。
Promise<Int> { fulfill in
fulfill(42)
}
先の例では、このクロージャ式の中で非同期処理 foo
を呼び出し、コールバックの中で fulfill
を呼び出すことでコールバックを Promise
化しているわけです。
Promise<Int> { fulfill in
foo { fulfill($0) }
}
この仕組みを使えば、任意の非同期処理を Promise
化することが可能です。後でもう一度触れますが、この Promise
のイニシャライザは async/await
の Proposal 中の suspendAsync
に相当します。
また、 async/await
の話と直接関係ないですが、僕が Promise
についておもしろいと思うのは、それそのものが状態でできているような存在なのに、イミュータブルのように振る舞う点です。 Promise
を使う側はすでに非同期処理が終わって値が決定されているのかを気にする必要はないですし、一度決定された値が後で変更されることもありません。何度 get
しても同じ値が得られます。 get
や map
, flatMap
に渡した関数がいつ呼ばれるかがわからないだけで、得られる値は常に一つです。状態変化を扱うのにそれを内部に隠蔽し、利用者はイミュータブルなコードを書けるというのが Promise
のおもしろいところです。
さらに余談ですが、この Promise
の実装はここ にあります。実質的にはわずか 38 行のコードです。これは、僕がこれまでに Swift で書いた最もキレイなコードの一つだと思うので、良かったら見てみて下さい。
throws/try
さて、 Swift 1 から少し時代が進んで、 2015 年の夏に Swift 2.0 がリリースされました。 Swift 2.0 で最も注目すべきだったのは何と言っても throws/try
です。なぜここで突然 throws/try
の話を始めたかと言うと、 throws/try
が async/await
ととても良く似た仕組みの上に成り立っているからです。
Swift 1 時代には Optional
を返すことで失敗し得る処理を扱うことができました。しかし、 nil
ではエラーの理由を表すことができないので、色々な理由でエラーが起こり得る場合には不便です。特に、エラーの原因によって処理を分けることは Optional
では不可能です。また、 Obj-C 由来の NSError
の扱いも Swift 1 時代は大変でした。
Swift 2.0 からは、 throws/try
を使うことによってエレガントにエラー処理をすることができるようになりました。
Swift の throws/try
は Java の検査例外とよく似ているのですが、そのまま持ってきたのではなく Swift にフィットするようによく考えて変更されています。 "SwiftはどのようにJavaの検査例外を改善したか" についてはこちらの投稿にまとめてあります。この投稿はあまり伸びませんでしたが、個人的にはとてもおもしろいことが書いてあると思うのでよかったら読んでみて下さい。
throws/try
を使えば失敗し得る処理を次のように書けます。
func loadFoo() throws -> Foo { ... }
do {
let foo: Foo = try loadFoo()
// `foo` を使う処理
} catch error {
// エラー処理
}
この throws/try
の仕組自体は僕はとても素晴らしいと感じていますが、一つ大きな問題があります。それが、非同期処理と組み合わせて使いづらいことです。
たとえば、これまで download
関数が失敗する可能性を無視してきましたが、現実的にはダウンロードは失敗し得る処理です。しかし、 download
に throws
を付けることはできません。
// コールバック(これではダメ⛔)
func download(from url: URL, completion: @escaping (Data) -> ()) throws { ... }
// Promise (これもダメ⛔)
func download(from url: URL) throws -> Promise<Data> { ... }
なぜなら、 throws
によるエラーは関数のコールに対して同期的に対処しなければならないからです。しかし、ダウンロード中のエラーは非同期に発生するので、同期的に処理することはできません。
do {
download(from: url) { (data: Data) in
// 本当はここでエラー処理したい
}
} catch let error {
// ここでエラー処理したいわけではない
}
↓のようにすれば無理やりコールバックや Promise
でエラー処理させることができますがちょっと辛いです。
// コールバック(一応できるがわかりづらい🤔)
func download(from url: URL, completion: @escaping (() throws -> Data) -> ()) throws { ... }
// Promise (これもできるがわかりづらい🤔)
func download(from url: URL) throws -> Promise<() throws -> Data> { ... }
これを使うコードは↓です。クロージャで受け取るのが値( data
)ではなく関数( getData
)という点がわかりづらいです。
download(from: url) { getData in
do {
let data = try getData()
// `data` を使う処理
} catch let error {
// エラー処理
}
}
Result
throws/try
は非同期処理と相性が悪いということで Result
ライブラリが流行りました。一番有名なのは antitypical/Result です。しかし、ここでは話の都合上、僕が実装した ResultK を使って説明します。
antitypical/Result と ResultK の一番の違いは、前者が Result<T, E: Error>
と型パラメータでエラーの型を指定できるのに対して、後者は Result<T>
とエラーの型が指定できないことです。 Swift の throws
節には Java のようにエラーの型を指定することができません。そのため、エラーの型が指定できない Result
の方が throws/try
にキレイに対応します。それが ResultK で説明する理由です。
Result
は次のように宣言されています。成功して T
型の値を持つか、失敗して Error
を持つかのどちらかであることを表す型です。
enum Result<T> {
case success(T)
case failure(Error)
}
Result
を使えば throws
で書かれた関数を↓のように書き換えることができます。 throws
を付与するのと Result
を return
するのが対応していることに注目して下さい。
// throws/try
func loadFoo() throws -> Foo { ... }
// Result
func loadFoo() -> Result<Foo> { ... }
Result
は enum
なので、↓のようにしてエラー処理することができます。
switch (loadFoo()) {
case .success(let foo)
// `foo` を使う処理
case .failure(let error)
// エラー処理
}
Result
を使ってうれしいのは非同期処理と組み合わせたときです。 Result
があれば先程の download
は次のように書けます。
// コールバック(非同期処理の結果が `Result<Data>` で得られるのでわかりやすい✅)
func download(from url: URL, completion: @escaping (Result<Data>) -> ()) { ... }
// Promise (非同期処理の結果が `Result<Data>` で得られるのでわかりやすい✅)
func download(from url: URL) -> Promise<Result<Data>> { ... }
map
, flatMap
Result
もモナドなので、 Optional
や Promise
と同じく map
や flatMap
を持ちます。
たとえば、 map
を使って Result
の中身を 2 乗するコードは↓のようになります。 Optional
や Promise
のときとまったく同じですね。
let number: Result<Int> = loadInt(from: path)
let square: Result<Int> = number.map { $0 * $0 }
flatMap
も同様です。 Optional
のときと同じように、 flatMap
を使って失敗し得る処理を結合することができます。
let json: Result<Data> = loadData(from: path)
let user: Result<User> = json.flatMap { decodeUser(from: $0) }
この処理では、まず JSON ファイルからデータを読み込み、次の読み込まれたデータを User
型の値にデコードしています。どちらも失敗しうる処理であり、 flatMap
で結合して両方に成功した場合だけ User
が得られます。
throws/try
と Result
先程、関数に throws
を付与するのと Result
を return
するのは同じだと言いました。
flatMap
にも対応するものがあります。 throws/try
と Result
で同じ処理が書かれた次のコードを見比べてみて下さい。
// throws/try
let json: Data = try loadData(from: path)
let user: User = try decodeUser(from: json)
// `user` を使う処理
// Result
loadData(from: path).flatMap { (json: Data) in
decodeUser(from: json)
}.flatMap { (user: User) in
// `user` を使う処理
}
どちらのコードも loadData
と decodeUser
に成功した場合だけ user
が得られ、失敗した場合にはその Error
が得られます。一番重要なのは、 flatMap
でモナドの中身を取り出して処理しているのと同じように、 try
もエラーの場合を剥がして中身を取り出す処理になっていることです。 try
して代入するのは本質的に flatMap
と同じことなのです。
小まとめ
-
throws
を付与 ==Result
をreturn
-
try
して代入 ==flatMap
// throws/try
func loadUser(from path: String) throws -> User {
let json: Data = try loadData(from: path)
return try decodeUser(from: json)
}
// Result
func loadUser(from path: String) -> Result<User> {
return loadData(from: path).flatMap { (json: Data) in
decodeUser(from: json)
}
}
Result
: throws/try
= Promise
: ?
さらに時は進んで 2016 年の 1 月、僕は Result
やエラー処理をテーマに try! Swift の発表の準備をしていました。
Result
は throws/try
と違って非同期処理と一緒に使いやすいです。一方で flatMap
をチェーンするコードより try
して代入を繰り返すコードの方がわかりやすいです。両者のいいとこどりができたら最高です。なので、 throws -> Foo
を Result<Foo>
を return
することのシンタックスシュガーにして、二つの世界を自由に行き来できるようにすればいいんじゃないかと考えました。
たとえば、↓のような感じです。これなら、普段は throws/try
で使っておいて、非同期処理などで必要なときだけ Result
として扱えます。
func loadFoo() throws -> Foo { // `-> Result<Foo>` でも同じ
...
}
let foo1: Foo = try loadFoo() // `try` すると `Foo` が得られる
let foo2: Result<Foo> = loadFoo() // `try` しないと `Result<Foo>` が得られる
このような話を発表しようと考えているときに、ふと Result
に対して throws/try
が考えられるなら、 Promise
に対しても同じようなものが考えられないかと思いました。なにせ、 Result
も Promise
も同じモナドです。 try
して代入することが flatMap
に相当するなら、同じことが Promise
に大してもできるはずです。
そう考えると、 C# の async/await
が(少し変更は必要ですが)ぴたりとそれに当てはまることに気付きました。 C# の async
は次のように async
を付けた上で Promise
( C# では Task
)を返します。
// C# の `async` を Swift っぽい構文で書くと
func download(from url: URL) async -> Promise<Data> { ... }
async
を書いた上で Promise
を返すのは冗長なので、↓のような構文にすると Promise
: async
= Result
: throws
でぴたりとハマります。
// Swift にフィットするように構文を変更
func download(from url: URL) async -> Data { ... }
つまり、↓の二つを同じ意味にしてしまうわけです。 throws
と Result
の関係とまったく同じです。
// async
func download(from url: URL) async -> Data { ... }
// Promise
func download(from url: URL) -> Promise<Data> { ... }
throws
と async
が対応するなら try
に対応するものも必要です。これも await
と考えればぴったりです。 throws/try
と Result
のときとまったく同じです。
// async/await
let data: Data = await download(from: url)
// `data` を使う処理
// Promise
download(from: url).flatMap { (data: Data) in
// `data` を使う処理
}
このような async/await
を考えると throws
: try
: Result
= async
: await
: Promise
という関係が成り立ちます。なんて美しい関係でしょう。
非同期エラー処理
Promise
と Result
が組み合わせられるように、 throws/try
と async/await
も組み合わせられます。非同期かつ失敗し得る download
関数は次のように書けます。
// async/await + throws/try
func download(from url: URL) async throws -> Data { ... }
// Promise + Result
func download(from url: URL) -> Promise<Result<Data>> { ... }
これらを使うときのコードは↓です。一つ目の flatMap
が await
に、二つ目の flatMap
が try
に対応しています。
// async/await + throws/try
let data: Data = try await download(from: url)
// `data` を使う処理
// Promise + Result
download(from: url).flatMap { $0.flatMap { (data: Data) in
// `data` を使う処理
} }
モナド地獄
そんなわけで僕は throws
は Result
を、 async
は Promise
を返すことのシュガーにしたらいいんじゃないかということを try! Swift で話しました。このときに Ray Fix さんに興味を持っていただき「もし Proposal を出すなら英文チェックするよ。」と申し出て下さったことから、冒頭に書いたネイティブチェックにつながりました。
try! Swift が終わって、 throws
を Result
のシュガーにする部分だけを切り出して swift-evolution に投げたところ、 Core Team の Joe Groff からこちらのコメントをもらいました。以下、僕の解釈で説明します。
たとえば、 Array<Data>
があったとして、これをすべてデコードして Array<User>
に変更したいとします。
let jsons: Array<Data> = ...
let users: Array<Result<Data>> = jsons.map { decodeUser(from: $0) }
しかし、得られたのは Array<Result<User>>
です。一つでも失敗したらエラーとしたい場合ほしいのは Result<Array<User>>
です。 Haskell などの言語では、 Array<Result<User>>
を Result<Array<User>>
に変換するための sequence
という関数が用意されていたりします。この他にも Result<Promise<Result<T>>
→ Promise<Result<T>>
、 Array<Result<Promise<T>>
→ Promise<Result<Array<T>>
なんかもやりたくなりそうです。
極端な話、次のようなことをやろうとしたら、モナド的には戻り値の型は Result<Promise<Result<Promise<Result<Foo>>>>>
です。
let a = try ...
let b = await ...
let c = try ...
let d = await ...
let e = try ...
return Foo(a, b, c, d, e)
でも、 99% のケースでは本当にほしいのは Promise<Result<Foo>>
のはずです。このモナドパズルを解かないといけないのは モナド地獄 です。
async/await
と throws/try
であれば async throws -> Foo
とすれば良いだけです。入れ子のパズルに苦しめられることもありません。これの意味するところは、 throws/try
や async/await
はモナドパズルの解き方を定めていて、それが自動で適用される ということです。
この話に関連して面白いのが rethrows
です。 map
して Result
を返した場合は結果は Array<Result<User>>
となりますが、エラーを throw
すると map
自体がエラーを throw
します。言い換えると、 Result<Array<User>>
相当のところまで変換してくれているということです。
// throws/try
let users: Array<User> = try jsons.map { try decode(from: $0) } // `Result<Array<User>>` 相当
// Result
let users: Array<Resut<User>> = jsons.map { decode(from: $0) }
Array<Result<T>>
がほしいことは滅多にないので、 rethrows
が勝手にモナドのネスト順序を入れ替えて Result<Array<T>>
相当まで変換してくれているということです。
Promise
のない async/await
Swift が Result
を採用しない道を進むなら、 async/await
にも Promise
がない方が良いように思います。
C# や JavaScript の async/await
は Promise
( C# では Task
)の上に構築されているので、 Promise
のない async/await
は挑戦的です。しかし、 throws/try
が Result
なしで実現できているのだから async/await
にもできそうなものです。
そんなことを SwiftJP Slack などで話してた んですが、 swift-evolution で非同期処理の話が始まらないので提案せずに放置していました。しかし、今年の GW 頃にやっぱり Swift に async/await
を導入するならこれしかない!と思い立ってまとめたのが冒頭で挙げたこちらです。
beginAsync
と suspendAsync
単に Promise
を返す代わりに async
と書き、 flatMap
の代わりに await
するのではいけません。 async/await
から Promise
を取り除くに当たっては、 Promise
の get
と init
に相当するものも提供しなければなりません。それが beginAsync
と suspendAsync
です(僕の提案中では nonblock
と asyncronize
です。さすがに名称は異なってますが、同じ道具が必要になってるのがおもしろいです)。
beginAsync
と suspendAsync
のシグネチャは↓です。
func beginAsync(_ body: () async throws -> Void) rethrows -> Void
func suspendAsync<T>(
_ body: (_ continuation: @escaping (T) -> ()) -> ()
) async -> T
func suspendAsync<T>(
_ body: (_ continuation: @escaping (T) -> (),
_ error: @escaping (Error) -> ()) -> ()
) async throws -> T
Promise
では get
を使って非同期的に値を受け取れました。 beginAsync
を使えばこれと似たようなことができます。
// Promise
download(from: url).get { data in
// `data` を使う処理
}
// async/await
beginAsync {
let data = await download(from: url)
// `data` を使う処理
}
また、 Promise
のイニシャライザを使ってコールバックで値を受け取る API を Promise
化できました。これも suspendAsync
で似たことができます。
// Promise
Promise<Data> { fulfill in
download(from: url) { data in
fulfill(data)
}
}
// async/await
await suspendAsync { fulfill in
await download(from: url) { data in}
fulfill(data)
}
}
こうして、 Promise
がなくても async/await
を実現することができました。
やや余談ですが、 Proposal の中では beginAsync
の body
に throws
が付けられており、 beginAsync
自体も rethrows
になっています。
func beginAsync(_ body: () async throws -> Void) rethrows -> Void
僕はこの throws
と rethrows
には反対です。 beginAsync
に渡された body
は非同期処理を行うので、即座にエラーを throw
することができません。 beginAsync
がエラーを rethrow
できるのは、 body
の中の最初の await
までにエラーが throw
された場合だけです。
do {
try beginAsync {
try foo() // ここで発生したエラーを・・・
await bar()
}
} catch { // ここで `catch`
// エラー処理
}
非同期的に発生したエラーは catch
できないので、エラーが throw
されたらクラッシュするしかありません。
do {
try beginAsync {
await bar()
try foo() // ここでエラーが発生しても・・・
}
} catch { // ここで `catch` できないのでクラッシュ!
// エラー処理
}
最初の await
の前に try
したい場合は、それを beginAsync
の外に出せるはずです。
do {
try foo() // `beginAsync` の外に出す
beginAsync {
await bar()
}
} catch {
// エラー処理
}
もし beginAsync
の body
に throws
が付いてなければ、 body
の中でエラー処理が強制されるので、 await
してから try
のようなケースも安全に対処できます。
beginAsync {
do {
await bar()
try foo()
} catch { // これがないとコンパイルエラー
// エラー処理
}
}
せっかくコンパイル時にチェック可能なエラー処理機構を持っているのに、 beginAsync
に渡すクロージャの中ではそれが台無しになってしまいます。
この点については一度 swift-evolution に書いたのですがうまく伝わってなさそうなので、今度もう一度投稿したいと考えています。
小まとめ
長々と書いてきましたが、 Proposal の中には出てこない Promise
モナドを考えて、 Result
や throws/try
との関係を考えると、 Swift にフィットする async/await
はこれしかないという気持ちになります。
その根拠は関係が「美しい」ことだけかもしれませんが、僕は経験則的に美しさは重要であると考えています。 関係や構造が美しく整理できるときは物事の本質を突いていることが多いです。
逆にそれを外すと、後で思いもよらないところで問題が発生して困ったりします。
たとえば、今回の async/await
の Proposal にも、非同期処理はほぼ確実にエラーを伴うんだから async
と await
だけ書けば throws
と try
も付いていることにしてはどうかという意見もあります。しかし、この一見筋が通ったように見える意見には問題があります。たとえば、後で紹介する Generator を async/await
で実現しようとすると throws/try
であってほしくありません。
人はすべてを見通せるわけではないので一見問題がなさそうに見えても何かを見逃している可能性があります。できるだけ「美しい」方を選択することで、将来的なトラブルの可能性を低減できるんじゃないでしょうか。
throws
: try
: △ = async
: await
: □
throws/try
と async/await
の関係を考えると Proposal に書かれていないいくつかの興味深いことが見えてきます。
throws
: try
: catch
= async
: await
: ?
throws/try
の do/catch
に対応するものを async/await
にも考えるとどうなるでしょうか。 do/catch
しない場合、 try
を含む関数は必ず throws
でなければなりません。
// do/catch なし
func foo() throws -> Bar { // `throws` 必須
return try bar()
}
// do/catch あり
func foo() -> Bar { // `throws` でなくて良い
do {
return try bar()
} catch {
// エラー処理
return Bar(...)
}
}
async/await
でも同様に await
を含む関数は async
でなければなりません。では、 do/xxx
を考えるとどのようなものである必要があるでしょう?
// do/xxx なし
func foo() async -> Bar { // `async` 必須
return await bar()
}
// do/xxx あり
func foo() -> Bar { // `async` でなくて良い
do {
return await bar()
} xxx
}
このような挙動を実現する xxx
は何を意味するでしょうか? bar()
は非同期なのに foo()
は( async
でないので)同期的に return
された Bar
を返せています。
これを実現するには、 xxx
によって非同期処理が同期化されなければなりません。つまり、 xxx
は bar()
の非同期処理が終わって結果が得られるまで待つという意味になるはずです。
「待つ」なので xxx
に wait
という単語を当てれば↓のようになります(他にも、 sync
とか block
とかの方がいいかもしれませんが)。
// do/wait なし
func foo() async -> Bar { // `async` 必須 → 非同期
return try bar()
}
// do/wait あり
func foo() -> Bar { // `async` でなくて良い → 同期
do {
return await bar()
} wait // `bar()` が完了するまでここで待つ
}
throws
: try
: try!
= async
: await
: ?
次は try!
に当たるものを考えてみましょう。
throws
が Result
を返すことに対応することを考えると、 try!
は強制的に Result
を unwrap するようなものです。 Optional
の !
に近い操作です。
// ! なし
func foo() throws -> Bar { // `throws` 必須
return try bar()
}
// ! あり
func foo() -> Bar { // `throws` でなくて良い
return try! bar()
}
では、 Promise
を unwrap する操作とか何でしょうか。それはやはり値が手に入るまで待つことを意味するのではないでしょうか。
// ! なし
func foo() async -> Bar { // `async` 必須
return await bar()
}
// ! あり
func foo() -> Bar { // `async` でなくて良い
return await! bar() // `bar()` が完了するまでここで待つ
}
try!
が失敗時にクラッシュするのと同じように、処理が完了してなかったらクラッシュと考えることもできるかもしれません。しかし、 try!
がクラッシュするのは Result
でいう .failure
由来の性質だと思うので、僕はクラッシュではなく同期的に待つ方が自然だと思います。
ただし、 await!
という表記は適切だとは思いません。 Swift では !
が Forced Unwrapping の他に、 try!
, as!
など一貫して「失敗しうる処理の失敗を無視する(失敗したらハンドリングせずクラッシュさせる)」という意味で使われているからです。あくまで、 try!
相当の強制的に Promise
を unwrap するような処理を考えると同期になるんじゃないかという話です。
throws
: try
: rethrows
= async
: await
: ?
次は rethrows
です。これについては Proposal でも簡単に触れられています。
rethrows
が付与されている高階関数は、渡されたクロージャが throws
だった場合に自分自身も throws
になります。
array.map { foo($0) } // `map` は `throws` でない
try array.map { try bar($0) } // `map` も `throws` になる
同じように、渡されたクロージャが async
だった場合に自身も async
になる reasync
のようなものも考えられます。
array.map { foo($0) } // `map` は `async` でない
await array.map { await bar($0) } // `map` も `async` になる
Generator
ここまで非同期と言い続けてきましたが、 async/await
はコルーチンを実現するための仕組みであって非同期に限ったものではありません。
非同期に限らない例として、いくつかの言語にある Generator と呼ばれる仕組みを紹介します。たとえば、↓は Python の例です。
# Python
def foo():
yield 2
yield 3
yield 5
g = foo()
print(next(g)) # 2
print(next(g)) # 3
print(next(g)) # 5
この Generator
は、専用の言語機能を持たなくても async/await
で実現することができます。たとえば、 Swift に async/await
があったとして、↓のような Generator
型を作ることができます。
let g = Generator { yield in
await yield(2)
await yield(3)
await yield(5)
}
print(g.next()!) // 2
print(g.next()!) // 3
print(g.next()!) // 5
Generator
の実装例はここにあります。
別々の言語機能として持つのではなく、 async/await
だけで両方実現できるのは本質を表していて美しいように思います。
まとめ
-
throws
:try
:Result
=async
:await
:Promise
- 美しいは正義