Next article: Corporate Training, NYC Workshop, and Book Update
Previous article: Friday Q&A 2017-08-11: Swift.Unmanaged
Tags: assembly fridayqna swift
Swift's error handling is a unique feature of the language. It looks a lot like exceptions in other languages, but the syntax is not quite the same, and it doesn't quite work the same either. Today I'm going to take a look at how Swift errors work on the inside.
Semantics
Let's start with a quick refresher on how Swift errors work at the language level.
Any Swift function can be decorated with a throws
keyword, which indicates that it can throw an error:
func getStringMightFail() throws -> String { ...
To actually throw an error from such a function, use the throw
keyword with a value that conforms to the Error
protocol:
throw MyError.brainNotFound
When calling a throws
function, you must include the try
keyword:
let string = try getStringMightFail()
The try
keyword doesn't do anything, but is a required marker to indicate that the function might throw an error. The call must be in a context where throwing an error is allowed, either in a throws
function, or in a do
block with a catch
handler.
To write a catch
handler, place the try
call in a do
block, and add a catch
block:
do {
let string = try getStringMightFail()
...
} catch {
print("Got an error: \(error)")
}
When an error is thrown, execution jumps to the catch
block. The value that was thrown is available in error
. You can get fancy with type checking and conditions and multiple catch
clauses, but these are the basics. For more information about all the details, see the Error Handling section of The Swift Programming Language.
That's what it does. How does it work?
Implementation
To find out how it works, I wrote some dummy code with error handling that I could disassemble:
struct MyError: Error {
var x: Int
var y: Int
var z: Int
}
func Thrower(x: Int, y: Int, z: Int) throws -> Int {
throw MyError(x: x, y: y, z: z)
}
func Catcher(f: (Int, Int, Int) throws -> Int) {
do {
let x = try f(1, 2, 3)
print("Received \(x)")
} catch {
print("Caught \(error)")
}
}
Of course, now that Swift is open source, I could just go look at the compiler code and see what it does. But that's no fun, and this is easier.
It turns out that Swift 3 and Swift 4 do it differently. I'll briefly discuss Swift 3, then look a bit deeper at Swift 4, since that's up and coming.
Swift 3 works by essentially automating Objective-C's NSError
convention. The compiler inserts an extra, hidden parameter which is essentially Error *
, or NSError **
. Throwing an error consists of writing the error object to the pointer passed in that parameter. The caller allocates some stack space and passes its address in that parameter. On return, it checks to see if that space now contains an error. If it does, it jumps to the catch
block.
Swift 4 gets a little fancier. The basic idea is the same, but instead of a normal extra parameter, a special register is reserved for the error return. Here's what the relevant assembly code in Thrower
looks like:
call imp___stubs__swift_allocError
mov qword [rdx], rbx
mov qword [rdx+8], r15
mov qword [rdx+0x10], r14
mov r12, rax
This calls into the Swift runtime to allocate a new error, fills it out with the relevant values, and then places the pointer into r12
. It then returns to the caller. The relevant code in Catcher
looks like this:
call r14
mov r15, rax
test r12, r12
je loc_100002cec
It makes the call, then checks if r12
contains anything. If it does, it jumps to the catch
block. The technique on ARM64 is almost the same, with the x21
register serving as the error pointer.
Internally, it looks a lot like returning a Result
type, or otherwise returning some sort of error code. The throws
function returns the thrown error to the caller in a special place. The caller checks that place for an error, and jumps to the error handling code if so. The generated code looks similar to Objective-C code using an NSError **
parameter, and in fact Swift 3's version of it is identical.
Comparison With Exceptions
Swift is careful never to use the word "exception" when discussing its error handling system, but it looks a lot like exceptions in other languages. How does its implementation compare? There are a lot of languages out there with exceptions, and many of them do things differently, but the natural comparison is C++. Objective-C exceptions (which do exist, although pretty much nobody uses them) use C++'s exceptions mechanism on the modern runtime.
A full exploration of how C++ exceptions work could fill a book, so we'll have to settle for a brief description.
C++ code that calls throwing functions (which is the default for C++ functions) produces assembly exactly as if it called non-throwing functions. Which is to say, it passes in parameters and retrieves return values and gives no thought to the possibility of exceptions.
How can this possibly work? In addition to generating the no-exceptions code, the compiler also generates a table with information about how (and whether) the code handles exceptions and how to safely unwind the stack to exit out of the function in the event that an exception is thrown.
When some function throws an exception, it walks up the stack, looking up each function's information and using that to unwind the stack to the next function, until it either finds an exception handler or runs off the end. If it finds an exception handler, it transfers control to that handler which then runs the code in the catch
block.
For more information about how C++ exceptions work, see C++ ABI for Itanium: Exception Handling.
This system is called "zero-cost" exception handling. The term "zero-cost" refers to what happens when no exceptions are ever thrown. Because that code is compiled exactly as it would be without exceptions, there's no runtime overhead for supporting exceptions. Calling potentially-throwing functions is just as fast as calling functions that don't throw, and adding try
blocks to your code doesn't result in any additional work done at runtime.
When an exception is thrown, the concept of "zero-cost" goes out the window. Unwinding the stack using the tables is an expensive process and takes a substantial amount of time. The system is designed around the idea that exceptions are thrown rarely, and performance in the case where no exceptions are ever thrown is more important. This assumption is likely to be true in almost all code.
Compared to this, Swift's system is extremely simple. It makes no attempt to generate the same code for throws
and non-throws
functions. Instead, every call to a throws
function is followed by a check to see if an error was returned, and a jump to the appropriate error handling code if so. These checks aren't free, although they should be pretty cheap.
The tradeoff makes a lot of sense for Swift. Swift errors look a lot like C++ exceptions, but in practice they're used differently. Nearly any C++ call can potentially throw, and even basic stuff like the new
operator will throw to indicate an error. Explicitly checking for a thrown exception after every call would add a lot of extra checks. In contrast, few Swift calls are marked throws
in typical codebases, so the cost of explicit checks is low.
Conclusion
Swift's error handling invites comparison with exceptions in other languages, such as C++. C++'s exception handling is extremely complicated internally, but Swift takes a different approach. Instead of unwind tables to achieve "zero-cost" in the common case, Swift returns thrown errors in a special register, and the caller checks that register to see if an error has been thrown. This adds a bit of overhead when errors aren't thrown, but avoids making things enormously complicated the way C++ does. It would take serious effort to write Swift code where the overhead from error handling makes any noticeable difference.
That's it for today! Come back again for more excitement, fun, and horror. As I have occasionally mentioned before, Friday Q&A is driven by reader suggestions. As always, if you have a topic you'd like to see covered here, send it in!
Comments:
I.e., in my apps (I often write code in C++ and Xojo, formerly RealBasic) I like to be able to throw an exc in deep functions if something is wrong, and since these languages do not force me to write a catch handler at every level (unlike Java and Swift), I end up with writing exception handlers at the top level (i.e. where an event originates, though, Xojo makes this especially easy by forwarding all unhandled exceptions to the initial Application object), where I'd then write a log file and tell the user that something went wrong. That log file would then optionally sent by the user to me, so that I could figure out where and possibly why something went wrong there.
This error log would include a stack trace because Xojo would include the stack trace on a throw, (and C++, well, see this, for example: https://stackoverflow.com/a/26883211/43615).
Of course, the advantage in Xojo is that even the runtime uses exceptions when reporting things like null pointers, out-of-bounds errors (for array access, for instance) and other low level errors. Swift doesn't do that, unfortunately (and I understand the reasoning behind some of it, but not all - I still want back a language that throws when encountering a div-by-zero or a value overflow in an expression or assignment).
So I wonder if I should change my programming paradigms and stop using exceptions for reporting unexpected states, or if I keep using them, whether I'll lose the ability to catch them at the highest level, along with learning the stack trace?
throw
or try
in a context where it's guaranteed that something will catch it.
If you want to be able to throw from anywhere and catch at the top level, then all of your functions would need to be declared as
throws
. Which of course you could do, if you wanted to! If you wanted to take it to an extreme, you could write versions of force-unwrapping, subscripting, division, etc. that throw rather than crashing when their preconditions are violated.
I don't think top-level exception handlers, and throwing exceptions for every single error, is a good idea. We should distinguish between programmer errors and external errors. Programmer errors can't be anticipated in code, pretty much by definition. Any attempt to handle them is fraught with peril. If, for example, some
Optional
contains nil
when you thought it couldn't possibly do so, what else is wrong with the state in your program? Trying to continue is a potential disaster.
External errors are things like disk failures, network failures, or badly formatted data. These you can anticipate and deal with in code. To do it properly, you need to test all of that error handling code with the failures you've written it to handle.
If the goal is better diagnostics for users when they hit bugs, I think the best bet is to crash on failure, and use some sort of crash reporting tool to get the stack traces to you.
throw
functions in Swift are (typically very slightly) slower than in C++ .
IIUC, the note at https://docs.swift.org/swift-book/LanguageGuide/ErrorHandling.html (under Handling Errors section) says that when exceptions are thrown, Swift is faster than C++. Why is it so? I understand that Swift can find out whether an exception occurred simply by reading a register; but then doesn't it still have to jump to the calling function frame, just like in C++? And wouldn't that jump be just as expensive as in C++?
How can this possibly work? In addition to generating the no-exceptions code, the compiler also generates a table with information about how (and whether) the code handles exceptions and how to safely unwind the stack to exit out of the function in the event that an exception is thrown.
When some function throws an exception, it walks up the stack, looking up each function's information and using that to unwind the stack to the next function, until it either finds an exception handler or runs off the end. If it finds an exception handler, it transfers control to that handler which then runs the code in the catch block.
When an exception is thrown, the concept of "zero-cost" goes out the window. Unwinding the stack using the tables is an expensive process and takes a substantial amount of time.
Comments RSS feed for this page
Add your thoughts, post a comment:
Spam and off-topic posts will be deleted without notice. Culprits may be publicly humiliated at my sole discretion.