Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exceptions and error handling #578

Closed
pluto439 opened this issue Nov 2, 2017 · 18 comments
Closed

Exceptions and error handling #578

pluto439 opened this issue Nov 2, 2017 · 18 comments
Milestone

Comments

@pluto439
Copy link

pluto439 commented Nov 2, 2017

If I understand correctly, "error" in zig are just one program-wide enum of numbers. Having errors as numbers instead of strings will make good error codes, and will be useful for exit codes (number a program returns after finishing, can be accessed in console and scripts) and exceptions (for filtering exceptions I'm interested in and not interested in). Especially interesting is placing close by meaning error codes close together, so that you can filter them by ranges of numbers.

Program-wide error codes are not as useful as return values that you just pass around, because it breaks abstraction a bit. Error return values just need to explain in short what happened and what to do about it, and it should be only a matter of those two functions. Error return values are just a part of an interface, it should be directly in the function's documentation. Maybe in some cases it's not even necessary to explain what exactly went wrong, just say what something went wrong and raise an exception, to let the programmer fix it.

I expect some trouble when two libraries will have different global error codes that conflict with each other. It would be nice to be able to easily switch between static and dynamic linking in a program, and those global enums may be a hindrance to that.

Another issue, is that I think that simply crashing a program on panic is unacceptable. Program should get programmer's attention on any suspicious operation and just pause the program, giving the programmer a chance to figure out what went wrong, save data, maybe change logic at runtime (which only microsoft's visual studio can, other tools just can't compare with it) to continue without restarting anything. Exceptions are more of a debugging tool than anything, and it's better to have as little of exception handling as possible in the final program, but they are a really useful debugging tool. This has to wait until the work on debugger and ide starts.

Zig uses two kinds of arithmetic operations, a + b with raises exception (actually, it just calls panic and quits) and a +% b which doesn't. It's a scary situation, when everything can potentially crash on any moment. I can take a risk if I run it in the debugger on my own machine, but I'll be afraid to release it.

Good usages of exceptions are long file parsing operations, when file can abruptly end at any time. Jpeglib actually uses exceptions there, setjmp longjump kind. They are pretty much multi-function goto here.

Ideally you should have two versions of jpeglib: one with error handling, and one without, when you are sure no error can appear.

Another good usage is when you want to make parts of your program more independant, when you don't want to let everything crumble just because one part of a system broke.

If you want to get into live coding, you will need exceptions sooner or later. Because hardware itself throws exceptions. You can't guarantee you will never make a division by zero, unless you will do an ugly extra check every time. Debugger api (at least on windows) works very simularly to exception handling, you have to work with same structures for both breakpoints and divisions by zero.

Error handling is the ugliest part of the zig at this point. It adds extra syntax in the language just to get errors. Just returning multiple values would've made things more simple, it would also free you from calling a separate function just to get exact error code (GetLastError and the likes).

One of the forth languages I've seen used an interesting method of checking for errors. It used "number THROW" everywhere. If number is zero, it did nothing.

I'm not sure if setjmp/longjmp is the best way to use exceptions, looks like you can only keep one error handler at the time.

Probably just being able to replace the panic function at runtime is good enough. It should accept error number instead of string, and it should be possible to ignore panic by just returning some value. Need to study how windows exceptions work.

Linux uses different approach to exception handling than windows, because windows method was patented at the time. They still use setjmp/longjmp since then. You probably thought there is some technical reason for not using exceptions, but no, it's just patents and money. I think windows method is more comfortable and secure, but linux is more clean and lightweight (and cumbersome). On windows you can stack exception handlers, create a chain of them, and it's integrated into the os itself. On linux, you can have only one exception handler.

Good link about exception handling http://www.godevtool.com/ExceptFrame.htm , it's 32bit. Among other things, describes how to autoexpand memory by just using exceptions, I want to try that. Since 64bit, windows have a bit different mechanism, details here https://en.wikipedia.org/wiki/Microsoft-specific_exception_handling_mechanisms .

Example of registering exceptions in structured exceptions handling. Probably useless for you, since llvm most likely has it's own ways for it.

push handler_address
push [fs:0]
mov [fs:0],esp
...
pop [fs:0]
add esp,4

tl;dr

Make panic accept a number instead of a string.

Make it possible to ignore panic (it will return to the same place it was called from), pretty much turning panic into throw.

Remove error unions, just return multiple values instead.

Use "error" numbers for exceptions, not for return values.

Once ide and debugger are done, pause on exceptions, and allow to manipulate data and modify code edit-and-continue style (which is probably patented too, don't know).

@thejoshwolfe
Copy link
Contributor

Sorry, didn't read your whole post yet, but I wanted to comment on this paragraph:

Zig uses two kinds of arithmetic operations, a + b with raises exception (actually, it just calls panic and quits) and a +% b which doesn't. It's a scary situation, when everything can potentially crash on any moment. I can take a risk if I run it in the debugger on my own machine, but I'll be afraid to release it.

This isn't really accurate; maybe the docs need to be updated to make this more clear. a + b in C (for signed integers) can cause crashes just like in Zig, and if you disable safety in Zig, then a + b will probably behave the same as in C (for signed integers), which is overflow resulting in undefined behavior, which will often just be wraparound like unsigned integers on many architectures.

Just because a +% b overflowing doesn't immediately crash in safety mode doesn't mean it's not going to cause a crash. Often times your code is not prepared to handle overflow wraparound, so it will trigger some other error, like reading an array out of bounds, attempting to allocate the entire the virtual address space in one allocation, etc. It's actually more convenient that your crash happens as early as possible rather than the operation silently continuing and causing havoc elsewhere in your code.

You should be just as scared of adding integers together as you are of dereferencing pointers; both can cause undefined behavior, and both are necessary for even very simple applications.

@pluto439
Copy link
Author

pluto439 commented Nov 2, 2017

Yes, I'm not sure why I prefer corrupting data over an actual crash. Both are pretty bad though.

Hope it would be possible to disable all those overflow checks on release, once I'm finished debugging and testing. These overflow checks must come at a price of extra conditional jump per every arithmetical operation, I'd prefer to not have those in release.

@thejoshwolfe
Copy link
Contributor

Hope it would be possible to disable all those overflow checks on release, once I'm finished debugging and testing.

It is. Did you read the docs? I actually haven't read them very thoroughly, so I'm not sure, but I think this is answered in the docs. Maybe we should work on making them more accessible or something.

@pluto439
Copy link
Author

pluto439 commented Nov 2, 2017

Yes. They are full of TODOs.

@andrewrk
Copy link
Member

andrewrk commented Nov 2, 2017

Yeah, understandable, the docs are not complete yet. But there is information about release modes:

@pluto439
Copy link
Author

pluto439 commented Nov 3, 2017

I'll try to use goto instead of actual exceptions for now, they should be faster anyway. Goto and giant functions probably.

@pluto439 pluto439 closed this as completed Nov 3, 2017
@thejoshwolfe
Copy link
Contributor

If possible, please post a link to your use of goto over in #346 when you get something working. We're considering removing goto, and we need compelling usecases for evidence that it should stay.

@pluto439
Copy link
Author

pluto439 commented Nov 7, 2017

if you don't need to restore from exception, compiler don't need to store all register values on exception.

if exception is in the same function, compiler can just turn it into goto. It will change instruction pointer and stack pointer.

if there is nothing you need to clean up in stack (no destructors), compiler can free it all at once.

if there is only one exception destination, and you don't need chaining, compiler can only store old stack pointer and instruction pointer (for an exception handler).

The problem with defer is that you can't fire it before the function ends. I've seen a code where two files were freed manually, and for the last one the defer was used.

@pluto439 pluto439 reopened this Nov 7, 2017
@pluto439
Copy link
Author

Just trying to figure out how errors can be possibly handled.

Nesting:

func inspect(troshka) {
    troshka1 = open(troshka)
    troshka2 = open(troshka1)
    troshka3 = open(troshka2)
    troshka4 = open(troshka3)
    draw_silly_face(troshka4)
    put_back(troshka4, troshka3)
    put_back(troshka3, troshka2)
    put_back(troshka2, troshka1)
    put_back(troshka1, troshka)
}
func inspect(troshka) {
    troshka1 = open(troshka)
    defer put_back(troshka1, troshka)

    troshka2 = open(troshka1)
    defer put_back(troshka2, troshka1)

    troshka3 = open(troshka2)
    defer put_back(troshka3, troshka2)

    troshka4 = open(troshka3)
    defer put_back(troshka4, troshka3)

    draw_silly_face(troshka4)
}
func inspect(troshka) {
    troshka1 = open(troshka)
    if not troshka1 goto end

    troshka2 = open(troshka1)
    if not troshka2 goto p_troshka1

    troshka3 = open(troshka2)
    if not troshka3 goto p_troshka2

    troshka4 = open(troshka3)
    if not troshka4 goto p_troshka3

    draw_silly_face(troshka4)

    put_back(troshka4, troshka3)

p_troshka3:
    put_back(troshka3, troshka2)

p_troshka2:
    put_back(troshka2, troshka1)

p_troskka1:
    put_back(troshka1, troshka)
end:
}
func inspect(troshka) {
    try {
        troshka1 = open(troshka)

        try {
            troshka2 = open(troshka1)
            //etc
        } finally {
            put_back(troshka2, troshka1)
        }

    } finally {
        put_back(troshka1, troshka)
    }
}
func inspect(troshka):
    try:
        troshka1 = open(troshka)

        try:
            troshka2 = open(troshka1)
            //etc
        finally:
            put_back(troshka2, troshka1)

    finally:
        put_back(troshka1, troshka)
func inspect(troshka) {
    drawn_successfully = 0

    troshka1 = open(troshka)
    assert(troshka1)
    finally p_troshka1

    troshka2 = open(troshka1)
    assert(troshka2)
    finally p_troshka2

    troshka3 = open(troshka2)
    assert(troshka3)
    finally p_troshka3


    troshka4 = open(troshka3)
    assert(troshka4)
    finally p_troshka4

    res = draw_silly_face(troshka4)
    assert(res == 0)
    drawn_successfully = 1

p_troshka4:
    put_back(troshka4, troshka3)

p_troshka3:
    put_back(troshka3, troshka2)

p_troshka2:
    put_back(troshka2, troshka1)

p_troskka1:
    put_back(troshka1, troshka)

    return drawn_successfully
}
func inspect(troshka) {
    drawn_successfully = 0

    troshka1 = open(troshka)
    assert(troshka1)
    finally p_troshka1

    troshka2 = open(troshka1)
    assert(troshka2)
    finally p_troshka2

    troshka3 = open(troshka2)
    assert(troshka3)
    finally p_troshka3


    troshka4 = open(troshka3)
    assert(troshka4)
    finally p_troshka4

    res = draw_silly_face(troshka4)
    assert(res == 0)
    drawn_successfully = 1

p_troshka4:
    put_back(troshka4, troshka3)

p_troshka3:
    put_back(troshka3, troshka2)

p_troshka2:
    put_back(troshka2, troshka1)

p_troskka1:
    put_back(troshka1, troshka)

    return drawn_successfully
}

@pluto439
Copy link
Author

Stumbled upon "using exceptions as goto", this seems like the best use of exceptions. This is the one I talk about when I say they can speed program up.

https://en.wikibooks.org/wiki/Python_Programming/Exceptions

The Python-based mailing list software Mailman does this in deciding how a message should be handled. Using exceptions like this may seem like it's a sort of GOTO -- and indeed it is, but a limited one called an escape continuation. Continuations are a powerful functional-programming tool and it can be useful to learn them.

@pluto439
Copy link
Author

I'd like to do the cheapest case of exception: the multi function goto one.

Goto is only possible in one function. If you'll try to goto into the other function, function arguments will be uninitialized, and stack will be corrupted. I keep calling exceptions "multifunction goto", because that's exactly what they are. They can only go back to some function that was already visited, because going forward will leave stack corrupted. Exceptions need to store current stack pointer to go back, and they allow to skip a lot of code at once. But they can only go back. This is an important optimization, you shouldn't ignore it.

To do this, I need:

on "try":

  1. store a label address in a global variable (label will be "catch")

  2. store current stack pointer in a global variable

on "throw":

  1. restore old stack pointer from a global variable

  2. jump to that label address

Will only work if there is nothing important in the local variables (no exception nesting, no "finally"), and you don't need to recover from an "exception". It's very lightweight, because it's literally just 4 operations, unlike usual exceptions which also save every register.

I tried googling "c get label address", looks like there is a non-standard feature just for that in gcc. Read here https://stackoverflow.com/questions/1777990/is-it-possible-to-store-the-address-of-a-label-in-a-variable-and-use-goto-to-jum . I don't know how exactly to do this in zig, maybe I should use inline assembly for this?

Don't think that exceptions are always super heavy and should be avoided, this is an example of a good use of exception.

@andrewrk andrewrk added this to the 0.3.0 milestone Nov 22, 2017
@pluto439
Copy link
Author

With large functions you can just goto everywhere you want, without all those troubles with exceptions. To use large functions though, deleting variables mid function is almost necessary, it's very uncomfortable overwise. #594

I though that using inline functions will fix it, but you can't goto from them. At least, writing a compiler for it is complicated.

I can try to write a simple demonstration of multi-function-goto-exception-like-thing in assembly, should I? I'm trying to compile ffmpeg right now, maybe I'll learn something in the process.

@pluto439
Copy link
Author

There is one possibility that exeption will be slower than just error codes. It needs further investigation. I think exceptions will still be faster, if they jump through 5 functions at once. Which is rare.

http://www.agner.org/optimize/#manuals

optimizing_assembly.pdf

9.6 Jumps and calls

Returns are predicted by the use of a return stack buffer, which can only hold a limited number of return addresses, typically 8 or more.

The return stack buffer will fail if there is a call without a matching return or a return without a preceding call. It is therefore important to always match calls and returns. Do not jump out of a subroutine by any other means than by a RET instruction. And do not use the RET instruction as an indirect jump. Far calls should be matched with far returns.

@kyle-github
Copy link

It is received wisdom that exceptions in C++ cost much more than function calls or returns. I say "received" because I personally have not done any direct measurements. Exceptions do have to unroll the stack and other fairly heavy things. They are not what I reach for when I am trying to write performant code.

@pluto439
Copy link
Author

pluto439 commented Dec 1, 2017

This is more lightweight than C++ exceptions #578 (comment)

Good compiler probably can compile standard exceptions to lightweight ones and even to gotos. Same code, but better implementation.

@pluto439 pluto439 mentioned this issue Dec 6, 2017
@andrewrk
Copy link
Member

andrewrk commented Dec 6, 2017

Another issue, is that I think that simply crashing a program on panic is unacceptable. Program should get programmer's attention on any suspicious operation and just pause the program, giving the programmer a chance to figure out what went wrong, save data, maybe change logic at runtime (which only microsoft's visual studio can, other tools just can't compare with it) to continue without restarting anything.

You can override the panic behavior by specifying a public panic function in the root source file (next to main):

pub fn panic(msg: []const u8) -> noreturn {
    // Here is the default implementation:
    if (builtin.os == builtin.Os.freestanding) {
        while (true) {}
    } else {
        @import("std").debug.panic("{}", msg);
    }
}

a + b with raises exception (actually, it just calls panic and quits)

It just calls panic. The default implementation of panic (seen above) calls os.abort.

It's a scary situation, when everything can potentially crash on any moment.

This is the reality of programming. Zig does not cause it; rather it brings the potential issues to attention of programmers.

Make panic accept a number instead of a string.
Make it possible to ignore panic (it will return to the same place it was called from), pretty much turning panic into throw.

Panic is unrecoverable. It is noreturn. You cannot recover from a panic. This is not going to be changed. A number makes sense if we could recover from a panic. But we cannot recover from a panic. So it's a string.

Remove error unions, just return multiple values instead.

See #632 for the current discussion on error handling. It's looking like we're going to do this, as you said.

Use "error" numbers for exceptions, not for return values.
Once ide and debugger are done, pause on exceptions, and allow to manipulate data and modify code edit-and-continue style (which is probably patented too, don't know).

We're not going to have exceptions.

@andrewrk andrewrk closed this as completed Dec 6, 2017
@pluto439
Copy link
Author

pluto439 commented Dec 6, 2017

You can override the panic behavior

But I still have to close the program after that. I need to take the proper action, and continue working.

We're not going to have exceptions.

Why?

@thejoshwolfe
Copy link
Contributor

thejoshwolfe commented Dec 8, 2017

You can override the panic behavior

But I still have to close the program after that. I need to take the proper action, and continue working.

You can do any behavior that doesn't return from the panic function. Maybe you kill the current thread, which is sorta like the behavior of an uncaught exception in Java. Maybe you longjmp out to a known safe state, which is sorta like a caught exception in Java.

Whatever you do, though, it's not going to be pretty. All of these options have problems like not running deferred code. The language is designed so that panics are indicative of programmer error, not runtime user-input error, so panic should never be called. There's no zig-approved recovery strategy from a panic. Whatever you choose to do, you're responsible for the ugliness.

And you can't just ignore the panic, because the rest of the calling function depends on panic not returning. This isn't just for when assert() fails, but also for when (??ptr).a finds that the pointer is actually null, when switch (x) { 0 => return a, 1 => return b, else => unreachable, } hits the unreachable case, and lots of other places. In these contexts just "ignoring" the panic makes no sense; you would run into even worse problems than you started with, like executing out-of-bounds memory.

We're not going to have exceptions.

Why?

The short answer is that throw/catch style exceptions do hidden control flow. Instead use explicit return, %return, etc.

Re-reading your OP, it actually sounds like we're talking about different kinds of exceptions. I was thinking Java/JavaScript/C++ -style throw/catch, but you mention hardware exceptions and registering handlers with the OS. If that's what you mean by "exceptions", i'll need to reinterpret this whole discussion.

For example implementing a memory watcher that suspends the program in a debugger when an address gets written to: for that you should probably be interacting with the OS (or at least the MMU in some way) about triggering and handling segfaults on the containing page. I don't know if those kinds of exceptions and exception handlers belong in the zig std library or builtin functions or language keywords or leave it to userspace or what. That's an unknown domain to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants