Hey all,
Here's a pitch for a feature that @Philippe_Hausler, @Joe_Groff, @Tony_Parker and I have been thinking about to make rethrows also work for protocol conformances. It's motivated by some of the concurrency work, but is separable and useful on its own. Philippe has been prototyping it in the compiler so we have a fairly good sense that this is implementable and should make rethrows more useful. I'll keep an up-to-date version of this proposal here.
Rethrowing protocol conformances
Swift's rethrows feature allows a function to specify that it will throw only in narrow circumstances where it is calling a user-provided function argument. For example, the Sequence.map operation is rethrows:
extension Sequence {
func map<Transformed>(transform: (Element) throws -> Transformed) rethrows -> [Transformed] {
var result = [Transformed]()
var iterator = self.makeIterator()
while let element = iterator.next() {
result.append(try transform(element)) // note: this is the only `try`!
}
return result
}
}
When calling Sequence.map, the map is only considered to throw when the function argument passed to the transform parameter can throw:
_ = [1, 2, 3].map { String($0) } // okay: map does not throw because the closure does not throw
_ = try ["1", "2", "3"].map { (string: String) -> Int in
guard let result = Int(string) else { throw IntParseError(string) }
return result
} // okay: map can throw because the closure can throw
Swift's rethrows is effective so long as the user-provided operations that potentially throw errors are passed via function parameters. However, many user-provided operations are provided indirectly through protocol conformances. This proposal extends the notion of rethrows to protocol conformances.
Motivation
Let's consider variant of map where getting the next element from an iterator could fail. This would allow us to (for example) better model input streams as sequences, because reading from an input stream can fail. The existing Sequence protocol doesn't support this, so we'll invent a new FailableSequence protocol and its corresponding iterator protocol:
protocol FailableIterator {
associatedtype Element
mutating func next() throws -> Element?
}
protocol FailableSequence {
associatedtype Iterator: FailableIterator
typealias Element = Iterator.Element
func makeIterator() -> Iterator
}
Now, a sequence type that (say) reads lines from a terminal can conform to FailableSequence:
/// Read a line from standard input
func readline() throws -> String? { ... }
struct ReadLine: FailableSequence {
struct Iterator: FailableIterator {
typealias Element = String
mutating func next() throws -> String? {
return try readline()
}
}
func makeIterator() -> Iterator { ... }
}
Note that types that conform to IteratorProtocol also satisfy the requirements of FailableIterator, and types that conform to Sequence also satisfy the requirements of FailableSequence, because a non-throwing method can satisfy a corresponding requirement that throws. For example, we can make Array conform to FailableSequence:
extension IndexingIterator: FailableIterator { } // Array.Iterator is an IndexingIterator
extension Array: FailableSequence { }
This makes FailableSequence more general than the existing Sequence. However, using arrays via FailableSequence is likely to be frustrating, because one has to assume that every operation that traverses a failable sequence can throw, even though traversing an array never throws. Historically, this is one of the reasons why Sequence doesn't support failable sequences.
Let's try to implement a form of map (call it map2) on FailableSequence:
extension FailableSequence {
func map2<Transformed>(transform: (Element) throws -> Transformed) rethrows -> [Transformed] {
var result = [Transformed]()
var iterator = self.makeIterator()
while let element = try iterator.next() { // error: call can throw, but the error is not handled; a function declared 'rethrows' may only throw if its parameter does
result.append(try transform(element))
}
return result
}
}
The error produced is correct: in a rethrows function, the only permissible throwing operations are calls to function parameters that are potentially-throwing functions. That guarantees, statically, that when the argument for transform is non-throwing, map2 will never throw. By having the call to next() be potentially throwing, we violate that guarantee because an error could be thrown from next() (and out through map2) even in cases where the transform argument is non-throwing.
This proposal seeks to make the above definition of map2 well-formed, and have a call to map2 be consider throwing when either the transform argument or the iterator's next operation is throwing. For example:
_ = try ReadLine().map2 { $0 + "!" } // okay: map2 can throw because ReadLine.Iterator's next() throws
_ = try ReadLine().map2 { (string: String) -> Int in
guard let result = Int(string) else { throw IntParseError(string) }
return result
} // okay: map2 can throw because the closure can throw and ReadLine.Iterator's next() throws
_ = [1, 2, 3].map2 { String($0) } // okay: map2 does not throw because the closure does not throw and Array.Iterator's next() does not throw
_ = try ["1", "2", "3"].map2 { (string: String) -> Int in
guard let result = Int(string) else { throw IntParseError(string) }
return result
} // okay: map2 can throw because the closure can throw
Proposed solution
The proposed solution is to consider protocol conformances to be a source of throwing behavior for rethrows, allowing rethrows to reason about the throwing behavior of user operations provided via protocol conformances.
Rethrows protocols
Rethrowing behavior for protocol conformances begins within the protocols themselves. A protocol requirement will be able to be marked as rethrows as follows:
protocol FailableIterator {
associatedtype Element
mutating func next() rethrows -> Element?
}
Such a protocol is called a rethrows protocol. When a type conforms to a rethrows protocol, Swift records whether the method used to satisfy the next() requirement was throwing or not. For example, the conformance of ReadLine.Iterator to FailableIterator throws (because ReadLine.Iterator.next() is marked throws), but the conformance of IndexingIterator to Iterator does not (because IndexingIterator.next() is not marked throws).
Rethrows checking with protocol conformances
Any generic requirement that requires conformance to a rethrows protocol becomes part of rethrows checking. For example, consider a simple wrapper over FailableIterator's next():
func getNext<Iterator: FailableIterator>(_ iterator: inout Iterator) rethrows -> Iterator.Element? {
return try iterator.next()
}
getNext(_:) is only using rethrows operations from FailableIterator, so it is well-formed: it only throws when the call to next() throws. Therefore, calls to getNext(_:) will throw only when the conformance provided for the Iterator: FailableIterator requirement throws. For example:
func testGetNext<C: Collection>(
indexing: inout IndexingIterator<C>, readline: inout ReadLine.Iterator
) throws {
getNext(&indexing). // okay: conformance of IndexingIterator: FailableIterator does not throw, so call does not throw
try getNext(&readline) // okay: conformance of ReadLine.Iterator: FailableIterator does throw, so call throws
}
The definition of a rethrows protocol is transitive: if any generic requirement within the definition of the protocol (e.g., via an inherited protocol or a requirement on an associated type) is a rethrows protocol, then at protocol is also a rethrows protocol. Therefore, FailableSequence is a rethrows protocol because its Iterator type must conform to the rethrows protocol FailableIterator:
protocol FailableSequence { // implicitly a rethrows protocol
associatedtype Iterator: FailableIterator // generic requirement on the rethrows protocol FailableIterator
typealias Element = Iterator.Element
func makeIterator() -> Iterator
}
This definition is what permits our map2 example to become well-formed:
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
}
}
Defining a method in an extension of the FailableSequence protocol implies a requirement Self: FailableSequence, and FailableSequence is a rethrows protocol. In the body of map2, both calls to potentially-throwing operations are covered by rethrows checking: the call to iterator.next() throws when Self.Iterator's conformance to FailableIterator throws, and the call to transform throws when the transform argument throws.
Conditionally-rethrowing conformances
Both IndexingIterator and ReadLine.Iterator are simple in the sense that the next() either can't throw or can throw, and they don't depend on anything else. However, consider an adapter over another FailableIterator that does nothing but pass-through its next() to the underlying iterator:
struct FailableAdapter<Wrapped: FailableIterator>: FailableIterator {
typealias Element = Wrapper.Element
var wrapped: Wrapped
mutating func next() rethrows -> Element? {
return try wrapped.next()
}
}
The FailableAdapter.next() function is permitted to be rethrows because it only calls into rethrows protocol requirements. Hence, calling next() on FailableAdapter<IndexingIterator<[Int]>> will not throw, but calling next() on FailableAdapter<ReadLine.Iterator> can throw.
This rethrows logic extends to the conformance of FailableAdapter to FailableIterator: because the next() method satisfying the rethrows requirement is itself rethrows, the conformance is throwing when the conformance of Wrapper: FailableIterator is throwing. This allows rethrowing-ness to compose, e.g.,
func adapted(arrayIterator: IndexingIterator<[Int]>, readLineIterator: ReadLine.Iterator) {
var adaptedArrayIterator = FailableAdapter(wrapped: arrayIterator)
getNext(&adaptedArrayIterator) // okay: FailableAdapter<IndexingIterator<[Int]>>: FailableIterator does not throw
var adaptedReadLineIterator = FailableAdapter(wrapped: readLineIterator)
try getNext(&adaptedReadLineIterator) // okay: FailableAdapter<ReadLine.Iterator>: FailableIterator can throw
}
Open questions
-
Q: This is so cool. Can we fix
Sequence?- A: Probably not directly, because of ABI. In theory we might be able to retroactively add
FailableSequenceas a protocol thatSequenceinherits (ditto forFailableIteratorandIteratorProtocol), with an additional rule that allowsSequenceandIteratorProtocolto not be rethrowing protocols because they've restated non-throwing versions of the protocols they inherit. This needs more thought, but would solve a longstanding weakness in theSequenceprotocol.
- A: Probably not directly, because of ABI. In theory we might be able to retroactively add
-
Q: Do we need to mark protocol requirements as
rethrows? Why won'tthrowswork?- A: It's possible that we could use
throwsas the indicator on protocol requirements, similar to what we for function parameters. That makes this potentially a source-breaking change, since there are existing protocol conformances that would become non-throwing and that could change the behavior of some existingrethrowsoperations.
Doug
- A: It's possible that we could use