Rebol2's "Hot Errors" (Abandoned by R3-Alpha, Red)

Rebol2 had a concept which had the inklings of definitional errors, in the sense that if you would RETURN an ERROR! value, it would escalate itself to an exception if not triaged with special functions at the callsite:

rebol2>> boom: func [] [return make error! "Boom"]

rebol2>> boom
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> type? boom
== error!

rebol2>> (boom) print "No triage, this won't print"
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> (disarm boom) print "Triaged, so this will print"
Triaged, so this will print

rebol2>> disarm boom

rebol2>> probe disarm boom
make object! [
    code: 800
    type: 'user
    id: 'message
    arg1: "Boom"
    arg2: none
    arg3: none
    near: [return make error! "Boom"]
    where: none
]

Why Was This Abandoned?

According to Carl:

"Prior versions of REBOL used 'hot' errors. That is, you had to treat error values in a special way or they would automatically trigger error processing. This behavior was originally implemented to keep errors from propagating too far from their origins (the principle was to preserve as much as possible the locality of the error)."

"These hot errors turned out to be overkill, and the benefit of error locality was offset by the difficulty of handling error values in general. (See the articles of Ladislav Mecir who wrote excellent notes on this subject). It could be quite tricky at times."

I'm not sure what articles he's referring to from Ladislav. The link he provides has an error section where Ladislav points out some bugs and says "any Rebol value should be obtainable as a result of an expression, a result of a parenthesized expression, a result of a block evaluation and a result of a function evaluation", which seems pretty uncontroversial.

So... What Was The Problem?

One big ergonomic problem is that TRY intercepted errors, but didn't disarm them. :frowning:

rebol2>> caught: try [boom]
** User Error: Boom
** Near: return make error! "Boom"

rebol2>> caught/id
** User Error: Boom
** Near: return make error! "Boom"

That's incredibly inconvenient. So why didn't TRY do a DISARM?

The problem is "hotness" was a property of all values of ERROR! type, and when you disarm them they'd become OBJECT!. If TRY were to DISARM the error it wouldn't be an ERROR! anymore, so you couldn't test to see if your expression had produced an error or not.

You get this because you're trying to pack too much information into one return result. It could have been addressed by having you write your TRY as arity-2 with an EXCEPT.

rebol2>> try-except: func [code handler /local e] [
             return either error? e: try code [handler disarm :e] [:e]
         ]

rebol2>> try-except [boom] func [e] [probe e/arg1]
"Boom"

rebol2>> try-except [1 + 2] func [e] [probe e/arg1]
== 3

Ren-C gives you this with infix, and you can use an arrow function, so it's more pleasing:

boom except e -> [probe e.arg1]

Hot Errors Would Have Been Only Half The Story

It seems Rebol2 was on the right track by having a state you could carry in a function's return result, that would promote to a divergent panic if not triaged at the callsite.

But for it to gel, you need to draw a sharp line between divergent panics and ERROR!, and not mix up their interception (in fact, you should practically never intercept divergent panics).

Rebol2's TRY was a one-size-fits-none construct, considering any typo or deeply nested errors to be on par with one that was "RETURN'd" directly from the function you were calling:

rebol2>> probe disarm try [read %nonexistent.txt]
make object! [
    code: 500
    type: 'access
    id: 'cannot-open
    arg1: "/C/Projects/rebol2/nonexistent.txt"
    arg2: none
    arg3: none
    near: [read %nonexistent.txt]
    where: 'halt-view
]

rebol2>> probe disarm try [rread %nonexistent.txt]
make object! [
    code: 300
    type: 'script
    id: 'no-value
    arg1: 'rread
    arg2: none
    arg3: none
    near: [rread %nonexistent.txt]
    where: 'halt-view
]

This makes it nigh impossible to act upon the information reliably.

The Wayward Drift To All-Errors-Are-Exceptions...

Despite Rebol2 being on the cusp of meaningful error handling, R3-Alpha went the way of the exception fallacy. Jumping across arbitrary levels of stack running arbitrary code in order to handle an error in stack levels above is something that the software industry has pretty much debunked.

For the most part, exceptions need to be reserved for things that should basically never happen. The rare systems that do handle them should be when it's required for mitigating damage or corruption that might occur if cleanup code doesn't get run. Exceptions are not what you want to use to deliver garden variety errors...you need direct contracts between caller and callee.

1 Like

I'll reiterate that I'm quite firm on the importance in discerning:

  • interceptable hot ERROR! by contract "multiplexed" in a function's return value (in the spirit of Rust Result, Haskell Either)

  • "non-interceptible" panics (exceptions)

...and that I think it was a major mistake in R3-Alpha and Red to fuse these into an "all-exceptions" model. That glommed typos and arbitrarily deep errors into the same handling.

But I've also stressed that "hot errors" raise some puzzling issues in handling, that need some care. For instance, just testing for them...

rebol2>> boom: func [] [return make error! "Boom"]

rebol2>> integer? boom
== false

rebol2>> even? boom
** Script Error: even? expected number argument of type: number char date money time

There you see that INTEGER? is the kind of function that's willing to gloss over the fact that a "hot" error happened, because its argument is "ANY-TYPE!"

But EVEN? didn't work because of a type error.

What if we wrote our own versions of INTEGER? and EVEN?

>> my-integer?: func [x] [integer! = type? x]

>> my-integer? boom
** User Error: Boom
** Near: return make error! "Boom"

>> my-even?: func [x] [0 = mod x 2]

>> my-even? boom
** User Error: Boom
** Near: return make error! "Boom"

It seems that if you don't have a type constraint, the ERROR! is promoted to a "panic". But if you have a type constraint that doesn't include ERROR!, it will behave like a typechecking error.

Let's try adjusting those with type constraints and see what happens:

>> my-integer?: func [x [any-type!]] [integer! = type? x]

>> my-integer? boom
== false

>> my-even?: func [x [integer!]] [0 = mod x 2]

>> my-even? boom
** Script Error: my-even? expected x argument of type: integer

Ren-C Concept: Type Checks Bubble ERROR!

I'm not that comfortable with:

rebol2>> integer? boom
== false

That seems much too likely to gloss over a misunderstanding of an error condition.

So I proposed type checks propagating ERROR! instead of returning logic on ERROR! input... (with the obvious exception of ERROR? giving a logic).

With Ren-C's definition of TRY (convert ERROR! to NULL, pass through other values) you can then say try integer? boom and that will defuse the error which propagates, getting you your false result. It looks better than integer?:relax boom or similar, and is strictly more powerful.