Is there a built in way to type erase AsyncSequence
conforming types? Otherwise it will be difficult to use AsyncSequences crossing api boundaries.
Can you expand on this?
AnySequence
is extremely inefficient (for some avoidable and unavoidable reasons), so you should try not to use it for "information hiding" whenever possible. Doing the same with AsyncSequence
probably isn't a good enough reason to add it (may even be a reason to not add it).
Long term, the intention is to expand opaque result types so that they can be used with where
constraints. This would allow implementors to return a concrete sequence as an opaque sequence where Element == T
, so prevent callers from relying on the specific type they return, allowing it to be changed later without a source break (or, in the case of a library built for distribution, an ABI break).
Lets say we have a function like this:
func getStrings() -> AsyncMapSequence<AsyncStream<Data>, String?> {
AsyncStream<Data> { continuation in
// here we would fetch some data and send it through the stream
}.map { data in
String(data: data, encoding: .utf8)
}
}
The return type quickly becomes unmanageable as we add more operators to the AsyncStream
. The current solution in swift is a type erasing wrapper (for example AnyPublisher
).
I totally agree with you that type erasure is an ugly necessity, and when the type system enhances to a point that it becomes unnecessary this will clearly be the far better solution.
In the meantime - how should we handle this?
I'm not sure if this is the best way to handle this but here is a naive type erased async sequence.
struct AnyAsyncSequence<Element>: AsyncSequence {
typealias AsyncIterator = AnyAsyncIterator<Element>
typealias Element = Element
let _makeAsyncIterator: () -> AnyAsyncIterator<Element>
@available(iOS 15.0, *)
struct AnyAsyncIterator<Element>: AsyncIteratorProtocol {
typealias Element = Element
private let _next: () async throws -> Element?
init<I: AsyncIteratorProtocol>(itr: I) where I.Element == Element {
var itr = itr
self._next = {
try await itr.next()
}
}
mutating func next() async throws -> Element? {
return try await _next()
}
}
init<S: AsyncSequence>(seq: S) where S.Element == Element {
_makeAsyncIterator = {
AnyAsyncIterator(itr: seq.makeAsyncIterator())
}
}
func makeAsyncIterator() -> AnyAsyncIterator<Element> {
return _makeAsyncIterator()
}
}
extension AsyncSequence {
func eraseToAnyAsyncSequence() -> AnyAsyncSequence<Element> {
AnyAsyncSequence(seq: self)
}
}
My suggestion would be to just copy and paste the signature from the return value, accepting that it might be a little long. The example you give isn’t particularly unmanageable, just a bit ugly. These things don’t tend to get silly-long unless you’re doing something result-buildery.
That's true, the case I show in the example is not a problem. But I think it is a valid point that this will become a problem when the usage of the new async feature of swift will increase.
It would be a logical step to enhance AsyncSequence
that it can replace Combine.
And as that grows in importance, that challenge can be met by improving opaque result types. Which are getting closer – this PR lays a big part of the ground work for them.
That would be the best solution. +1
Another example of where a type-erased wrapper would currently be helpful: if you have conditional logic that returns a different AsyncSequence
per branch. For example, in a reducer-based architecture you may switch
on an enum of user actions for a screen, and different actions may fire off different kinds of side effects. There's currently no way to express this using the standard library and we are forced to write our own type-erased wrapper.
Here's hoping we get where
constraints on opaque result types soon
Another, potentially more efficient, solution to this without type erasure is an Either
type, where the types returned by the different branches can be known at compile time. That type can conditionally conform to AsyncSequence
when all its generic placeholders do.
Currently you can write one fairly easily with two branches (I think... I've implemented this for regular Sequence
at least). Possibly with variadic generics it will be possible to write one for N types. But for now, if you can have two you can nest them i.e. Either<T, Either<U, V>>
and that's enough to satisfy the conditional conformance.
Note, where
clauses on opaque result types do not solve this problem. In that case, all branches would be required to return the same type.
True, but they would (again) resolve the issue of the clutter that Either
Types would cause. Maybe adding these conditional Types to the stdlib would be worthwhile? Or maybe even some StreamBuilder
(like SwiftUIs ViewBuilder
)?
Ah yeah I meant generalized existentials...guess that's probably still far off?
I've found that result builders are not really up to the task when complex generics are involved, unfortunately.
This solution is unfortunately not a tenable one when maintaining complex application logic over time. Imagine an application with dozens of reducers, each with dozens of enum cases to switch over. Both the explicit nested embedding and each function's signature is going to get very intense, and any changes to an action enum will cause a lot of churn in code that ideally shouldn't see any.
(Not to mention if you have a composition operator for combining reducers into a single one, that Reducer
type is going to be very, very intense.)
I am working on a library and could also make use of a type erased AnyAsyncSequence
. We want to expose some AsyncSequence
properties, but doing so as the specific type (in this case AsyncPublisher
) isn't great as it's leaking implementation details. We might want to change the backing type at some point.
We decided to use a custom type conforming to AsyncSequence that just internally holds onto a concrete type that we can switch out later. This allows to not leak implementation details and gives us flexibility in the future. But we are looking forward to this:
Long term, the intention is to expand opaque result types so that they can be used with
where
constraints. This would allow implementors to return a concrete sequence as an opaque sequencewhere Element == T
, so prevent callers from relying on the specific type they return, allowing it to be changed later without a source break (or, in the case of a library built for distribution, an ABI break).
Another use-case for the thread, assuming full generalised existentials are still a while away. Opaque result types don't seem to cover this.
class Player {
let media: Media // Concrete type is determined dynamically in the initialiser - can't be generic.
}
protocol MediaSource {
// I specifically want to be able to observe changes over time in this value
var isPlaying: AnyAsyncSequence<Bool> {get}
…
}
struct AudioMediaSource: MediaSource { /* AVAudioPlayer */ }
struct VideoMediaSource: MediaSource { /* AVPlayer */ }
struct CustomMediaSource: MediaSource { /* A corresponding third-party playback solution */ }
Not applicable:
- Convert isPlaying to use an associated type - Media can no longer be stored in a property.
- Opaque type constraints (
where
clause) - opaque types are not allowed as a protocol requirement, and the Media property specifically isn't limited to a single type. - Change the requirement to
@Published var isPlaying: Bool
- property wrappers can't be used in a protocol.
Workarounds which make the case for type erasure:
- Wrap the underlying async sequences in an AsyncStream - this is effectively type erasure, though it's a little tricky to handle extras like cancellation properly.
- Use
AnyPublisher
from Combine - an existing type-erased wrapper. - Manually implement AnyMedia.
Workarounds:
- Replace the protocol with a single struct, and the concrete types with enum cases. Do a switch within each method on the struct to provide appropriate behaviour. This isn't compatible for a framework use-case where third-parties may wish to add conformances, but could work for me.
Although some AsyncSequence
is pretty interesting, it doesn't seem to help when I need to expose abstract logic through concrete APIs.
For example, if I have some use case logic that retrieves a list of orders, I'd want to reuse some underlying custom decoder and error handling that I can pass to all my fetch methods (see how fetchModels<T>()
is used within fetch orders and products methods):
struct MyOrder: Decodable {...}
func fetchOrders() -> AsyncStream<[MyOrder]> {
fetchModels() // Decodes using inference
}
func fetchProducts() -> AsyncStream<[MyProduct]> {
fetchModels()
}
...
var values: AsyncStream<URLSessionWebSocketTask.Message>> {...}
func fetchModels<T>() -> AsyncStream<[T]> where T: Decodable {
values.compactMap { (result: URLSessionWebSocketTask.Message) in
switch result {
case let .success(message):
switch message {
case let .string(string):
guard let data = string.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
case let .data(data):
return try JSONDecoder.apiDecoder.decode(T.self, from: data)
@unknown default:
return nil
}
case let .failure(error):
return nil
}
}
}
I'm having a hard time seeing how some AsyncSequence
would help here. I'm using inference for fetchOrders() -> AsyncStream<[MyOrder]>
to call fetchModels<T>()
and passively let it know type to decode within it. Casting didn't work, plus it broke the generic inference (so it can internally know what to decode as):
func fetchOrders() -> AsyncStream<[MyOrder]> {
fetchModels() as AsyncStream<[MyOrder]>
}
func fetchProducts() -> AsyncStream<[MyProduct]> {
fetchModels() as AsyncStream<[MyProduct]>
}
func fetchModels<T>() -> some AsyncStream {...}
The AnyAsyncSequence
made by @John-Connolly worked amazingly tho (thank you!!! ):
func fetchOrders() -> AnyAsyncSequence<[MyOrder]> {
fetchModels().eraseToAnyAsyncSequence()
}
func fetchProducts() -> AnyAsyncSequence<[MyProduct]> {
fetchModels().eraseToAnyAsyncSequence()
}
func fetchModels<T>() -> AnyAsyncSequence<T> {...}
The some AsyncSequence
route seems much more elegant but I can't make it solve my scenario like the type erasing can. Maybe someone can make the opaque type version work in this case as good as the type erasing, otherwise not ideal but hopefully we can get the native AnyAsyncSequence
counterpart like AnyPublisher
.
Unfortunately, solution from John-Connolly is far from perfect... because from AsyncSequence
(for extension and generics) we don't know if AsyncIteratorProtocol.next
throws, rethrows or doesn't throw an error... so AnyAsyncSequence
from AsyncStream
needs try
for iteration and AsyncStream doesn't... It all makes me unhappy... Why just not to create two protocols AsyncSequence
and AsyncThrowingSequence
and just return AsyncStream
or AsyncThrowingStream
for AsyncSequence.map/compactMap
as Sequence.map/compactMap
returns Array
... it's very hard to use AsyncSequence
for interfaces and dependency injection... will continue using Combine
with AnyPublisher
. I don't like AsyncSequence
very much, it's very inconvenient... thanks for great API...
This is ridiculous. Makes AsyncSequence
confined to demos at best unfortunatelly.
why not atleast a primary associated type so I can do some AsyncSequence<String>
?