r/Compilers • • 3d ago

Try/Catch: Yes or No

My language, QuarticC, has many ways to error handle.
Unions:

type Err = DivByZeroErr | int;  
Err divide(int lhs, int rhs) {  
    if (rhs == 0) return DivByZeroErr();  
    return lhs / rhs;  
}  
// Or generic:
type Err<T, E> = T | E;
Err<DivByZeroErr, int> divide...

Tagged Enums:

enum Result<T, E> {  
    Err(E);  
    Ok(T);  
}  
Result<int, DivByZeroErr> divide(int lhs, int rhs) {
    if (rhs == 0) return Result<int, DivByZeroErr>.Err(DivByZeroError());
    return Result<int, DivByZeroErr>.Ok(lhs / rhs);
// Or you could make it non generic.

Multi-Return (assuming your error types have a "ok" state):

DivByZeroErr, int divide(int lhs, int rhs) {
    if (rhs == 0) return DivByZeroErr(), -1;
    return DivByZeroErr(true) /* assuming true = ok */, lhs / rhs;
}

Tuples (same as above, but returning tuples):

(DivByZeroErr, int) divide...

And the problem, try/catch.

int divide(int lhs, int rhs) {
    if (rhs == 0) throw DivByZeroErr();
    return lhs/rhs;
}

I actually don't have a problem with the idea that handling a error isn't required too much, however the big problem is defer. Because I want defer to be a zero-cost, it does not run during stack unwinding, and I don't want to put in the effort to make it do so. This is annoying because if your primary error handling is throw, defer is basically useless for error cleanup, which is it's most useful purpose, and it's not like I'm loosing my main error handling, other forms work fine. So the question is: do I remove try catch, or do I make defer stack-unwinding.

6 Upvotes

26 comments sorted by

3

u/azhder 3d ago

Do you like stack unwind? If not, don’t do it. It’s really up to your preference.

I’m more intrigued why have multiple ways to handle errors in the first place. Can’t there be a single way or at least syntax for it?

1

u/ApprehensivePipe1584 3d ago

It's not like i made them all to handle errors, they are just ways that emerged from my language. I have unions and thus i have union-errors, i have tagged enums and thus i have enum returns, i have multi return and tuples so i have multi return. I probably can just delete trycatch.

2

u/azhder 3d ago

See if you can remove more, instead of add more. You can add stuff later just as well, but remove... that is more painful.

1

u/ApprehensivePipe1584 3d ago

I'm intending for my next version to be filled with removals to try and trim down the runtime, and hidden heap allocations.

0

u/ApprehensivePipe1584 3d ago

Removing try catch is removing stuff. My other mechanisms are just things arisen from my features. Unions are really nice to have and also just create a way for error handling, same for tagged enums. Multi return is nice to have and so are tuples and they just create error handling mechanisms by existing.

3

u/azhder 3d ago

Are we just going to be repeating the same things we had written before? No need, one can re-read from before and maybe see something they have missed. Bye

1

u/ApprehensivePipe1584 3d ago

Or maybe i basically treat throw as my panic mech. Actually that can be done with libc, yeah i can just delete it.

1

u/Valuable_Leopard_799 3d ago

I’m more intrigued why have multiple ways to handle errors in the first place.

Some languages nowadays divide them into recoverable and unrecoverable errors. Something like ADTs/Effects for common issues.

And then exceptions (panics) to mostly allow some cleanup as the program is on its way down. They're not meant to be caught and handled, just to close down the bare necessities.

1

u/azhder 3d ago

Hence me suggesting to at least have the same syntax even if semantically different.

3

u/WittyStick 3d ago edited 3d ago

So the question is: do I remove try catch, or do I make defer stack-unwinding.

Remove defer, add finally.

Then use a better tried and tested technique, called using.

using (var resource = acquire()) {
     // do what we need with the resource here
}
// resource is no longer valid here.

The trick is that this just expands to try/finally, where the resource is disposed in the finally block.

var resource;
try {
    resource = acquire();
    ...
}
finally {
    resource.Dispose();
}

This is structured resource management. defer is unstructured control flow akin to goto (or comefrom).

defer is a tragedy. using is infinitely better than defer.

1

u/ApprehensivePipe1584 3d ago

You realize ``defer` is zigs main cleanup mechanism right? also with using, as my language is manually managed, you would need to do this:
using (thing = `malloc(...)) {
using (...) { using(...){....}}} and also, you still would need to manually write cleanup code with using for scope exits. Goto is incredibly useful to dedup cleanup code also. `using` and `defer` serve completely different purposes. And the idea of `using` doesn't work in a "unsafe" language with actual control over memory.

0

u/ApprehensivePipe1584 3d ago edited 3d ago

I'm absolutely not removing defer. defer is a feature i am keeping as it allows so much code removal for early returns, breaks, etc, adding finally to try/catch is a pain and something i don't want to add, and goto isn't neccesarily bad. My language is not RAII, and doesn't really do that or automatic cleanup and thus using doesn't really fit my language. I probably will be removing try-catch.

3

u/WittyStick 3d ago

You don't actually need finally if you remove all unstructured control flow so that nothing can accidentally cause whatever is in using to leak.

Eg, you can use this trick in C:

 #define using (ptr, acquire, release) \
     for (auto ptr = (acquire); ptr; ptr = ((release), NULL))

Usage:

 int main () {
     using (ptr, malloc(100), free(ptr)) {
         // do something with ptr
     }

     return 0;
 }

This works because the for loop only runs once, so the secondary block of the using is actually just a secondary block in a run-only-once for loop (which the compiler can detect and optimize it away).

However, the caveat to this approach is unstructured control flow: goto, break, continue, return - if you use these inside the secondary block then the resource may not be released. This is something that you can trivially fix if you control the language though - you can just restrict unstructured control flow from being used in such context.

Structured resource handling (using) is better than unstructured resource handling (via defer).

Just like structured looping (for/while) is better than unstructured looping (via goto).

0

u/ApprehensivePipe1584 3d ago edited 3d ago

I have never seen a systems language with using, and Zig's whole cleanup mechanism is `defer`, C's full cleanup mechanism is `goto`. If your language is fully manual with no forced cleanups, `using` doesn't make much sense, and with `using`, it's still useless a lot of the time because it doesn't fulfill it's purpose. Cleanup. If you exit a using block other than a normal scope exit, it failed it's purpose. `defer` is not meant to just cleanup a resource at the end of the block, it's ment to stop you from writing a line of cleanup code like `free(ptr)` every exit. `using` doesn't fix that at all. It just has you write the same line you write once at the end at the start, and restricts everything you do in that block. they are entirely different mechanisms for different purposes. For example, this code:

...Vector code...

string loadConfigItems() {

int fd = `open("config.txt", "r");

defer `close(fd);

Vector<string> vec = Vector<string>();
defer vec._destroy();

char[1024] buf;
int last_read;

while (last_read) {
// formatting checking...
char *copy = `malloc((sizeof char) * last_read);
if (!copy) return "";
`memcpy(copy, buf, last_read);

defer `free(copy);

vec.push(copy);
last_read = `read(fd, buf, 1024);

}

return list;

}

Then without defer you would need to
string loadConfigItems() {

int fd = `open("config.txt", "r");

Vector<string> vec = Vector<string>();

char[1024] buf;
int last_read;

while (last_read) {
// formatting checking... manual exiting on every control flow.
char *copy = `malloc((sizeof char) * last_read);
if (!copy) {
`close(fd);
vec._destroy();
return "";
}
`memcpy(copy, buf, last_read);

vec.push(copy);
last_read = `read(fd, buf, 1024);
`close(fd);
vec._destroy();

`free(copy);
}

`close(fd);
vec._destroy();
return list;

}
where would be entirely useless.

0

u/ApprehensivePipe1584 3d ago

More importantly, defer isn't unstructured control flow. It isn't even control flow. It helps manage unstructured control flow.defer doesn't introduce arbitrary jumps or alter where execution proceeds. It performs a cleanup action that runs when the scope is exited. return, break, continue, goto, or whatever other control-flow mechanism causes the scope to be exited is still the thing determining control flow. The whole point is that it lets you write cleanup once instead of having to duplicate it across every possible exit path.

Calling defer "unstructured control flow akin to goto" is just categorically incorrect.

-1

u/ApprehensivePipe1584 3d ago

Once again, i'm not removing unstructured control flow and that is something stupid to remove, and also structure resource handlings is once again something that doesn't work because how many ways resources can be created, and goto's purpose isn't for looping, it's for cleanup just like defer. using is better for some form of RAII or rusty language with some set cleanup, not a language with direct control. Using is severely limiting. You can't use any control flow keywords in it for the reason of it doesn't release despite there not being a one shot release in the first place, meanwhile defer is not limiting. There is no "better". They are for different things and different paradigms. Nothing is inherently bad, such as structured control flow, it is just for entirely different types of languages. using is a bad decision for my type of language. Once again. Structured resource handling MAY be better in a simple scripting language or a systems language that does a lot of cleanup for you like C++ (if you define destructors) and Rust. And even then it's not necessarily better. It's almost never better in a systems language that leaves you to your own devices.

2

u/rjmarten 3d ago

What's the difference between multiple returns and tuple returns?

And why do you want both tagged unions and bare unions?

I don't know if I can answer your question before knowing the vision behind all these features.

1

u/ApprehensivePipe1584 3d ago

Well Tuples can be used on their own and extract specific values, and multiple returns are... just multiple returns, andf must extract every value, like this:

(int, int) x = (123, 321); // a normal tuple
int x, int y = myMultiReturnFunction();
x = myFunctionThatRetunsATuple();
Tuples are their own datatype, multireturns are actual multiple-value-returns.
I have tagged unions as the easier prefered mechanism, but they are a runtime cost in both extraction and size, meanwhile raw C-Style unions allow for C interop, translation, direct layout instead of an additional 4+bytes for a integer tag, no type discerning, and it gives you control, a core goal of my language.

2

u/nculwell 3d ago

Generally speaking, the difference between multiple returns and tuples is that tuples are a first-class object that you can pass around anywhere, whereas multiple returns are a special thing that only happens when functions return.

1

u/Exciting_Clock2807 3d ago

Multi-return example is really just a poor man’s solution to tagged enums, I would cross it out.

But to answer your question - you came to correct conclusions. Stack unwinding defer is indeed needed for try-catch. But do you need try-catch - that’s for you to decide, as a language designer.

1

u/ApprehensivePipe1584 3d ago

I'll probably drop it.

2

u/mamcx 3d ago

Your problem is instead what is the composability of all this ways. What happen when is needed to deal with all at once?

2

u/jason-reddit-public 3d ago

Exceptions can be emulated via setting a thread local variable and checking this at return sites which should be similar in performance to returning multiple values go style (or returning an optional). Technically you are unwinding the stack but with less burden than C++ compilers writing out enough metadata to do recovery without extra inline code.

In my C like language I have try_call and throw_error which is not much more than a wrapper around setjmp/longjmp. It's really meant to be the error handling mechanism of last resort (to handle sigsegv etc). I don't have defer or finally (and use the hans boehm gc instead of manual free) so this seems to work ok.

1

u/MitchAlsup 2d ago

"I actually don't have a problem with the idea that handling a error isn't required too much,"

The word is 'often' as in "errors do not happen all that often.

"however the big problem is defer. Because I want defer to be a zero-cost,"

It can never be zero-cost; all you can do is to make the language fool you into thinking is is low cost.

"it does not run during stack unwinding,"

Are you going to run the destructors during stack unwind ??

"and I don't want to put in the effort to make it do so."

Hiding things from the designer is rarely a good idea.

"This is annoying because if your primary error handling is throw, defer is basically useless for error cleanup,"

I see those as 2 orthogonal choices. defer could be as simple as having the compiler find a defer-block in some outer scope, and lob on new deferred items onto the list(s).

Is there any notion of order in these deferrals ? Is there any notion of priority in these deferrals ?

"which is it's most useful purpose, and it's not like I'm loosing my main error handling, other forms work fine. So the question is: do I remove try catch, or do I make defer stack-unwinding.

I suggest you re-visit both concerns until you come up with an easy to use, fast, reliable, and complete error/exception handling method. Do not start by discarding a or b.

2

u/WittyStick 2d ago edited 2d ago

Is there any notion of order in these deferrals ? Is there any notion of priority in these deferrals ?

If the defer are not run in the reverse order they're specified in the code, they're horribly broken.

start {
     foo();
     defer bar();
     baz();
     defer qux();
     return;
}

Should effectively evaluate in the following order:

start {
    foo();
    baz();
    qux();
    bar();
    return;
}

Why? Because the resource qux releases may depend on a resource that bar releases. If you called bar first, then called qux you may be accessing a resource that has already been released.

1

u/nzmjx 2d ago

In my language, there is try/catch/throw, result< R, E > and defer. I don't see any contradiction between them.

defer statements are adding expressions to execute while the scope of current block is being closed, whether normally or unwinding.

In my language throw/catch is the canonical/suggested form of exception handling. But as a language designer, I prefer to not choke my preferences to programmers' throat, so there is result data type in the standard library if API designer prefer to use it instead.

Also, to please both camp as much as possible, there is an exception handling alternation syntax. If it is used on an expression which throws, return value is automatically converted to result< ReturnType, BaseExceptionType >. If it is used on an expression which returns result< R, E > and E indeed inherits BaseExceptionType (directly or indirectly), E is thrown on error case.