Multiple errors with static types

Yes! what I suggested was adding a dedicated language feature for this, instead of somehow trying to fit both common and exceptional errors in the same box.

If in your use case is okay with just logging failure, that's fine, but not every use case is. You could completely disregard such an exception system, the defaults described would not differ from existing behavior.

You are assuming a desktop, but there are consoles like the Switch which are more constrained, and being careful with memory is much more important.

It would be incompatible with Swift to suggest allocation as an Error in the first place. And I don't think most would be happy with the degree of strictness Zig provides, where even appending to a dynamic array requires try for that incredibly rare chance. This is why I also described a theoretical way to implement handling such exceptions in a completely opt-in way.

How would you safely integrate such an allocator with application code? You'd get a conceptual model where these entities have to integrate somehow, which would be hard to do in Swift especially taking concurrency into account.
Simple control flow is far easier to understand.


Again, you're somehow trying to reserve error handling for exceptions. You're free to do that, but I don't believe this is what this system was intended for.

Not sure what you mean. Are you suggesting putting multiple layers of abstraction to handle something which can just be a function, like loading a file? I hope I misunderstand.
That's not actually removing the control flow, it's just obfuscating it. I don't think forcing code to conform to current shortcomings of the language is preferable to seeking improvement.

Instead of trying to hide errors, the language should make dealing with them easy and convenient.

It is not the intention of the language to restrict the use of untyped errors in Embedded Swift. If it's not possible to do so today, it is a shortcoming and not by design:

1 Like

A big problem with exceptions (here, I’m using that term to refer to the ability to throw errors that can be later caught without having a syntactic indication in between the throw and catch points to indicate to the developer and the compiler that an abnormal exit mode is possible) is that of cleanup.

If some code in my function raises an exception and it is propagated up to a parent function that catches the exception, do defer blocks in my function run? (This could be possible to get working if you doubled code size by compiling every function twice — once for the uncaught exception case and once for the exception case). What if I do some teardown at the end of my function because there’s only a single return point so I don’t really need a defer? Would that cleanup get arbitrarily skipped? What if I did myMutex.withLock { thingThatThrowsException() }? Would the mutex be unlocked? Would the state protected by the mutex be valid if you try to look at it later? How about reference types — are the reference counts decremented if you break out of a scope that’s holding a reference? And what’s the overhead of checking for exceptions again and again in straight-line code in order to implement some subset of the above functionality?

Aside from the boilerplate overhead, changing error representation by successively wrapping in new sum types also has runtime overhead even if you make it implicit. I'm not sure if I'd actually recommend the following approach yet, given that Swift doesn't make it ergonomic, but an idea to explore might be to group errors together under one generic enum, using the existence or lack thereof of the generic arguments to select what set of cases are available in different situations:

protocol Flag { init() }
extension Never: Flag { init() { fatalError() } }
struct Always: Flag { init() { } }

enum MyError<Load: Flag, Compile: Flag>: Error {
    case compileError(_: Compile = Compile())
    case loadError(_: Load = Load())
}

By binding one or the other flag type to Never, the associated error cases become impossible:

// If we only want to deal with load errors, we can bind Compile == Never
typealias LoadError = MyError<Always, Never>
// Or if we only want to deal with compile errors, we can bind Load == Never
typealias LoadError = MyError<Never, Always>
// If we need to deal with both kinds of error:
typealias BuildError = MyError<Always, Always>

func foo(x: LoadError) {
    // We only need to handle load errors here
    switch x {
    case .loadError:
        print("butz")
    }
}

func bar(x: CompileError) {
    // We only need to handle compile errors here
    switch x {
    case .compileError:
        print("butz")
    }
}

func baz(x: BuildError) {
    switch x {
    case .loadError:
        print("butz")
    case .compileError:
        print("butz")
    }
}

To indicate that a function only produces a subset of cases, while allowing it to be used within a computation that can also produce the other cases, it could theoretically be generic over the cases it doesn't produce:

func load<Compile: Flag>() throws(MyError<Always, Compile>) { ... }
func compile<Load: Flag>() throws(MyError<Load, Always>) { ... }

func loadAndCompile() throws(BuildError) {
    try load()
    try compile()
}

Unfortunately, as it stands, it looks like the implementation of typed throws doesn't involve the thrown error type in generic type inference, and complains:

Compiler errors
/Users/jgroff/foo.swift:33:11: error: generic parameter 'Compile' is not used in function signature
31 | }
32 | 
33 | func load<Compile: Flag>() throws(MyError<Always, Compile>) {
   |           `- error: generic parameter 'Compile' is not used in function signature
34 | }
35 | 

/Users/jgroff/foo.swift:36:14: error: generic parameter 'Load' is not used in function signature
34 | }
35 | 
36 | func compile<Load: Flag>() throws(MyError<Load, Always>) {
   |              `- error: generic parameter 'Load' is not used in function signature
37 | }
38 | 

/Users/jgroff/foo.swift:40:9: error: generic parameter 'Compile' could not be inferred
31 | }
32 | 
33 | func load<Compile: Flag>() throws(MyError<Always, Compile>) {
   |      `- note: in call to function 'load()'
34 | }
35 | 
   :
38 | 
39 | func loadAndCompile() throws(MyError<Always, Always>) {
40 |     try load()
   |         `- error: generic parameter 'Compile' could not be inferred
41 |     try compile()
42 | }

/Users/jgroff/foo.swift:41:9: error: generic parameter 'Load' could not be inferred
34 | }
35 | 
36 | func compile<Load: Flag>() throws(MyError<Load, Always>) {
   |      `- note: in call to function 'compile()'
37 | }
38 | 
39 | func loadAndCompile() throws(MyError<Always, Always>) {
40 |     try load()
41 |     try compile()
   |         `- error: generic parameter 'Load' could not be inferred
42 | }
43 | 

so we'd have to make an already weird technique even uglier to cope with dummy type parameters:

func load<Compile: Flag>(_: Compile.Type = Never.self) throws(MyError<Always, Compile>) {
}

func compile<Load: Flag>(_: Load.Type = Never.self) throws(MyError<Load, Always>) { }

func loadAndCompile() throws(MyError<Always, Always>) {
    try load(Always.self)
    try compile(Always.self)
}

I didn't say I would like that kind of exceptions in Swift, what I described was essentially a variant of Swift's existing error handling but existing on the side and sort of defaulting to try! — so the default is to panic, and you instead opt into handling such errors with a try like keyword, like before class or array allocation.

Though I'm not sure if Swift can reasonably do this, since for example collection copy on write would make most mutation potentially throw allocation errors :frowning:
There's even language features where allocation happens behind the scenes, so it's not obvious how those could be handled.

Zig handles allocation errors everywhere and it seems to be fine, so it doesn't seem like checking and forwarding them all over the place is a performance concern. It even does so with defer and errdefer in the language.


Fundamentally, all workarounds involving grouping errors together in some way can't work in cases like the one which made me start this thread — LoadError and CompileError come from distinct modules.

You can test memory allocation failures (and more generally, any other recoverable errors you have, like I/O errors) by introducing a debugging mode, where your allocation function (or I/O or whatever) randomly returns failure on 1 out of N calls, for some small-ish N. Then you run your test suite as usual and make sure nothing fails.

3 Likes

Also, since you always want to test at least one failure you should probably use a decrementing counter initialized to a random value in 0..<N and trigger the failure when the counter reaches zero.

My point is that there are good reasons why the language is like it is right now—even if, to me, it would be desirable to have the 2 features I listed above—and given the state of the language, extracting errors with business logic significance out of the error space and into models that express that logic in the return type of a function is the best approach, and has worked very well for me and others.