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

The latest groundbreaking concept of Ren-C is... the definitional failure

But First, We Have To Define Failure...

Failure is an isotopic state. It's what you would get if you UNMETA a QUASI! ERROR!

>> quasi 'foo
== ~foo~

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

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

Being an isotope, you can't store failures in variables easily. But unlike other isotopes, the error you receive is not a generic "could not assign isotope" message... it reports the error! as it is in the isotope itself.

>> var: unmeta quasi 'foo
** Error: Cannot store isotopic WORD! in VAR

>> var: unmeta quasi 1020
** Error: Cannot store isotopic INTEGER! in VAR

>> 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 isotopic 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 QUASI! 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"]]

Now let's use it in a simple REFRAMER called CURTAIL:

curtail: reframer function [frame [frame!]] [
    return do frame except e -> [
        if e.id == 'need-non-null [return void]
        raise e
    ]
]

If you don't remember what reframers do, they just have access to a function call before you run it. Here we are looking for the function we're running to give us a NEED-NON-NULL error.

But we're not looking for just any NEED-NON-NULL error that might go by. We're only interested in ones that are coming out of the call we're processing. If that happens, we just vaporize the expression.

>> compose [(null)]
** Script Error: non-NULL value required (see MAYBE, TRY, REIFY)

>> curtail compose [(null)]
; void (decays to none)

You can see something like this simplifying null checks:

>> ver: 1.2.3
>> date: null

>> print [curtail spaced ["Version:" ver] curtail spaced ["Date:" date]]
Version: 1.2.3

But like I said, it's not just any NEED-NON-NULL...

>> a: 1 b: null c: 3
>> get-ver: func [] [to tuple! reduce [a b c]]

>> print [curtail spaced ["Version:" get-ver] curtail spaced ["Date:" date]]
** Script Error: non-NULL value required (see MAYBE, TRY, REIFY)

Still Need Proof This is Good? Try Definitional ATTEMPT

Remember the old, bad ATTEMPT?

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

>> attempt [print "Attempting but made typos" rread %nonexistent-file.txt]
; null

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

>> 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]

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