Add sleep(for:) to Clock
01 何が問題だったのか
Swift 5.7 で導入された Clock プロトコル(SE-0329)は、指定した instant まで待つ sleep(until:tolerance:) を提供していました。しかし duration を指定して「この長さだけ待つ」ための API は用意されておらず、Task の sleep(for:) と比べて API に不揃いがありました。
この不揃いは、単に書き味の問題にとどまりません。any Clock<Duration> のような existential を使おうとすると致命的な問題になります。Clock は Duration だけを primary associated type として公開しており、Instant associated type は existential では完全に消去されます。そのため existential の clock では now を取り出して advanced(by:) することができず、sleep(until:) を呼び出す手段もありません。結果として、existential に持たせた clock では実質的に何もできない状態でした。
これが問題になるのは、本番では ContinuousClock を、テストやプレビューでは時間を手動で進められる制御可能な clock を使い分けたい、という依存注入のパターンです。たとえば「画面表示から 5 秒後にウェルカムメッセージを出す」機能を持つモデルを考えます。
class FeatureModel: ObservableObject {
@Published var message: String?
func onAppear() async {
do {
try await Task.sleep(until: .now.advanced(by: .seconds(5)))
self.message = "Welcome!"
} catch {}
}
}
このままでは、テストで onAppear の挙動を検証するのに実時間で 5 秒待たされますし、Xcode のプレビューでも同じ待ち時間が発生してスタイルの調整がしにくくなります。
これを避けるには clock を外から注入したいところですが、existential では sleep(until:) が呼べないため次のコードはコンパイルできません。
class FeatureModel: ObservableObject {
@Published var message: String?
let clock: any Clock<Duration>
func onAppear() async {
do {
try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(5))) // エラー
self.message = "Welcome!"
} catch {}
}
}
同じ理由で Task.sleep(until:clock:) に existential を渡すこともできません。仕方なくジェネリックパラメータ C: Clock<Duration> を導入すると、FeatureModel を扱うあらゆるコードにジェネリックが伝播してしまい、内部実装の詳細であるはずの clock の選択が外部に漏れ出してしまいます。
02 どのように解決されるのか
Clock プロトコルの extension メソッドとして sleep(for:tolerance:) を追加します。中身は単に self.now.advanced(by: duration) を計算して sleep(until:tolerance:) に委譲するだけの薄いラッパーです。
extension Clock {
/// Suspends for the given duration.
public func sleep(
for duration: Duration,
tolerance: Duration? = nil
) async throws {
try await self.sleep(until: self.now.advanced(by: duration), tolerance: tolerance)
}
}
このメソッドは Instant 型を露出しないため、any Clock<Duration> のような existential からも呼び出せます。これにより、先ほどの依存注入のパターンをジェネリックなしで自然に書けるようになります。
class FeatureModel: ObservableObject {
@Published var message: String?
let clock: any Clock<Duration>
func onAppear() async {
do {
try await self.clock.sleep(for: .seconds(5))
self.message = "Welcome!"
} catch {}
}
}
本番では ContinuousClock() を渡し、テストやプレビューでは時間を手動で進められる自前の clock を渡す、という使い分けがそのまま成立します。
なお、絶対的な instant が手元にあるときは依然として sleep(until:tolerance:) を使うほうが適切です。sleep(for:) は呼び出し時点の now を基準にするため、「ある固定の時刻まで待つ」意図を正確に表したい場合には向きません。
Task.sleep 側の整合
clock.sleep(for:) と Task.sleep(for:) の API 形状を揃えるため、Task.sleep(for:) にも任意の clock と tolerance を受け取れるオーバーロードが追加されます。clock のデフォルトは ContinuousClock() です。
extension Task where Success == Never, Failure == Never {
public static func sleep<C: Clock>(
for duration: C.Duration,
tolerance: C.Duration? = nil,
clock: C = ContinuousClock()
) async throws {
try await sleep(until: clock.now.advanced(by: duration), tolerance: tolerance, clock: clock)
}
}
あわせて Task.sleep(until:tolerance:clock:) の clock 引数にも ContinuousClock() のデフォルト値が与えられ、clock を省略して簡潔に書けるようになります。
// これまで通り clock を明示してもよい
try await Task.sleep(until: clock.now.advanced(by: .seconds(1)), clock: clock)
// clock を省略すると ContinuousClock が使われる
try await Task.sleep(until: .now.advanced(by: .seconds(1)))
いずれの API も、タスクがキャンセルされた場合は例外を投げて中断する点は従来と同じです。