NOTE: The below summarizes history with some adjustments made for simplification purposes. See posts in Archive regarding frames for all the exact detours.
Something like this has worked ever since the first FRAME!:
f: make frame! append/
f.series: [a b c]
f.value: 10
>> eval f
== [a b c 10]
Notice that although APPEND has refinements, you don't have to explicitly go through and set them to NULL. That was done for you.
That might make you think the result of (make frame! append/) looked like this:
>> make frame! append/
== #[frame! [ ; you might think...
series: ~null~
value: ~null~
part: ~null~
dup: ~null~
line: ~null~
]]
e.g. all fields defaulting to null, whether they are refinements or not.
That's Not What It Did... Here Is Why
Even before the full-on unification of ACTION! and FRAME! into one datatype, there was a concept that you could make an action out of a frame. This meant some state of the frame variables had to represent the idea that an argument was still to be gathered.
The goal was something like this:
f: make frame! append/
f.value: 10
; don't assign f.series, leave it however it was
>> run f [a b c]
== [a b c 10]
If all the argument slots were eagerly set to ~null~
then you'd get something like:
>> run f [a b c]
** Error: APPEND doesn't allow ~null~ for its VALUE argument
So instead, MAKE FRAME! would leave the slots all unset, to indicate they were unspecialized:
>> make frame! append/
== #[frame! [
series: ~
value: ~
part: ~
dup: ~
line: ~
]]
If it got to the point of execution, optional parameters (e.g. refinements) would be turned from NOTHING into NULL implicitly. But if you didn't set a slot for a required parameter, it would give a helpful error:
f: make frame! append/
f.value: 10
>> eval f
** Error: APPEND's SERIES argument is unspecified
But What If You Want To Specialize To Nothing?
It might seem that "nothing" is a rare thing to want to specialize to. But it's a legitimate value, and represents a valid frame state.
e.g. let's say you wanted to write UNSET as a specialization:
/unset: specialize set/ [value: ~]
This would wind up making UNSET a synonym for SET. Because it would think you were saying "specialize SET's value to be unspecialized"...which is what it was by default.
In this sense, using NOTHING as the unspecialized value just pushed the problem around a little bit. No matter what you pick to represent the unspecialized state, you're going to have a problem. It just happens that functions which take ~null~ antiforms are relatively common compared to those that take ~ antiforms.
Leveraging ^META On a Stable Antiform
If you start thinking about being sneaky, you might imagine adding extra hidden bits somewhere to say "no, this is a magic kind of specialized NOTHING". But sneaky hidden bits are a tangled web, adding cost in the routines to manipulate them.
So the idea was to use a not-so-hidden bit: any function that could legitimately take "nothing" as an argument had to take it as a ^META parameter.
x: 1020
f: make frame! set/
f.var: $x
f.value: first [~] ; ~ is meta-NOTHING, aka "quasi-BLANK!", aka "TRASH"
>> eval f
== ~ ; anti
>> x
** Error: X is unset (antiform BLANK!)
This works, but creates an additional burden: functions that truly want to receive a value that can represent any stable form must take their arguments as ^META... which usually you'd think you only need for unstable forms.
There's Still an Ambiguity: Gather, Or Error?
The above was the status quo for a couple of years: MAKE FRAME! gave you back a frame whose slots were all unset. Those unset slots represented arguments that were unspecialized.
But still you have a question: should an unspecialized argument be gathered from a callsite, or should it trigger an error?
That decision came from the operation. If you used EVAL on a FRAME!, it would assume all the frame slots were finalized...and any nothing cells would raise errors. If you used RUN (or converted the FRAME! into an action) then it assumed the unspecialized slots meant you wanted to gather arguments.
Evolution: Antiform PARAMETER! For Unspecialized Slots
A big change came through with a user exposure of the PARAMETER! type. With antiform parameters representing unspecialized slots, you had the signal of "this is unspecialized", but also the information required to gather the parameter: what types it checked, whether it was a refinement or not, whether it should be taken literally from a callsite, etc.
This heralded even more exposure of the mechanics of function composition to user mode. Instead of just specializing a function argument to a value, you could do things like "tweak" the argument's accepted types.
For instance: what if you wanted a version of APPEND that only appended integers?
>> ap-int: make frame! append/
>> ap-int.value: anti make parameter! [integer!] ; or whatever syntax
== ~#[parameter! [integer!]]~ ; anti
>> /ap-int: anti ap-int
== ~#[frame! ...]~ ; anti
>> ap-int [a b c] 1020
== [a b c 1020]
>> ap-int [a b c] "illegal"
** Error: AP-INT requires [integer!] for its value argument
This meant that MAKE FRAME! gave back something that looked rather weird:
>> make frame! append/
== #[frame! [
series: ~#[parameter! [
~void~ any-series? port! map! object! module! bitset!]
]~
value: ~#[parameter! [~void~ element? splice?]]~
part: ~#[parameter! :[any-number? any-series? pair!]]~
dup: ~#[parameter! :[any-number? pair!]]~
line: ~#[parameter! :[]]~
]]
Although this gives you a useful and actionable information about the parameters, that's fairly noisy for most purposes. You haven't done any assignments to the frame, and yet it looks like it's "full".
Let's put a pin in that.
Are ^META Exceptions Still Needed?
In this model, there is no way of expressing a specialization of a function to an antiform parameter unless that function defined the parameter as ^META.
That doesn't necessarily mean we'd have to be prescriptive. I mentioned that EVAL expects all the arguments to be specified to their final values, while RUN accepts some will be unspecialized. So EVAL could treat antiform parameters as the actual values to pass, while RUN would gather them from the callsite.
This would produce a strange conflation. You couldn't tell by looking at a frame whether or not an antiform parameter was a legitimate argument, or an unspecialized parameter definition.
As an example, consider the HOLE? function, that tells you whether or not a value is an antiform parameter. Let's assume it's operating in a world that it doesn't need to take the argument as ^META:
>> f: make frame! hole/
== #[frame! [value: ~#[parameter! [any-value?]]~]
The interface says it takes ANY-VALUE? as the first parameter to HOLE? Yet we don't have any mechanical way of telling that f2
isn't a completed frame for calling HOLE?. So if EVAL accepts this:
>> eval f
== ~okay~ ; anti
It really seems the best plan is to keep erroring when you try to EVAL frames with antiform PARAMETER! in them, when those parameters are required:
>> eval f2
** Error: HOLE?'s VALUE parameter is unspecified (antiform PARAMETER!)
It does require the ^META exception: functions which need to accept antiform arguments must take that parameter in a meta form. A function like HOLE? would fit into this category, and so would SET (which has to take unstable antiforms anyway, e.g. to do SET of a BLOCK! to a PACK)
Under this design, any function that can accept antiform parameters at all, will also able to be specialized with antiform parameters.
Denoising MAKE FRAME!: Different Frame Makers?
I pointed out that having antiform parameters in frame slots is a bit noisy.
So what if there were two ways of making frames: one that gives you the parameter antiforms (suitable for tweaking and writing your own specialization operations), and another that clears the fields out?
For instance, keeping the old behavior for MAKE FRAME!:
>> make frame! append/
== #[frame! [
series: ~
value: ~
part: ~
dup: ~
line: ~
]]
But if you wanted antiform parameters, you could just copy the non-antiform version of the FRAME!:
>> copy unrun append/
== #[frame! [
series: ~#[parameter! [
~void~ any-series? port! map! object! module! bitset!]
]~
value: ~#[parameter! [~void~ element? splice?]]~
part: ~#[parameter! :[any-number? any-series? pair!]]~
dup: ~#[parameter! :[any-number? pair!]]~
line: ~#[parameter! :[]]~
]]
In this design, you'd have two stable forms which you'd have to use ^META conventions to take as an argument.
Antiform ~ would still have to turn into ~null~ if EVAL found it in a refinement slot. But I'm not sure PARAMETER! antiforms would need to do that too. One could argue that you should never be EVAL'ing a frame with antiform parameters in it, so it could be a sort of safety mechanism (?). I'm not sure if it's necessary to enable, so I'd probably just raise an error until I saw a compelling case.
Is The Added Nuance Worth It?
Continuing to handle unset variables as placeholders is not strictly necessary, given that antiform parameters are the more fundamental "unspecialized" slot representation.
But it's definitely a lot better than something I tried that sucked: which was trying to make variables holding antiform PARAMETER! act like they were unset. This made manipulating function interfaces programmatically very painful.
It would mean sacrificing another value to have to be passed ^META. Though bear in mind, most of the damage is done from having one form: e.g. a function like SET is having to take its value ^META already in order to be able to set things to antiform PARAMETER!. So it isn't like you'd be able to specialize SET with antiform ~
to get UNSET if this wasn't done... it would affect the NOTHING? function (and probably not a lot else).
Having used MAKE FRAME! a fair bit, I am inclined to believe it's worth it. It's a clear indicator of whether you've assigned fields or not, with some teeth by actually making the variables unset (erroring on use before assignment, and reacting to things like DEFAULT).