Swift で Concurrency (async, await) と伝統的な Closure の両方に対応するメソッドを実装する


https://github.com/mironal/TwitterAPIKit のリクエストを行うそれぞれのメソッドはレスポンスの結果を Concurrency (async, await)形式と Closure 形式のどちらでも取得できるようにしています。

何も考えずに実装するとコードの量が増えて大変なので、そのあたりを簡単にするためにどのように実装したかについて説明したいと思います。

まず最初に Concurrency なコードと Closure なコードの違いを説明します。

Swift の Concurrency は以下のようなコードです。

class HogeClass {
    func asyncFunction() async -> Int {
        return 0
    }
}

let hoge = HogeClass()
let value = await hoge.asyncFunction()

伝統的な Closure を使ったコードだと以下のようなコードになると思います(こちらはみんなが見覚えのある方)。

class HogeClass {
    func asyncFunction(_ block: (Int) -> Void) async -> Int {
        block(0)
    }
}

let hoge = HogeClass()
hoge.asyncFunction { hoge in 
}

両方に対応した実装

課題

さてここで、 Concurrency と Closure の両方に対応していく場合はどうしたらいいでしょうか?

単純に考えると以下のように2つずつ実装していくことになると思います。

class HogeClass {
     func asyncFunction() async -> Int {
        return 0
    }
    func asyncFunction(_ block: (Int) -> Void) async -> Int {
        block(0)
    }
}

この場合、メソッドの数が増えてば増えるほど非常に実装が面倒になっていきます。

100個メソッドがあったら Concurrency の方と Closure の方をそれぞれ実装して200個の API を実装する必要があります。しかもちょっと違うだけのコードをたくさん書かなくてはいけないので非常に退屈です。

解決策

色々探して https://github.com/Alamofire/Alamofire がいい感じに対応していたので参考にしました。

この問題は実際の値を格納したコンテナを返し、そのコンテナが Concurrency と Closure の両方に対応してあげることで解決できます。

ここで言ってるコンテナとは ResultOption のように実際の値を格納している型のことです。

細かい部分は省略しますが、以下のように実装できます。

// 例えば以下のようなコードをコンテナとして実装
struct AsyncResult<T> {
   // Concurrency 対応
   var result: T {
	get async {
	    return await withCheckedContinuation { c in
	            result { value in
		        c.resume(returning: value)
	            }
	    }
	}
   }
   // Closure 対応
   func result(_ block: (T) -> Void) {
       // ここは工夫して実装する必要があります
       block(0)
   }
}

// 先の HogeClass は以下のように実装できます。
// メソッドの実装は一つですみます。
class HogeClass {
    func asyncFunction() -> AsyncResult<Int> {
	return AsyncResult()
    }
}

// 使用するときは以下のように Concurrent 方式でも Closure 方式でも結果を取得できます。

let hoge = HogeClass()

let value = await hoge.asyncFunction().result

hoge.asyncFunction.result { value in }

これにより、100個メソッドがあっても100個実装するだけで大丈夫になります。
実際には AsyncResult 周りのコードを書く必要があるので 110個程度の実装量でしょうか?

追加情報

この方法のもう一つのいいところは Concurrent の方を extension で表現できるところです。

実際に Concurrent 対応するとなると

  • #if compiler(>=5.5.2) && canImport(_Concurrency)
  • @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)

などのコードを追加する必要があってやや煩雑になるので、 extension に切り分けてしまうとそのあたりが簡単にできます.

// AsyncResult.swift
struct AsyncResult<T> {
   // Closure 対応
   func result(_ block: (T) -> Void) {
       block(0)
   }
}

// AsyncResult+Concurrency.swift

#if compiler(>=5.5.2) && canImport(_Concurrency)

@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
extension AsyncResult {
    var result: T {
	get async {
	    return await withCheckedContinuation { c in
	            result { value in
		        c.resume(returning: value)
	            }
	    }
	}
   }
}
#endif

更に細かい実装例

実際のコード例を幾つか貼っておきます(やや煩雑です)。

https://github.com/mironal/TwitterAPIKit/blob/main/Sources/TwitterAPIKit/Extensions/Concurrency.swift

https://github.com/mironal/TwitterAPIKit/blob/main/Sources/TwitterAPIKit/SessionTask/TwitterAPISessionDelegatedJSONTask.swift

まとめ

Concurrent 方式でも Closure 方式の両方に対応したメソッドの作り方を紹介しました。

しかし、この方法は戻り値を格納したコンテナのコードを書く必要がある場面も出てきます。

なので 似たコードを2回書く手間 と コンテナのコードを書く手間 のコストをよく比較検討するといいと思います。

私が作っている TwitterAPIKit も最初は Concurrent 方式でも Closure 方式の両方を書いていこうかと思っていましたが、 Twitter API は200個程度あって非常に面倒だなと思ったので今回紹介した方法を取りました。

これからしばらくは Swift Concurrency への移行期間となる可能性があり Closure 形式と混在する必要がある場面があると思いますので参考になれば幸いです。