samuelgoto / proposal-block-params

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

`with`-like performance concerns #13

Open dead-claudia opened 7 years ago

dead-claudia commented 7 years ago

The implicit this binding spurs some with-related performance concerns. The callback body could, in sloppy mode, be desugared almost precisely to this:

with (typeof x === "object" && x != null ? x : Object.create(null)) {
    // body
}

For well-known reasons at this point, engines have severely struggled to optimize with, and I suspect they'll have similar objections to this.


Here's a few potential solutions:

samuelgoto commented 7 years ago

In the current formulation, every nested block gets called with "this". It is not a with like resolution: lexically, it tells where to look at things. For example:

server (app) {
  get("/route") {
  }
}

gets desugared to:

server (app, function() {
   // "this" here gets introduced because it is a block-param call AND it is nested lexically
  this.get("/route", function() {
  });
})

So, no, I don't expect this to have "with"-like resolution semantics (and performance implications and design considersations that go along with "with"-like semantics).

Does that make sense?

dead-claudia commented 7 years ago

What is app within the server callback's body?

samuelgoto commented 7 years ago

App is the first argument to the call to server. It is defined earlier.

dead-claudia commented 7 years ago

I'm referring to this hypothetical scenario:

const app = express()

server(app) {
    get("/route") {
        app // Is this the Express app or `this.app`?
    }
}
samuelgoto commented 7 years ago

In the current formulation, app refers to the const app above.

const app = express()

server(app) {
    get("/route") {
        // This is the express app NOT this.app. Normal identifier
        // resolution/scoping applies.
        app

        // The only change in the current formulation is to pre-pend "this" 
        // to inner block params.
        // e.g.
        // app { ... }
        // Would be equivalent to
        // this.app(function() { ... })
        // so, unless it is structured as a block param call, it is evaluated 
        // with the current semantics.
    }
}
dead-claudia commented 7 years ago

Oh okay. What if this.app is not defined? What's the fallback?

Also, this precludes potential use cases with nested DSLs, where you could hypothetically have in the browser a routing library, then a render(elem) { ... } call inside to render your view for that particular route. You could solve this by making the top-level DSL invocation explicit and differentiable from a normal call.

samuelgoto commented 7 years ago

If this.app is not defined, it is equivalent to let a = undefined; a();. That is, it throws.

Ah, good point about nesting DSLs. A couple of options:

1) The nested DSL uses the binding operator: e.g. ::render(elem) {} 2) The nested DSL somehow registers with the outer DSL. e.g. @component class MyElement extends React.Component { ... } with annotations.

Do you think that would be sufficient?

On Tue, Oct 31, 2017 at 2:42 AM, Isiah Meadows notifications@github.com wrote:

Oh okay. What if this.app is not defined? What's the fallback?

Also, this precludes potential use cases with nested DSLs, where you could hypothetically have in the browser a routing library, then a render(elem) { ... } call inside to render your view for that particular route. You could solve this by making the top-level DSL invocation explicit and differentiable from a normal call.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/samuelgoto/proposal-block-params/issues/13#issuecomment-340710073, or mute the thread https://github.com/notifications/unsubscribe-auth/AAqV6vyMo6i30jmVy-O0XU3nYlhWkuT9ks5sxut4gaJpZM4QKNBf .

-- f u cn rd ths u cn b a gd prgmr !

dead-claudia commented 7 years ago

If this.app is not defined, it is equivalent to let a = undefined; a();.

That is, it throws.

Okay, that works as expected.

Ah, good point about nesting DSLs. A couple of options:

1) The nested DSL uses the binding operator: e.g. ::render(elem) {} 2) The nested DSL somehow registers with the outer DSL. e.g. @component class MyElement extends React.Component { ... } with annotations.

I'd strongly prefer the first option (independent of syntax) with nesting DSLs, since it avoids making the outer DSL's responsibility to make sure it gets invoked correctly.

Also, for consistency and reduced confusion, we should require that top-level DSLs also use the same operator nested DSLs use. That way, it's 1. clear you're using a DSL and not just plain JS, and 2. there's only one syntax.

samuelgoto commented 7 years ago

This thread just made me realize that my current formulation registering DSLs isn't going to work very well. Specifically, some use cases are not DSLs at all, but are control structures. For example:

foreach ({item, index} in array) {
  unless (index % 2 == 0) {
    item.odd = true;
  }
}

Here, foreach and unless are independently provided functions that take block lambdas, so, they are unaware of themselves.

My previous formulation, was something along the lines of:

a {
  b {
  }
}

Would translate to:

a(function() {
  b.call(this, function() {
    ...
  });
})

Which would enable this case to work as:

foreach (array, function({item, index}) {
  unless.call(this, index % 2 == 0, function() {
    item.odd = true
  })
})

As opposed to:

foreach (array, function({item, index}) {
  // this.unless is defined in the call from foreach, which implies
  // it needs to be aware of unless's implementation which
  // doesn't sound desirable.
  this.unless(index % 2 == 0, function() {
    item.odd = true
  })
})

This would enable foreach and unless to be provided independently while still having access to this.

dead-claudia commented 7 years ago

And this is why on my first read, I had with-related concerns. It seemed to imply dynamic DSLs (this.foo(...)) while it stated static resolution (foo.call(this, ...)), seemingly confusing the two.

On Wed, Nov 1, 2017, 16:10 sam goto notifications@github.com wrote:

This thread just made me realize that my current formulation: registering DSLs isn't going to work very well. Specifically, some use cases are not DSLs at all, but are control structures. For example:

foreach ({item, index} in array) { unless (index % 2 == 0) { item.odd = true; } }

Here, foreach and unless are independently provided functions that take block lambdas, so, they are unaware of themselves.

My previous formulation, was something along the lines of:

a { b { } }

Would translate to:

a(function() { b.call(this, function() { ... }); })

Which would enable this case to work as:

foreach (array, function({item, index}) { unless.call(this, index % 2 == 0, function() { item.odd = true }) })

As opposed to:

foreach (array, function({item, index}) { // this.unless is defined in the call from foreach, which implies // it needs to be aware of unless's implementation which // doesn't sound desirable. this.unless(index % 2 == 0, function() { item.odd = true }) })

This would enable foreach and unless to be provided independently while still having access to this.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/samuelgoto/proposal-block-params/issues/13#issuecomment-341226170, or mute the thread https://github.com/notifications/unsubscribe-auth/AERrBK_z74M0x5LbEIH6QF_d9bd6BZI8ks5syNAfgaJpZM4QKNBf .

samuelgoto commented 7 years ago

Ah, I see now what you mean by with-like concerns (clarifying question: are you concerned about performance or with-like identifier resolution confusion?).

This formulation could deal with both cases, but would introduce complexity:

a {
  // ...
  b {
    // ...
  }
  // ...
}

desugaring to:

a(function() {
  // ...
  (b in this ? this.b : b)(function() {
     // ...
  })
  // ...
})

Method resolution takes precedence over global/function resolution.

Note that, unlike with:

How does this sound?

samuelgoto commented 7 years ago

FWIW, this approach is consistent with Kotlin: it resolves function identifiers looking at "this" first before looking at the global scope. Here is an example:

// This does not get called, unless we remove the "case" method
// in Select.
fun case(condition: Boolean, block: () -> Unit) {
    println("This does not called!")
    block()
}

fun select(condition: Boolean, block: Select.() -> Unit) {
    var sel = Select();
    sel.block()
}

class Select {
    // If this method wasn't here, it would've used
    // the global function case.
    fun case(condition: Boolean, block: () -> Unit) {
       println("This gets called!")
       block()
    }
}

fun main(args: Array<String>) {
  var expr: Boolean = true;

  select (expr) {
    case (true) {
        println("Totally true")
    }
    case (true) {
        println("Totally false")
    }
  }
}
dead-claudia commented 7 years ago

This formulation could deal with both cases, but would introduce complexity:

a {
  // ...
  b {
    // ...
  }
  // ...
}

desugaring to:

a(function() {
  // ...
  (b in this ? this.b : b)(function() {
     // ...
  })
  // ...
})

I'm still not a fan, because even though you've restricted it to only the calls, it's still sitting in a with problem for DSL invocations only.

Kotlin has static types and method names are always resolved statically, so it can compile everything down to optimized static calls (if they're not inline fun). In a dynamic language where method names are resolved at runtime, this is flat out impossible to do, and ICs for this would be difficult to optimize.

IMHO, an explicit "this starts a DSL" with nested variants being implicit would be best.

dead-claudia commented 7 years ago

And in response to a few another parts:

Ah, I see now what you mean by with-like concerns (clarifying question: are you concerned about performance or with-like identifier resolution confusion?).

Both. With Kotlin, you can always verify which type a method comes from, but with JS, it might not be so clear (especially if this is backed by a proxy, which would be entirely reasonable for a DOM builder DSL). Additionally, methods are always auto-bound in Kotlin, so calling a method is not dissimilar to calling a function (and thus they can get away with it), just you have a bound receiver instead of no receiver. (In reality, Kotlin does not really have a concept of a "receiver", just functions and classes with instance methods.)

  • the object you are looking at for resolving identifiers is fixed (i.e. this)

with's lookup is fixed, too - it's just not a variable you can explicitly access within the block. The only difference here is that you don't filter out object[Symbol.unscopables] members, where with does.

[Kotlin example...]

That's probably better written as this, using inline funs to avoid allocating the closure (also, note that it's block(select), not select.block()):

// This does not get called, unless we remove the "case" method
// in Select.
inline fun case(condition: Boolean, block: () -> Unit) {
    println("This does not called!")
    block()
}

class Select {
    // If this method wasn't here, it would've used
    // the global function case.
    inline fun case(condition: Boolean, block: () -> Unit) {
       println("This gets called!")
       block()
    }
}

inline fun select(condition: Boolean, block: Select.() -> Unit) {
    block(Select())
}

fun main(args: Array<String>) {
  val expr = true

  select (expr) {
    case (true) {
        println("Totally true")
    }
    case (false) {
        println("Totally false")
    }
  }
}

// Normally, you'd use `when` instead:
fun main(args: Array<String>) {
  val expr = true

  when (expr) {
    true => println("Totally true"),
    false => println("Totally false"),
  }
}
samuelgoto commented 7 years ago

Originally, I had the following desugaring:

a {
  b {
  }
}

Would be rewritten as

a(function() {
  b.call(this, function() {
  })
})

Which would avoid the with-like identifier resolution challenges while still passing the reference to this to enable nesting.

The drawback, is that it requires you to import every single reference to every single function that takes block-params, which could be awkward. In the context of DOM construction, this would be as painful as import {html, body, div, span, img, a, ....} from "framework.js" for every single element you'd need.