How is throws implemented: is it like C++ exceptions, what does try compile to, and what does it cost?
Predict first
What does the SIL of a throwing function contain?
import Swift
struct Boom: Error {}
@inline(never) func risky(_ n: Int) throws -> Int { if n > 1 { throw Boom() }; return n }
@inline(never) func caller() -> Int { (try? risky(2)) ?? -1 }
- An @error result and try_apply at the call site
- A landing pad and unwind table
- A setjmp call
- Nothing special
Predict first
And in arm64 assembly at -O?
import Swift
struct Boom: Error {}
@inline(never) func risky(_ n: Int) throws -> Int { if n > 1 { throw Boom() }; return n }
@inline(never) func caller() -> Int { (try? risky(2)) ?? -1 }
- A test of register x21 after the call
- A call to _Unwind_RaiseException
- A jump through a vtable
- No branch
Mental model. Swift’s throws is a shop where every till has a second tray for complaints; the cashier looks in it after every sale. C++ exceptions are a fire alarm that clears the building and is rung rarely.
Swift errors are ordinary return values, not unwinding exceptions. A throwing function has an extra result channel in its type, visible in SIL as (Int, @error any Error), and the caller uses try_apply, which branches to a normal block or an error block. On arm64 the error is passed in a dedicated register (x21); in the assembly the caller sets it to zero before the call and tests it afterwards with cbz/cbnz. There are no unwind tables or landing pads for Swift errors and no stack walking, so a throw costs about as much as a return plus an allocation for the error value (an existential box for any Error), and the success path costs one extra compare per call.
That is why Swift can mark throws in the signature and still be fast, and why there is no “zero-cost happy path” argument as in C++. It also means try? and try! are simply different handling of the error branch, defer blocks run on both paths, and typed throws (throws(MyError)) can avoid the existential box by returning a concrete error type directly. I checked the SIL and arm64 assembly; the register choice is architecture specific and I did not run x86_64.
Errors are returned, not unwound.
The error register is checked with cbz or cbnz.
Common traps
- Treating throwing as expensive like Objective-C or C++ exceptions. It is cheap; do not contort APIs to avoid it.
- Assuming Objective-C
NSExceptionis caught by Swiftcatch. It is not; it needs Objective-C handling.
They might follow up with
- What does typed throws save? The existential allocation and dynamic casts on the error path.
- Does rethrows cost extra? It is checked at the call site; the call convention is the same.
Related questions
- 1.8.3 What are typed throws (throws(MyError))? Why do they exist?
- 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…
- 5.1.4 How do you read SIL to tell static dispatch from dynamic dispatch?
- 5.1.3 How do you look at what the compiler produced at each stage?
← How is an async function different in compiled code, what does await…