Conflation vs. Safety, RETURN and "Heavy" Void/Null

"Heavy" void and null are a novel solution shaped to solve a specific problem. As a reminder of what the goal is...

The Goal is to Please @rgchris AND Please me

NULL is the signal of "soft failure". It's a unique result when a loop is halted by a BREAK, when PARSE fails...etc. VOID is the signal of "nothingness", which is the product of conditional expressions that don't take a branch. Neither can be stored in blocks.

NULL's property of not being storable in blocks makes it critical to disambiguating this historical problem:

redbol>> third [a b #[none]]
== #[none]

redbol>> third [a b]
== #[none]

In a language that prides itself on letting you work with code structure, this is the tip of the iceberg of the problems that null solves, and you will find the distinction's utility across the board (obviously, in tools like COMPOSE). It facilitates rigorous analysis and rearrangements...without needing to drop to C or write convoluted code:

>> third [a b _]
== _

>> third [a b]
; null

Hence NULL and VOID have taken the place of blank ("none!") in many places. (See BLANK! 2022, Revisiting the Datatype for a summary.)

But unlike the elements in a block, a branch that evaluates isn't required to be non-NULL or non-VOID. Which leads us to the long running question of what to bend these branches to so they don't conflate with the branch-not-taken result.

Chris has (rightly) expressed concern

At times I've said that it's not that big a deal that branches can't evaluate to NULL and get distorted. "You didn't have NULL before at all, so why get so worked up about control constructs not returning it?"

But the now-pervasive nature of NULL means it can't be avoided. So:

"How do you express branching code which wants to do some work but also produce NULL as an evaluative product?"

Conflation was not a problem, e.g. in Rebol2:

rebol2>> exampler: func [x] [
     print "returning sample or none if not found"
     case [
         x = <string> [print "sample string" {hello}]
         x = <integer> [print "sample integer" 3]
         x = <none> [print "sample none" none]
     ]
  ]

rebol2>> exampler <string>
returning sample or none if not found
sample string
== "hello"

rebol2>> exampler <blatz>
returning sample or none if not found
== #[none]

rebol2>> exampler <none>
returning sample or none if not found
sample none
== #[none]

However NULL is now a basic currency of "soft failure". As such it would not be uncommon to be in the situation where a branching decision process would want to intentionally return NULL as part of the work it does.

Without a mechanism to address this, unpleasant convolutions would be needed, for instance surrounding anything that wanted to tunnel a NULL with a CATCH and THROW'ing it:

x: catch [
    throw switch 1 + 2 [
        1 [print "one" 1]
        2 [print "two", <two>]
        3 [print "three", throw null]
     ]
]

Definitely not good. But regarding the pleasing-me-part, remember I am trying to avoid this situation:

>> block [a b]

>> case [
     true [
          print "case branch"
          item: third block
     ]
   ] else [
     print "else branch"
   ]

case branch
else branch  ; ugh

I don't want the CASE branch to evaluate to NULL just because some expression in the branch was incidentally NULL. That would mean the ELSE tied to the CASE runs even though the code for the branch ran.

Enter Heavy Forms: PACKs with One Element

One thing a "heavy" void has in common with a "plain" void is that neither can be put in blocks. But it automatically "decays" into regular VOID when stored into variables.

>> if true [void]
; first in pack of length 1

>> x: if true [void]

>> x

The twist is that the heavy void is different enough from true VOID such that a THEN or an ELSE can consider them a situation where the branch did not run:

>> if false [<ignored>]

>> if true [void]
; first in pack of length 1

>> if true [void] else [print "This won't run"]

The reason functions like ELSE can "see" the isotope is that they don't take an ordinary parameter on their left. They take a ^META argument. These can see the distinction between a void in an isotopic pack and a "true" VOID.

I'd largely say this has been working well...certainly better than its conceptual predecessors. It makes piping VOID (or NULL) out of branches trivially easy, when the fear of conflation is not a problem.

>> x: switch 1 + 2 [
     1 [print "one" 1]
     2 [print "two", <two>]
     3 [print "three", null]
   ]
three
; first in pack of length 1
== ~null~

>> x
; null

The automatic decay in variable storage prevents you from needing an explicit operation to turn ~null~ isotopes into pure nulls:

>> x: decay switch 1 + 2 [
     1 [print "one" 1]
     2 [print "two", <two>]
     3 [print "three", null]
   ]
three
; null

Non-Meta Arguments Decay Null isotopes

The "auto-decay" of heavy ~null~ means no variable can ever hold one. And there's also a rule that no normal parameter can ever be passed a "pack" like the heavy forms, only ^META parameters.

So it seems useful if normal arguments would automatically decay null isotopes:

>> foo: func [x] [if null? x [print "Yup, it's null"]]

>> foo if true [null]
Yup it's null

>> metafoo: func [^x] [
    case [
        null? x [print "regular null"]
        x = '~[~null~]~ [print "heavy null"]
        true [print "something else"]
     ]
   ]

>> metafoo if false [null]
regular null

>> metafoo if true [null]
heavy null

There is a manual DECAY operator which can be used at non-parameter moments where you want to decay a heavy null:

>> if true [null]
; first in pack of length 1
== ~null~  ; isotope

>> decay if true [null]
== ~null~  ; isotope

Func RETURN Only Decays If You Have A RETURN: Spec

Consider this pattern:

foo: func [x] [
    return switch x [
         1 [print "one", #one]
         2 [print "two", null]
         3 [print "three", <three>]
    ]
]

>> foo 1 + 2
two
== ???   ; should this be heavy null or just null?

Once upon a time, decaying was the default, and there was a refinement called /ISOTOPE on RETURN which asked it not to decay.

Today, there is no automatic decay unless you have a return spec that doesn't mention PACK?s. So return: [any-value?] would be enough to get the auto-decay.

Hopefully It All Makes Sense

"A designer knows he has achieved perfection not when there is nothing left to add, but when there is nothing left to take away."

3 Likes

Great refresher and summary of the issues.

4 posts were split to a new topic: Handling MATCH and "Falsey" Types

A post was merged into an existing topic: BLANK! 2022: Revisiting The Datatype