DEV Community

Christoffer Lernö
Christoffer Lernö

Posted on

More on error handling in C3

When we left off, C3 was looking like this:

int! index = atoi(readLine());
if (index) {
  printf("Thx for the number\n");
  // Index is now int.

I somewhat off-handedly mentioned that some sort of guard statement would be needed to extract the error and the need to handle things like index && index > 0

As usual in language design, things become less easy the more you flesh out the spec.

The first obvious problem is using if (index) for unwrapping.

Here's a problematic piece of code:

bool! b = someCall();
// Is this checking if b is true or non error?
if (b) { ... }

A way around this would be to explicitly indicate the success check:

bool! b = someCall();
// Use the ? to indicate unwrapping
if (b?) { ... }

This seems fine, but now that we made b? doing implicit unwrapping we're making pretty complicated things possible:

bool! b = someCall();
if (i > 0 && b? && ((b = someCall())? || i > 100) { ... }

In the example above the compiler has to figure out that b might possibly have an error...

To deal with this we need to do real full flow typing, which increases the complexity of implementing the compiler by quite a bit. That's not the only problem: flow typing means types implicitly change. A quick look at the code above - is it easy to see that b will not be unwrapped in the body?

So flow typing has both advantages and disadvantages.

One of the core principles I try to follow building this language is that it should not be hard to write a compiler for it. It's by necessity a multi pass compiler, but other things it's nice to keep simple.

There are ways to do so. For example, unwrapping might require what in other languages are called a "if-let":

bool! b = someCall();
if (bool b1 = b?) { ... }

Here there is no implicit unwrap, it's just another variable introduced in the scope. This is all well, but pretty verbose. It would be nice to have a shortcut for the bool b = b? case.

Again the language design becomes more complex than one likes. C3 has a pretty flexible if statement that allows you to write things like:

if (int a = foo(), b = bar(), int c = baz()) { ... }

However in this case only the final result (that of baz()) counts. If it looked like this:

if (int a = foo()?, b = bar(), int c = baz()) { ... }

We'd have to make sure that the call to foo() didn't return an error AND that baz() was non zero.

So what should we do?

It's time to take a step back and review our options without making assumptions that we unwrap things with if.

First let us construct our guard statement – the one taking a block to execute if there is an error:

int! i = ...
catch (err = i)
   ... handle the error ...

We can do some very simple flow typing here:

  1. If a variable is caught using a catch
  2. And the catch has a jump at all exits
  3. Then the variable can be types to the non failable version of it after the catch.
int! i = ...
catch (err = i)
// i is int here

So that works. This is much easier than if we hade overloaded if to handle error unwrapping. What if we introduce try to be like if but only for unwrapping:

try (int j = i) 
 ... only executes if i is not an error ...
try (i) 
 ... i is implicitly unwrapped to int ...

So to wrap up, here are some elements of the error handling:

int! i = ...

// Default value if it is an error
int j = i else 0;

// Jump on error
int k = i else return;

// Check error
try (i)
  printf("i was: %d\n", i);

// Conditional execution
// this line is only called
// if i is not an error.
printf("i was: %d\n", i);

// Composition:
bool! b = checkFoo(getFoo(i));
int! l = i + 1;

// Returning something that may be an error
if (z > 0) return i;

// Check if error
bool wasError = check(i);

// Check if success
bool wasSuccess = try(i);

// Returning an error
return MyError!;

The error handling still has some ways to go, but it's getting closer to something that also handles the various possible corner cases and not just the simplest use cases.

Oldest comments (2)

powerofzero profile image
Agatha Zeren

A couple thoughts:

The implicit conditional execution seems non-ideal to me, as it seems like it could be very easily lead to bugs where one forgets to handle an error and important code is just skipped silently. If you want this pattern, (though I'm not sure that it's necessary) then perhaps use ? to indicate you are doing this. (See Swift's optional chaining):

int! foo = something_fallible();
int! bar = takes_an_int(foo?);

Also, if a function returns a void!, then is it allowed to be invoked on a line by itself?

You also, there is an interesting interaction with defer. There are cases where you want defer on normal return, error return, or both. I'm not sure what the best way to write these is. Also, it would be useful to be able to access errors in the error version of defer, for logging, etc. Or even be able to modify the error, for instance to add a stack trace. I'm not sure of the usefulness in the other cases, but they probably exist, and it would probably be good to add for symmetry. Actually, if a single defer gave access to the value, one could check whether it was an error or normal return.

lerno profile image
Christoffer Lernö

There are some possibilities if one wants to guard against forgotten errors - and those are similar to Go:

  1. Require a function to store a failable in a variable, or assign it to “nothing” (_ in Go)
  2. Together with the above, require use of said variable unless it is the “nothing” variable.
  3. One can go even further requiring it is either used in a call chain or passed to either try or catch.

The above may be set as simply warnings or notices.

And yes, a function returning void! can be invoked on a line by itself.

defer, defer catch and defer try will invoke on any, error and normal return respectively.

The error is likely to be available in the catch version of the defer.

It is an interesting idea to unify them. I’ll give it some thought.