FAIL vs. RETURN RAISE: The New Age of Definitional Errors!

The latest groundbreaking isotope-powered concept of Ren-C is... the definitional error

But First, We Have To Define Failure...

Definitional ("raised") errors are the antiform state. It's what you would get if you UNMETA a QUASI! ERROR!

>> quasi 'foo
== ~foo~

>> unmeta quasi 'foo
== ~foo~  ; anti

>> unmeta quasi make error! "foo"
** Error: foo
** Near: [*** make error! "foo" **]

Being an unstable isotope, you can't store raised errors in variables directly. But if you try to, it reports the error! as it is in the antiform itself. This is a what we call a "failure".

>> var: unmeta quasi make error! "foo"
** Error: foo
** Near: [*** make error! "foo" **]

There's also a special behavior that they cannot occur in generic midstream evaluations:

>> (1 + 2 unmeta quasi make error! "foo" 3 + 4)
** Error: foo
** Near: [*** make error! "foo" ** 3 + 4]

So far this doesn't seem so profound. Rebol2 and Red can DO an ERROR! and get a failure, which you also can't store in a variable or keep going in the middle of an expression.

BUT here's the twist:

>> ^ unmeta quasi make error! "foo"
== ~make error! [
    type: '
    id: '
    message: "foo"
    near: [*** make error! "foo" **]
    where: [unmeta args]
    file: '
    line: 1
]~
  • The UNMETA did not raise an irrecoverable error.

  • It created an antiform error state, and then waited to see if something would ^META it or not.

  • There was a ^META and so it gave you back the QUASIFORM! error state

This is a crucial difference, as we will see. But first...

NOTE: I will be using the operation RAISE in the remaining text, instead of UNMETA QUASI on ERROR! So approximately this:

raise: lambda [e [text! block! error!]] [unmeta quasi make error! e]

Let's Address the "Definitional" Part

What I mean when I say "definitional" is that there's a difference between these two cases:

bigtest: func [n] [
   if n < 1020 [raise [n "is not big"]]
   print [n "sure is a big number"]
] 

definitional-bigtest: func [n] [
   if n < 1020 [return raise [n "is not big"]]
   print [n "sure is a big number"]
] 

You won't notice a difference if you call them directly

>> bigtest 304
** Error: 304 is not big
** Where: raise if bigtest args
** Near: [raise [n "is not big"] **]

>> definitional-bigtest 304
** Error: 304 is not big
** Where: raise if definitional-bigtest args
** Near: [return raise [n "is not big"] **]

But try using ^META and you'll see they are different:

>> ^ bigtest 304
** Error: 304 is not big
** Where: raise if bigtest args
** Near: [raise [n "is not big"] **]

>> ^ definitional-bigtest 304
== ~make error! [
    type: '
    id: '
    message: "304 is not big"
    near: [return raise [n "is not big"] **]
    where: [raise if definitional-bigtest args]
    file: '
    line: 1
]~

Functions can now choose to tell us when an error was something they knew about and engaged, vs. something incidental that could have come from any call beneath them in the stack.

Sound important? It should.

NOW BLAST SOME MUSIC FOR THIS WATERSHED MOMENT

I've added EXCEPT, which is an enfix operation that reacts to failures...while THEN and ELSE just pass them on.

>> raise "foo" then [print "THEN"] else [print "ELSE"] except [print "EXCEPT"]
EXCEPT

As we saw in the beginning, if someone doesn't handle the failure it gets raised eventually:

>> raise "foo" then [print "THEN"] else [print "ELSE"]
** Error: foo
** Near: [raise "foo" ** then [print "THEN"] else [print "ELSE"]]

Remember the old, bad ATTEMPT?

rebol2>> attempt [print "Attempting to read file" read %nonexistent-file.txt]
Attempting to read file
== none

rebol2>> attempt [print "Attempting but made typos" rread %nonexistent-file.txt]
== none

It was too dangerous to use. With READ upgraded to turn its file-not-found error to be definitional, and a new ATTEMPT that's based on ^META and not TRAP, we get safety!

attempt: lambda [
    {Evaluate a block and returns result or NULL if an expression fails}
    code [block!]
][
    reduce-each ^result code [
        if (quasi? result) and (error? unquasi result) [
            break  ; BREAK will mean overall result is NULL
        ]
        unmeta result
    ]
]

>> attempt [print "Attempting to read file" read %nonexistent-file.txt]
Attempting to read file
== ~null~  ; anti

>> attempt [print "Attempting but made typos" rread %nonexistent-file.txt]
Attempting but made typos
** Script Error: rread word is attached to a context, but unassigned
** Near: [rread ** %nonexistent-file.txt]

For another example, see CURTAIL

It will take time for natives to be audited and have their random fail()s turned to be definitional-RAISE-when-applicable. Until then, most won't have errors that can be intercepted like this. I added the error for file not found and the NEED-NON-NULL for COMPOSE and DELIMIT and REDUCE, but it will take a while.

But other than that...

It's Here. It's Now. It's Committed!

:boom:

Note That Non-Definitional Fail Still Exists...

If you want to immediately go to the failure state, use FAIL as you historically would.

That's clearer than calling RAISE with no RETURN, where a reader can't tell if it's going to be piped along and eventually RETURN'd or ^META'd somewhere.

return case [
    ... many pages of code ...
    ... [raise "Some error"]  ; need to be able to choose FAIL here if you meant that
    ... many pages of code ...
 ]

When writing a function and deciding if an error should be RETURN RAISE or FAIL, think about the use case. Do you feel that the call is fundamentally malformed (in the way a type checking error on a parameter would be thought of as a mistake), or did you understand what was asked clearly...but just couldn't do it?

It's subtle, but I think the pattern is emerging pretty clearly of when you should FAIL vs. RETURN RAISE.

4 Likes