[Pitch] Rethrowing protocol conformances

Right, my mistake. I meant to write something like T.Iterator.IteratorProtocol: nothrow but your syntax was better anyway.

Awesome, thanks!

What would happen if I use the generic that are failable:

struct FailableJoinedSequence<S: FailableSequence> { ... }

Would/Should FJS.Iterator be able to keep track of the throwability of S.Iterator, and mark throwable accordingly?


From the github:

extension FailableSequence {
  func map2<Transformed>(transform: (Element) throws -> Transformed) rethrows -> [Transformed] {
    var result = [Transformed]()
    var iterator = self.makeIterator()
    while let element = try iterator.next() { // okay: FailableIterator.next() is rethrows
      result.append(try transform(element))   // okay: transform is throws
    }
    return result
  }
}

This is a very peculiar example. The throwability comes from Self.Iterator which is never mentioned in the function signature. This means that rethrows reaches far and deep into Self. I feel that it could be hard to keep track of, especially since there's no marking in FailableSequence that the throwability follows into Iterator.


What if instead of per-function marking, we do a per-protocol marking. It doesn't seem to affect much of the functionality. Further, I think it's better to have on-the-fly rethrowability by marking throws on the conformance.

// Normal protocol
protocol FailableIteratorProtocol {
  associatedtype Value
  func next() throws -> Value
}

protocol FailableSequence {
  // `FailableSequence` throws if `Iterator.next` throws
  associatedtype Iterator: throws FailableIteratorProtocol

  func makeIterator() -> Iterator
}

// Throws if the conformance of `S` to `FailableSequence` throws
func map2<S: throws FailableSequence>(_: S) rethrows {
  ...
}

which would make it much more explicit at function declaration which of the conformances contribute to the rethrow. It would also allow for future directions like nothrows:

// `S`'s conformance to `FS` can't have throwing function
func map3<S: nothrows FailableSequence>(_: S) { ... }

or rethrowable generics:

// S now contributes to `rethrows`
struct JoinedSequence<S: throws FailableSequence> {
  struct Iterator: FailableIterator {
    func next() rethrows -> S.Element { ... }
  }
}

This is an interesting and cool extension, I like the general direction, but I agree with the mid-thread suggestion that using "rethrows" in the protocol decl was both too clever and unnecessary.

My understanding of this is that you want this to work with multiple protocol requirements, for example:

protocol P1 { 
  func f1() throws
  func f2() throws
}

and when implemented by:

struct S1 : P1 { func f1() {} func f2() {} }

and used by something like:

func genericThing<T : P1>(a : T) rethrows {
  try a.f1()
  try a.f2()
}

That a rethrowing function wouldn't be considered throwing.

A couple of questions about this:

  1. I assume this means that a type is considered "throwing" if any of the protocol requirements are fulfilled by a throwing function? I think this is true even if the genericThing doesn't actually use one of throwing requirements because we want the implementation of genericThing to be able to evolve over time.

  2. What happens with resilience when someone comes along and adds a new "f3" throwing requirement to P1 with a default implementation (that could throw)?

Does that break rethrow checking in genericThing? If so, how do we make it invalid to add new throwing requirements to protocols?

If this is an issue, then I think that "rethrow checking" is something that a protocol should opt into somehow, because you'd have to guarantee that you will never add a new throwing requirement in the future. This seems like an attribute that P1 would have to adopt or something.

-Chris

4 Likes

Yes, that's correct.

Yes, that's correct.

Yes, it could end up breaking rethrow checking. Good catch!

This is a great point. It is limiting how the protocol evolves: any newly-added throwing requirement must have a non-throwing default implementation.

Doug

1 Like

I just can’t shake the feeling that this is going to have so many unintended consequences. Can I declare a rethrows that only depends on some parameters? Is self special? Are protocol extensions different from concrete methods? How do I do wrapper structs, like @Lantua’s example? Is it all generic parameters or just the innermost ones?

It stinks because there are absolutely good use cases for this, most notably all the API on AsyncSequence. But it makes rethrows feel extremely complicated. When the source of rethrows-ness is “closure parameters that throw” it’s very obvious from looking at the function declaration which those are. Allowing that to include requirements of associated types of generic parameters (of enclosing contexts?) is just a little too much indirection for me.

I’d definitely feel better about it if throws was declared on each generic parameter that was a source of throwing requirements, including Self, as @Lantua played with. You’d have to allow a way to specify it other than on constraints for constraints that are already in scope (again, particularly relevant for Self). But having it be only some protocol constraints but not all is probably overkill.

So…how about parameterized rethrows? You can put the name of a generic parameter or a throwing closure parameter in the parentheses; the default (to not change behavior) is “all throwing closure parameters”. You could even put a specific requirement if you wanted.

extension FailableSequence {
  func first() rethrows(Self) -> Element { … }
  // or
  func first() rethrows(Iterator) -> Element { … }
  // or
  func first() rethrows(Iterator.next) -> Element { … }

  func map<Result>(_ transform: (Element) throws -> Result) rethrows(Self, transform) -> [Result] { … }
}

struct LazyMapSequence<Underlying: FailableSequence, Result> {
  func next() rethrows(Underlying) -> Result
}

This is a bit explicit, to be sure, but we can always come up with shorter forms later. I don’t think having the general form will turn out to be a problem as long as we don’t try to get too specific (for instance, depending on a particular requirement, which would then make adoption of new throwing requirements a source-breaking change).

Note that this still doesn’t allow perfect propagation of throws; in particular, there’s no good way to parameterize LazyMapSequence on the throwing-ness of the transform without further features. I do sometimes wish we had a more first-class effects system for things like this.

9 Likes

Self is not special; it is any parameter in the generic signature of the function that adopts a protocol that can be determined as a source of rethrowing. I have a branch with the functionality GitHub - phausler/swift at rethrow_protocol if you are interested in my current thoughts of how this can be implemented. Of course we are still working out the details.

Is there any movement on this?

I've been experimenting with the AsyncSequence code in the new concurrency framework and it seems all the functions written for AsyncSequence (eg reduce, min, max etc) have been written with the assumption that this pitch will be implemented.

Currently I have written my own reduce which throws instead of rethrows. Obviously I would prefer that the one implemented in Swift works and I could use that.

2 Likes

Just to note, this kind of granularity makes which parts of the protocol are used by any operation part of the operation's interface. It's very similar to noexcept in C++, which is in large part my fault and IIUC has been a failure because people never really know how much detail to expose. IMO it's actually better to start with a limited amount of detail (just as we did by only supporting rethrows with functions at first) and let the need for more become apparent.

Fair point. I think this is my primary concern:

@Douglas_Gregor I see this is now partially implemented and used inside of Swift Concurrency for AsyncIteratorProtocol - did nothrow manage to make it in? At the moment I am not able to create a conformance to AsyncSequence and access the iterator contents without it throwing, even if the underlying sequence does not produce errors

1 Like

This feature has never been reviewed to be an official part of Swift; it's in a sort of purgatory of existence.

1 Like

I see, it's a little shame we are not currently able to express conformance to AsyncSequence for their non-throwing variants.

It would be nice to see support for non-throwing conformance given we have some version of the rethrows protocol already - Im surprised my use-case is not as common

1 Like

how so? Non throwing root async sequences are definitely possible. As a matter of fact there is one in the unit tests for AsyncSequence. It is just the intermediaries like ones that change the throwing behavior of a base generic parameter from throwing to non throwing that are not currently possible.

@Philippe_Hausler I want to be able to create conformance, not implement a new sequence type.

public struct AsyncSequencePublisher<S: AsyncSequence>: Publisher

Calling iterator.next will always result in a throwing function. There is no way to express it otherwise

var iterator = sequence.makeAsyncIterator()
let value = try await iterator.next()

I would like to be able to offer versions of the API, one for throwing sequences (Failure == Error) and one for non-throwing sequences (Failure == Never)

Yea... That side has some issues, there are two approaches to evolving things that I see as potential paths (these are likely not the only ways to solve the problem): the highly disputed typed throws.... or... the highly disputed generic effects. A solution in that space is blocking a few interesting bits of code that could be done.

No matter how you cut it, the solution will have some pretty far reaching effects to the language as a whole.

Inside of this proposal thread I saw mentioned the keyword nothrow to be used to constrain against a non-throwing variant of a rethrows protocol - with regards to generic effects and typed throws I do not understand why they would also solve this case, other than having a side effect of making this API safer and richer

protocol Collection: Sequence where Self.Iterator: nothrow IteratorProtocol

You have a generic effect in that where clause. It is saying that any Collection is creatable as a Sequence when the generic effect of the throwing is "nothrow". Granted that is a bit of an extreme case of interpretation of how the generics system works (but is somewhat true today with other conditional constraints).

One of the key reasons why only the values API and not the other way around was introduced was because of this limitation.

I wonder if there are some parts of the ABI already depending on this feature. If that were the case, we probably could just accept this pitch into the language...

In my opinion this problem would still be much better solved with typed throws, but it seems that the core team kinda already decided otherwise.

1 Like

The ship has sailed on ABI interactions; AsyncSequence types all require this to function. However typed throws are still an option in conjunction with rethrowing conformances; shortly after this pitch was initially posed I worked on a branch that added typed throws (with a minimal subset of failure types since I didn't want to delve into the actual implementation of a union type just yet). It worked and was ABI compatible from what I could tell.

8 Likes

This is a really cool pitch and with generics, this will make error handling really simple with rethrowing types. However, I was wondering if there is any plan for standard library protocols (especially Error protocol) to be made rethrowable. This would remove a lot of boilerplate where we have to provide duplicate implementation based on if type is Never or any other Error type. Consider following example:

@rethrows
protocol Error {
    func rethrow() rethrows // default implementation will throw the underlying errror
}

extension Never: Error {
    func rethrow() {
        // do nothing or crash ?
    }
}

enum Result<Success, Failure: Error> {
    func get() rethrows -> Success {
        // if failure is never, the function never throws
        // for any other type of error, the function might throw
    }
}

struct Task<Success: Sendable, Failure: Error> {
    var value: Success {
        get async rethrows {
            // never throws if Failure is Never
        }
    }
}
1 Like