Hey all,
I took a stab at implementing the AsyncSequence
part of this protocol, to assess whether it's possible to introduce typed throws in a manner that is both backward compatible and achieves the composability we want, with support for any AsyncSequence<Element, Failure>
and such. The implementation in the compiler and library is in this pull request, along with toolchains to play with, but the salient details are below.
tl;dr we can stage in the new Failure
associated type without breaking existing code, and the unfinished/unofficial @rethrows
can be removed over time.
With my implementation, AsyncSequence
and AsyncIteratorProtocol
both get Failure
associated types and adopt primary associated types, as in the proposal. AsyncIteratorProtocol
gets a new function requirement _nextElement()
that is a typed-throws version of next()
:
protocol AsyncIteratorProtocol<Element, Failure> {
associatedtype Element
associatedtype Failure: Error = any Error
mutating func next() async throws -> Element?
mutating func _nextElement() async throws(Failure) -> Element?
}
public protocol AsyncSequence<Element, Failure> {
associatedtype AsyncIterator: AsyncIteratorProtocol
associatedtype Element where AsyncIterator.Element == Element
associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure
func makeAsyncIterator() -> AsyncIterator
}
Because existing AsyncIteratorProtocol
-conforming types only implement next()
, we need to provide a default implementation of _nextElement
:
extension AsyncIteratorProtocol {
/// Default implementation of `_nextElement()` in terms of `next()`, which is
/// required to maintain backward compatibility with existing async iterators.
public mutating func _nextElement() async throws(Failure) -> Element? {
do {
return try await next()
} catch {
throw error as! Failure
}
}
}
I didn't also implement next()
in terms of _nextElement()
, but we'd want to do that so that new async sequences could implement just _nextElement()
.
Now, one of the harder problems is how to get the right Failure
type for existing async sequences. If the async sequence doesn't get recompiled, it'll get the default Failure
type of any Error
at runtime. This is fine---either it doesn't throw anything in practice, or its clients will see the any Error
instance.
When the async sequence does get recompiled, we want to pick an appropriate Failure
type even when there is no explicitly-specified one. I ended up using the following inference logic based on the next()
implementation:
- If
next()
throws nothing, Failure
is inferred to Never
.
- If
next()
throws, Failure
is inferred to any Error
.
- If
next()
rethrows, Failure
is inferred to T.Failure
, where T
is the first type parameter with a conformance to either AsyncSequence
or AsyncIteratorProtocol
. If there are multiple such requirements, take the errorUnion
of them all.
The async for..in
loop switches from using next()
to using _nextElement()
, so iteration over an async sequence throws its Failure
type. This subsumes the specialized behavior for @rethrows
(if Failure
is Never
, you don't need the try
because nothing is thrown), and gives us typed-throws behavior for iteration.
@rethrows
protocols had another bit of special behavior, which is that conformance requirements to @rethrows
protocols can be considered as sources of errors for rethrowing. So, you can currently write a rethrows
function like this:
extension AsyncSequence {
func contains(_ predicate: (Element) async throws -> Bool) rethrows -> Bool { ... }
}
and this function can throw if either the AsyncSequence
throws (i.e., it's Failure
type is not Never
) or if the predicate
throws. @pyrtsa noted this issue. I've partially addressed the problem by introducing a specific rule that allows requirements on AsyncSequence
and AsyncIteratorProtocol
to be involved in rethrows
checking, so existing code that uses rethrows
in this manner with async sequences will continue to work.
However, that doesn't address the fact that we can't write a proper typed throws signature for contains
. As @pyrtsa noted, we could elevate errorUnion
to an actual type in the type system, so we could write, e.g.,:
extension AsyncSequence {
func contains<E: Error>(_ predicate: (Element) async throws(E) -> Bool) throws(errorUnion(Failure,E)) -> Bool { ... }
}
that's effectively what I've turned rethrows
into, implicitly. We'd need to do something like this to fully replace the current rethrows
behavior.
Doug