I guess there can be Java way: in addition to declared exceptions, we could add unchecked exceptions which are not declared in signatures, but still can be caught. If such an exception is not handled, it crashes the app. However, I don’t quite understand how it would look like in Swift where people often write do {} catch {}
to catch anything instead of handling only expected types of exceptions
The design of Swift’s error system is a reaction to and repudiation of Java’s checked and unchecked exceptions.
Well, maybe. However, I believe Java’s exception system could solve the problem and that Swift can become better refining its exceptions
I wonder if setjmp / longjmp could work here (e.g. Swift calling into C that calls those). Not the nicest API by any stretch of imagination but could do the trick.
BTW, we have throwing getters but not setters, why is so? Is this something just not currently implemented and planned for the future or are there inherent reasons against throwing setters?
Interestingly, this is considered as never returning code:
func foo() -> Never {
while true {}
}
and so is this:
func foo() -> Never {
let v = true
while v {}
}
but not this:
let v = true
func foo() -> Never {
while v {}
} // 🛑 Error: Global function with uninhabited return type 'Never' is missing call to another never-returning function on all paths
nor this:
func bar() {
let v = true
func foo() -> Never {
while v {}
} // 🛑 Error: Local function with uninhabited return type 'Never' is missing call to another never-returning function on all paths
}
Hey Kyle. could you please elaborate a bit?
As far as I understand, the issue with Java/C++ style exceptions is the huge cost of stack unwinding in error cases, and the the associated binary bloat. I'm not advocating for that at all, and I much prefer the modern approach to errors in Swift/Rust.
That said, I think the checked and unchecked exceptions is Java is actually fantastic, e.g.
- For reasonably-recoverable errors (e.g.
NumberFormatException
), you can't throw without declaring it and forcing your caller to have to handle it - For other (e.g.
ArrayIndexOutOfBounds
) you can throw it without your caller needing to handle it, but they still can if they want to.
What's not to like about that? (At least in principle, the implementation approach is what sucks about it, IMO)
(edit: Ignore Java's typed throws. You can have the distinction between checked vs unchecked exceptions without needing the checked exceptions to also be strongly-typed. That's an orthogonal topic.)
I don't think this is possible, from an implementation perspective.
A function like func f() throws -> T
is conceptually similarly as if it returns Result<T, any Error>
and switching
on the result (either continuing on .success
or invoking the appropriate try
/try?
/try!
behaviour on .failure
).
The caller needs to know that its calling a throws
function, so that it can add the branch necessary to handle the error case. This couldn't work if the callee was allowed to throw an error without declaring it, because the caller wouldn't know to check it.
Personally I'm largely fine with Java's exception model, based on my experience with it not causing me any particular frustration, although I can certainly see that it can be [mis]used in ways that cause frustration - basically, by going to either extreme of making everything checked or making everything unchecked.
It's not a perfect comparison point for Swift, though, because there's substantial structural & ergonomic differences between Java & Swift's exception systems in other ways (e.g. Swift's convention of using enums is really nice).
But I know the common complaints are:
-
Checked exceptions can be a drag on code evolution because they tend to propagate up the stack (i.e. some leaf function changes what exceptions it declares it can throw, so now every caller has to as well, if it didn't already happen to handle it with an overly-broad catch clause).
Some people also complain that they have to add new catch clauses for newly listed checked exceptions, but honestly that's so directly against the whole purpose that I don't see any validity to that, except maybe as an expression of "I don't like strongly-typed languages". It's like arguing that Swift got enums totally wrong and there should be no exhaustiveness checking.
-
Unchecked exceptions can arise from anywhere so you never know for sure what your control flow will be (
NullPointerException
is a particularly common example).This is such a common complaint that there's a dedicated section on it in the Java documentation.
Nominally Swift avoids the first point by not having checked exceptions at all (all exceptions are existentials that can be any Error
-conforming type at runtime), and the second point by just crashing.
To crash or not to crash
I've never encountered anyone, no matter how much they hate Java's exception model, who wishes that it would just crash instead. Their objection is only that they can't tell when & where unchecked exceptions will arise. Swift is no better in this respect.
Proponents of crashing without recourse argue that in those cases the program state is undefined and it's safer to kill everything and start over.
Of course, that overlooks many practical needs such as have been raised by this thread.
It's also pretty disingenuous, I think - e.g. attempting to index a collection out of bounds triggers a crash, even though absolutely nothing about the program's state is necessarily undefined or corrupt at that point (it's usually a programming error at worst - in other languages it's a style choice or even the only way because they choose "assume good, recover on error" rather than "assume bad, check up front").
That said, proliferating try
s is unappealing and Swift is really not designed to permit unstructured exceptions ("unstructured" in the same general sense as Structured Concurrency). I suspect it's a false dichotomy anyway - that there's a lateral solution in the same vein as how type inference largely moots the typed vs untyped debates.
Java does have a stronger runtime resiliency guarantee than Swift, as is usually the case for a managed runtime environment. So nominally its unchecked exceptions can be handled gracefully in more cases, than in Swift where the condition that caused the exception might be indicative of, or caused, program state corruption. Though in practice I'm not sure there's that much of a gap - you can still corrupt the JVM in Java, especially if you mix in the moral equivalent of Swift's unsafe code, and most truly undefined-behaviour error conditions in Swift are either not immediately noticed at all or handled in other ways (e.g. segfaults).
Nonetheless, there is likely a lot of Swift code already written which is not exception-safe, because it doesn't make appropriate use of defer
or similar mechanisms to ensure program state is restored to something acceptable in the event of unexpected interruption. It's interesting to note that Swift doesn't even have finally
clauses for try
-catch
blocks, further discouraging exception-safe-but-agnostic code.
It's worth noting, though, that there's also a growing amount of Swift async code which is not correct & safe either, because people are still grappling with how to write re-entrant code (for Actors).
I believe this area is worth exploring.
If we do introduce unchecked exceptions, how could that look? I guess we'd need a different syntax for try/catch analogues to support them, as the current syntax won't quite work:
func bar() {
throw NSError() // 🛑 Error is not handled because the enclosing function is not declared 'throws'
}
func foo() {
1/Int()
}
do {
foo()
} catch { // 🔶 'catch' block is unreachable because no errors are thrown in 'do' block
}
A quick and dirty proof of concept:
obj-c file:
#include <Foundation/Foundation.h>
typedef void (^DoBlock)(void);
typedef void (^CatchBlock)(void);
static int* jmpBuf;
void try3(DoBlock doBlock, CatchBlock catchBlock) NS_SWIFT_NAME(try3(tryBlock:catchBlock:));
void try3(DoBlock doBlock, CatchBlock catchBlock) {
jmp_buf buf;
if (setjmp(buf) == 0) {
jmpBuf = buf;
doBlock();
} else {
catchBlock();
}
}
void throw3(void) {
longjmp(jmpBuf, 1);
}
test:
func divide(_ a: Int, _ b: Int) -> Int {
if b == 0 {
print("throwing exception")
throw3()
}
return a / b
}
func test() {
try3 {
print("before divide")
divide(0, 0)
print("after divide")
} catchBlock: {
print("caught exception")
}
print("afterwards")
}
test()
outputs expected:
before divide
throwing exception
caught exception
afterwards
I'm not sure it's the right direction. That said, for argument's sake if it were to be done, then I guess it'd have to be via a distinct error metatype, similar to Java's RuntimeException. So e.g. UnexpectedError
as a peer to Error
.
Logically it'd have to be possible to throw it without an explicit throws
annotation nor a try
from the caller. Otherwise it's just an Error
like any other, and I don't think requiring try
et al with this class of errors is currently viable - when you consider unlikely but possible errors like malloc failures, virtually everything in non-embedded Swift can throw an 'unchecked' exception, and try
is only useful if it's unusual.
If Swift had much more powerful flow analysis, things might be different. e.g. what if integer arithmetic was throwing by default unless the compiler could prove that it couldn't throw (e.g. it knows both operands are bounded in a way that precludes under- or over-flow)? But that's a long way from where Swift is now.
So, if we have to allow these exceptions even in the absence of try
, then really the crux problem is clean [enough] stack unwinding. There will definitely be people who insist that then all Swift code would have to be exception-safe. I've not really explored how difficult that is in Swift. In C/C++ and the like it's very difficult. In Java it's moderately difficult (but the failure modes tend to be biased towards the benign, and the nature of GC helps cover up a lot of things that would otherwise be memory leaks etc).
But, I'm not sure it needs to be absolutely safe. We have plenty of particularly relevant experience that it can work despite big safety holes in the language and code, thanks to Objective-C and its long history in Apple's platforms. The ability to message nil without crashing is an instructive example - I've seen countless bugs that would have made a Swift program crash yet an Objective-C program just benignly fails to do a specific thing and otherwise soldier's on (most importantly, giving the user opportunity to save their data!). Despite the fact that almost no code in Objective-C was written to be exception safe, and exceptions or messages to nil frequently caused memory leaks and the like.
Still, I have to reiterate that I think this (unchecked exceptions in the style of Java) is an uncreative solution. I have to believe there's a better way, we just need to be clever enough to find it.
I wonder if there's something to the idea of having to install a handler in advance, to 'opt into' unchecked exceptions rather than making them always enabled, and defaulting to the existing behaviour of crashing in the absence of this explicit request to handle them.
This would be different to e.g. signal handlers, in that you'd still unwind the stack just like for a regular exception. But that way you don't even bother unwinding the stack if nothing's going to handle it anyway - saving time and preventing any possible further data corruption or ill side-effects. But for cases where it's worth that risk - e.g. unit testing - it gives you a way.
Maybe something like:
// Peer of `Error` for these "unchecked exceptions".
protocol NormallyFatalError {}
…
try hard {
// Code goes here.
} catch NormallyFatalError {
// Optional recovery code.
}
Bike-shedding over the name aside - although the phrase "try hard" does tickle me tremendously - maybe this has potential?
There's potential for abuse - e.g. cargo-culting like "always 'try hard' because it's safer" - but then there usually is, for any feature. With careful documentation and appropriate guidance, this could be limited by convention to a limited number of use cases.
Maybe there's also some constraints the Swift compiler could impose on code within one of these "hardened" execution contexts, to further minimise the possibility of undesirable consequences. e.g. it requires WMO and within the module any code that can be called in this mode is compiled with extra defences baked in (e.g. using implicit defer
blocks for all release
calls, to minimise memory leaks). But, short of full-program LTO with no dynamic libraries (rarely applicable in Swift) that can't be exhaustive (or would have significant limitations, like you can't call out of the current module from such "hardened" code…?).
Just thinking out loud.
IIRC, the selling point of C++ exceptions is that there is no CPU overhead for "no error" case. That's not the case in Swift which has an overhead for every throwing call doing the "if error" check. This overhead is minute but it is there.
There should be the corresponding "throw hard", right? As normal "throw" would complain about "being used in a non throwing function".
If I understand you correctly, in this line of thought you are suggesting the same underlying mechanism to work for both normal and hardened exceptions, with the minor syntax difference that hard throws could be thrown from a function that is not marked throwing. Does it mean that compiler will treat all functions potentially "hard throwing" thus incurring the overhead mentioned above, or could it only do so for functions for which it proves they are hard throwing?
Ditto :)
Maybe, or maybe not - it could be that you cannot (in normal code) throw such an exception directly, but rather you just call fatalError
or similar (like today).
That would have the benefit (?) of restricting how such 'unchecked exceptions' could possibly arise, and discourage careless use (since even if you're sure that by convention in your code base you always catch 'unchecked exceptions', you'd still be writing fatalError
everywhere which carries a kind of warning with it).
And in that sense it's similar to a lot of other test-centric machinery which isn't strictly-speaking the "right" way to do something in production, but is perfectly fine in a build or test environment.
Don't use this code
You can use signal handlers to catch failures.
// C header:
typedef void(func_with_ctx_t)(void *ctx);
bool with_signal_handler(int sig, func_with_ctx_t *func, void *ctx);
// C source:
typedef void(c_signal_handler_t)(int);
typedef struct {
int *jb;
} resume_vector_t;
#define SIG_COUNT (SIGUSR2+1)
resume_vector_t resume_vectors[SIG_COUNT];
void signal_handler(int sig) {
resume_vector_t rv = resume_vectors[sig];
longjmp(rv.jb, 1);
}
bool with_signal_handler(int sig, func_with_ctx_t *func, void *ctx) {
c_signal_handler_t *old_handler = signal(sig, signal_handler);
resume_vector_t old_rv = resume_vectors[sig];
jmp_buf jb;
bool success = false;
if (setjmp(jb) == 0) {
resume_vectors[sig].jb = jb;
func(ctx);
success = true;
}
// restore
resume_vectors[sig] = old_rv;
signal(sig, old_handler);
return success;
}
// Swift:
struct SignalReceived: Error {}
struct InternalInconsistency: Error {}
func withSignalHandler<R>(signal: Int32, do body: () throws -> R) throws -> R {
try withoutActuallyEscaping(body) { body in
typealias BodyWrapped = () -> Void
var result: Result<R, Error>?
let bodyWrapped: BodyWrapped = {
result = Result(catching: body)
}
let ctx = Unmanaged.passRetained(bodyWrapped as AnyObject)
let success = with_signal_handler(signal, { ptr in
guard let ptr else {
return
}
let bodyWrappedBoxed = Unmanaged<AnyObject>.fromOpaque(ptr).takeRetainedValue()
let bodyWrapped = bodyWrappedBoxed as! BodyWrapped
bodyWrapped()
}, ctx.toOpaque())
if success {
guard let result else {
assertionFailure()
throw InternalInconsistency()
}
return try result.get()
}
throw SignalReceived()
}
}
@main
struct App {
static func main() {
do {
let r: Int = try withSignalHandler(signal: SIGTRAP) {
_ = [][0]
print("Unreached 1")
return 42
}
print("Unreached 2", r)
} catch {
print(error)
}
print("Resume")
}
}
// Will print:
// Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range
// SignalReceived()
// Resume
This sounds a lot like Rust's panics. Which can be set to either allow custom handlers, or be set to a mode where a panic will crash like fatalError
. IIRC the reason custom panic handlers are allowed is to enable testing and Erlang-style fault tolerance which are exactly the problems discussed here. Panics in Rust are used in the exact same places that fatalError
would be in Swift. I wonder if we could reuse some of their machinery?
The big wrinkle here is ABI stability. Rust can always recompile the standard library to use your selected panic mode, but (on stable platforms) Swift would have to pick one. It could also provide duplicate entry points for all functions that can trap to cause a potentially recoverable panic instead, but I don't think that's practical.
PS. Maybe it's possible to setup a stack unwind handler at the begining of code that handles the crashes caused by fatalError
. That would handle the case of panic recovery from standard library sources, while allowing something a little more optimal for panics the compiler can see.
That's cool. Works with fatalError
as well.
Doesn't work from within Xcode or there's a secret option to toggle which I am not aware of.
I don't know. It seems like a simple continue
should do the trick, but LLDB sits on brk 1
(on arm), which is this Builtin.int_trap()
and don't want to allow exception propagation.
Maybe someone else know the answer, but I debugged it with prints.
You can't safely unwind through Swift code, so don't use longjmp or C++ exceptions. You can't safely resume Swift execution after a crash, so if you're going to use a signal handler to observe it, you still need to crash afterward. Simple cases may appear to work but you will see weird unpredictable behavior if you try to continue. The best way to handle a crash is to run the possibly-crashing code in its own process and look for the crash from a supervisor process. The next-best is to run it on its own thread, and leave the thread wedged if it crashes.
That was out of the question from the beginning. It's more like an educational excerise.
Exceptionally unfortunate some platforms don't allow this.
I'd say the next-best is to run it on a dedicated coroutine, leave the stack when it fails, and return the actual system thread back to the thread pool. Threads are expensive resource after all. Do you see any issues with this?