Invisibility Reviewed Through Modern Eyes

The first idea of making constructs that would "vanish completely" leveraged a special kind of enfix function, that would receive the entire evaluated value of the left hand side:

 elide: enfix func [
     left [<evaluate-all> any-value!]
     right
 ][
     print ["ELIDE got left as" mold left]
     return left
 ]

 >> 1 + 2 + 3 comment [magic!]
 hi
 ELIDE got left as 6
 == 6  ; wow!

This was a workaround for the (seeming?) fundamental fact that you can't have such a thing as "invisible variables" or "invisible values". Certain functions just faked invisibility by repeating the previous value in the evaluator chain.

The possibilities seemed endless. For instance, imagine something like this:

case [
   conditionA [...code...]
   elide print "conditionA didn't succeed but running this"
   conditionB [...code...]
   conditionC [...code...]
]

To do that in Rebol2 or Red would be incredibly awkward. e.g. using a condition that runs code but evaluates to false, and then a throwaway block for the never-executed branch:

case [
   conditionA [...code...]
   (
       print "conditionA didn't succeed but running this"
       false
   ) [<unreachable>]
   conditionB [...code...]
   conditionC [...code...]
]

Similar awkwardness would arise in things like ANY and ALL, where you'd have to switch from using true and false based on which you were using...

any [conditionA (print "vanish" false) conditionB]
all [conditionA (print "vanish" true) conditionB]

Beyond being awkward, it simply can't work if what you want to vanish is the last expression. But ELIDE handled all these cases:

any [conditionA elide print "vanish" conditionB]
all [conditionA elide print "vanish" conditionB]
any [conditionA conditionB elide print "vanish"]
all [conditionA conditionB elide print "vanish"]

It Was A Neat Trick...But Problems Emerged

The trick of invisibility requiring a function to receive its left hand side meant a GROUP! or COMMA! would break these constructs, as there was no access to a previous value:

 >> 1 + 2 + 3 (elide print "hi")
 hi
 ELIDE got left as null
 == ~null~  ; not 6, d'oh!

 >> 1 + 2 + 3, elide print "hi"
 hi
 ELIDE got left as null
 == ~null~

Plus being enfix forced the invisible functions to execute in the same step as whatever came before them, causing unsuspected results:

>> case [
        1 = 1 [print "branch"]
        elide print "reached here first :-("
        1 = 2 [fail "Unreachable"]
    ]
ELIDE got left as [print "branch"]
reached here first :-(
branch

There we see that when the evaluator visited the [print "branch"] block in the CASE it had to greedily run the ELIDE, which evaluates its argument and then yielded the code block as its result. CASE ran that code after the elide...out of order from what was desired.

Issues seemed to keep compounding. These invisible functions couldn't be reliably used with MAKE FRAME!, and people trying to simulate the evaluator's logic found it hard to detect and wrap them. That led to major issues with UPARSE trying to implement combinators that acted like ELIDE.

So the enfix mechanism wasn't going to cut it. But it was too late: having been able to try out and develop all kinds of invisible constructs convinced me of their value. I had to try another way...

Formalizing a VOID State And Corresponding Meta State

The seeming impossibility of having a "void value" was addressed with the idea of folding special treatment in the evaluator of voids, but offering a meta domain in which they could be handled safely.

>> var: void
; void

>> 1 + 2 var
== 3

>> var: meta void
== '

>> 1 + 2 var
== '

>> 1 + 2 unmeta var
== 3

The concept of being able to pipe around and process "slippery" values in this meta domain (including unset states and other isotopes) wound up being very successful.

Evaluators like DO and UPARSE would specially preserve the last evaluative value in order to give the illusion of invisibility when voids were seen on the next step. Other constructs got to make a choice as to whether they wanted to embrace voids as part of the mechanic, or think of them as errors:

>> comment "comments returned void"  ; Note: console doesn't show void results

>> if comment "hi" [print "not tolerated in conditions"]
** Error: IF doesn't accept void as its condition argument

>> all [comment "begin" 1 + 2 10 + 20 comment "end"]
== 30

>> any [comment "begin" 1 + 2 10 + 20 comment "end"]
== 3

e.g. for the above to work, ALL has to hang on to the last evaluated result as it goes...in case the next evaluated result is a comment. This allows the 30 to fall out.

A Flexible Approach... But... Here Be Dragons

Something that concerned me early on was that what had started as a narrow ability of just a few functions (like COMMENT and ELIDE) was becoming a case where generalized execution could possibly return voids, leading to unexpected results.

>> code: [comment "some arbitary code block"]

; ... then much later ...

>> result: (mode: <reading> do code)
== <reading>

>> result
== <reading>  ; oops

Increasing dependence on void as a "vanishing" alternative to the noisier null also raised the demand for void variables, with them becoming slippery to generate:

>> parse [x] [rule: ['x (void) | 'y ([some "y"])]
== x

>> rule
== x  ; wanted void

VOID was becoming a victim of its own popularity. When it was rare coming from only a few constructs like ELIDE and COMMENT it was rare to see problems. Yet when every IF/CASE/SWITCH statement that didn't run a branch started returning voids, things got hairier.

There were also snags when making void isn't a parameter...but a product of something like the body on a MAP-EACH. At first it looks fine:

map-each item [1 <one> 2 <two> 3 <three>] [
    maybe match tag! item  ; leaving it as NULL would be an error
]
== [<one> <two> <three>]

But what if you had something else in the loop body?

map-each item [1 <one> 2 <two> 3 <three>] [
    append log spaced ["Logging:" item]
    maybe match tag! item  ; remember, void vanishes
]
== ["Logging: 1" "Logging: <one>" "Logging..." ...]

Once Again: Isotopes To The Rescue

As part of solving the problem of multi-returns, parameter pack isotopes were introduced. These would "decay" to their first item, unless something handled the isotope specially.

>> pack [1 + 2 10 + 20]
== ~['3 '30]~  ; isotope

>> a: pack [1 + 2 10 + 20]
== 3

 >> [a b]: pack [1 + 2 10 + 20]  ; SET-BLOCK! handles PACK! isotopes specially
 == 3

 >> a
 == 3

 >> b
 == 30

Unpacking the packs raised a question: What if the PACK! is Empty? As far as variable assignment goes, it seems it can't do anything. So erroring made the most sense:

>> pack []
== ~[]~  ; isotope

>> a: pack []
** Error: No values available in empty parameter pack

Then there was a breakthrough of the next thought: Empty isotopic packs could be used as the vanishing intent!!

>> 1 + 2, pack []
== 3

This would mean VOID could be less "slippery" in the evaluator, being treated normally most of the time and falling out of expressions vs. vaporizing:

>> 1 + 2, if false [<a>]  ; overall result is void (no console output)

>> 1 + 2, if true [<a>]
== <a>

Empty isotopic packs were then given a name: "NIHIL"

The terminology has varied over time. I accomplished the distinction another way before isotopic packs, and at that time I called the distinction "impure invisibility" (non-vaporizing) vs. "pure invisibility" (vaporizing).

But I think the "impure" vs. "pure" terms just caused confusion. This is where things stand today, and it seems comfortable:

>> 1 + 2 void

>> 1 + 2 nihil
== 3

>> 1 + 2 null
== ~null~   ; isotope

I Think This Is A Comfortable Balance

No matter what way you slice it, an expression that can truly vaporize is something that can make you uneasy. Look at this CASE statement and imagine if FOO or BAR could vanish:

 case [
     foo [print "hi"]
     bar [print "bye"]
 ]

Sure... we can lament that if FOO comes back as NIHIL, it will wreck the geometry of the CASE completely. After dropping the FOO it will treat [print "hi"] as a condition and use BAR as a code branch.

But if FOO is a function that takes a BLOCK! as a parameter, it will also wreck the geometry of the CASE completely! This is just the cost of doing business in the Rebol paradigm.

This modern model with NIHIL being an unstable isotope brings us back to where you can only get vanishing function calls--not vanishing variables. FOO can be a variable holding VOID, but there's no risk of that wrecking the CASE statement...because CASE is not one of the constructs that willfully erases VOID. Neither does the foundational evaluator (and nor does UPARSE)

So long as usage of NIHIL is judicious, I think this is about as terra firma as the rest of Rebol is. And UPARSE stands as a great example of a system that has been able to build on meta-representation in order to be able to pipe around vanishing states using "special gloves" and build upon it to make new invisible behaviors...

>> parse "aaabbb" [collect some keep "a", elide some "b"]
== ["a" "a" "a"]

It's rather satisfying.

2 Likes