Here is a train of thought to help people realize why isotopes are needed, and why unifying their behaviors and mechanisms under a common umbrella makes sense. It starts from the issue of solving /ONLY and then explains the generalization.
As time permits, I'll come back and try to improve this...
NOTE: Terminology has changed over time to where what was once called "the isotopic form" of a value is now called "the antiform", in order to be more consistent with the meaning of the word isotope as describing a group of forms in other fields. You may see lingering references to the old usage.
Years of fretting over the /ONLY debacle converged on a somewhat inescapable conclusion:
It's better to carry the intent of whether a value needs to be spliced on that value...as opposed to having subtle variants of core operations that modulate the splicing.
I'd worked up to a point where I was implementing the "mark of intent" by adding a quoting level to suppress splicing. Yet this faced likely accidents when someone had a quoted value in a variable...and really meant to use it somewhere as-is, with the quote--vs. thinking of the quote as a splice-suppression signal which the operation should remove.
Then @rgchris made this remark:
If Trying This In Historical Redbol, What Might One Do?
As a rough first cut, let's represent splices with a specially recognizable 2-element wrapper block. We'll signal it's a splice with a series in the first slot--checking for the unique identity of that series. Then put the block itself as the second element:
splice-cue: "!!!splice!!!"
spread: func [block [block!]] [
return reduce [splice-cue block]
]
splice?: func [value] [
if not block? :value [return false]
return same? splice-cue first value
]
Then we can write our new versions of things like APPEND that are specifically aware of this construct.
append*: func [series [series!] value] [
return either splice? :value [
append series second value
][
append/only series :value
]
]
It works more or less in your average Redbol, e.g. in Red:
red>> append* [a b c] spread [d e]
== [a b c d e]
red>> append* [a b c] [d e]
== [a b c [d e]]
red>> append* [a b c] 'd
== [a b c d]
red>> append* [a b c] first ['d]
== [a b c 'd]
In fact, this is essentially how the bootstrap executable for Ren-C simulates the SPREAD behavior.
But the weaknesses are immediately apparent!!!
Not A Distinct Type: Too Easy To Overlook Handling
There's no special type for the spliced block...it's just a BLOCK!. This means any routine that hasn't been written to handle it, will just let it leak through.
red>> reduce [spread [a b c] [a b c]]
== [["!!!splice!!!" [a b c]] [a b c]] ; not [a b c [a b c]]
Changing to some other generic type that can contain a block...such as an OBJECT!...doesn't help matters. You are kind of in trouble any time an operation willfully lets you put these into an array.
The first instinct might be to introduce a new SPLICE! datatype, with a system-wide rule that splices can't be put into arrays. (Enforcing such a rule across all array-manipulating code is challenging...so let's sort of make a note of that fact, but continue.)
Because of the peculiar nature of not being able to be put in a block, there'd have to be a decision made about function arguments as to whether or not they took this type. Many functions designed to handle generic values would not be able to handle them, so there'd presumably need to be some typeset like ANY-NOTSPLICE! or ANY-NORMAL!.
How To Represent A Type That Can't Be Put In A Block?
Now we've got several things to ponder about our new type. For instance: what you should see here?
>> obj: make object! [foo: spread [d e]]
== make object! [
foo: ???
]
We just said that a defining feature of SPLICE! is that you can't accidentally put them in blocks. But the argument to MAKE OBJECT!, namely [foo: ???]
, is a block. If ???
can't itself be a splice!, then what is it?
This brings up a possibly-related question: what if you want a way to put the intent of whether to splice or not into "suspended animation?"... in a way that you could collect it?
Here's a sort of contrived example of the puzzle:
generate: func [n [integer!]] [
if even? n [return reduce [n n + 1]]
return spread reduce [n n + 1]
]
wrap: func [
return: [...]
in [splice! block!]
][
...
]
unwrap: func [
return: [splice! block!]
wrapped [...]
][
...
]
n: 0
pending: collect [while [n < 4] [keep wrap generate n]]
data: copy []
for-each item pending [append data unwrap item]
How would you write WRAP and UNWRAP such that at the end of the code above, you'd get:
>> data
== [[0 1] 1 2 [2 3] 3 4]
If the system didn't provide some answer to this, you'd end up needing to re-invent something kind of equivalent to the primitive ["!!!splice!!!" [...]]
mechanic as a means of persistence:
>> pending
== [[0 1] ["!!!splice!!!" [1 2]] [2 3] ["!!!splice!!!" [3 4]]]
Isotopes Were Designed For This!
Isotopes are a set of curated answers for these problems. Originally they were introduced to address issues like what an UNSET! was...which has some of the same class of problems as SPLICE! (such as not wanting to be put in BLOCK!s, and not accepted by default or by most routines).
Isotopes introduce two new variants of datatypes called antiforms and quasiforms. Antiforms cannot be put in blocks.
Isotopes are:
-
general - all base value types (e.g. unquoted things) can have antiforms and quasiforms
-
efficient - antiforms and quasiforms do not require allocations, and merely are a different state of a byte in the value cell (the same byte that encodes quoting levels)
-
"meta-representable" - all antiforms can be produced by evaluating their quasiforms, and quasiforms can be produced by evaluating quoted quasiforms.
I mentioned at the outset that it would be somewhat costly to bulletproof all of native code against the ability to do something like append a specific data type like "SPLICE!" to a block. But with isotopes this problem has been solved once for all the forms...so the same code that prevents a so-called "UNSET!" from winding up in arrays works for splices. That's because a splice is actually a group! antiform, and an unset variables actually hold BLANK! antiforms!
Above I asked:
What you should see here?
>> obj: make object! [foo: spread [d e]] == make object! [ foo: ??? ]
Isotopes give us the answer, that it's foo: ~(d e)~
. This is the previously mentioned "QUASIFORM!" of GROUP!, which when evaluated produces an antiform of GROUP!...which by convention represents a splice.
But antiforms themselves have no canon representation. The console can print out a comment or show them in a different color, but to talk about them having a representation doesn't make much sense as you'll never see them in source.
>> ~(d e)~
== ~(d e)~ ; anti
I also asked:
"How would you write WRAP and UNWRAP such that at the end of the code above, you'd get:"
>> data == [[0 1] 1 2 [2 3] 3 4]
With group antiforms representing splices, you don't need to write WRAP and UNWRAP... because these operations are built in operations called META and UNMETA. And the pending array would look like:
>> pending
== ['[0 1] ~(1 2)~ '[2 3] ~(3 4)~]
When the QUOTED! blocks are UNMETA'd, they become regular blocks and then are appended as-is. When the QUASIFORM! groups are UNMETA'd they become antiforms and give the splice intent. This produces the desired "suspended animation" to preserve the intent.
That suspended animation is also used in the ^META parameter convention, which indicates a function argument can accept arbitrary antiforms... and the add-quoting-or-quasi behavior brings those antiform variables into a reified state so they can be safely handled.
The Proof Is In The Capabilities
I've explained about splices, and mentioned how it crosses needs with unset variable states.
But it's also how NULL and VOID are implemented, as antiforms of WORD! states that can't be put in blocks.
The ERROR! antiform is used to have a sneaky out-of-band way to return definitional errors
The FRAME! antiform is what we'd traditionally think of as an "ACTION!" or "FUNCTION!". It triggers execution if accessed via WORD! references. This makes it safe to handle items picked out of blocks without worrying about defusing actions...because only quasiform or plain frames can be put in blocks in the first place!
It's natural for there to be some confusion with the new idea--especially given all its churn through the course of design. But the design is becoming clearer, and I think people are going to find this gives solidity to writing complicated but coherent code...vastly outpacing historical Redbol.