Skip to content

Instantly share code, notes, and snippets.

@mikdusan
Last active February 13, 2021 04:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikdusan/4a14547a094b0e2cc9eb206fd1c6bb81 to your computer and use it in GitHub Desktop.
Save mikdusan/4a14547a094b0e2cc9eb206fd1c6bb81 to your computer and use it in GitHub Desktop.

REVISION 3 - Friday 2021-02-12

note: changelog is at bottom of document
note: co-author @marler8997

The primary goal of this proposal is to lexically distinguish error control-flow from regular flow-control:

  1. return statements do not effect error flow-control or error-stack changes
  2. replace current return error.BadValue; with throw error.BadValue;
  3. require explicit syntax to capture an error-union value

secondary (and orthogonal) to but included in this proposal:

  1. add new builtin @throwCurrentErrorHideCurrentFrame() to throw without pushing error/frame onto error-stack
  2. add new builtin @clearErrorStack() for explicit control of when to clear the error-stack.
  3. expose error-union tags as if union(enum) { payload: T, err: E }

Related proposals:

ziglang/zig#1923 (comment) overlaps with @clearErrorStack() and opinions related to #1923 are encouraged.

#2562 is no longer required because return <value> is not ambiguous with error handling.

#2647 is proposing syntax changes to enable tagged-error-unions; see bottom of document for more details.

#5610 is orthogonal to this proposal.


pros

  • no more ambiguity between error and value semantics
  • common case is to avoid error semantics unless explicit lexical syntax is used
  • simplified compiler errors for common cases
  • lexical distinction that an error-union value has been taken
  • lexical distinction when/where an error-stack has been cleared
  • allow inspection and propagation of unchanged error-stack

cons

  • new keyword throw
  • overload catch to work as a prefix keyword catch <error-union>
  • requires rare source code changes for decls of error-union types (eg. 16 instances identified by std.zig test coverage)
  • requires source code changes for error-union callsites to choose between error or value semantics, most will be error semantics

example.zig with this proposal in mind

fn afunc() !(error {Bad}) {
    // UNCHANGED-BEHAVIOUR
    var a = try doit();

    // UNCHANGED-BEHAVIOUR
    var b = doit() catch (err) { ... };

    // UNCHANGED-BEHAVIOUR
    if (try doit()) {}

    // UNCHANGED-BEHAVIOUR
    if (doit()) {}
    else |err| {}

    ////////////////////////////////////////

    // OLD: propagate error
    // NEW: return an error value, does not effect error-stack, no error-flow-control.
    return error.Bad;

    // OLD: n/a
    // NEW: propagate error (equivalent to OLD `return error.Bad`)
    throw error.Bad;

    // OLD: `c` is type error-union
    // NEW: do not let an error-union _easily_ become a value. compile-error: unhandled error
    var c = doit();

    // OLD: n/a
    // NEW: let an error-union become a value with overloaded keyword `catch` in a prefix position, `d` type is error-union
    var d = catch doit();
    // exposed union tags
    _ = d.payload; // safety checked
    _ = d.err;     // safety checked

    // UNCHANGED-BEHAVIOUR
    if (d) {}
    else |err| {} 

    // OLD: compile-error: expected type 'bool', found '@typeInfo(@typeInfo(@TypeOf(foo)).Fn.return_type.?).ErrorUnion.error_set!void'
    // NEW: compile-error: unhandled error
    if (doit()) {}
}

fn f0() !void {
    var a = doit() catch (err) {
        // UNCHANGED-BEHAVIOUR but used to be `return` and now `throw`
        //
        // EXIT-ERROR-STACK: push,propagate
        // [1] `f0()`           error {Overflow,Megaflow}
        // [0] `doit()`         error {Overflow,Megaflow}
        throw err;
    };

    // UNCHANGED-BEHAVIOUR
    // sugar for above
    var a = try doit();
}

fn f1() !u32 {
    // error-stack: EMPTY

    var a = doit() catch (err) {
        // ENTRY-ERROR-STACK:
        //     [0] `doit()`     error {Overflow,Megaflow}

        if (cond) {
            // UNCHANGED-BEHAVIOUR
            // on-error case: push nested error-stack and throw new error
            //
            // EXIT-ERROR-STACK: push,propagate
            // [2] `f1()`       error {Overflow,Megaflow}
            // [1] `doit()`     error {Overflow,Megaflow}
            // [0] `doit()`     error {Overflow,Megaflow}
            try doit();
        }

        switch (err) {
            // NEW-BEHAVIOUR
            // case: throw a new error of same value
            // it is "new" because error-stack depth has increased
            //
            // EXIT-ERROR-STACK: push,propagate
            // [1] `f1()`       error.Overflow
            // [0] `doit()`     error {Overflow,Megaflow}
            .Overflow => throw err,

            // NEW-BEHAVIOUR
            // case: throw a new error of different value
            // it is "new" because error-stack depth has increased
            //
            // EXIT-ERROR-STACK: push,propagate
            // [1] `f1()`       error.Unknown
            // [0] `doit()`     error {Overflow,Megaflow}
            .Megaflow => throw error.Unknown,
        }

        if (cond) {
            // NEW-BEHAVIOUR
            // case: clear error-stack and throw new error
            //
            // EXIT-ERROR-STACK: reset,push,propagate
            // [0] `f1()`       error.BadArgument
            @clearErrorStack();
            throw error.BadArgument;
        }

        if (cond) {
            // NEW-BEHAVIOUR
            // case: clear error-stack and re-throw current-error (top of error-stack)
            // if error-stack is empty a runtime error `error.EmptyErrorStack` shall be generated
            //
            // EXIT-ERROR-STACK: propagate
            // [0] `doit()`     error {Overflow,Megaflow}
            @clearErrorStack();
            @throwCurrentErrorHideCurrentFrame();
        }
    };

    // assert( error_stack.len == 0) if (previous catch-block was not entered)
    // assert( error_stack.len >  0) if (previous catch-block was entered)

    // This particular programmer desires to clear error-stack before any value return.
    // Expected to be more common than not clearing, but much less common than try/throw occurrances.
    @clearErrorStack();
    // assert( error_stack.len == 0)

    return 42;
}

// fn return-type: error-union
fn f2() !u32 {
    // OLD-BEHAVIOUR
    // both accepted; both do same thing
    return doit();
    return try doit();

    // NEW-BEHAVIOUR
    // compile-error
    return doit();
    // ok
    return try doit();

    // UNCHANGED-BEHAVIOUR
    return 42;
}

// fn return-type: value-error-union
// no motivating use case: emergent behaviour
fn f3() @typeInfo(@TypeOf(f2)).Fn.return_type.? {
    // NEW-BEHAVIOUR
    // let an error-union become value and return value
    // error-stack is untouched, no error control-flow, just a regular value being returned
    return catch f2();

    // desugared
    // `veu` is type error-union
    const veu = catch f2();
    // return value-error-union
    return veu;
}

fn f4() !void {
    // compile-error: `f3` does not throw
    _ = try f3();

    // ok: it's just a value-error-union
    _ = f3();
}

fn doit() !void {
    if (__random__) {
        throw error.Overflow;
    } else {
        throw error.Megaflow;
    }
}

table of fundamental error-handling effects and corresponding syntax

  • throw → error control-flow and exits function
  • push → pushing one (or more) errors onto error-stack
  • clear → clear error-stack (length becomes 0)
throw push clear syntax example
0 0 0 const x = catch doit();
0 0 1 const x = catch doit(); @clearErrorStack();
0 1 0 invalid - why push onto error-stack if not throwing?
0 1 1 invalid - why push onto error-stack if not throwing?
1 0 0 @throwCurrentErrorHideCurrentFrame();
1 0 1 invalid - why throw empty error-stack?
1 1 0 const result_value = try doit();
throw error.BadValue;
1 1 1 @clearErrorStack(); throw error.BadValue;

Questions and Answers

Q. Why a new keyword throw only to handle an expression of error-type. Could existing keyword try be overloaded?

A. The try keyword means "maybe throw" and overloading it to accept expressions of type error would change that use to mean always throw. The semantics of try would depend on the value of the expression after it and its no longer clear what try x does.


Q. Why isn't @throwCurrentErrorHideCurrentFrame() a keyword and why is it so long?

A. The motivating case for this feature is rare and did not warrant promotion to a keyword. Also we wanted the rare use to be self-explanatory, conveying "a throw of current-error without pushing error/frame onto error-stack".

// this could be made more generic
fn trace_errors(f: fn() !void) !void {
    f() catch |err| {
        std.debug.warn("error traced: {}\n", .{err});
        @throwCurrentErrorHideCurrentFrame();
    }
}

Q. Why isn't @clearErrorStack() a keyword or some other logic engaged to automatically clear?

A. We found multiple cases where a programmer may want to clear the error stack at different places or even not at all. By providing this builtin, the programmer has full control over when to clear the error stack, if at all. Note that this builtin does not prevent other features from also being implemented to clear the error stack such as ziglang/zig#1923 (comment) . Also this is relatively rare comparing to common usage of error flow-control and we again decided against promotion to a keyword.


What #2647 syntax would look like under this proposal:

// syntax from #2647
{
    // from comment: https://github.com/ziglang/zig/issues/2647#issuecomment-500694683
    return error{ .InvalidChar = index };

    // from comment: from https://github.com/ziglang/zig/issues/2647#issuecomment-501545471
    return ParseError{ .InvalidChar = index };
}

// syntax under this proposal
{
    // error flow-control
    throw error{ .InvalidChar = index };
    throw ParseError{ .InvalidChar = index };
    throw .{ .InvalidChar = index };

    // return union-init is regular flow-control
    return .{ .age, value };
}

REVISION 3 changes

Sorry for the fast update. Rev2 really helped put a lot of things in perspective and with @marler8997's help we brainstormed for a bit and here is the product.

  • introduce builtins @clearErrorStack() and @throwCurrentErrorHideCurrentFrame()
  • and consequently drop new keyword failagain
  • and consequently discontinue block-logic or impacting keywords to clear error-stack
  • and consequently use keyword throw instead of overloading try
  • bikeshed: rename fail to throw
  • update code samples

REVISION 2 changes

  • replace new keyword notry with overload of catch
  • replace overload of keyword continue with overload of keyword try
  • replace new keyword raise with fail because it feels less like exceptions
  • clarify how this proposal differs from #1923
  • update code samples
@mikdusan
Copy link
Author

mikdusan commented Feb 12, 2021

see file above

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment