Pitching Optional Throws in Swift

Optional Throws in Swift

Hi all, I have been looking for a way to add some flexibility to error handling, and I'm interested in fielding feedback on this idea.

Thanks for your time,
Michael

Background

Motivation

There are many cases when jurisdiction of error handling is unclear. Developers may question whether to handle or propogate errors.

Choosing to throw errors provides the benefit that callers can flexibly handle problems.

Choosing to not throw errors provides the benefit of simplifying syntax to users (no do-try-catch).

Existing Solution: Fatal Crash

When operating in this unclear territory, developers may choose to fatally crash, like array subscript getters myArray[1]. Fatal errors are currently the best way to omit error-handling syntax for users. Developers may then opt-in to bounds checks with if-statements if they so choose to prevent fatal crashes.

Choosing to fatally crash provides users with two options:

  1. Omit error handling syntax and risk a fatal crash
  2. create some bound checks to avoid a fatal crash

Shortcomings of Fatal Crash

Opting into bound checks using if-statements is largely inconsistent with current error handling practices in Swift (if let, guard try?, rethrows...).

However, forcing these functions to throw could be a controversial topic -- regarding these calls are ubiquitous, and demanding error catching would uproot many existing codebases.

A Middle Ground

Another solution here is to provide two implementations of the same function: one that throws errors, and a non-throwing function that fails.

"Optional throws" is my pitch to create a throwing function and a non-throwing function, shorthanded through a single function signature.

Optional throws will let a function throw errors and provide default error handling. The user of the function will opt-in to handle thrown errors, or let the function handle its own errors. This would provide a safer way to encapsulate fatalError() calls, like in array subscript getters, and streamline error-handling syntax. Callers may opt to catch errors, or let the function fatally crash.

Optional Throws Syntax

Throwing functions in Swift look like this:

func willThrow() throws {
  throw MyError.oops
}

Optional throws introduce a throws? keyword in the function signature, indicating the function is elligible to throw errors. The ? syntax in Swift is already well-defined to mark optional values.

With introducing guard-throw?, functions can define default error handling. The guard-throw? syntax should be interpreted as:

"If this function is allowed to throw, throw this error. If the client does not handle errors, handle it here."

func maybeThrow() throws? {
  guard throw? MyError.oops else {
    fatalError("Oops")
  }
}

In Swift, the else clause of a guard statement expects a terminating instruction, like return or fatalError(). This control flow is well established, and will enforce that a guard-throw? always terminates the function -- either via throwing or handling within the else clause.

The floor is open for more syntax considerations.

Calling Optionally Throwing Functions

The compiler should implicitly determine whether the function will throw or provide default handling. This lets callers more flexibly streamline error handling, or opt-in to custom error handling.

Traditionally in Swift, all calls to throwing functions must be rethrown or preceded with try and handled with a do-try-catch, if-let-try, or guard-let-try-else.

With optionally throwing functions, the above cases will opt to throw errors. If the function is called in a context with unhandled errors, the function will call the default error handling within the else clause.

func useCustomHandling() {
  do {
    try maybeThrow() // use throwing function signature since we are "try"-ing 
  } catch let error {
    print("Custom handling: " + error)
  }
}

func useDefaultHandling() {
  maybeThrow() // use non-throwing function signature since we are not "try"-ing 
}

func useRethrow() rethrows {
  maybeThrow() // use throwing function signature since we are rethrowing 
}

Additional consideration:

func useOptionalRethrow() rethrows? {
  maybeThrow() // use optional-throwing function signature; the `useOptionalRethrow()` caller will define context
}

Use case: Array subscripts

Out-of-bounds array subscripts throw an unhandled fatal runtime error. Enforcing error handling for every subscript would break many code bases, but optional throws can give developers a choice to opt-in to error handling.

Currently, the only bound-safe option we have is an if-statement:

func getItem<T>(index: Int, array: Array<T>) -> T? {
  if index < array.count {
    return array[index]
  }
  return nil
}

If-statement bound checks are inconsistent with error-handling conventions in Swift. With optional throws, subscript getters can throw an error for handling, instead of forcing a fatal crash.

func getItem<T>(index: Int, array: Array<T>) -> T? {
  guard let item = try? array[index] else { return nil }
  return item
}

Furthermore, since the function signature is optionally throwing, developers can choose to leave their codebases unaltered and opt to fatally crash, as per the default error-handling implementation of the array subscript getter.

func getItem<T>(index: Int, array: Array<T>) -> T {
  return array[index] // crashes with fatal error if out of bounds.
}

Note that with optional throws, all above examples of the array subsript getter are allowed uses.

Discussion

Control Flow Effects

This control flow branch produces different results, depending on the call site. This is potentially confusing when call sites may produce different results from the same call.

func confusingThrow() throws? -> Int {
  guard throw? MyError.oops else {
    return 42
  }
}

func a() -> Int? {
  return confusingThrow() // returns 42
}

func b() -> Int? {
  return try? confusingThrow() // the same call throws an error and evaluates to return nil
}

One consideration is to force optional-throws to only terminate via fatal crash, retutn nil, or return void when not handled at the call site. This is a different restriction than other guard-else statements, so this is also potentially confusing.

Alternatively, an entirely new syntax may be proposed that more clearly coerces the failed throw? to fatally crash, return nil, or return Void.

However, I personally find this proposal behaves expectedly, and the flexibility of this syntax outweighs the potential confusion.

Single Guarded Expression

Guard statements may contain multiple expressions, however, since throw is an exit instruction, a guard-throw? may only contain one expression. It is possible to warn other expressions will not be executed, and provide a fix-it to remove all other guarded expressions.

2 Likes

Possibly, we could even consider to shorthand throw! MyError.oops to throw the error in handled contexts, or execute a fatalError in unhandled contexts.

1 Like

Interesting. Solves the problem that Swift does not allow overloading based on throws / non-throws alone, presumably without placing additional burdened on the already-overburdened type solver.

2 Likes

Really interesting. I think the mechanic itself is really useful. However, I do have some concerns about the usage of the question mark ? idiom, as well as the usage of the term “optional” to describe the behavior. To the best of my knowledge, Swift has so far only used “optional” to describe the actual Optional type, and not the behavior of a function itself. I.e. a function can return an optional object but cannot optionally return? Does that makes sense? I feel as though the usage of the optional term might not be a best fit here. I think this mostly applies to the usage of ? in the function signature, not so much for the throw?, as it is closer to something Swift does have, which is the try? functionality.

1 Like

That's right. The language normally uses the question mark to refer to the actual Optional, and this proposal does not currently work exactly that way.

Since the guard statement expects expressions to evaluate as a boolean, throw? MyError.oops evaluates to true/false depending on the eligibility of the function to throw. This is slightly different than try? someExpression() because that actually evaluates to nil.

The function signature uses throws?, but again, it's not "throwing nil". Regarding this, it's possible to consider another keyword without the ? sugar.

If this pitch moves forwards as-is, it would expand our current association of ? sugar beyond just the Optional type.

However, if it's more valuable to reserve ? for Optional, we could consider a completely different syntax like so:

func foo() recovers {
  throw MyError.oops recover {
    print(error)
  }
}

or:

func foo() recovers {
  guard throw MyError.oops else {
    return
  }
}

In a previous discussion about C++ exception handling, I suggested the introduction of throws! to solve the issue that most C++ functions are needlessly implicitly declared as being able to throw even though most of them will never throw anything. (The discussion then kind of derailed towards discussing other usages for throws!, a very good read!)

How throws! would work is simple: if the caller does not explicitly wrap the call in a try, it'll be implicitly wrapped in a try!. I think it's a much simpler and straightforward way to achieve the goals of this pitch. No need to pass a flag to the callee telling it whether the context is throwing or not and no need to add additional code paths in the callee.

8 Likes

That's a very cool approach!

I would say that implicitly wrapping in try! seems a bit inflexible. In addition to a fatal crash, I think some value in this proposal is offering flexibility to recover with some default implementation - such as logging an error and returning nil. However, throws! does address concerns regarding confusing side effects from the else clause.

1 Like

This pitch needs clearer motivation and perhaps some examples to compare. It's very unclear why anyone would want this. Why would I want to write two versions of my code when the user can just discard the error if they don't care about it? I can see it helping with the overload issue, but that seems fixable on its own, if that's even a good idea. And, fundamentally, this seems rather antithetical to Swift's error handling philosophy: error producing code must always be visible. Altering the logic of your code because you forgot to include try seems to be the opposite of what the current design was trying to accomplish.

10 Likes

I think you may want to read the Error Handling Rationale. It's a long, thorough explanation of why Swift's error handling works the way it does. In particular, it elucidates the difference between three kinds of errors, and how they are associated with different error-handling features:

  • Simple domain errors indicate that an input whose validity was uncertain turned out to be invalid, like trying to convert a String that doesn't contain digits to an Int; these are handled with Optional return values.

  • Recoverable errors indicate which of a variety of unusual-but-anticipatable failure conditions occurred, like "file not found" or "insufficient permissions" when reading a file; these are handled with try and throw.

  • Logic errors indicate probable programmer mistakes, like a divide-by-zero, that the programmer cannot provide error handling code for because the condition should have been prevented in the first place; these are handled with intentional crashes (precondition(_:) failures, force unwraps, arithmetic overflows, etc.).

I mention this because the example in the pitch doesn't seem to think clearly about these categories.

Let's think about the Array subscript. It intentionally crashes on failure because Array's designers think of out-of-bounds as a logic error: the vast majority of array element accesses use an index that the programmer "knows" is valid (because the index comes from some sort of loop or computation based on the array's actual size), so if an index turns out to be out of bounds, there is probably a mistake in the program. But even when you do want to use an arbitrary integer that you don't know is valid, out-of-bounds would be a simple domain error, so you'd want to handle it with an Optional return value. And yet the feature you're proposing would only support throwing—the mechanism used for recoverable errors, the one category that doesn't make sense for the Array subscript.

For that reason, I don't think this feature is really fit-for-purpose. But there are also practical issues, like the ABI compatibility concerns. This feature can only work by passing at least one additional piece of information—whether or not the function should throw its errors—into the function. But there are millions of binaries already out in the world that call the Array subscript without providing this extra information. So changing the subscript to use this feature would break a lot of existing binaries.

I really think that existing features like try!, try?, force-unwrap, and ?? are already the right solution for these problems. They don't allow the caller to completely ignore the possibility of an error, but they make it incredibly easy to express common fallback behaviors. That seems to me like a good enough solution.

32 Likes
btw, you may define a custom array subscript operator to get nil instead of crashes on out of bounds accesses
func test() {
    var a = ["hello"]
    print(a[0, nil])        // Optional("hello")
    print(a[0, "world"])    // Optional("hello")
    print(a[1, nil])        // nil + "out of bounds array subscript getter"
    print(a[1, "world"])    // "world" + "out of bounds array subscript getter"
    a[1, nil] = "globe"     // "out of bounds array subscript setter"
}

extension Array {
    subscript(index: Int, default: Element?) -> Element? {
        get {
            if index < 0 || index >= count {
                print("out of bounds array subscript getter")
                return `default`
            }
            return self[index]
        }
        set {
            if index < 0 || index >= count {
                print("out of bounds array subscript setter")
                return
            }
            self[index] = newValue!
        }
    }
}

in regards to the pitch itself i don't understand neither the problem nor the solution, but i may be missing something.

2 Likes

I like the idea of using a custom getter, yet the functionality is not obvious from the syntax alone. For me, something like var item = try? array[1] ?? “hello” is very clear and consistent with Swift syntax. However this will never be possible because overloading with throws is not possible.

yep, i don't know how to make "default" parameter name explicit to make it compatible with dictionary subscripts.

array[1, default: value]

indeed. it is possible to implement this though:

if let v = array[i] {
   ...
}

(hint: i is not Int here)

Regarding the Logic errors

The correct handling of these error conditions is an open question

And such a proposal offers a way for users to opt into handling errors that are otherwise impossible to handle.

Programmers are already making bound checks like if i < array.count, and in my mind, this pitch streamlines these common checks. It also maintains the option for developers to choose the fatal crash and not handle errors.

Regarding ABI stability, I agree this may not be possible, but I think there may be a solution such as generating two functions, or possibly throwing the error behind the scenes and inlining the catch closure at the call site.

now i know how. version of subscript with explicit default parameter
extension Array {
    subscript(index: Index, default defaultValue: @autoclosure () -> Element?) -> Element? {
        get {
            if index < 0 || index >= count {
                print("out of bounds array subscript getter")
                return defaultValue()
            }
            return self[index]
        }
        set {
            if index < 0 || index >= count {
                print("out of bounds array subscript setter")
                return
            }
            self[index] = newValue!
        }
    }
}

func test() {
    var a = ["hello"]
    print(a[0, default: "hello"])        // Optional("hello")
    print(a[0, default: "world"])    // Optional("hello")
    print(a[1, default: nil])        // nil + "out of bounds array subscript getter"
    print(a[1, default: "world"])    // "world" + "out of bounds array subscript getter"
    a[1, default: nil] = "globe"     // "out of bounds array subscript setter"
}

usage:

if let v = array[1, default: nil] {
   ...
}

the auto closure part here is not essential, just an optimisation. what's essential is that subscript operator uses different rules compared to normal functions:

    subscript(index: Index, default default: Element) -> Element { ... }
    is to:
    subscript(index: Index, default: Element) -> Element { ... }

    as:
    func foo(_ index: Index, default: Element) -> Element { ... }
    is to:
    func foo(_ index: Index, _ default: Element) -> Element { ... }

you may define a subscript with both parameters present if you like:

    array[index: 0, default: value]

or define a "safe" version of subscript with no second parameter:

if let v = elements[at: 1] {
   ...
}

It's not that easy to search, and I have the feeling I did not find the thread I remembered... but for reference:

1 Like

Reading on to Typed Propogation and Higher Order Polymorphism, I am even now more inclined to believe the door is open for a proposal like this.

It is valuable to be able to overload higher-order functions based on whether an argument function throws; it is easy to imagine algorithms that can be implemented more efficiently if they do not need to worry about exceptions.

We do not, however, particularly want to encourage a pattern of duplicating

This proposal addresses the listed benefit and the listed concern from this document.

Later, in regards to rethrows:

... with vague plans to allow it to be parameterized in the future if necessary

And a syntax like throws? and rethrows? essentially parameterizes these features.

While the document's opinion remains neutral on these matters, I'd say this proposal does not interfere with the intent.

Thanks for the references. I like Chris Lattner's comment here. I didn't think about how something like this might work with actors, though.

One other issue I just thought of: because try covers an entire sub-expression, you might end up with things you don't expect to throw errors doing so. For instance, suppose you write:

let contents = try readFile(at: filenames[i])

The programmer's intent here is to call readFile(at:) and allow it to throw filesystem errors. But the try also covers filenames[i], so that subscript will now throw on an error instead of trapping. The possibility of the subscript throwing probably didn't occur to them—after all, how many times have they used a subscript and seen it trap on an invalid index?—so this is probably not what they intended, and their error-handling code will not expect a bounds-checking error and might do the wrong thing.

This isn't normally a problem because, with the current language, an API either requires try or it doesn't. Even if some particular use of a throwing API in a subexpression isn't explicitly marked with try, you know from other uses of that API that it throws. But introducing this feature would undermine that property—the subscript usually doesn't throw, but in this case, it does.

11 Likes

Does the try actually cover the subexpressions? I guess I've never done something like this. I would have assumed let contents = try readFile(at: try filenames[i]) covers the subexpression.

Yeah that definitely complicates some things.

Regarding this section of the error handling rationale:

It is valuable to be able to overload higher-order functions based on whether an argument function throws

What it means is that functions should be overloadable based on whether an argument with a function type throws, not based on whether the function itself throws. Consider this function:

func execute<R>(_ function: () throws -> R) rethrows -> R {
    return try function()
}

The above function is actually syntactic sugar over the following two functions:

func execute<R>(_ function: () -> R) -> R {
    return function()
}

func execute<R>(_ function: () throws -> R) throws -> R {
    return try function()
}

These two functions more clearly demonstrate that they are overloaded based on whether their argument throws, which is distinct from them being overloaded based on whether the function itself throws. (The rethrows keyword is actually how Swift discourages a pattern of duplication.) In fact, the same section of the error handling rationale says

It shouldn't be possible to overload functions solely based on whether the functions throw.

I believe that this is so that the try operator can be applied to subexpressions and to make Swift’s chosen overload more clear.

try (try executeThrowingMethod()).executeAnotherThrowingMethod()
// if the `try` operator couldn’t be applied to subexpressions, then code like this could become very verbose

Logic errors are possible to handle, though. In fact, Swift expects these errors to be handled by the programmer.

I don’t think bound checks are common enough in code for this proposal to make a noticeable impact in clarity — most of the time, an Array index will be derived from the Array itself. In fact, I think this change could reduce clarity, as it would make it harder to figure out which overload of a function is called. It would also mean we’d either have use the try operator much more, or we’d have to start applying (and remembering to apply) a donttry operator.

Another aspect of crashing upon a logic error is performance: logic errors may have to be checked many times while a program’s is running, so speed is very important in this context. Crashing upon a logic error helps offset the overhead of these checks. (I don’t believe the Swift team has ever told us why this is, but I assume it’s because it may enable certain compiler optimizations and because most modern processors execute things out-of-order. Crashing, unlike throwing, eliminates a dependency chain which means that the processor can execute more instructions at the same time. It would be nice if somebody with more technical knowledge than me could confirm this, though.)

Changing the behavior of Array accesses is a commonly rejected proposal. You may want to read the reasoning for this by clicking on this link.


Regarding the comment by Chris Lattner you mentioned, I’m almost certain that he wasn’t referring to the proposal itself. It seems that he was actually referring to this comment by David Waite which proposes a way to recover from crashes in a distributed concurrency model (I believe like erlang or like this proposal). The PDF he links to doesn’t talk about “optional throwing” at all, but it does mention actors in distributed compute models and terminating a crashing actor instead of the entire program at the very bottom.

1 Like