samuelgoto / proposal-block-params

A syntactical simplification in JS to enable DSLs
204 stars 8 forks source link

Give up transpiling into functions #42

Open theScottyJam opened 2 years ago

theScottyJam commented 2 years ago

There seems to be two conflicting ideas in this proposal. 1. we want this to transpile down to simple functions. 2. we want to follow the Tennent's Corresponde Principle. The reality is, you can't have both.

  1. There's no reasonable way to handle await, as described in #28.
  2. There's really not a great way to handle break, continue, or return. Here's an example scenario that would be impossible to handle:

    function myApiFn(callback) {
    aquireLock()
    try {
      callback(someResource)
    } finally {
      releaseLock()
    }
    }
    
    function main() {
    myApiFn {
      // There's no practical way to make this `return` work.
      // It would have to punch through myApiFn() and force-stop it, but that would put it in a bad state.
      return
    }
    }
  3. What happens if I try to hold onto a block parameter and call it later? For example:

    function myApiFn(callback) {
    setTimeout(callback, 1000)
    }
    
    function main() {
    for (let entry of entries) {
      myApiFn (entry) {
        // This line won't execute until 1000ms later,
        // after main() has returned.
        break
      }
    }
    }

As far as I can tell, the only appropriate way to design for these scenarios, is to make it so any function that can accept a block parameter as input must be a pauseable function (i.e. a generator/iterator). This function will pause whenever a block parameter executes, and then continue execution when the block parameter finishes. If the block param did an await, the pause might last until the await is over. If the block param did a break or continue, then we simply don't continue execution of the iterator, and we toss it.

That's all pretty high level, so let me show how this might look like in practice.

@blockParamHandler
function* tryDoing(args, myBlockParam) {
  try {
    const result = yield BlockParam.invoke(myBlockParam, ...args)
    return { result, error: null }
  } catch (error) {
    return { result: null, error }
  }
}

// This all works:
for (const entry of entries) {
  const { result, error } = tryDoing([]) {
    if (someCondition) break
    await someAsyncTask(data)
  }
  ...
}

// This also works
const { result, error } = tryDoing([myData], data => {
  return JSON.parse(data)
})

So, here's what's going on in detail.

tryDoing() is a function that's specifically designed to handle blockParams. You'll notice it's pauseable (it's a generator). You'll also notice it's decorated by a built-in @blockParamHandler decorator, which is important, since this decorator is what will basically cause the generator to be weaved together with the block parameter. Further down, when we do tryDoing([]) { ... }, this will cause the tryDoing function to automatically be called with a BlockParam instance as it's argument, which remember, isn't a function anymore. In fact, a BlockParam instance is much more like a sentinel that represents a particular block of code, but is in no way directly executable (you can't just call it).

Inside the tryDoing() function, you'll find this interesting line: BlockParam.invoke(myBlockParam, ...args). This isn't invoking the block-parameter, instead, it's creating a command that basically says "please run the block parameter represented by the myBlockParam sentinel with these args". We then yield this command. The @blockParamHandler decorator will notice that the function it's decorating yielded with this command, and using it's magic (because it's built-in), it'll let the block param start executing. If the block-param does an await, then the @blockParamHandler decorator will patiently wait before it continues execution of the generator. If the block-param does a break or continue, then the decorator will silently toss the running generator and break will proceed as normal. Finally (assuming there wasn't a break or continue), the decorator will give control back to the running generator to finish what it was doing.

Note that this solution, as presented, will make it impossible to ever intercept break/continue and provide custom behaviors. I think this is actually a good thing, which I argue in #41, but if this sort of behavior is really desired, I'm sure there's ways around it (i.e. making break/continue throw a catchable error).

I also want to point out that tryDoing() fully supports regular callbacks as well. The @blockParam decorator will cause the tryDoing function to act like a normal function to the outside world (internally, it's a generator, but for everyone else, it's just a normal function). If you call tryDoing with a normal function, then tryDoing will eventually run the yield BlockParam.invoke(myBlockParam, ...args) line using a normal function instead of a block parameter, and it'll all work as normal. The BlockParamHelper.invoke() function will issue the command (which will get yielded) "Please call this function with these arguments", then the @blockParamHandler decorator will simply call that function and return control back to the generator. This means, any function that supports block parameters will also automatically support callbacks.

This solution does have the consequence that you can't just use block parameters on arbitrary functions. The functions have to be designed to support them. I do not believe there's a way around this issue. I will note that if you try to pass a block parameter to a function that only accepts functions, an error will be thrown because 1. it is a runtime error to use the fn () { ... } syntax on anything that's not decorated with @blockParamHandler, and 2. block parameters are not callable. This prevents API users from doing this on unsupported functions, and lets API designers migrate to supporting block parameters when they're ready, without making a breaking change.