LoginSignup
251
163

More than 3 years have passed since last update.

Proposalには載っていないSwift 5のasync/awaitが素晴らしいと思う理論的背景

Last updated at Posted at 2017-10-24
追記 (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" というタイトルで驚きのメールが流れました。

async-await-actors.png

これは Swift における並行処理関連の提案で、主に async/awaitactor についてのものです。本投稿では、そのうち async/await に着目します。 async/await といえば C# や JavaScript が思い浮かびますが、 Swift の async/await は Swift にフィットするように独自の変更が加えられています。

( JavaScript の Promiseasync/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 のメールが投稿されました。中身を確認してみると自分が考えていたのとほぼ同じ内容だったのでびっくりというわけです。

native-check.png

(原型をとどめないネイティブチェックの図)

しかし、 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 の頃には Optionalnil でエラーを表すことが一般的でした。しかし、 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 メソッドは mapflatMap をごちゃ混ぜにしたものだと気付きました。モナドというのは、 ArrayOptional のように mapflatMap を持っている型のことです。そこで、僕は 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 乗するコードは次のようになります。 ArrayOptional とまったく同じなのがわかると思います。

let number: Promise<Int> = downloadInt(from: url)
let square: Promise<Int> = number.map { $0 * $0 }

この場合、 numberget して将来得られる値が 3 だとすると、 squareget して将来得られる値は 9 となります。

このようにして、 Promisemap メソッドを使えば 今はまだ手に入っていない将来の値に対して操作を加えることができます

flatMap

Promise にとってより重要なのが flatMap です。

PromiseflatMap を考える前に、まず OptionalflatMap (API Reference) についておさらいしましょう。

たとえば、 [String: String] から値を取り出し、得られた String を整数に変換したいとします。 Web アプリで GET パラメータを処理する場合などにありがちです。この場合、 parameters["id"] 等で得られる値は String ではなく String? です。また、 StringInt に変換する処理も、パースに失敗すると 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 になり、両方に成功した場合には Intid が得られることです。つまり、結果としてほしい値の型は 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) }

OptionalflatMap で結合すると両方に成功した場合だけ値が得られました。 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)!
  }
}

これがわかりやすいかどうかは人によると思います。ネストが深くてもコールバックの方が読みやすいという人も当然いると思います。

それでは、なぜこんなにも延々と PromiseflatMap の話をしているのかと言うと、 asyncPromisereturn することに、 awaitflatMap に対応するからです。

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 しても同じ値が得られます。 getmap, flatMap に渡した関数がいつ呼ばれるかがわからないだけで、得られる値は常に一つです。状態変化を扱うのにそれを内部に隠蔽し、利用者はイミュータブルなコードを書けるというのが Promise のおもしろいところです。

さらに余談ですが、この Promise の実装はここ にあります。実質的にはわずか 38 行のコードです。これは、僕がこれまでに Swift で書いた最もキレイなコードの一つだと思うので、良かったら見てみて下さい。

throws/try

さて、 Swift 1 から少し時代が進んで、 2015 年の夏に Swift 2.0 がリリースされました。 Swift 2.0 で最も注目すべきだったのは何と言っても throws/try です。なぜここで突然 throws/try の話を始めたかと言うと、 throws/tryasync/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 関数が失敗する可能性を無視してきましたが、現実的にはダウンロードは失敗し得る処理です。しかし、 downloadthrows を付けることはできません。

// コールバック(これではダメ⛔)
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 を付与するのと Resultreturn するのが対応していることに注目して下さい。

// throws/try
func loadFoo() throws -> Foo { ... }

// Result
func loadFoo() -> Result<Foo> { ... }

Resultenum なので、↓のようにしてエラー処理することができます。

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 もモナドなので、 OptionalPromise と同じく mapflatMap を持ちます。

たとえば、 map を使って Result の中身を 2 乗するコードは↓のようになります。 OptionalPromise のときとまったく同じですね。

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/tryResult

先程、関数に throws を付与するのと Resultreturn するのは同じだと言いました。

flatMap にも対応するものがあります。 throws/tryResult で同じ処理が書かれた次のコードを見比べてみて下さい。

// 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` を使う処理
}

どちらのコードも loadDatadecodeUser に成功した場合だけ user が得られ、失敗した場合にはその Error が得られます。一番重要なのは、 flatMap でモナドの中身を取り出して処理しているのと同じように、 try もエラーの場合を剥がして中身を取り出す処理になっていることです。 try して代入するのは本質的に flatMap と同じことなのです。

小まとめ

  • throws を付与 == Resultreturn
  • 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 の発表の準備をしていました。

Resultthrows/try と違って非同期処理と一緒に使いやすいです。一方で flatMap をチェーンするコードより try して代入を繰り返すコードの方がわかりやすいです。両者のいいとこどりができたら最高です。なので、 throws -> FooResult<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 に対しても同じようなものが考えられないかと思いました。なにせ、 ResultPromise も同じモナドです。 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 { ... }

つまり、↓の二つを同じ意味にしてしまうわけです。 throwsResult の関係とまったく同じです。

// async
func download(from url: URL) async -> Data { ... }

// Promise
func download(from url: URL) -> Promise<Data> { ... }

throwsasync が対応するなら try に対応するものも必要です。これも await と考えればぴったりです。 throws/tryResult のときとまったく同じです。

// 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 という関係が成り立ちます。なんて美しい関係でしょう。

非同期エラー処理

PromiseResult が組み合わせられるように、 throws/tryasync/await も組み合わせられます。非同期かつ失敗し得る download 関数は次のように書けます。

// async/await + throws/try
func download(from url: URL) async throws -> Data { ... }

// Promise + Result
func download(from url: URL) -> Promise<Result<Data>> { ... }

これらを使うときのコードは↓です。一つ目の flatMapawait に、二つ目の flatMaptry に対応しています。

// 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` を使う処理
} }

モナド地獄

そんなわけで僕は throwsResult を、 asyncPromise を返すことのシュガーにしたらいいんじゃないかということを try! Swift で話しました。このときに Ray Fix さんに興味を持っていただき「もし Proposal を出すなら英文チェックするよ。」と申し出て下さったことから、冒頭に書いたネイティブチェックにつながりました。

try! Swift が終わって、 throwsResult のシュガーにする部分だけを切り出して 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/awaitthrows/try であれば async throws -> Foo とすれば良いだけです。入れ子のパズルに苦しめられることもありません。これの意味するところは、 throws/tryasync/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/awaitPromise ( C# では Task )の上に構築されているので、 Promise のない async/await は挑戦的です。しかし、 throws/tryResult なしで実現できているのだから async/await にもできそうなものです。

そんなことを SwiftJP Slack などで話してた んですが、 swift-evolution で非同期処理の話が始まらないので提案せずに放置していました。しかし、今年の GW 頃にやっぱり Swift に async/await を導入するならこれしかない!と思い立ってまとめたのが冒頭で挙げたこちらです。

beginAsyncsuspendAsync

単に Promise を返す代わりに async と書き、 flatMap の代わりに await するのではいけません。 async/await から Promise を取り除くに当たっては、 Promisegetinit に相当するものも提供しなければなりません。それが beginAsyncsuspendAsync です(僕の提案中では nonblockasyncronize です。さすがに名称は異なってますが、同じ道具が必要になってるのがおもしろいです)。

beginAsyncsuspendAsync のシグネチャは↓です。

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 の中では beginAsyncbodythrows が付けられており、 beginAsync 自体も rethrows になっています。

func beginAsync(_ body: () async throws -> Void) rethrows -> Void

僕はこの throwsrethrows には反対です。 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 {
  // エラー処理
}

もし beginAsyncbodythrows が付いてなければ、 body の中でエラー処理が強制されるので、 await してから try のようなケースも安全に対処できます。

beginAsync {
  do {
    await bar()
    try foo()
  } catch { // これがないとコンパイルエラー
    // エラー処理
  }
}

せっかくコンパイル時にチェック可能なエラー処理機構を持っているのに、 beginAsync に渡すクロージャの中ではそれが台無しになってしまいます。

この点については一度 swift-evolution に書いたのですがうまく伝わってなさそうなので、今度もう一度投稿したいと考えています。

小まとめ

長々と書いてきましたが、 Proposal の中には出てこない Promise モナドを考えて、 Resultthrows/try との関係を考えると、 Swift にフィットする async/await はこれしかないという気持ちになります。

その根拠は関係が「美しい」ことだけかもしれませんが、僕は経験則的に美しさは重要であると考えています。 関係や構造が美しく整理できるときは物事の本質を突いていることが多いです。

逆にそれを外すと、後で思いもよらないところで問題が発生して困ったりします。

たとえば、今回の async/await の Proposal にも、非同期処理はほぼ確実にエラーを伴うんだから asyncawait だけ書けば throwstry も付いていることにしてはどうかという意見もあります。しかし、この一見筋が通ったように見える意見には問題があります。たとえば、後で紹介する Generator を async/await で実現しようとすると throws/try であってほしくありません。

人はすべてを見通せるわけではないので一見問題がなさそうに見えても何かを見逃している可能性があります。できるだけ「美しい」方を選択することで、将来的なトラブルの可能性を低減できるんじゃないでしょうか。

throws : try : △ = async : await : □

throws/tryasync/await の関係を考えると Proposal に書かれていないいくつかの興味深いことが見えてきます。

throws : try : catch = async : await : ?

throws/trydo/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 によって非同期処理が同期化されなければなりません。つまり、 xxxbar() の非同期処理が終わって結果が得られるまで待つという意味になるはずです。

「待つ」なので xxxwait という単語を当てれば↓のようになります(他にも、 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! に当たるものを考えてみましょう。

throwsResult を返すことに対応することを考えると、 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
  • 美しいは正義
251
163
4

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
251
163