|
| 1 | +# Generalize effect polymorphism for `AsyncSequence` and `AsyncIteratorProtocol` |
| 2 | + |
| 3 | +* Proposal: [SE-0421](0421-generalize-async-sequence.md) |
| 4 | +* Authors: [Doug Gregor](https://github.com/douggregor), [Holly Borla](https://github.com/hborla) |
| 5 | +* Review Manager: [Freddy Kellison-Linn](https://github.com/Jumhyn) |
| 6 | +* Status: **Review scheduled (January 26...Febuary 7, 2024)** |
| 7 | +* Implementation: https://github.com/apple/swift/pull/70635 |
| 8 | +* Review: ([pitch](https://forums.swift.org/t/pitch-generalize-asyncsequence-and-asynciteratorprotocol/69283)) |
| 9 | + |
| 10 | +## Introduction |
| 11 | + |
| 12 | +This proposal generalizes `AsyncSequence` in two ways: |
| 13 | +1. Proper `throws` polymorphism is accomplished with adoption of typed throws. |
| 14 | +2. A new overload of the `next` requirement on `AsyncIteratorProtocol` includes an isolated parameter to abstract over actor isolation. |
| 15 | + |
| 16 | +## Table of Contents |
| 17 | + |
| 18 | +* [Introduction](#introduction) |
| 19 | +* [Motivation](#motivation) |
| 20 | +* [Proposed solution](#proposed-solution) |
| 21 | +* [Detailed design](#detailed-design) |
| 22 | + + [Adopting typed throws](#adopting-typed-throws) |
| 23 | + - [Error type inference from `for try await` loops](#error-type-inference-from-for-try-await-loops) |
| 24 | + + [Adopting primary associated types](#adopting-primary-associated-types) |
| 25 | + + [Adopting isolated parameters](#adopting-isolated-parameters) |
| 26 | + + [Default implementations of `next()` and `next(isolation:)`](#default-implementations-of-next-and-next-isolation) |
| 27 | + + [Associated type inference for `AsyncIteratorProtocol` conformances](#associated-type-inference-for-asynciteratorprotocol-conformances) |
| 28 | + + [`rethrows` checking](#rethrows-checking) |
| 29 | +* [Source compatibility](#source-compatibility) |
| 30 | +* [ABI compatibility](#abi-compatibility) |
| 31 | +* [Implications on adoption](#implications-on-adoption) |
| 32 | +* [Future directions](#future-directions) |
| 33 | + + [Add a default argument to `next(isolation:)`](#add-a-default-argument-to-next-isolation) |
| 34 | +* [Alternatives considered](#alternatives-considered) |
| 35 | + + [Avoiding an existential parameter in `next(isolation:)`](#avoiding-an-existential-parameter-in-next-isolation) |
| 36 | +* [Acknowledgments](#acknowledgments) |
| 37 | + |
| 38 | +## Motivation |
| 39 | + |
| 40 | +`AsyncSequence` and `AsyncIteratorProtocol` were intended to be polymorphic over the `throws` effect and actor isolation. However, the current API design has serious limitations that impact expressivity in generic code, `Sendable` checking, and runtime performance. |
| 41 | + |
| 42 | +Some `AsyncSequence`s can throw during iteration, and others never throw. To enable callers to only require `try` when the given sequence can throw, `AsyncSequence` and `AsyncIteratorProtocol` used an experimental feature to try to capture the throwing behavior of a protocol. However, this approach was insufficiently general, which has also [prevented `AsyncSequence` from adopting primary associated types](https://forums.swift.org/t/se-0346-lightweight-same-type-requirements-for-primary-associated-types/55869/70). Primary associated types on `AsyncSequence` would enable hiding concrete implementation details behind constrained opaque or existential types, such as in transformation APIs on `AsyncSequence`: |
| 43 | + |
| 44 | +```swift |
| 45 | +extension AsyncSequence { |
| 46 | + // 'AsyncThrowingMapSequence' is an implementation detail hidden from callers. |
| 47 | + public func map<Transformed>( |
| 48 | + _ transform: @Sendable @escaping (Element) async throws -> Transformed |
| 49 | + ) -> some AsyncSequence<Transformed, any Error> { ... } |
| 50 | +} |
| 51 | +``` |
| 52 | + |
| 53 | +Additionally, `AsyncSequence` types are designed to work with `Sendable` and non-`Sendable` element types, but it's currently impossible to use an `AsyncSequence` with non-`Sendable` elements in an actor-isolated context: |
| 54 | + |
| 55 | +```swift |
| 56 | +class NotSendable { ... } |
| 57 | + |
| 58 | +@MainActor |
| 59 | +func iterate(over stream: AsyncStream<NotSendable>) { |
| 60 | + for await element in stream { // warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary |
| 61 | + |
| 62 | + } |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +Because `AsyncIteratorProtocol.next()` is `nonisolated async`, it always runs on the generic executor, so calling it from an actor-isolated context crosses an isolation boundary. If the result is non-`Sendable`, the call is invalid under strict concurrency checking. |
| 67 | + |
| 68 | +More fundamentally, calls to `AsyncIteratorProtocol.next()` from an actor-isolated context are nearly always invalid in practice today. Most concrete `AsyncIteratorProtocol` types are not `Sendable`; concurrent iteration using `AsyncIteratorProtocol` is a programmer error, and the iterator is intended to be used/mutated from the isolation domain that formed it. However, when an iterator is formed in an actor-isolated context and `next()` is called, the non-`Sendable` iterator is passed across isolation boundaries, resulting in a diagnostic under strict concurrency checking. |
| 69 | + |
| 70 | +Finally, `next()` always running on the generic executor is the source of unnecessary hops between an actor and the generic executor. |
| 71 | + |
| 72 | +## Proposed solution |
| 73 | + |
| 74 | +This proposal introduces a new associated type `Failure` to `AsyncSequence` and `AsyncIteratorProtocol`, adopts both `Element` and `Failure` as primary associated types, adds a new protocol requirement to `AsyncIteratorProtocol` that generalizes the existing `next()` requirement by throwing the `Failure` type, and adds an `isolated` parameter to the new requirement to abstract over actor isolation: |
| 75 | + |
| 76 | +```swift |
| 77 | +@available(SwiftStdlib 5.1, *) |
| 78 | +protocol AsyncIteratorProtocol<Element, Failure> { |
| 79 | + associatedtype Element |
| 80 | + |
| 81 | + mutating func next() async throws -> Element? |
| 82 | + |
| 83 | + @available(SwiftStdlib 5.11, *) |
| 84 | + associatedtype Failure: Error = any Error |
| 85 | + |
| 86 | + @available(SwiftStdlib 5.11, *) |
| 87 | + mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? |
| 88 | +} |
| 89 | + |
| 90 | +@available(SwiftStdlib 5.1, *) |
| 91 | +public protocol AsyncSequence<Element, Failure> { |
| 92 | + associatedtype AsyncIterator: AsyncIteratorProtocol |
| 93 | + associatedtype Element where AsyncIterator.Element == Element |
| 94 | + |
| 95 | + @available(SwiftStdlib 5.11, *) |
| 96 | + associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure |
| 97 | + |
| 98 | + func makeAsyncIterator() -> AsyncIterator |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +The new `next(isolation:)` has a default implementation so that conformances will continue to behave as they do today. Code generation for `for-in` loops will switch over to calling `next(isolation:)` instead of `next()` when the context has appropriate availability. |
| 103 | + |
| 104 | +## Detailed design |
| 105 | + |
| 106 | +### Adopting typed throws |
| 107 | + |
| 108 | +Concrete `AsyncSequence` and `AsyncIteratorProtocol` types determine whether calling `next()` can `throw`. This can be described in each protocol with a `Failure` associated type that is thrown by the `AsyncIteratorProtcol.next(isolation:)` requirement. Describing the thrown error with an associated type allows conformances to fulfill the requirement with a type parameter, which means that libraries do not need to expose separate throwing and non-throwing concrete types that otherwise have the same async iteration functionality. |
| 109 | + |
| 110 | +#### Error type inference from `for try await` loops |
| 111 | + |
| 112 | +The `Failure` associated type is only accessible at runtime in the Swift 5.11 standard library; code running against older standard library versions does not include the `Failure` requirement in the witness tables for `AsyncSequence` and `AsyncIteratorProtocol` conformances. This impacts error type inference from `for try await` loops. |
| 113 | + |
| 114 | +When the thrown error type of an `AsyncIteratorProtocol` is available, either through the associated type witness (because the context has appropriate availability) or because the iterator type is concrete, iteration over an async sequence throws its `Failure` type: |
| 115 | + |
| 116 | +```swift |
| 117 | +struct MyAsyncIterator: AsyncIteratorProtocol { |
| 118 | + typealias Failure = MyError |
| 119 | + ... |
| 120 | +} |
| 121 | + |
| 122 | +func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator { |
| 123 | + let closure = { |
| 124 | + for try await element in s { |
| 125 | + print(element) |
| 126 | + } |
| 127 | + } |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +In the above code, the type of `closure` is `() async throws(MyError) -> Void`. |
| 132 | + |
| 133 | +When the thrown error type of an `AsyncIteratorProtocol` is not available, iteration over an async sequence throws `any Error`: |
| 134 | + |
| 135 | +```swift |
| 136 | +@available(SwiftStdlib 5.1, *) |
| 137 | +func iterate(over s: some AsyncSequence) { |
| 138 | + let closure = { |
| 139 | + for try await element in s { |
| 140 | + print(element) |
| 141 | + } |
| 142 | + } |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +In the above code, the type of `closure` is `() async throws(any Error) -> Void`. |
| 147 | + |
| 148 | +When the `Failure` type of the given async sequence is constrained to `Never`, `try` is not required in the `for-in` loop: |
| 149 | + |
| 150 | +```swift |
| 151 | +struct MyAsyncIterator: AsyncIteratorProtocol { |
| 152 | + typealias Failure = Never |
| 153 | + ... |
| 154 | +} |
| 155 | + |
| 156 | +func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator { |
| 157 | + let closure = { |
| 158 | + for await element in s { |
| 159 | + print(element) |
| 160 | + } |
| 161 | + } |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +In the above code, the type of `closure` is `() async -> Void`. |
| 166 | + |
| 167 | +### Adopting primary associated types |
| 168 | + |
| 169 | +The `Element` and `Failure` associated types are promoted to primary associated types. This enables using constrained existential and opaque `AsyncSequence` and `AsyncIteratorProtocol` types, e.g. `some AsyncSequence<Element, Never>` or `any AsyncSequence<Element, any Error>`. |
| 170 | + |
| 171 | +### Adopting isolated parameters |
| 172 | + |
| 173 | +The `next(isolation:)` requirement abstracts over actor isolation using [isolated parameters](/proposals/0313-actor-isolation-control.md). For callers to `next(isolation:)` that pass an iterator value that cannot be transferred across isolation boundaries under [SE-0414: Region based isolation](/proposals/0414-region-based-isolation.md), the call is only valid if it does not cross an isolation boundary. Explicit callers can pass in a value of `#isolation` to use the isolation of the caller, or `nil` to evaluate `next(isolation:)` on the generic executor. |
| 174 | + |
| 175 | +Desugared async `for-in` loops will call `AsyncIteratorProtocol.next(isolation:)` instead of `next()` when the context has appropriate availability, and pass in an isolated argument value of `#isolation` of type `(any Actor)?`. The `#isolation` macro always expands to the isolation of the caller so that the call does not cross an isolation boundary. |
| 176 | + |
| 177 | +### Default implementations of `next()` and `next(isolation:)` |
| 178 | + |
| 179 | +Because existing `AsyncIteratorProtocol`-conforming types only implement `next()`, the standard library provides a default implementation of `next(isolation:)`: |
| 180 | + |
| 181 | +```swift |
| 182 | +extension AsyncIteratorProtocol { |
| 183 | + /// Default implementation of `next(isolation:)` in terms of `next()`, which is |
| 184 | + /// required to maintain backward compatibility with existing async iterators. |
| 185 | + @available(SwiftStdlib 5.11, *) |
| 186 | + @available(*, deprecated, message: "Provide an implementation of 'next(isolation:)'") |
| 187 | + public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? { |
| 188 | + nonisolated(unsafe) var unsafeIterator = self |
| 189 | + do { |
| 190 | + let element = try await unsafeIterator.next() |
| 191 | + self = unsafeIterator |
| 192 | + return element |
| 193 | + } catch { |
| 194 | + throw error as! Failure |
| 195 | + } |
| 196 | + } |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +Note that the default implementation of `next(isolation:)` necessarily violates `Sendable` checking in order to pass `self` from a possibly-isolated context to a `nonisolated` one. Though this is generally unsafe, this is how calls to `next()` behave today, so existing conformances will maintain the behavior they already have. Implementing `next(isolation:)` directly will eliminate the unsafety. |
| 201 | + |
| 202 | +To enable conformances of `AsyncIteratorProtocol` to only implement `next(isolation:)`, a default implementation is also provided for `next()`: |
| 203 | + |
| 204 | +```swift |
| 205 | +extension AsyncIteratorProtocol { |
| 206 | + @available(SwiftStdlib 5.11, *) |
| 207 | + public mutating func next() async throws -> Element? { |
| 208 | + // Callers to `next()` will always run `next(isolation:)` on the generic executor. |
| 209 | + try await next(isolation: nil) |
| 210 | + } |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +Both function requirements of `AsyncIteratorProtocol` have default implementations that are written in terms of each other, meaning that it is a programmer error to implement neither of them. Types that are available prior to the Swift 5.11 standard library must provide an implementation of `next()`, because the default implementation is only available with the Swift 5.11 standard library. |
| 215 | + |
| 216 | +To avoid silently allowing conformances that implement neither requirement, and to facilitate the transition of conformances from `next()` to `next(isolation:)`, we add a new availability rule where the witness checker diagnoses a protocol conformance that uses an deprecated, obsoleted, or unavailable default witness implementation. Deprecated implementations will produce a warning, while obsoleted and unavailable implementations will produce an error. |
| 217 | + |
| 218 | +Because the default implementation of `next(isolation:)` is deprecated, conformances that do not provide a direct implementation will produce a warning. This is desirable because the default implementation of `next(isolation:)` violates `Sendable` checking, so while it's necessary for source compatibilty, it's important to aggressively suggest that conforming types implement the new method. |
| 219 | + |
| 220 | +### Associated type inference for `AsyncIteratorProtocol` conformances |
| 221 | + |
| 222 | +When an `AsyncIteratorProtocol`-conforming type provides a `next(isolation:)` function, the `Failure` type is inferred based on whether (and what) `next(isolation:)` throws using the rules described in [SE-0413](/proposals/0413-typed-throws.md). |
| 223 | + |
| 224 | +If the `AsyncIteratorProtocol`-conforming type uses the default implementation of `next(isolation:)`, then the `Failure` associated type is inferred from the `next` function instead. Whatever type is thrown from the `next` function (including `Never` if it is non-throwing) is inferred as the `Failure` type. |
| 225 | + |
| 226 | +## Source compatibility |
| 227 | + |
| 228 | +The new requirements to `AsyncSequence` and `AsyncIteratorProtocol` are additive, with default implementations and `Failure` associated type inference heuristics that ensure that existing types that conform to these protocols will continue to work. |
| 229 | + |
| 230 | +The experimental "rethrowing conformances" feature used by `AsyncSequence` and `AsyncIteratorProtocol` presents some challenges for source compatibility. Namely, one can declare a `rethrows` function that considers conformance to these rethrowing protocols as sources of errors for rethrowing. For example, the following `rethrows` function is currently valid: |
| 231 | + |
| 232 | +```swift |
| 233 | +extension AsyncSequence { |
| 234 | + func contains(_ value: Element) rethrows -> Bool where Element: Hashable { ... } |
| 235 | +} |
| 236 | +``` |
| 237 | + |
| 238 | +With the removal of the experimental "rethrowing conformances" feature, this function becomes ill-formed because there is no closure argument that can throw. To preserve source compatibility for such functions, this proposal introduces a specific rule that allows requirements on `AsyncSequence` and `AsyncIteratorProtocol` to be involved in `rethrows` checking: a `rethrows` function is considered to be able to throw `T.Failure` for every `T: AsyncSequence` or `T: AsyncIteratorProtocol` conformance requirement. In the case of this `contains` operation, that means it can throw `Self.Failure`. The rule permitting the definition of these `rethrows` functions will only be permitted prior to Swift 6. |
| 239 | + |
| 240 | +## ABI compatibility |
| 241 | + |
| 242 | +This proposal is purely an extension of the ABI of the standard library and does not change any existing features. Note that the addition of a new `next(isolation:)` requirement, rather than modifying the existing `next()` requirement, is necessary to maintain ABI compatibility, because changing `next()` to abstract over actor isolation requires passing the actor as a parameter in order to hop back to that actor after any `async` calls in the implementation. The typed throws ABI is also different from the rethrows ABI, so the adoption of typed throws alone necessitates a new requirement. |
| 243 | + |
| 244 | +## Implications on adoption |
| 245 | + |
| 246 | +The associated `Failure` types of `AsyncSequence` and `AsyncIteratorProtocol` are only available at runtime with the Swift 5.11 standard library, because code that runs against prior standard library versions does not have a witness table entry for `Failure`. Code that needs to access the `Failure` type through the associated type, e.g. to dynamic cast to it or constrain it in a generic signature, must be availability constrained. For this reason, the default implementations of `next()` and `next(isolation:)` have the same availability as the Swift 5.11 standard library. |
| 247 | + |
| 248 | +This means that concrete `AsyncIteratorProtocol` conformances cannot switch over to implementing `next(isolation:)` only (without providing an implementation of `next()`) if they are available earlier than the Swift 5.11 standard library. |
| 249 | + |
| 250 | +Simiarly, primary associated types of `AsyncSequence` and `AsyncIteratorProtocol` must be gated behind Swift 5.11 availability. |
| 251 | + |
| 252 | +Once the concrete `AsyncIteratorProtocol` types in the standard library, such as `Async{Throwing}Stream.Iterator`, implement `next(isolation:)` directly, code that iterates over those concrete `AsyncSequence` types in an actor-isolated context may exhibit fewer hops to the generic executor at runtime. |
| 253 | + |
| 254 | +## Future directions |
| 255 | + |
| 256 | +### Add a default argument to `next(isolation:)` |
| 257 | + |
| 258 | +Most calls to `next(isolation:)` will pass the isolation of the enclosing context. We could consider lifting the restriction that protocol requirements cannot have default arguments, and adding a default argument value of `#isolated` as described in the [pitch for actor isolation inheritance](https://forums.swift.org/t/pitch-inheriting-the-callers-actor-isolation/68391). |
| 259 | + |
| 260 | +## Alternatives considered |
| 261 | + |
| 262 | +### Avoiding an existential parameter in `next(isolation:)` |
| 263 | + |
| 264 | +The isolated parameter to `next(isolation:)` has existential type `(any Actor)?` because a `nil` value is used to represent `nonisolated`. There is no concrete `Actor` type that describes a `nonisolated` context, which necessitates using `(any Actor)?` instead of `some Actor` or `(some Actor)?`. Potential alternatives to this are: |
| 265 | + |
| 266 | +1. Represent `nonisolated` with some other value than `nil`, or a specific declaration in the standard library that has a concrete optional actor type to enable `(some Actor)?`. Any solution in this category requires the compiler to have special knowledge of the value that represents `nonisolated` for actor isolation checking of the call. |
| 267 | +2. Introduce a separate entrypoint for `next(isolation:)` that is always `nonisolated`. This defeats the purpose of having a single implementation of `next(isolation:)` that abstracts over actor isolation. |
| 268 | + |
| 269 | +Note that the use of an existential type `(any Actor)?` means that [embedded Swift](/visions/embedded-swift.md) would need to support class existentials in order to use `next(isolation:)`. |
| 270 | + |
| 271 | +## Acknowledgments |
| 272 | + |
| 273 | +Thank you to Franz Busch and Konrad Malawski for starting the discussions about typed throws and primary associated type adotpion for `AsyncSequence` and `AsyncIteratorProtocol` in the [Typed throws in the Concurrency module](https://forums.swift.org/t/pitch-typed-throws-in-the-concurrency-module/68210/1) pitch. Thank you to John McCall for specifying the rules for generalized isolated parameters in the [pitch for inheriting the caller's actor isolation](https://forums.swift.org/t/pitch-inheriting-the-callers-actor-isolation/68391). |
0 commit comments