Why APPEND of NULL fails, and APPEND of VOID is a No-Op

When the idea of NULL was first introduced, it was the only non-valued state...which one could be assured could not be put in a block. It was the result of conditionals that didn't take their branches, a failed PICK or SELECT, and it was the contents of an unset variable.

I sensed that there were two competing opportunities for using NULL with mechanical functions like APPEND.

  • The opportunity to have an "escape" out of a value bearing slot to say "no, I actually don't want to append a thing".

    >> append copy [<a> <b> <c>] (if 1 = 1 '[<d> <e>])
    == [<a> <b> <c> <d> <e>]
    
    >> append copy [<a> <b> <c>] (if 1 = 2 '[<d> <e>])
    == [<a> <b> <c>]
    
  • The opportunity to catch an error in intent when NULL is an accident.

    >> data: [<a> <b> <c>]
    >> block: [<d> <e>]
    
    >> append data third block
    ** Error: APPEND doesn't accept NULL as its value argument
    
    >> append data maybe third block
    == [<a> <b> <c>]
    

In the beginning I was excited about the coding styles afforded by the first choice.

But quietly accepting NULL loses the hot-potato benefit. If you say append data third block and there is no third element in the block, doesn't that seem like a good time to complain? Rebol code can pretty quickly get hard to read and debug, and wouldn't it be nice if the person who wrote that line could articulate that there may not be a third thing in the block by saying append data maybe third block?

Coming up with what MAYBE would return to "approve" the lack of an append ran up against a bit of a problem, because NULL was the only non-valued type. So for a time it was tried to make the default behavior of BLANK! to be to add nothing, and you need an /ONLY to override that. It seemed a missed opportunity for null to exploit its out-of-band status. But the thought was that "purposefully adding blanks wasn't all that common--certainly a lot less common than adding a block as-is, and that requires /ONLY!"

Hindsight 2022: In 2018 it was a long way from the tools and mechanics of generalized isotopes, which made as-is manipulation with APPEND, INSERT, etc. the universal default. It's funny to look back on it, and think about how difficult it was to try and make design decisions based on how often people appended blanks as-is or not. That kind of guesswork offers little grounding to those writing code, who should be able to be assured that if they pick an element out of one block and append it to another, that the default is to have moved the item intact.

1 Like

This decision was reversed in 2018 shortly after it was implemented, in particular because NULLs started taking over a lot of former BLANK! roles.

A failed ANY or ALL, a FIND that didn't find anything, a MATCH that didn't take... those all started returning NULL, and not blank. This was the new return value protocol, where BLANK! is no longer used as a return value meaning "no match".

It gave BLANK!s a renewed purpose, but a purpose for which they should be thought of as "things". They were no longer signals of failure. And as such, their "realness" for an APPEND seemed more foundational, with me saying:

It's likely better to embrace blank's "nothingness-without-triggering-errors-on-access" as its primary facet, with its ability to be represented in a block as secondary.

However...

1 Like

On the other side of this statement: both aspects seemed to come into play, whether you like it or not. It's not going to be all one way or the other. You're going to sometimes be thinking of a blank as a "thing", and sometimes as a "nothing".

Let's look away from APPEND and COMPOSE for a moment, and think about maps. Today these are different:

my-map/key: _   ; sets the key to a blank value
my-map/key: null   ; removes the key

Here you don't have the option of an /ONLY. So if blanks were treated as nothings, you'd lose the ability to put them into maps with this mechanism. But as long as they are distinct, you have the choice to say either:

my-map/key: thing-that-might-be-blank
my-map/key: opt thing-that-might-be-blank

Going back to APPEND and COMPOSE, the /ONLY is already controlling one aspect: splicing or not. But...

  • ...what if you want to splice if it's a block, and treat it like a literal blank if it isn't?
  • ...what if you want to not splice if it's a block, but treat it as nothing if it's a blank?

When in Doubt, Favor the Mechanically Consistent Behavior

Sometimes things seem annoying when you look at them as individual cases, but you realize their necessity if the big picture is taken into account.

I think needing OPT at the callsite is just one of these necessary evils--that's not even that evil.

For historical perspective: When I initially complained about NONE!s not "vaporizing" in things like COMPOSE, BrianH was very adamant that NONE!s were things. He believed a lot of Rebol's idiomatic behavior involved having empty placeholders in slots of regular record-like structures. So he was generally resistant to using them to opt-out when blocks were being produced.

So I guess this just kind of informs what the system's bias is: Blanks are values first and foremost, and nothings second.

The answer is...

Use VOID to mean append nothing, and NULL gets an Error

That's the conclusion I came to with COMPOSE and DELIMIT, and I think it has turned out to be the right one. It has eliminated many casual errors there, and provided new features from responding to the NULL errors definitionally.

So why not make APPEND 100% compatible?

 >> var: null

>> append [a b c] var
** Error: APPEND doesn't take NULL (use MAYBE if intentional)

>> append [a b c] maybe null
== [a b c]

We haven't historically thought of VOID as something to pass a function as a parameter. Because in the olden days, you couldn't receive a void: the evaluator would keep going until it actually produced a value (or NULL).

But non-interstitial invisibility is a thing of the past now (too dangerous, too messy). Hence ^META parameters can detect pure voids. And the interesting property of voidness (error when fetched via WORD! variables) means you have to do a head-check about "did I really mean this?"

2 Likes

So there's a bit of a snag on this, when the thing you're 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..." ...]

Now you've got this stray product of an append being the result of the MAP-EACH :-/

I don't know if this is a good enough reason to disallow a MAP-EACH body from evaluating to void and thus adding nothing. The same property applies everywhere, e.g. in COMPOSE slots--you can't just decide to evaluate the whole slot to void in the last expression. It isn't how invisibility works.

But it has some benefit, as it means you move your MAYBE to the outer level vs something tacked on down at the end:

compose [... (maybe any [...]) ...]

We might look at it this way with the MAP-EACH as well; if each iteration of the loop isn't going to be adding something, we say that at the top:

map-each item [1 <one> 2 <two> 3 <three>] [
    maybe (
        append log ["Logging:" item]
        match tag! item
    )
]

Note that there is another tool here, which is CONTINUE:

map-each item [1 <one> 2 <two> 3 <three>] [
    append log spaced ["Logging:" item]
    match tag! item else [continue]
]
== [<one> <two> <three>]

In any case, this just shows that there's a bit of a twist to the use of void, making it something quite different from NULL...so there's some risk of using it incorrectly.

1 Like

Historically, BrianH and others (including me) believed that mutating operations should not let you "opt out" of the target for the mutation. There was too much potential for confusion if you wrote:

 append block [1 2 3]

...and it silently didn't append, because block was a NONE!.

...Yet Another Historical Spectre Annihilated... :boom: :ghost: :boom:

The VOID in, NULL out solution handles this about as elegantly as we could hope.

If the variable were void, you'd get an error on accessing it:

>> unset 'block

>> append block [1 2 3]
** Error: BLOCK is unset, void, voided... whatever you want to call it...

And if you had a variable set to NULL, that wouldn't be glossed over either. The BLOCK reference would evaluate to null without an error, but it's not a valid parameter type and would fail in type checking:

>> block: _

>> append block [1 2 3]
** Error: APPEND doesn't accept null for its series argument (use MAYBE if intended)

But you can turn that null into a void with MAYBE

>> maybe block
; void

>> append maybe block [1 2 3]
; null

Here was some awkward code in UPARSE trying to use the blackhole when subpending was NULL, to skip the REMOVE-EACH:

remove-each item any [subpending #] [
    if group? item [eval item, true]
]

We see it instantly gets less confusing:

remove-each item (maybe subpending) [
    if group? item [eval item, true]
]

What's more, operations that have a natural "MAYBE" implicit in them--such as an IF or SWITCH or other branching construct, "just work"

>> block: [a b c]

>> flag: false

>> append if flag [block] spread [d e f]
; null

>> flag: true
 
>> append if flag [block] spread [d e f]
[a b c d e f]

^-- In regards to the "too many notes"... here's a good example to make "note" of, @rgchris ! NULL is a good thing, and an isotopic form of this non-value pays dividends--just as we've seen isotopic forms of regular values starting to show off, and only getting better...

1 Like

Something I didn't notice here is that we have another way of dealing with this, via an ordinary evaluated result... an empty splice !

map-each item [1 <one> 2 <two> 3 <three>] [
    append log spaced ["Logging:" item]
    match tag! item else [spread []]
]
== [<one> <two> <three>]

I don't know what we'd call this...or if it even needs a name. It can just be its quasi representation ~()~ for "empty splice".

There's no need for any special "decay" or anything, because the operation already has semantics for empty arrays, which matches that of void!

>> append [a b c] case [false [<skip>] true [print "Hi", ~()~]] else ['d]
Hi
== [a b c]

>> append [a b c] case [false [<skip>] false [print "Hi", ~()~]] else ['d]
== [a b c d]

:exploding_head:

2 Likes