samuelgoto / proposal-block-params

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

Scope Questions #16

Open rwaldron opened 6 years ago

rwaldron commented 6 years ago

EDIT:

The readme has been updated since this was first posted, however the changes made do not sufficiently address all of the scoping problems. Ref: https://github.com/samuelgoto/proposal-block-params/commit/3280e50fb3a18cfa29f6755302ca568434a49b8e

Original follows the break


Re: the example from the readme:

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

// ... is desugared to ...
a(function() {
  ...
  this.b(function() {
    ...
  })
  ...
});
  1. What is this inside the callback function? In strict mode code, that desugaring has no this unless explicitly bound:

    "use strict";
    function a(m, callback) {  
      console.log(m);
      console.log(`inside a(): typeof this === ${typeof this}`);
      callback();
    }
    
    function b(callback) { callback(); }
    
    a("hello", function() { 
      this.b(function() {
        console.log(`(inside b(): typeof this === ${typeof this})`); 
      });
    });
    
    // hello
    // (inside a(): typeof this === undefined)
    // Uncaught TypeError: Cannot read property 'b' of undefined
  2. The example does work with non-strict mode code, but also assumes that b was created via VariableStatement or FunctionDeclaration in the top level scope:

    function a(m, callback) {  
      console.log(m);
      console.log(`(inside a(): typeof this === ${typeof this})`);
      callback.call(this);
    }
    
    function b(callback) { callback(); }
    
    a("hello", function() { 
      this.b(function() {
        console.log(`(inside b(): typeof this === ${typeof this})`); 
      });
    });
    
    // hello
    // (inside a(): typeof this === object)
    // (inside b(): typeof this === object)
    • If b was created as a LexicalDeclaration, it won't have a binding on global this object:

      let a = function(m, callback) {  
        console.log(m);
        console.log(`(inside a(): typeof this === ${typeof this})`);
        callback.call(this);
      };
      
      let b = function(callback) { callback(); };
      
      a("hello", function() { 
        this.b(function() {
          console.log(`(inside b(): typeof this === ${typeof this})`); 
        });
      });
      
      // hello
      // (inside a(): typeof this === object)
      // Uncaught TypeError: this.b is not a function 
    • That also means that this won't work in Module Code—which is strict mode code by default.
  3. (2) falls down when the user defined functions have an explicit this object set:

    var unbounda = function(m, callback) {  
      console.log(m);
      console.log(`(inside a(): typeof this === ${typeof this})`);
      callback.call(this);
    };
    
    var unboundb = function(callback) { callback(); };
    
    var thisObject = {};
    
    var a = unbounda.bind(thisObject);
    var b = unbounda.bind(thisObject);
    
    /*
    a("hello") {
      b {
        console.log(`(inside b(): typeof this === ${typeof this})`); 
      }
    }
    */
    
    a("hello", function() { 
      this.b(function() {
        console.log(`(inside b(): typeof this === ${typeof this})`); 
      });
    });
    
    // hello
    // (inside a(): typeof this === object)
    // Uncaught TypeError: this.b is not a function 
samuelgoto commented 6 years ago

I was originally thinking that the caller would set this and pass the right methods that are supported. For example:

function a(block) {
  block.call({
    b: function(block) {
      // ...
    }
  });
}

Enabling

a {
  b {
  }
}

To be equivalent to:

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

However, in this discussion I think I'm going back to my original formulation, which was to desugar things as:

a {
 b {
  }
}

// equivalent to

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

In this formulation, a would still be possible to pass a reference to b such that a use case like the following to work:

select (foo) {
  case (bar) { ... } 
}

WDYT?

dead-claudia commented 6 years ago

Just thought I'd drop in and note that there is also some relevant discussion in #13, primarily starting here.

rwaldron commented 6 years ago
a {
  b {
  }
}

// equivalent to

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

I addressed this in 1 and 2 of my first comment: this, as in b.call(this, function() {, is undefined in strict mode code and global in non-strict mode code.

dead-claudia commented 6 years ago

@rwaldron Not if a calls its callback like func.call(inst, ...). a sets this, and it's only undefined or global if a calls it like func(). this isn't bound, even though it looks like it should be.

It still falls into the trap of making nested DSL calls ambiguous.

rwaldron commented 6 years ago

Not if a calls its callback like func.call(inst, ...). a sets this

I explicitly addressed this in number 3 of my first comment.

samuelgoto commented 6 years ago

I addressed this in 1 and 2 of my first comment: this, as in b.call(this, function() {, is undefined in strict mode code and global in non-strict mode code.

Can you help me understand what you mean here? Specifically:

b.call(this, function() {, is undefined in strict mode

Can you help me understand what is undefined? For example:

(function() { "use strict"; function b() { console.log(this) } b.call({c: 1}) })()
// > {c: 1}

Allows b.call({c:1}) to pass a this reference in strict mode.

samuelgoto commented 6 years ago

WRT

I explicitly addressed this in number 3 of my first comment.

and

(2) falls down when the user defined functions have an explicit this object set:

Yes, you are correct that if a user-defined function was bound per var b = unbounda.bind(thisObject); the this reference would be changed as a would call b.call(notherthis).

I think that's working as intended, in that's part of the contract for the functions that take block params in that they cannot assume that the bindings would be kept (and would rather point to the parent).

rwaldron commented 6 years ago

Can you help me understand what is undefined? For example:

(function() { "use strict"; function b() { console.log(this) } b.call({c: 1}) })()
// > {c: 1}

Allows b.call({c:1}) to pass a this reference in strict mode.

Of course it does, because you used call({c:1}), but this feature cannot assume that such a thing will always work: if b is an arrow function or the result of fn.bind(...), then b.call({...}) will have no effect:

(function() { "use strict"; let b = () => { console.log(this) }; b.call({c: 1}) })()
// undefined

(function() { "use strict"; function f() { console.log(this) }; let b = f.bind({ d: 1}); b.call({c: 1}) })()
// { d: 1 }

t if a user-defined function was bound per var b = unbounda.bind(thisObject); the this reference would be changed as a would call b.call(notherthis).

I don't understand what you're saying here. Once var b = unbounda.bind(thisObject); occurs, the bound this of b can never be changed again, it cannot be overridden by a b.call(...)

in that's part of the contract for the functions that take block params in that they cannot assume that the bindings would be kept

If you're telling me that block params can change the bound this of a function object, then I believe there is an object-capability security violation. @erights can you check my assessment?

erights commented 6 years ago

If you're telling me that block params can change the bound this of a function object, then I believe there is an object-capability security violation. @erights can you check my assessment?

Yes. While I am supportive of the overall direction, @samuelgoto knows that I am against the specific this binding semantics he's proposing. Once that's fixed, the blocks should expand to arrow functions rather than function functions, as arrow functions are already much closer to TCP. (The remaining TCP violations still must be statically prohibited or fixed, but that's another matter.)

samuelgoto commented 6 years ago

Of course it does, because you used call({c:1}), but this feature cannot assume that such a thing
will always work: if b is an arrow function or the result of fn.bind(...), then b.call({...}) will have no effect:

I understand that if b is an arrow function or the result of fn.bind, then b.call() will have no effect. I think that, perhaps, what I am genuinely confused about, is that the feature is meant to be used primarily from the newly introduced syntax:

a() { // <- this is a block param. neither an arrow function or a previously bound function
  //
}

Is your point that the function a here cannot assume / observe that / whether the parameter that was passed was passed via the newly introduced syntax? Example:

a(() => { "hello" });
function a(block) {
  // I cannot assume that block.call() will have any effect because block may
  // have been passed as an arrow function or as a previously bound function.
}

Did I understand that correctly? Is that the point that you are trying to make?

samuelgoto commented 6 years ago

(oops, sorry for closing/reopening, pressed the wrong button)

samuelgoto commented 6 years ago

Just reporting back on this thread here with what I think was forward progress made in this thread.

I'm generally in agreement with the desire to move away from the this nesting mechanism as well as the with-like scoping mechanism to find the variable names.

Just to give context, the use case that I think represents why we need a nesting mechanism is select and when: they are attached to one another in some way and need to pass information back and fourth. Here is an example:

select (expr) {
  when (cond) {
    // execute this block if cond == expr.
  }
}

This was initially proposed as a series of nested function() {}s and passing this around, which, like it was pointed out earlier, creates all sorts of challenges.

Looking a bit into what this could look like with arrow functions, here is what we explored in the other thread:

For example, this is what you write instead:

select (expr) {
  ::when(cond) {
    // ... this gets executed if expr == cond ...
  }
}

Which gets transpiled as:

select (expr, (__parent__) => {
  __parent__.when(cond, (__parent__) => {
    // .. gets executed if expr == cond ...
  });
})

This gives select the ability to choose which implementation of when to be used, because :: looks at methods in an object that gets passed to the block param from select. For example:

function select(expr, block) {
  block({
    // this is the "when" implementation that gets accessed when called like ::when() {}
    when(cond, inner) {
      if (expr == cond) {
        inner();
      } 
    }
  });
}

Does that address some of the concerns raised here with regards to scoping and this?

samuelgoto commented 6 years ago

Similar discussion here too:

https://github.com/samuelgoto/proposal-block-params/issues/21#issuecomment-347964929

ljharb commented 6 years ago

Related to #24