Virtual-Binding-Based "Error Propagation Operator" 🤯

In Rust, if you define your function interface as possibly returning an Error, then you might find a circumstance where you call a function whose error you wish to propagate up to become your function's error.

Here's how you'd traditionally write something like that, in a case where it potentially propagates an error from one of two calls:

fn read_username_from_file_traditional() -> Result<String, io::Error> {
    let f = match File::open("username.txt") {
        Ok(file) => file,
        Err(e) => return Err(e),
    };
    
    let mut username = String::new();
    match f.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

But there's an application of "the ? operator" (which doesn't really have another name) which will do this propagation for you.

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();
    File::open("username.txt")?.read_to_string(&mut username)?;
    Ok(username)
}

Historically We Couldn't Do Things Like This

There was no way for such an operator to be able to know what the concept of RETURN was.

Hence you see things like:

change: combinator [
    "Substitute a match with new data"
    return: [~#change~]
    parser [action!]
    replacer [action!]  ; !!! How to say result is used here?
    <local> ^replacement
][
    [^ remainder]: parser input except e -> [  ; first find end position
        return fail e
    ]

    [^replacement #]: replacer input except e -> [
        return fail e
    ]

    ; CHANGE returns tail, use as new remainder
    ;
    remainder: change:part input ^replacement remainder
    return ~#change~
]

But Virtual Binding Means We COULD Do It

I'm pretty dead-set on using ? for OPT. But let's say we were using !! for this.

The behavior would be like writing this at the callsite:

let ^result: some-call xxx yyy zzz
if error? ^result [return ^result]
result: ^result

So you could rewrite the above as:

change: combinator [
    "Substitute a match with new data"
    return: [~#change~]
    parser [action!]
    replacer [action!]  ; !!! How to say result is used here?
    <local> ^replacement
][
    [^ remainder]: !! parser input  ; first find end position
    [^replacement #]: !! replacer input

    ; CHANGE returns tail, use as new remainder
    ;
    remainder: change:part input ^replacement remainder
    return ~#change~
]

That's awesome. I don't know that I love the !! for the name. As the comment above shows, I use !!! for attention. One exclamation point?

    [^ remainder]: ! parser input  ; first find end position
    [^replacement' #]: ! replacer input

That's too slight. Something out of left field... maybe a visual indication of "pass this error up?"

    [^ remainder]: --^ parser input  ; first find end position
    [^replacement' #]: --^ replacer input

Or something to indicate what you're passing up, like * for "problem"?

    [^ remainder]: *--^ parser input  ; first find end position
    [^replacement #]: *--^ replacer input

Interesting looking ideas... but would be a caret-in-word exception. :frowning: (There's not--I suppose--any particular reason why you can't have carets in words if they're not at the head...we allow it for tick marks.)

Nuance of Slashes I Realized

I've mentioned the goal that if you write something like not/even? that would act as if you had written not even?. I presumed that wouldn't be useful typically, but it would be useful if you were using a terminal slash, and trying to pass the cascade of functions as a single value somewhere, e.g.

 >> match not/even?/ 7 
 == 7

BUT there's a rule in infix deferred processing that it runs "one expression evaluation to the left".

This means !!/foo could be used to not disrupt the infix deferred logic. It's more general than just this case, which is awesome. But it definitely helps here.

I'll Write It Up, But It Needs a Name...

Suggestions, please!

1 Like

It is great! I love that Ren-C has become a language where you can do this.

(And personally, I’d be fine with calling it ?.)

I think ‘error propagation operator’ is a fine name. But somehow, I suspect that name would be a little too clunky for your tastes…

2 Likes

Still a lot of questions to be answered, but it really is night and day from the historical binding model.

One question to answer with !! though is what to do about it when the concept of "what to call to propagate" isn't named RETURN.

Though as it so happens, generators aren't allowed to YIELD a raised error. There is only one raised error contractually that arises from a generator, and that's the generation termination error. This way if you intercept a raised error from a generator you can assume the reason why and act on it. All other raised errors will be promoted to abrupt failure before the YIELD can give them back.

But maybe you have a CATCH + THROW situation and you want the throw to be what you use.

The just-a-thought-for-now concept of dialecting function calls might have some options here. What about !!:throw

result: catch [
   !!:throw some expression
   !!:throw some other expression
   print "Then you get here"
   throw <success>
] except e -> [
   print ["You failed!" mold e]
]

So it could presume RETURN if you don't say otherwise, yet still be used for other things.

The dialected function call concept has been simmering a bit, and I do keep finding places where I think it would be nice.

Prior to that being decided, there could still be a convention to name your own variants, e.g. !!-THROW

result: catch [
   !!-throw some expression
   !!-throw some other expression
   print "Then you get here"
   throw <success>
] except e -> [
   print ["You failed!" mold e]
]

And you could define it within a context if you planned to use it a lot:

result: catch [
   let !!: !!-throw/
   !! some expression
   !! some other expression
   print "Then you get here"
   throw <success>
] except e -> [
   print ["You failed!" mold e]
]

Though I will make the not-insignificant-point that you can beat this by wrapping your generator up to use lift/unlift.

Basically wrap GENERATOR such that your version's YIELD does a lifted-yield, and that the generator itself is adapted to unlift the result.

You will have successfully built a generator which can conflate ERROR! antiforms with the "no more to generate" error.

(If it turns out people actually find they want to do this commonly, it could just be a refinement. GENERATOR:CONFLATE or something.)

I'm trying to implement a poor-man's version of the error propagation operator inside the C source itself, using some various tricks.

But given that it's C I have to use a C identifier for the name.

I actually was thinking of TRAP:

 Index i = TRAP(Series_Index(series));
 if (i > 0)
     Do_Something();

Imagine that transforming into something like:

 Index i = Trap_Series_Index(series);
 if (Did_Trap_Error()) return Trapped_Error();
 if (i > 0)
     Do_Something();

The idea is that it's using a global (well, thread-local) state to communicate if the function raised an error. The Trap_XXX() name suggests that it is a function participating in this protocol, and hence you shouldn't use its result directly (e.g. because the index could be invalid).

Hence the macro would look like:

#define TRAP(expr) \
     Trap_##expr; if (Did_Trap_Error()) return Trapped_Error();

Could TRAP Be The Name Of The Operator?

change: combinator [
    "Substitute a match with new data"
    return: [~#change~]
    parser [action!]
    replacer [action!]  ; !!! How to say result is used here?
    <local> ^replacement
][
    [^ remainder]: trap parser input  ; first find end position
    [^replacement #]: trap replacer input

    ; CHANGE returns tail, use as new remainder
    ;
    remainder: change:part input ^replacement remainder
    return ~#change~
]

The part of the name that sort of makes sense is that it's one of the operations that stops an ERROR! from promoting to a panic. So it "traps" it.

On first glance, that doesn't really convey anything about "and after you trap it, use it as the return value for the current meaning of RETURN"

The thing is, when you see it written like this... there's kind of nowhere else for the error to go, but up. You evaluated the expression, it produced an ERROR!, you've trapped it...

...but you're a single arity-function. What are you going to do with the error? If you return it as your synthesized result, you're a no-op. If you panic, you served no purpose..that's what would have presumably happened otherwise. If you return a WARNING! or similar, you've conflated with non-erroring states...

EXCEPT is an infix function and arity-2, it does routing. But TRAP as a prefix function would either need to take a handler as a second argument, or be single-arity and do this forwarding/propagation.

It Seems Promising

I like the idea of the parity with the interpreter source, and that means making it a word has benefit.

So I'll try going in this direction for now.

2 Likes

So I've tried TRAP, and I definitely do like having the same word used in the C sources in my poor-man's Rust trick... being a word and not something symbol-y is good. I'm just about ready to commit to it.

But in terms of leaving no-stone-unturned, had another weird idea for a name: BUBBLE.

:bubbles:

Off-the-wall, but... all words that enter the programming domain start as seeming odd, I think ("THROW, CATCH... What is this, a football?") But things are driven by analogy.

change: combinator [
    "Substitute a match with new data"
    return: [~#change~]
    parser [action!]
    replacer [action!]  ; !!! How to say result is used here?
    <local> ^replacement
][
    [^ remainder]: bubble parser input  ; first find end position
    [^replacement #]: bubble replacer input

    ; CHANGE returns tail, use as new remainder
    ;
    remainder: change:part input ^replacement remainder
    return ~#change~
]

BUBBLE Isn't Errorish, TRAP Isn't Bubble-ish

But bubble looks weird, and I think the brevity and relevance of TRAP wins out here. You learn what it means.

You have to read it as "make this function trap the error". e.g. make the whole function act as a trap.

1 Like

Aaaand I've committed it... to my side branch.

This makes a native that acts a bit like Rust's "?" operator so that
if you pass it an ERROR!, it will propagate that with a call to whatever
RETURN is defined as in the local scope.

Hence this:

    trappy: func [] [
        let x: trap 1 * 0
        let y: trap 1 / 0
        return x + y
    ]

Acts equivalently to if you'd written:

    trappy: func [] [
        let x: (1 * 0) except e -> [return fail e]
        let y: (1 / 0) except e -> [return fail e]
        return x + y
    ]

Not a ton of the code is modernized to use definitional failures well... so there aren't actually all that many instances of EXCEPT in the code base to replace with TRAP yet. The exception would be UPARSE, where you can see it tightens things up quite a bit:

New Executables... Soon-ish... I hope...

While it seems like I'll be off on the side branch forever, things are starting to align to where I will hopefully not be too much longer before all these cool features make it to the deployed web console, and a batch of new EXEs.

(The good news about why it's taking so long is because I keep finding solutions to longstanding problems, and I feel like I need to push every solution through as far as it can go until it sort of plateaus, at which point it will be time for a release.)

2 Likes

I'll mention something kind of curious.

You can actually put the TRAP on the outside of the assignment:

trappy: func [] [
    trap let x: 1 * 0
    trap let y: 1 / 0
    return x + y
]

If you weren't using LETs here and depending on the variables surviving the scope, you could even use parentheses:

trappy: func [<local> x y] [
    trap (x: 1 * 0)
    trap (y: 1 / 0)
    return x + y
]

This is because the way plain assignment works, it will skip the assignment in case of an error and the overall expression evaluates to an error.

Note that ^META assignments are different:

trap ^x: fail "different!"

This would not skip the assignment. Which means that's fundamentally distinct from:

^x: trap fail "different!"

I'll mention that the new REQUIRE operator is a general tool for ducking the assignment of errors and failing afterwards when using meta assignment:

^x: require some-potentially-erroring-thing

Part of what makes this interesting is that I've aligned the C code to use the same techniques and terminology. So you will see calls to trap () that propagate errors to the return of the running function (via macro) by testing a global error state. These calls have to be on the outside in order to be "safe", to avoid usages which might break due to being used in branches without scopes. Random details here--but--it's cool that the usermode constructs support the style that the C code is forced to use.

2 Likes