🤼

swift-concurrency-extrasのTask拡張について

2024/12/08に公開

TCA Advent Calendar 2024の記事です。

はじめに

TCAに使われているswift-concurrency-extrasのTaskの拡張について解説します。バージョンはv1.3です。

もし何か間違いがあったらコメントいただければ助かります。

Taskの拡張について

主に3つの拡張が用意されています。

  • Task.never()
  • var cancellableValue
  • Task.megaYield()

Task.never()

テストコードやViewのプレビュー用に使うモックのために使います。
これはテストコードやモック化で「実際の値は必要ないが、コンパイル上あるいはAPI上はasync関数として何らかの戻り値を期待されている」といった状況で活用できます。

具体的には

  • 環境依存処理(Dependency)をテストする際に、その戻り値をわざわざ生成する必要がない、あるいは生成したくない場合
    • そこでTask.never()を使って「依存関数を呼べるが永遠に応答しない」状態を擬似的に作り、テスト対象側がその状態でどのように振る舞うかを確認できる。
      • タイムアウトを検出する、キャンセルを行うなど

READMEから具体的なコード例

READMEで説明されているのは次のような関数型がある場合です。

struct SettingsClient {
  var fetchSettings: () async throws -> Settings
}

これをテスト用もしくはプレビュー用にDIする際に実装を次のようにすることが可能です。

SettingsClient(
  fetchSettings: {
    // ジェネリクスにより戻り値としてSettingsを返せるようにしていて、
    // コンパイルエラーにならない
    try await Task.never()
  }
)

具体的なコード例ではSettingsClientfetchSettingsメソッドがasync throws -> Settingsを返すことになっていますが、
テスト上はあえて何も返したくない場合に、try await Task.never()を返すことで、
その関数が永遠に応答しない挙動を模倣しています。

永遠に終了しない副作用を再現

TCAのコードではVoiceMemosTestsでプレイヤーの再生処理をテストするのに使われています。

https://github.com/pointfreeco/swift-composable-architecture/blob/1.17.0/Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift#L446

たしかに、再生という処理の開始時にすぐに停止してしまって困るテストがある場合、このような手法は有用そうです。

型を初期化しないメリット

関数が永遠に応答しないようにする理由の1つは、
先ほどの例でSettingsをわざわざ作りたくない場合も有用でしょう。
Settingsがどんなものかを適当に考えてみます。

SettingsClient(
  fetchSettings: {
    // returnする際にSettingsの初期化をすることを考える。
    // こちらでidやuserなどがあると想定。
    // ただ、このidやuserなどは直接使わず、
    // コンパイラを納得させるためだけに返しているなら無駄すぎる。
    Settings(
      id: ID(...)
      user: User(name: "user1", ...),
      pattern: Pattern(...),
      name: "name"
    )
  }
)

上記のように、Settings型をinitする際にStringなどは簡単に適当な値を作れますが、別の型を使わないといけない場合ははるかに面倒になります。

その型が自動で生成される仕組みを作ったりするのもまた別の複雑なデメリットを生んでしまうでしょう。なので型を生成しなくて問題ないのであれば型を作らなければいいだけなのでしょう。

実装

never()メソッドの実装は大きくわけて2パターンに分かれています。

  • TaskのSuccessがNeverでなくFailureがNever
      • 先述のSettingsClientなど値を返す場合
  • TaskのSuccessとFailureが両方Never
      • TCAのReducerのrun内で何も返さない場合

TaskのSuccessがNeverでなくFailureがNever

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/Task.swift#L34-L42

AsyncStream<Success>.neverを呼び出し、非同期でそれが所得できたら1つ目をreturnしています。

そして最後にキャンセルの例外をthrowしています。

AsyncStream<Success>.neverは無限に何も起こらないことで無限ループを実現しています。

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/AsyncStream.swift#L84-L86

そのためこのnever()は外部からキャンセルされたとき処理を抜け、最後に例外をthrowしているということでしょう。

TaskのSuccessとFailureが両方Never

両方がNeverのときはもっとシンプルです。SuccessがNeverのためループ内でも何もしません。

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/Task.swift#L44-L50

cancellableValue

Task.cancellableValue is a property that awaits the unstructured task's value property while propagating cancellation from the current async context.

意味がわからないので今回は解説しません。直接.valueする場合との違いがわかりませんでした。

実装

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/Task.swift#L44-L50

withTaskCancellationHandlerself.cancel()しているのは、
そうしないとawaitしている側にキャンセルが伝わらない?

実験してみたところ、これがなくても外部からTaskがキャンセルされていればawait側でcatchする際にエラーが伝わっているし、
Task内部でもTask.isCancelはtrueになるので内部でもキャンセルが伝播しています。
よくわからないというのが本音です。

Task.megaYield

Task.megaYieldは不安定なテストを安定させるために何度もyieldを呼び出すことで、テスト対象やモックされた副作用を実行できる可能性を高めるためのメソッドのようです。

Task.yieldはそもそも呼び出した際に他のTaskの実行を促します。ですが確実性がないためにこのメソッドがあるようです。

関連する記事は別に書いてあります。

https://zenn.dev/yimajo/articles/b1ddb17ba56370

実装

複数回Task.detachedをawaitしています。
繰返しの回数として、引数で指定しない場合環境変数を使い、環境変数がないとデフォルトで20回繰り返します。

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/Task.swift#L44-L50

おわりに

TCAではTask.never()は副作用のモック化のほか、Reducerのbodyに仕込みキャンセルのテストに使われ、Task.megaYieldやcancellableValueはテスト自体に使われています。

Discussion