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

let 'catch' define named error handlers #5421

Closed
networkimprov opened this issue May 24, 2020 · 21 comments
Closed

let 'catch' define named error handlers #5421

networkimprov opened this issue May 24, 2020 · 21 comments
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@networkimprov
Copy link

networkimprov commented May 24, 2020

I'm the author of golang/go#27519 -- one of very few Go error handling proposals to remain open, after numerous other ideas to improve Go's error handling have been debated and discarded.

Since Zig is close to finalizing its language spec, I'd like to throw this into the ring. I've been watching Zig with interest from a distance. I'm disappointed in Go as a language, altho its tools, runtime, and stdlib are quite good.

The goal here is clear error handling inside complex business logic with a myriad of recovery schemes, some of which are shared. The construct below may seem alien to you at first sight, but it's completely natural to zillions of folks writing catch blocks in Java, Python, and Javascript. (And sure, you can approximate it with a nested function, but that is both alien to this constituency and heavy on boilerplate.)

In Zig today,
f() catch |err| switch (err) {...} can't skip code on error, a standard feature of catch,
if ... else |err| {...} skips code like standard try/catch, but yields a noisy indent cascade, and
try + errdefer is a case of catch (!) that throws unrecoverable (?) errors to the caller.

I respectfully submit that these are not the ideal semantics for a next-gen C.

Instead, let catch define named function-local error handlers. Unlike exceptions, this does not propagate errors up the stack, and ties a statement that can yield an error to a specific handler. It offers these features:

  1. Multiple statements may reference a single error handler,
  2. a handler may appear anywhere in the function after the statement(s) that reference it,
  3. triggering a handler skips any statements between the trigger point and the handler, and
  4. execution continues after the handler.
const f = fn(iPath []const u8) !void {                      // EDIT: was Go, now Zig
   var buf: [1024]u8 = undefined;

   const file|eos| = std.fs.openFileAbsolute(iPath, .{});   // path may not exist
   defer file.close();
   |eos| = file.seekTo(42);
   const len|eos| = file.read(buf);
   
   |epr| = process(buf[0..len]);                      // skip this on OS error
   
   catch eos switch (eos) {                           // handle OS error
      error.FileNotFound => |epr| = process(null),
      else => @panic(...),
   }
   catch epr {                                        // handle processing error
      std.log.println(epr);
      return;
   }

   finish();                                          // called unless catch returns
}

Zig's if (...) |val| {...} else |err| {...} syntax can get noisy:

if (f()) |val| {
   one(val);
   if (g()) |val| {
      two(val);
   } else |err| {
      fixtwo(err);
   }
} else |err| {
   fixone(err);
} 

With catch this could be either of:

const val|e1| = f();  // maybe just: val | e1
one(val);
val|e2| = g();
two(val);
catch e2 fixtwo(e2);
catch e1 fixone(e1);

one(_|e1| = f());
two(_|e2| = g());
catch e2 fixtwo(e2);
catch e1 fixone(e1);

And catch could infer the error from the preceding statement:

const val|err| = f();
catch { log(err); => 1; } // inits val to 1

const val|_| = f();
catch { => 1 }            // inits val to 1

When error var is omitted, catch is an operator like today:

const val = f() catch 1;

For any operation that yields an error which is not caught, returning an error could be implicit. That makes try unnecessary.

fn f() !void {
   const val|_| = g(); // if g() returns error, f() returns it
   |_| = h();          

   h();                // terse variants
   const val = g();    
}

We would want some built-in handler names for common cases:

|@panic| = f() // panic on error
|@log|   = f() // log error and return it
|@logc|  = f() // log error and continue

There's a lot of detail in golang/go#27519 and the requirements doc linked from it. If there's interest, I can work with folks here to massage that material into a Zig proposal.

@Vexu Vexu added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label May 24, 2020
@Vexu Vexu added this to the 0.7.0 milestone May 24, 2020
@pixelherodev
Copy link
Contributor

How does this actually change the language? Typical error handling in Zig looks like this:

const foo = bar() catch |err| switch (err) {
    error.A => {},
    else => {},
};

What would this look like with your proposal?

@networkimprov
Copy link
Author

In the proposal,
a) multiple statements may reference a single error handler,
b) a handler may appear anywhere in the function after the statement(s) that reference it,
c) triggering a handler skips any statements between the trigger point and the handler, and
d) execution continues after the handler.

These are present in the first example above. I've added this list to the text.

@pixelherodev, your example is for a handler bound to a single statement with no intermediate statements, so it would look much the same.

@ifreund
Copy link
Member

ifreund commented May 24, 2020

you can already get fairly close to this with #1717 which is accepted:

const std = @import("std");

const explode = fn() !void {
    return error.Boom;
}

const sleep = fn() !void {
    return error.CantSleep;
}

const run = fn() !void {
    return error.Tripped;
}

pub const main = fn() void {
    const blk = fn() !void {
        try explode();
        try sleep();
        try run();
    }

    blk() catch |err| switch (err) {
        ...
    };
}

@squeek502
Copy link
Collaborator

squeek502 commented May 24, 2020

Seems very similar to handling errors using goto in C. Not sure I see the benefit over the status-quo, and the hidden goto seems strange. There's no precedence for goto style jumps in Zig, is there?

@ifreund
Copy link
Member

ifreund commented May 24, 2020

Seems very similar to handling errors using goto in C. Not sure I see the benefit over the status-quo, and the hidden goto seems strange. There's no precedence for goto style jumps in Zig, is there?

I agree, this is literally goto with a different name. We have defer and more relevantly errdefer to handle this. Therefore, I'd be against accepting this proposal.

@zzyxyzz
Copy link
Contributor

zzyxyzz commented May 25, 2020

@ifreund

I agree, this is literally goto with a different name.

While this is mostly true, it should be noted that this proposal only allows forward jumps, so the potential for spaghetti code is reduced significantly.

We have defer and more relevantly errdefer to handle this.

Not quite. The whole point of this proposal (if I understand it correctly), is to simplify the direct handling of multiple instances of multiple failure modes within a single function. The try-errdefer combination works best if there is only a single failure mode to be handled.

That said, I do think that this proposal would make control flow sufficiently confusing that the potential benefits are probably not worth it.

@ifreund
Copy link
Member

ifreund commented May 25, 2020

You're right, the semantics of the proposed syntax are slightly different than goto. However, I think they have the potential to be just as hard to follow in terms of control flow.
Consider the proposed example:

|val|e1| = f();  // maybe just: val | e1
one(val);
|val|e2| = g();
two(val);
catch e2 fixtwo(e2);
catch e1 fixone(e1);

and a more idiomatic version of the "status quo" snippet:

    const val = f() catch |err| {
        fixone(err);
        return;
    };
    one(val);
    const val2 = g() catch |err| {
        fixtwo(err);
        return;
    };
    two(val2);

The control flow in the second example is in my opinion far easier to understand without becoming cumbersome. I think that goto-esque jumps in control flow violate the zig zen, in particular:

  • Communicate intent precisely.
  • Favor reading code over writing code.
  • Reduce the amount one must remember.

@networkimprov
Copy link
Author

networkimprov commented May 25, 2020

Isaac, I believe you added returns which don't appear in my code. If you change them to goto and add a label, then it would be the same ;-)

Agreed that this, and traditional exceptions, entail an implicit goto. But so do if, else, and for.

You could indent my second example to indicate what code is skipped on error (altho that doesn't work for my first example, where the first catch can invoke the second):

val|e1| = f();
   one(val);
   val|e2| = g();
      two(val);
   catch e2 fixtwo(e2);
catch e1 fixone(e1);

@ifreund
Copy link
Member

ifreund commented May 25, 2020

ok, perhaps this is closer to the control flow you intended?

    const val = f() catch |err| fixone(err);
    one(val);
    const val2 = g() catch |err| fixtwo(err);
    two(val2);

if not, my question become how does the program continue after the catch statements if it doesn't return and val/val2 are not initialized?

|val|e1| = f();  // maybe just: val | e1
one(val);
|val|e2| = g();
two(val);
catch e2 fixtwo(e2);
catch e1 fixone(e1);
// I guess execution continues here

but what is done if we errored? If fixtwo() initializes val then my example above is clearer and more concise. If not, I'm really not sure why you don't just return. I don't see a how a hypothetical function would be able to continue without the two variables it tried and failed to initialize.

@networkimprov
Copy link
Author

It's a contrived example to demonstrate the noise of if (...) |val| {...} else |err| {...} and unwrapping in general. My first example is more representative of authentic catch handler usage. If you can suggest ways to Zigify it, I'd be glad to rev it.

@ifreund
Copy link
Member

ifreund commented May 25, 2020

Here's a zig version of your first example in go. I'm not too familiar with go so feel free to correct me if i've gotten something wrong. This also depends on #1717

const main = fn() !void {
    var buf: [1024]u8 = undefined;

    const readToBuf = fn (buf: []u8) ![]u8 {
        const file = try std.fs.openFileAbsolute("/path/to/file", .{});
        defer file.close();

        try file.seekTo(42);

        const len = try file.read(buf);

        return buf[0..len];
    };

    const maybe_slice = readToBuf(&buf) catch |err| switch (err) {
        error.FileNotFound => null,
        else => return err,
    };

    try process(maybe_slice);

    finish();
}

Edit: fixed some mistakes

@networkimprov
Copy link
Author

Thanks! I rev'd the issue to use Zig, plus new syntax for catch handlers. I also changed my other examples to use const val|err| from |val|err|.

Re the use of nested functions as a sort of try {...} block with a single error handler, I rather doubt many ppl would code that way. The goal here is to make it easy to recover from errors locally when at all possible.

@networkimprov networkimprov changed the title let 'catch' define local error handlers let 'catch' define named error handlers May 25, 2020
@squeek502
Copy link
Collaborator

Here's my take on translating the example into status quo Zig that compiles & runs:

const std = @import("std");

fn f(path: []const u8) !void {
   var buf: [1024]u8 = undefined;
   var len: usize = 0;

   if (std.fs.cwd().openFile(path, .{})) |file| {
      defer file.close();
      file.seekTo(42) catch unreachable;
      len = file.read(&buf) catch unreachable;
   } else |err| switch(err) {
      error.FileNotFound => {}, // len will be 0
      else => unreachable,
   }

   process(buf[0..len]) catch |err| {
      std.debug.warn("{}\n", .{err});
      return;
   };

   finish();
}

pub fn main() !void {
   try f("file");
   try f("missing");
   try f("filez");
}

fn process(bytes: []u8) !void {
   std.debug.warn("processing: \"", .{});
   var has_z: bool = false;
   for (bytes) |byte| {
      if (byte == 'z') {
         has_z = true;
      }
      std.debug.warn("{c}", .{byte});
   }
   std.debug.warn("\"\n", .{});
   if (has_z) {
      return error.NoZAllowed;
   }
}

fn finish() void {
   std.debug.warn("finish called\n", .{});
}

file has the contents:

abcdefghijklmnopqrstuvwxyzabcdefghijklmnopthese are the read bytes

filez has the contents:

abcdefghijklmnopqrstuvwxyzabcdefghijklmnopthese are the read bytez

Output is:

processing: "these are the read bytes"
finish called
processing: ""
finish called
processing: "these are the read bytez"
error.NoZAllowed

The thing that makes this example strange is the abundance of catch unreachable which is not recommended in Zig. Thinking about what those catch unreachables could be replaced with also highlights why this proposal might not fit that well with Zig: reusing error handlers is not very feasible in general, since (in this example) each of openFile, seekTo, and read have different possible error sets, so any attempt at them sharing error handlers will either be a compile error or will be very likely to lead to mishandling some cases (instead of thinking 'how do I recover from read giving error X, you have to think about all three functions' failure modes at the same time). If, instead, the same exact function were called multiple times and each needed to handle errors in the same way, then creating a wrapper function seems like a fine workaround that avoids the need for sharing error handlers.

If the possible errors from seekTo and read were all handled, however, it would lead to a lot of nesting as shown in the second example of the OP, so that is one thing that this proposal might be able to address, but at the (significant, imo) cost of introducing goto-style control flow.

@networkimprov
Copy link
Author

Ryan, thanks for your analysis. Note that I made my examples trivial for the sake of clarity.

Regarding unreachable, I mostly write applications, not modules, so I abort on unexpected errors if they could indicate a bug or filesystem glitch. I think that's representative of the programming population. The Zig team necessarily writes a lot of modules, which should not abort.

And most application developers today are familiar with exceptions, which help keep the happy path cohesive and minimally indented.

Thinking "about all three functions' failure modes at the same time" can be an advantage -- you share what's common and switch () for the rest. However the handler might need more context than the error integer, so maybe:

|eos.xyz| = f()  // xyz is an identifier
...
catch eos switch (.id) {
   xyz => ...
}

@squeek502
Copy link
Collaborator

squeek502 commented May 26, 2020

I abort on unexpected errors if they could indicate a bug or filesystem glitch. I think that's representative of the programming population.

I'm just some guy, so I might be wrong about this, but as I understand it, Zig actively wants to change this. See the Errors section of this talk. and especially this part talking about catch unreachable, and the error sets portion of it as well.

Again, as I understand it, catch |err| switch is mostly meant to be used when recovery is known to be possible. Returning the error or using try is meant to be used as the 'default', and catch unreachable is meant to be reserved for asserting that an error is not possible (see this PR for a recent example of catch unreachable usage and its justification).


About shared error handlers: just to be sure we're on the same page, note that the following code will fail to compile:

const std = @import("std");

pub fn main() !void {
   const file = std.fs.cwd().openFile("file", .{}) catch |err| switch(err) {
      error.FileNotFound => return err,
      else => unreachable,
   };
   defer file.close();

   // copy+pasted error handling from openFile
   file.seekTo(42) catch |err| switch(err) {
      error.FileNotFound => return err,
      else => unreachable,
   };
}
.\5421-2.zig:12:12: error: expected type 'std.os.SeekError', found 'error{FileNotFound}'
      error.FileNotFound => return err,
           ^
lib\zig\std\os.zig:828:5: note: 'error.FileNotFound' not a member of destination error set
    FileNotFound,
    ^
.\5421-2.zig:11:32: note: referenced here
   file.seekTo(42) catch |err| switch(err) {
                               ^
lib\zig\std\start.zig:252:40: note: referenced here
            const result = root.main() catch |err| {
                                       ^

This behavior doesn't seem congruent with the 'shared error handlers' part of the proposal.

@networkimprov
Copy link
Author

networkimprov commented May 26, 2020

Ok, a catch handler would have to be able to switch on the errorset, and then the error.

Aborting when you know the program is wrong, or the system under you is hosed, is Good Practice, IMO. That doesn't prevent the user or watchdog from restarting immediately.

EDIT: I misused unreachable; have changed it to @panic()

@squeek502
Copy link
Collaborator

squeek502 commented May 26, 2020

Ok, a catch handler would have to be able to switch on the errorset, and then the error.

Unless I'm missing something, that would mostly defeat the purpose of shared error handling (or it would have the same effect as enforcing that labeled error handlers are specific to a single error set).

Aborting when you know the program is wrong, or the system under you is hosed, is Good Practice, IMO. That doesn't prevent the user or watchdog from restarting immediately.

catch unreachable implies more than that, though. It's not an abort in release-fast and release-small modes, and will lead to undefined behavior in those modes. It's absolutely not meant to be used when your intention is 'don't handle this error, just crash' (which, again, as I understand it, is something Zig explicitly is trying to get programmers to change their mindset on; if you want to be lazy and not handle each error specifically in Zig, then your best option is to bubble the error via try or return err).

@ifreund
Copy link
Member

ifreund commented May 26, 2020

Note that you can still handle all the errors at once in status quo zig like so:

const std = @import("std");

fn readToBuf(buf: []u8) ![]u8 {
    const file = try std.fs.openFileAbsolute("/path/to/file", .{});
    defer file.close();

    try file.seekTo(42);

    const len = try file.read(buf);

    return buf[0..len];
}

fn main() !void {
    var buf: [1024]u8 = undefined;

    const maybe_slice = readToBuf(&buf) catch |err| switch (err) {
        error.FileNotFound => null,
        else => return err,
    };

    try process(maybe_slice);

    finish();
}

The only difference is that without #1717 you must define the readToBuf() function outside of the main function.

@networkimprov
Copy link
Author

networkimprov commented May 27, 2020

I inserted two paragraphs in the issue text #5421 (comment), starting with:

The goal here is clear error handling inside complex business logic...

@networkimprov
Copy link
Author

I've extended the proposal with items for uncaught errors and built-in handler names.

@networkimprov
Copy link
Author

I realized you can also use catch invocations as expressions when they yield a value. The following two would be equivalent:

const val|err| = f();
do(val);
catch err { ... }

do(_|err| = f());
catch err { ... }

@andrewrk andrewrk modified the milestones: 0.7.0, 0.8.0 Oct 27, 2020
@andrewrk andrewrk modified the milestones: 0.8.0, 0.9.0 May 19, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

7 participants