Default Values and MAKE FRAME! - 2024 Edition

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! :[]]~
]]

:face_with_diagonal_mouth:

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

:thinking:

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).

Still paying off technical debt, here...this frame stuff has been agonizing.

As part of trying out this "two unspecialized forms" concept, I started tightening the screws so I can better reason about the invariants. It quickly exposed something fundamental, that I might classify as "genuinely interesting".

Consider the native for testing if something is an INTEGER?. Let's say it's able to accept ANY-VALUE?, and it should not return true for PARAMETER! antiforms:

/integer?: native [return: [logic?] value [any-value?]]

We don't want to be in a situation where a function like this needs to take its argument as ^META, because that's a trap we don't want to fall in... where every parameter has to be meta. You don't want the edge case to spread like that.

But now we have to ask: what if you have an antiform PARAMETER!, and you ask INTEGER? of it. :face_with_diagonal_mouth:

The options are limited to basically 2 choices:

  1. Antiform PARAMETER! is removed from the ANY-VALUE? class. That means testing INTEGER? on an antiform parameter would trigger an abrupt failure.

  2. Antiform PARAMETER! is made legal in EVAL frame contexts to mean "itself" (while its meaning in RUN frame contexts is "unspecialized").

When you put it that way, I get bad vibes from (1).

Though (2) has consequences that I've explained. Any function that takes antiform PARAMETER! which doesn't take it ^META will not be able to create partial specializations that fix arguments as antiform parameters, while still gathering other parameters. You will only be able to use antiform parameters in full specializations.

But I realized you -could- create partial specializations on functions taking antiform parameters non-META. You'd just have to get creative: make a variant with the parameter adjusted to be ^META and then, use an ENCLOSE...after all the arguments are fulfilled, unmeta it before passing through to the original function.

Having a way to do it is good enough for me. I do not think that partial specializations to antiform parameters is going to be something anyone does frequently (or ever, probably, outside of the test files I'm going to write that prove it can be done.)

Down To Just One ^META Exception: NOTHING (~)

A "genuinely interesting" aspect of this is that it brings a renewed motivation to the existence of the unset state. It has been challenged before:

Why Have an "Unset State" in Rebol-like Languages?

I actually was reflecting on the question of the necessity of "nothing" once I realized that protecting-you-from-typos is no longer one of the reasons, due to binding becoming "strict". So I had a panicky moment where I wondered if ~null~ and ~ antiforms should be merged, after all. :worried:

Almost certainly not... but with PARAMETER! antiforms now in frames as "themselves", something has to give. There's a strong incentive to pare that back to as few states as possible... and hence, just one thing: NOTHING.

Note it's thus subject to the limitations of (1) above, and hence you can't call INTEGER? on NOTHING.

So NOTHING becomes (as it was) the one stable antiform state that a variable can hold, which cannot be accepted by functions that don't take ^META parameters.

If we didn't do something special, the error you'd get would look like:

>> integer? ~
** Error: INTEGER?'s VALUE argument is unspecified (~ antiform)

Maybe that's good enough, though the argument-gathering machinery could pre-empt the FRAME! typechecking layer with a clearer message:

>> integer? ~
** Error: INTEGER?'s VALUE argument can't be NOTHING (~ antiform)

That may be seem like splitting hairs, but I don't think it is.

Casualty: Unblocks EVAL of Unspecialized FRAME!s

So with antiform parameters being treated as-is under EVAL, then when you don't use MAKE FRAME! and get the slots filled with nothing, you get the slots filled with antiform parameters. And that means this would happen:

>> f: copy unrun parameter?/
== #[frame! [value: ~#[parameter! [any-value?]]~]

>> eval f
== ~okay~  ; anti

I can live with this. COPY on an action's FRAME! is something you should do only if you're building something you mean to run as an action, and if you EVAL it instead, that's kind of your fault.

Maybe there could be some kind of prevention of this, but I'd be loathe to see the FRAME! type bifurcate into "EVAL-able frames" and "RUN-able frames".

Anyway, this is far less a concern with MAKE FRAME! unsetting the slots, than it was when those were antiform parameters.