This is only my hand-wavy take as I worked on the implementation of the parsing before. I certainly haven’t tested if that’s actually possible, so one of the authors of the pitch can maybe say more.
Agreed. This will unlock so much unrealised potential for ‘AsyncSequence’. Beyond the primary associated type benefits, no longer needing to define both throwing and non throwing source sequence variants, plus the ability to finally define a generic ‘AsyncSource’ type will really make it a much more expressive and versatile type.
While the rationale for softening the stance that untyped throws are all we need is not complete in this poposal, I agree with the reasons given by @John_McCall in Precise Error Typing. While it's unfortunate that the parenthesis are required to make the grammar unambiguous, I'd rather deal with some extra parentheses than an ambiguous grammar.
Overall +1.
not sure if this was discussed somewhere before, but what about:
func foo() throws<FooError>
// instead of
func foo() throws(FooError)
at least at first glance it feels more type-related and less function-y to me...
< >
was always more indicative of a generic context, ()
otherwise just wraps content in it.
Also, issues with parsing with no parenthesis have been noted before by @Zollerboy1
I don't have a strong opinion either way here about throws X
vs. throws(X)
, but there are plenty of places in the parser involving contextual keywords where disambiguation occurs based on whether the tokens are separated by a line break. For example,
let a = [any P]() // allowed
let a = [any
P]() // not allowed
If the version without parentheses is preferred, I think it could be implemented such that an error type following throws
cannot be separated from it by a newline:
func f() throws mutating // mutating treated as a terribly named error type
func g()
func f() throws
mutating func g() // mutating treated as a modifier on g()
Very excited to see the section about AsyncSequence
here. This truly unlocks a lot of unrealised potential for AsyncSequence
when it comes to API design.
I have some questions on the proposal:
- Does this supersede the underspecialized
@rethrows
? - Can a protocol have multiple associated types where different methods use different types, e.g.
public protocol Foo<Failure1, Failure2> {
associatedtype Failure1: Error = any Error
associatedtype Failure2: Error = any Error
func bar1() throws(Failure1)
func bar2() throws(Failure2)
}
- We should include a section around
withChecked[Throwing]Continuation
andwithUnsafe[Throwing]Continuation
. Those are essential building blocks for most async algorithms and the place where we currently cannot nicely generalise between throwing and non-throwing algorithms. - The one issue that this opens up with
AsyncSequence
is that currently a lot of algorithms and types such asAsyncStream
are throwingCancellationError
s onnext()
in the throwing variants. If theFailure
type becomes generic we cannot throw this error anymore. This isn't necessarily a blocker but it means users ofAsyncSequence
s with a typed throw cannot differentiate anymore if the sequence terminated due to cancellation or due to finishing (same situation as we have with non-throwingAsyncSequence
s right now)
Here, the []
marks the beginning and the end of the expression, not the any
being in place, I think.
You can see the implementation here. When any
is reached in an expression context, the isContextualExpressionModifier
function checks whether the next token is at the start of a line to determine whether the keyword is a modifier or not. It's independent of whether it's inside an array or not.
A clearer example of the same logic would be this:
let x = consume f() // consume is a modifier
let x = consume // consume is an identifier
f() // f() is a separate statement
I'd like to second this as well.
As for the ambiguity concern raised by @Zollerboy1, I think it'd be a shame bringing in a whole lot of code noise for a relatively rare edge case.
Maybe we could do something like the following?
public struct AsyncThrowingStream<Element, Failure: Error> {
public enum StreamError: Error {
case taskCancelled
case other(Failure)
}
// ...
}
extension AsyncThrowingStream: AsyncSequence {
public struct Iterator: AsyncIteratorProtocol {
public mutating func next() async throws(StreamError) -> Element? {
// ...
}
}
}
In discussions I've had with the community about typed throws, I recall two branching motivations:
- wanting to specify the full set of errors that can be thrown
- wanting to specify a minimum set of capabilities for the errors that can be thrown
The first is well explained the proposal and handled by using concrete types, but for the latter, a more natural fit might be to declare a protocol refining Error
, and throwing its existential type:
protocol ErrorWithBacktrace {
var backtrace: Backtrace { get }
}
// I don't want to promise what kinds of errors I'll throw, but I
// promise they'll carry a backtrace
func doStuff() throws(any ErrorWithBacktrace) {}
I would expect support for this to fall out from what's proposed, though the proposal doesn't explicitly state whether it's allowed.
If it is supported, that raises a question of how to join different error types that already have a subtyping relationship. Do we still fall all the way back to any Error
, or do we take advantage of the subtype relationship if there is one?
struct SpecificErrorWithBacktrace: ErrorWithBacktrace {
var backtrace: Backtrace
}
do {
if x {
throw SpecificErrorWithBacktrace()
} else {
try doStuff() // throws any ErrorWithBacktrace
}
} catch {
// is `error` still `any Error` here, or `any ErrorWithBacktrace`?
}
As for whether the syntax is throws(T)
, throws<T>
, or throws T
, although I have to say that I personally find the parens a bit visually noisy, we should also consider potential other future parameterized effects that functions may have and the effect that would have on the syntax. For instance, if we introduce generators, we might use the syntax (T, U, V) yields(Element) -> Void
or something like that to indicate the type of the generator. New effect keywords like yields
will likely have to be contextual keywords for compatibility purposes, further increasing the potential for ambiguity if we compose them like (T, U, V) throws yields(Element) -> Void
, so having parentheses (or some kind of brackets) around the parameters seems like good future-proofing. (This is not to say we're going to do generators imminently or anything like that, it's just an example of another function effect that would have obvious use for a type parameter like throws
.)
It would be nice to make new keywords reserved rather than contextual in new language modes.
All else being equal I prefer new keywords being contextual, depending on how common of a word we're using and whether it's something one would reasonably want to use as an identifier.
If we really want to talk about the bright sparkling future, it's fun to think about supporting user-defined effects and handlers too, which wouldn't be keywords at all.
Even in a new language mode, we need to be considerate about whether the benefit of reserving a keyword and thus requiring anyone who used that name to change their code is higher than adding some new rules in the parser or adding a little bit of "noise" to the syntax to make it unambiguous.
Given the large and ever increasing number of contextual keywords in Swift, I'm not sure squatting on all of them even in a new language mode would be worth the breakage it would cause.
(And this is coming from someone whose life would be a lot easier if keywords were always reserved, because I'm constantly having to add special cases in swift-format to make sure we don't split tokens in a way that causes them to be parsed incorrectly.)
If the guidance is:
- if you throw a single error type, you can use typed throws
- but if you throw multiple error types, you have to throw
any Error
How does that related to Embedded Swift, where you can't use the any Error
existential?
How will authors in embedded environments without any Error
handle methods that need to call multiple different throwing methods with different error types?
// If we can't throw `any Error` in Embedded Swift, what do we do here?
func throwsMultipleErrors() throws(???) {
try methodA() // throws ErrorA
try methodB() // throws ErrorB
}
In essence, when there are multiple possible thrown error types, we immediately resolve to the untyped equivalent of
any Error
.
How does this work when targeting a runtime without existential types like Embedded Swift, which was cited as a major motivation for this pitch? (Ha, I see @cal beat me to this question )
I find myself in begrudging agreement that throws
should be as expressive as Result
, though I would be inclined to argue that parameterizing Result
on its Failure
type was a mistake.
Regardless, I still strongly believe that resilient functions should not incorporate their error types into their ABIs. If the pitched feature were implemented as-is, that would mean the compiler should reject typed throws
when compiling in resilient mode.
I think there’s an obvious alternative that at least warrants mention in the pitch: enhancing Error
with the functionality that has most strongly motivated the request for types throws, that being the ability to exhaustively switch over known error domains:
// Adds a `code` property to Error.
// The type of `code` is determined by an associated `Domain` type.
// For source compatibility, Error defaults to a Domain with no codes.
protocol Error {
associatedtype Domain: ErrorDomain = DefaultErrorDomain
var domainName: String
var code: Domain.Code
}
protocol ErrorDomain {
associatedtype Code
static var name: String
}
struct DefaultErrorDomain: ErrorDomain {
typealias Code = Void
}
extension Error where Domain.Code == Void {
var code: Void { () }
}
extension Error {
var domainName: String { Self.Domain.name }
}
This preserves the ability to use Error
as the error currency type for resilient libraries while still allowing the caller to test the Domain and then possibly switch over the code
:
do {
try openFile()
} catch {
switch(error.domainName) {
case NSPOSIXErrorDomain:
// blah
case NSCocoaErrorDomain:
// blah
}
}
In a restricted environment like EmbeddedSwift, these types might not be parameterized in order to force a simpler error ABI:
protocol Error {
#if EMBEDDED_SWIFT
typealias Domain = ErrorDomain
#else
associatedtype Domain: ErrorDomain = DefaultErrorDomain
#endif
var domainName: String
var code: Domain.Code
}
#if EMBEDDED_SWIFT
struct ErrorDomain {
typealias Code = Int
static var name: String
}
#else
protocol ErrorDomain {
associatedtype Code
static var name: String
}
struct DefaultErrorDomain: ErrorDomain {
typealias Code = Void
}
extension Error where Domain.Code == Void {
var code: Void { () }
}
extension Error {
var domainName: String { Self.Domain.name }
}
#endif
If we don't want it to infect their ABI only, there's an option of accepting typed throws
but using the same ABI as throwing Error
, which would also have the benefit (?) of allowing existing library-evolution-enabled APIs to narrow their throws
type without breaking their ABI.
That would still be a source-breaking change for clients, which the compiler should prevent resilient libraries from doing.