LET Binding? (A Limited Form of Virtual Binding?)

I've previously lumped LET in with the question of how we might avoid copying loop bodies every time the loop runs.

They seem like similar problems. You have a named variable (or variables) that you want to allocate space for, and then bind to code that hadn't previously been aligned with an interest in those variables.

But maybe there's value to thinking about LET as a separate problem.

Today's "Fake" LET

Consider the following situation:

foo: func [code] [
    let a: 1
    do compose [a + ((code))]
]

>> a: 1000
>> foo [a + 20]
== 1021

In today's world, LET is a no-op. All it does is signal FUNC...while it's making a "relativized" copy of the body...to collect a in that list. That's exactly how old FUNCTION once looked for SET-WORD!s), meaning the above is to if you had said foo: func [code <local> a]. It's considered less harmful since we use LET more narrowly than we use SET-WORD!

Though a new copy of the function body is created, it's a copy that is re-used every time the function runs. That allows frames be resolved against the blocks in a cascading fashion as they are delved into.

So the reason you see 1021 and not 1002 is because the walk of FOO's body doing the local binding for a never saw [a + 20], it just saw code. Manual binding with the a after composition would mean both notions of a would be forced to match:

foo: func [code] [
    let a: 1
    do bind (compose [a + ((code))]) 'a
]

>> a: 1000
>> foo [a + 20]
== 1002

That's lossy..and the reason you don't see many people messing with BIND, and just leaving the bindings as-is. Incidental name sharing can create havoc outside of carefully controlled circumstances. You're better off saying DO CODE instead of composing material live into places where the bindings can get mucked with.

One Small New Idea For a Virtual LET

The "real" LET idea considered that LET isn't a no-op, but that it somehow augments the context as it runs...making the variable it adds visible in the flow after it:

foo: func [code] [
    ; at this point, there is no `a` defined
    let a: 1
    ; going into the DO statement, there's an ambient new `a` that
    ; is in effect now
    do compose [a + ((code))]
]

One issue with virtual binding is the cost of consulting a side-table for what might be every word lookup...to know if it has been overridden or not.

But another issue is semantic. How would it know which a to override, and which to leave alone? If it overrides all the As, then it's like we've got that lossy BIND operation running on everything.

However...what if we don't think of LET as being fully general? For starters, let's imagine a LET that only can be used if it occurred inside of a relativized function body (as this one does). It could then say that its override only applies to other A that also originated out of the original function body.

It's a mechanic that would work on a somewhat limited basis. New LETs that you conjure out of thin air won't work. But then again, "pre-scanning for SET-WORD!s" didn't let you just suddenly fabricate new code once the function was running and gather set-words as local. At least here you could get an error... "your LET is too late, not relativized in a function body."

So in the above you could say do compose [let a: 10, a + ((code))] but you couldn't put a LET in the code, compose it in at runtime, and have it work. At least it wouldn't work the same way. But I think perhaps the best idea is to have it not work at all.

Performance And Semantic Implications

At first glance this looks a lot like what would happen if you gather things locally to the frame. Though one difference is that the runtime nature of LET would scope its influence to blocks:

a: "global"

foo: func [] [
    block: [print a]
    do block  ; prints "global"
    loop 2 [
        let a: "local"
        do block  ; prints "local"
    ]
    comment [do block]  ; prints "global"
]

That also points out a cost factor: with that LET happening each time through the loop, you are generating a new unique identity.

two-as: func [] [
    result: collect [
        repeat n 2 [
            let a: n
            keep 'a
        ]
     ]
]

>> data: two-as
== [a a]

>> reduce data
== [1 2]

Even keeping an open mind, that's pretty brutal to think about. Also these lets would be strangely promiscuous, willing to bind against any relativized material from the function body despite having such an identity.

A tidier notion might be to say that the dynamic frame would only create one instance of these LETs. It would still have the "global" and "local" distinguishing behavior.

Anyway, just a little more thinking about the possibilities. There might be something here to thinking that LET runtime augments frames in a scoped way, but if the LET creates new variable identities on every loop iteration it seems to cause more problems than it would solve.