AnyAsyncSequence

Is there a built in way to type erase AsyncSequence conforming types? Otherwise it will be difficult to use AsyncSequences crossing api boundaries.

7 Likes

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).

13 Likes

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?

1 Like

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)
    }

}

5 Likes

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.

1 Like

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.

1 Like

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.

3 Likes

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 :smiley:

5 Likes

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.

3 Likes

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)?

1 Like

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.)

4 Likes

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.

1 Like

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 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).

2 Likes

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!!! :star_struck:):

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.

2 Likes

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...

4 Likes

This is ridiculous. Makes AsyncSequence confined to demos at best unfortunatelly.

2 Likes

why not atleast a primary associated type so I can do some AsyncSequence<String>?

2 Likes