Swift Delight: try

Part of the Swift Regrets series.

Swift’s whole error handling model definitely has pros and cons, but I just want to focus specifically on try. That said, I do have to talk about the model a bit to establish what Swift does differently.

There are essentially two classes of error handling implementation: caller-checked, and “zero-cost exceptions”. In the caller-checked model, after a function returns, the caller checks something to see if there was an error—the return value, a special out-parameter, something. By contrast, in the “zero-cost exceptions” model a function doesn’t return if there’s an error. Instead it jumps to some cleanup code and eventually to an error handler, manually resetting the stack as needed. (There are a few ways to implement this, some more “zero-cost” than others.) It’s “zero-cost” because if you don’t have an error there’s nothing to check, no extra code run, but if there is an error it usually ends up being more work / code size than it would have been to just check at the call site. So it’s a trade-off.

These are the two main implementation models. The user model in turn can look like manual checking or like automatic propagation, and that’s why we’re talking about all this. Swift’s error handling user model is automatic propagation but it’s implemented with manual checking.

(…Technically I could have skipped everything up to that point, but y’all like learning, right? So to learn a lot more about error handling impls and user models, John McCall did an excellent survey and write-up as part of designing Swift’s error model.)

Anyway, Swift’s error handling model looks a lot like C++’s or Java’s or C#’s, with throw and catch keywords. But in C++ or Java or C#, you can’t tell what statements might throw errors. Which means you have to be very careful about which order you do operations in, lest one of them throw an error and leave your object, or something else, in an inconsistent state. The classic example is a mutex left locked, but more subtle would be something like a partially-written output file.

Swift’s contribution to the space is moving try from “something in this block might throw” to “something in this statement might throw”. (The block marker goes from try/catch to do/catch, and do/while goes to repeat/while.) Like Optional replacing null, the real win is in the lines that don’t have try: now you know they can’t throw. And all other “exiting” control flow in a function is marked: break, continue, throw, and return. try might be conditional, but that doesn’t mean it should be completely unmarked.

This doesn’t solve all the “inconsistent state” / “cleanup” problems; you still have to think about that at all the try lines. But it’s certainly a step forward!

I don’t think Swift’s try is perfect. To avoid a deluge of required keywords on one line, it applies to everything to its right, rather than just the operation immediately after it. That is, in try foo(bar()), bar() is permitted to throw as well. I also think there’s room to improve around calling rethrows functions with single-expression closures, where you end up with a try outside and inside. And over the years several people have argued that it’s just noise in certain circumstances, though I don’t always agree. So it might still evolve in the future, or in the next language.

But overall I think marking the control flow that is propagating errors is something that makes the auto-propagating user model more usable all around, in a way that makes it easier to not make mistakes.

P.S. What about the manual checking model? I think the equivalent feature is locally opting in to propagation, like Rust’s ? operator. let foo = bar()?; in Rust is almost exactly the same as let foo = try bar() in Swift, despite the differing user model elsewhere.