samuelgoto / proposal-block-params

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

Stop trying so hard to mimic built-in control structure syntax #41

Open theScottyJam opened 2 years ago

theScottyJam commented 2 years ago

The core of this proposal is awesome - allowing us to pass blocks into functions. Unfortunately, on top of this core seems to be this idea of trying to allow users to make user-land control structures that look like built-in ones - while this objective is pretty cool, I think a bunch of this effort is just overcomplicating the proposal and its syntax, and is perhaps part of the reason for the current pushback on this proposal. In the end, there's no real reason to have user-land control structures that look like built-in ones, and if we stop trying to make them work and look the same, we'll find that the proposal becomes simpler at only a minor verbosity cost.

Let me run through some concrete examples, and how we can simplify this proposal if we step away from trying to conform the syntax to the existing syntax of control structures, starting with the biggest one, break and continue.

There's a whole issue dedicated to discussing break/continue (#8)., and how we might support it. If we choose to make it so this proposal isn't trying to mimic the syntax of control structures, then the way break/continue should behave in a block param becomes much more obvious. It'll always cause an outer for/while loop to break or continue. You can never make it so your user-land control structure intercepts the break/continue to do something else. If you want to make a custom control structure that supports break/continue-like behavior, you can still do this via sentinels.

while (true) {
  // ctrl is just a plain object provided by forEach(), that holds some unique sentinels (perhaps symbols)
  forEach (array) do (item, ctrl) {
    if (condition1) {
      break // This will break out of the while loop. The forEach() function can never intercept it.
    } else if (condition2) {
      ctrl.break // This is a special sentinel that will ask the forEach function to break.
    }
  }
}

This will also leave room for userland to provide other program-control-sentinels beyond break/continue. For example, maybe you want one that lets you skip a number of iterations in the loop.

forEach (array) do (item, ctrl) {
  if (condition) {
    ctrl.skip({ count: 2 })
  }
}

The other nice thing is that the behavior of the built-in break and continue become very predictable. You know how they're going to behave, always. You don't have to learn and know what the userland control structure does to know that a break found within it will only break out of built-in loops.

Next, let's look at the "chaining" idea presented in the README as a potential future extension to this proposal. Here's the example from the README, modified so it doesn't use reserved keywords.

myIf (arg1) {
  ...
} myElse myIf (arg2) {
  ...
} myElse {
  ...
}

The alternative, if we don't support this sort of chaining, isn't bad at all.

myIf (arg1) {
  ...
}
.myElseIf (arg2) {
  ...
}
.myElse {
  ...
}

There's really no incentive to provide support for chaining syntax like that, when it's already so easy to mimic this sort of thing in userland. Sure, the syntax looks a little different, but there's nothing wrong with that.

Next is the "functization" extension idea from the README, where we automatically pass in expressions as thunks, to support patterns similar to what a while loop does:

let i = 0;
until (i == 10) {
  ...
  i++
}

But, again, the alternative isn't bad at all. In fact, it's better, because it self-documents the fact that it is a thunk, not a direct expression. Built-in syntax (like while loops) can get away with not having these be explicit thunks because everyone's familiar with the built-in syntax and how it works. Conversely, people are not going to know the details of every userland control structure out there, which makes it much more important for us to explicitly state if a parameter is a thunk or not.

let i = 0;
until (() => i == 10) {
  ...
  i++
}

I get that these last two points involved potential extensions to the root proposal, and don't involve the root proposal itself, but the fact that those extensions options are in the README show this underlying desire to try and make this syntax mimic built-in syntax, which is an objective I'm hoping to shoot down.

Lastly, there's no reason for us to hold onto the fn () { ... } syntax if we're not trying to mimic the syntax of built-in control structures. If we want to to make sure it's always possible for the language to provide new control structures without conflicting with userland ones, then perhaps we ought to consider a different syntax. I'm not going to try and bike-shed alternative syntaxes here, I just want to point out that we don't need to tightly hold onto the fn () { ... } syntax, and we can consider other options.