AnyAsyncSequence

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

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

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

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

}

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

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:

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

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

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

1 Like

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.
Terms of Service

Privacy Policy

Cookie Policy