I commonly write function generators that put in some boilerplate to make some variables and service routines available to the generated function. But these frequently have weaknesses, and I thought I'd write up an example to illustrate...and explain some ways to mitigate the problems.
Example: PROMISE Generator With RESOLVE and REJECT
Imagine I want to make a function variation that's like a JavaScript promise, with a RESOLVE and REJECT...which are defined per-promise.
Let's say the first cut of the new generator looks something like this:
promise: func [spec body] [
body: compose [
let resolve: lambda [result] [ ; lambdas lack their own return
some code here
return whatever ; intending return of generated FUNC
]
let reject: lambda [error] [
more code here
return whatever
]
(spread body) ; code here should see RESOLVE and REJECT
]
return func spec body
]
When the frame is created for the new function, it will run this body that's been extended with some boilerplate. But that frame's arguments could have the name of any of the functions you're using in the bodies of RESOLVE or REJECT. e.g. what if I said foo: promise [code /more] [...]
... the implementations of RESOLVE and REJECT would be disrupted from what they thought the words they had used meant.
Once you notice this, you might think the solution is to pre-compute more things:
promise: func [spec body] [
body: compose [
let resolve: ^(lambda [result] [
some code here
return whatever
])
let reject: ^(lambda [error] [
more code here
return whatever
])
(spread body) ; code here should see RESOLVE and REJECT
]
return func spec body
]
(Note use of ^META group in order to turn the isotopic frame produced by FUNC into a quasi frame, so that under evaluation in the function body it becomes isotopic again. The compose would fail if you tried to compose the isotopic frame in directly.)
That's a bit better in terms of insulating the boilerplate code from stray bindings coming from the user-supplied spec (though there's still the weakness of LET if the user wrote something like promise [let [integer!]] [...], which if you cared you could address by composing :LET in as its literal function value).
But it does too good a job: the COMPOSE runs during the PROMISE fabrication time, and so the notion of RETURN used by RESOLVE and REJECT are is the return for the PROMISE generator itself... not the produced FUNC as intended. This is true of anything else you need to have picked up from the instance (let's say REJECT was implemented in terms of RESOLVE, or needed some other local).
One way of addressing this would be to slip the instance RETURN in as a parameter, e.g. via specialization of the precomputed code:
promise: func [spec body] [
body: compose [
let resolve: specialize ^(lambda [result ret] [
some code here
ret whatever
]) [ret: :return]
let reject: specialize ^(lambda [error ret] [
more code here
ret whatever
]) [ret: :return]
(spread body) ; code here should see RESOLVE and REJECT
]
return func spec body
]
There you've got an added assumed term which can break things, e.g. promise [let [integer!] specialize [block!]] [...]
or similar. But at least some code here and more code here are running under the understandings that the PROMISE generator author had of what those implementations meant.
Once you've separated out that which can be precomputed vs. that which can't, there's no need to make the precomputed part every time:
promise-resolve*: lambda [result ret] [
some code here
ret whatever
]
promise-reject*: lambda [error ret] [
more code here
ret whatever
]
promise: func [spec body] [
body: compose [
let resolve: specialize :promise-resolve* [ret: :return]
let reject: specialize :promise-reject* [ret: :return]
(spread body)
]
return func spec body
]
Could Some Kind of COMPILE Operation Help?
Weaknesses due to redefinitions of things like LET and SPECIALIZE makes me wonder if situations like this could be helped by an operation that would replace words that look up to functions with references to the functions. The cell can retain the symbol for the word, which can make debugging and errors more tolerable.
promise: func [spec body] [
body: compose (compile [let specialize] [
let resolve: specialize :promise-resolve* [ret: :return]
let reject: specialize :promise-reject* [ret: :return]
(spread body)
])
return func spec body
]
Merging with COMPOSE would be more efficient, and could help cue that you want to avoid compiling the things in GROUP!s. Maybe it could assume you wanted to compile references to actions unless you threw in some kind of escaping:
promise: func [spec body] [
body: compile [
let resolve: specialize :promise-resolve* [ret: $ :return]
let reject: specialize :promise-reject* [ret: $ :return]
(spread body)
]
return func spec body
]
I've thought about this kind of thing for a while, but never got around to writing it.
Paranoia Plus Efficiency: Body As GROUP! vs. Spliced
One improvement to this code is to splice the body as a group instead of spreading it itemwise in a block.
To see why this matters, consider something like:
func-with-a-as-one: func [spec body] [
return func spec compose [
let a: 1
(spread body)
]
]
Now let's say someone wrote:
>> test: func-with-a-as-one [x] [+ 9, return a + x]
>> test 1000
== 1010 ; not 1001
Accidentally or intentionally, the function was defined as:
func [x] [
let a: 1
+ 9, return a + x
]
You can avoid this by quarantining the body, using (as group! body) instead of (spread body) in the COMPOSE.
func [x] [
let a: 1
(+ 9, return a + x)
]
As an added benefit, the AS alias is cheaper memory-wise than copying the elements in item-wise (though it adds one extra GROUP! evaluation step to the function).
Another Loophole: What If RESOLVE/REJECT Are Args?
If you use LET, currently that will override whatever definition is in play. So if someone were to write promise [x y reject] [...] they'd not be able to see the REJECT argument, and wouldn't get an error.
You can force an error by dropping the LETs, and expanding the specification to include definitions.
promise: func [spec body] [
body: compile [
resolve: specialize :promise-resolve* [ret: $ :return]
reject: specialize :promise-reject* [ret: $ :return]
(as group! body)
]
return func compose [(spread spec) <local> resolve reject] body
]
So that's just sort of a peek into the effort it would take to make a relatively hygienic function generator. Some things like worrying about taking SPECIALIZE as an argument might be beyond the concerns of the average one-off task. But if you write a bunch of indiscriminate boilerplate using arbitrary words to refer to functions, it's very easy to get bitten when an argument reuses those words.