[Swift] AsyncSequence を作る方法

     
用意に時間のかかる Sequence を処理する時に、AsyncSequence は便利です。この記事では、AsyncSequence の使い方ではなく、AsyncStream を使って、AsyncSequence を提供する方法を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.3
  • Xcode 13.3
  • iOS 15.4

AsyncSequence

Sequence おさらい

AsyncSequence の詳細に入る前に、Sequence をおさらいします。

例えば、データの複数要素(コレクション)を持つ data 変数が Sequence に準拠していると以下のようなコードを書くことができます。


let data = ["a", "b", "c"] // Array
for datum in data {
   // process datum
}

data が Sequence に準拠していると makeIterator メソッドで Iterator を作成することができます。
コンパイラは上記のコードを以下のように展開していると考えられます。


let iterator = data.makeIterator()
while let datum = iterator.next() {
  // process datum
}

このケースで使われるコレクションは、要素が既に存在していますが、必要に応じて要素を準備したいケースも考えられます。

例えば、ネットワーク経由でダウンロードするイメージの配列を使った処理の時に、ダウンロードできた分だけを 逐次 処理していきたい時もあります。

このような時にぴったりなのが、AsyncSequence です。

AsyncSequence

AsyncSequence は、以下のように使用されます。

data は、AsyncSequence に準拠していると想定します。


let data = ... // AsyncSequence
for await datum in data {
  // process datum
}

Sequence の時と同じように、上記のコードは以下のように展開されています。


let data = ... // AsyncSequence
let iterator = data.makeAsyncIterator()
while let datum = await next() {
  // process datum
}

next は、await 付きで呼び出されるため、要素を1つ進める毎に、suspend されることがあることを意味します。

時間がかかる処理が next 内で処理されているケースでは、suspension point にしておいた方が、スレッドが占有されることを防ぐことができます。

自分で AsyncSequence を作る

AsyncSequence は便利なのですが、Apple の Framework で積極的に使っている段階にはなっていないようです。

調べた限りでは、特定の Framework 例えば HealthKit で使用されていますが、EventKit では、使用されていません。

具体的には カレンダーに登録されているイベント(EKEvent)を取得するための API としては 以下のものしか用意されていません。


func enumerateEvents(matching predicate: NSPredicate, 
               using block: @escaping EKEventSearchCallback)

typealias EKEventSearchCallback = (EKEvent, UnsafeMutablePointer) -> Void

いわゆるコールバックを使った API です。

# EKEventSearchCallback の2つ目の引数は、検索を中止するかのフラグです。

AsyncSequence として使用するのにぴったりなので、条件にマッチする EKEvent を AsyncSequence として返すようにしてみます。

作成したい API は、以下とします。

public func events(start: Date, end: Date, calendars:[EKCalendar]?) -> AsyncSequence

enumerateEvent で引数に取る predicate を作成するのに必要な情報を渡すと、AsyncSequence を返すという API です。

タイミング問題

EventKit から EKEvent が渡されてくるのは、EventKit が決めるタイミングです。

AsyncIterator の next が呼ばれるのは、使う側のタイミングです。

両者が一致していることはありません。(偶然一致するかもしれませんが、あくまで偶然です)

一致しない時はどうすれば良いかを考察しておきます。

EventKit から EKEvent が渡されることを 「EKEvent 取得」とし、AsyncIterator の next が呼ばれることを 「EKEvent 消費」と呼ぶことにします。

EKEvent 取得 の直後に EKEvent 消費 がくるのであれば、EKEvent 取得 で渡された EKEvent を EKEvent 消費 で返せば OK です。EKEvent 1つをローカルに記憶できるようにしておけばよいでしょう。

ですが、上記のようなタイミングで処理が要求されることは まれ です。

EKEvent取得 が複数回起こった後に、EKEvent消費 が起こるという時に備えて、EKEvent取得 で渡された EKEvent は、すべて記憶しておかないといけません。EKEvent 消費 が起こった時に、その中の1つ(通常一番最初取得した EKEvent)を返せば良いと思いますが、残りは EKEvent消費 がさらに起こることを想定して継続して保持しておかないといけません。

逆に、EKEvent取得が起こる前に、EKEvent消費が起こった場合は、どうすればよいでしょうか?
記憶しておくべき EKEvent はありませんが、EKEvent 取得が起こるまで、EKEvent消費には待ってもらわないといけません。

このように、AsyncSequence を自分で作ろうとすると内部でバッファリングすることが必要になりますし、外部に await してもらうよう調整する必要があります。

自分で作るのもアリですが、ベースとして使うことのできる AsyncStream が用意されています。

AsyncStream で作る AsyncSequence

EventKit の EKEvent を取得して AsyncSequence にするメソッドも、AsyncStream を使って実装していきます。

AsyncSteam についての Apple のドキュメントは、こちら

実装

概念は複雑ですが、実装は非常に単純です。

AsyncStream の initializer に closure を渡します。この closure の中で処理が行われます。

この closure には、continuation が渡されますので、asyncIterator の next() で渡すべき要素が準備できたならば、continuation.yield を使って、要素を渡します。

EKEventStore から 条件にマッチする EKEvent を取得して、AsyncStream に変換する関数は以下のようになります。


    public func events(start: Date, end: Date, calendars:[EKCalendar]?) -> AsyncStream {
        let ekEventStore = ... // EKEventStore
        AsyncStream { continuation in
            let predicate = ekEventStore.predicateForEvents(withStart: start, end: end, calendars: calendars)
            ekEventStore.enumerateEvents(matching: predicate) { (event, stop) in
                continuation.yield(event)
            }
            confinuation.finish()
        }
    }

enumerateEvent の callback に渡される EKEvent が Sequence 要素として渡したいものですので、 continuation.yield に渡しています。
処理が終わった後には、.finish を呼び出して終了を伝えます。

まとめ

AsyncSequence を生成するには AsyncStream を使用すると簡単

AsyncSequence を生成するには AsyncStream を使用すると簡単
  • AsyncStream の initializer の closure で設定する
  • closure の中で continuation.yield を使って、Element を返す

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です