Skip to content

AWB alternative 2 - instance vars and secret methods #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
allenwb opened this issue Feb 2, 2018 · 53 comments
Closed

AWB alternative 2 - instance vars and secret methods #7

allenwb opened this issue Feb 2, 2018 · 53 comments

Comments

@allenwb
Copy link
Collaborator

allenwb commented Feb 2, 2018

Max-min classes 1.1. AWB alternative 2

An updated version of this proposal is below at #7 (comment)

Here is a proposal that is closer to the @zenparsing parsing original, but perhaps simpler. With this proposal, I don't necessarily see the need for a classes 1.2 follow-on

Basic Concepts

  • This proposal adds the concept of secret class elements to ECMAScript class definitions. Secret class elements are only accessabe from within the body of a class definition.
  • There are three kinds of secret class element, static initializer blocks, instance variable definitions, and secret method definitions.
  • A class definition may contain at most one static initializer block it is automatically executed as the final step of class def evaluation
    • The static initializer block is a secret of the class definition, there is no way to invoke it from outside of the class definition
  • A instance variable definition defines one or more secret variables that exist in each instance of a class.
    • An instance variable defnition within a class body looks like this:
    var x,y;  //each instance created by this class constructor will have these two instance variables
    • Within a class body, instance variables are accessed using the -> operator like this:
    this->x = 0;
    console.log(obj->y);
    • Instances variable names are lexically scoped and visible to everything contained in a class body.
    • Attempting to access an instance variable using -> produces a runtime ReferenceError if the object does not have the specific named instance variable defined by this class definition. In other words, A reference to an instance variable only works when the objectise a normally constructed instance of this class or one of its subclasses.
    • Instance variable definitions don't have initializers. Instance variables are usually initialized by the constructor. An uninitialized instance variable has the value undefined.
  • A secret method definition is a concise method that can only be directly involked from inside the class body.
    • A secret method definition looks exactly like a concise method except its name is prefixed by ->
    • Here are some examples of secret method definitions
    ->foo() {return super.bar()}
    get ->x() {return this->_x}  //a secret accessor method
    set ->x(v) {this->_x = v}
    • Secret method names are lexically scoped and visible to everything contained in a class body.
    • Within a class body, secret methods are invoked using the -> operator like this:
    this->foo();
    obj->x = 5;   //invokes a secret set accessor method
    this->foo.bind(this);  //access the function value of a secret method
    • Secret methods can be accessed/invoked with any object supplied to the left of the ->. It is up to the code of the method body whether or not a runtime error will occur if the this value is not an instance of the containing class or one of its subclasses. Access to instance variables that don't exist for the this value would, of course, throw.
  • It is an early error if the name occuring to the right of a -> operator is not the lexically visible name of a secret method.
  • No reflection on instance variables, instance variable names, secret methods, or secret method names
  • Lexically scoped instance variable and secret method declarations introduce new names into a parallel scope (does not shadow non-secret names)

Example

class Point {
   var x;    //or:  var x, y;
   var y;  
   
   ->brandcheck() {
      try {this->x} catch {return false};
      return true;
   }
       
   get x() {return this->x} //normal accessor properties
   get y() {return this->y}
   
   add(another) {  //a normal method property
      if (another.brandcheck()) 
        return new Point(this->x+another->x, this->y+another->y);
      else 
        return new Point(this->x+another.x, this->y+another.y);
   }
   
   constructor (x,y) {
      this->x = x;
      this->y = y;
   }
}

Technical Notes and Rationale

  • var is repurposed to declare instance variables because its name is suggestive of "instance VARiable". Note that several early ES class proposals including the original ES4 proposals used var for this purpose.
  • Secret names (instance variables and secret methods) exit in the same namespace so an instance variable may not have the same name as a secret method.
  • The kind of thing a secret name is bound to is based upon its declration. -> may statically determines from the supplied name whether it is accessing an instance variable, a secret accessor method, or invoking a secret method.
  • Any concise method form may be used to define a secret method. It's the -> prefix to the name that makes it a secret method.
  • Secret methods don't have any special semantics for their bodies. The Home object of a secret method is the prototype object defined by the class.
  • There is no particular reason why we couldn't have static instance variables and static secret method definitions. But it is simplier to not have to explain how they differ from the non-static forms.
  • Instance variable declrations do not have initializers to simplify things. Without them, we don't have to define their evaluaiton order or the scoping of the initializer expressions. People are already used to using constructors to initialize instance state.
  • It is an early error to assign to a secret method reference (unless it is an accessor method)
  • Because secret methods are statically resolvable and immutable, it is easy for an implementation to statically in-line them. No flow analysis or dynamic specialization required.
  • The var declarations of a class collective and statically defined the "shape` of the secret instance state introduced by that class definition. Subclass can add additional secret state "shapes" but overall it is a step closer to objects with a fixed shape determined at class definition time and should also make it easier to lift the internal checks needed on instance var accesses.
@zenparsing
Copy link
Owner

I need more time to think about it but with a little bikeshedding this could be something...

Speaking of which, the -> prefix might be a little too sigily for some tastes. I wonder if we could come up with a keyword that would fit just as well? Obviously, not private or protected. Maybe internal?

Could we get away with a keyword without breaking the user's intuition regarding ->?

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

Well I really like using the same symbol sequence for both the actual selection operator and the sigil that distinguishes a property based concise method from a "secret" concise method. It really ties the two together. The problem with a keyword is that the actual word you choose can carry too much implied meaning and clutter. And, how much push back to we get from the generator *.

But, if we really wanted a keyword why not "secret" I also considered "hidden" as terminology before I went with "secret" in the write-up. It might actually be better.

And, of course we could try .. instead of ->. Initially I liked foo..bar better because is was so similar to foo.bar. But -> grew on me. Mess chance of a type stutter and the distinction between the two operator is big enough that it may be worth a bit more emphasis.

You could try some examples using just those variations. I did a bunch of that various class forms in the early ES6 days. In the end, there just wasn't that much meaningful different between various forms.

@zenparsing
Copy link
Owner

I've updated the examples to use this syntax, with hidden as the placement modifier.

I think a -> prefix might get a little funky once we consider some other combinations we'll have to deal with:

class C {
  ->*foo() {}
  ->async foo() {}
  ->async *foo() {}
}

I thought "secret" might be a little too strong (or James Bond-esque), but "hidden" seems to provide the right connotation.

Since hidden methods can be called with any object as the receiver, it appears that they are completely sufficient for "static private":

class C {
  hidden factoryA() {
    return new this('a');
  }
  hidden factoryB() {
    return new this('b');
  }
  static factory(kind) {
    return kind === 'A' ? this->factoryA() : this->factoryB();
  }
}

Do you agree? This seems at the same time both wonderful and surprising to me!

@zenparsing
Copy link
Owner

One aspect that might stand out to people is the lack of initializer expressions on instance variables. I agree that we don't need them and the design is simpler without them. Another benefit of leaving initializers out is that the installation of instance variables is atomic: an instance either has all of them (defined by a single class) or none of them. I've desperately wanted that ever since starting to work on private state.

Even if we add initializer expressions in a future update, I think that we ought to retain that property.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

what I had in mind was more along these lines:

class C {
  *->foo() {}
  async ->foo() {}
  async *->foo() {}
}

the -> always directly precedes the name.

I'm beginning to like the "hidden" terminally more than "secret", regardless of whether we use it as a keyword or just as terminology.

The main issue WRT "static" hidden methods is the Home object binding if they use super. In principle there is no reason that we could have static hidden methods which would have a correct home object binding. The only reason I left it out was to simply the overall proposal. But consistency (any concise method form can be hidden) is also a form of simplification.

But, for me, having static hidden methods is a point in favor of the sigil instead of the keyword:

static ->factoryB() { } seems more concise then static hidden factoryB() {} or is it hidden static factoryb() {}. Gets even worse with static hidden async *factoryB() {}.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

Yes, I prefer to leave the initializers out and require the declarations (no creation of instance vars by assignment)

I think we need to be careful of feature-creep. One of the strongest arguments agains the current set of proposed class extensions is the overall complexity when you include everything.

@zenparsing
Copy link
Owner

static hidden factoryB() {} or is it hidden static factoryb() {}

If we go the keyword route, we could allow either I think (similar to the method modifiers in C#):

ClassElement:
  ClassElementModifier* MethodDefinition ;

ClassElementModifier:
  'static'
  'hidden'

Since static results in a different Home object, then I think we'll need to either:

  1. Add support for hidden static
  2. Make hidden methods not have a Home at all

Option 2 sounds somewhat nice to me in an abstract way, since these methods are kind of "homeless": they aren't actually attached to any objects. What would be the disadvantage of not having a Home (and thereby disallowing super)? I suppose that it could make it more difficult to transparently refactor normal methods that contain super references.

On the other hand, option 1 will probably be more intuitive to users.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

I'd go with option 1. I like the simplicity of being able to say any concise methods (that includes static methods) can be turned into a hidden method, and it doesn't change the behavior of the method.

For people who don't use super it won't technically matter but it's still natural for them to conceptually distinguish methods used to define the class (ie, constructor object) behavior and methods used to define instance behavior. And people who do use super and write static methods won't be surprised if things break if they invoke such a static method with an instance object. Basically, it feels simpler to have static hidden method then it would be to exclude them.

Allowing arbitrary ordering of class modifiers doesn't help the simplicity story very much. Programmers still have to pick an ordering and if they settle on using a specific ordering and they suffer some uncomforted each time they read code that uses a different order. And it certainly makes the specification more complex because it has to deal with the possibility of multiple occurrences of a modifier.

I still think we should lead with the name sigil and save the hidden keyword as a fallback position.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

Max-min classes 1.1. AWB alternative 2a

This is the same basic proposal at that at the head of this issue thread, but with terminology and other updates based upon the above discussion.

Here is a proposal that is closer to the @zenparsing parsing original, but perhaps simpler. With this proposal, I don't necessarily see the need for a classes 1.2 follow-on

The starting point for this proposal are ECMAScript class definitions as defined in the ECMAScript 2018 Language Specification. No, other proposed extensions are assumed or required.

Basic Concepts

  • This proposal adds the concept of hidden class elements to ECMAScript class definitions. Hidden class elements are only accessible from within the body of a class definition.
  • There are three kinds of hidden class elements, static initializer blocks, instance variable definitions, and hidden method definitions.
  • A class definition may contain at most one static initializer block it is automatically executed as the final step of class def evaluation
    • The static initializer block is a hidden part of the class definition, there is no way to invoke it from outside of the class definition
  • Instance variable definitions and hidden method definition define variables and methods that have lexically scoped hidden names.
    • Hidden names are accessed similarly to properties but use -> instead of . as the referencing operator.
  • A instance variable definition defines one or more hidden variables that exist as part of the state of each instance of a class.
    • An instance variable defnition within a class body looks like this:
    var x,y;  //each instance created by this class constructor will have these two instance variables
    • Within a class body, instance variables are accessed using the -> operator like this:
    this->x = 0;
    console.log(obj->y);
    • Instances variable names are lexically scoped and visible to everything (including nested functions and class definitions) contained in a class body.
    • Attempting to access an instance variable using -> produces a runtime ReferenceError if the left operand is not an object with the specific named instance variable defined by this class definition. In other words, A reference to an instance variable only works when the object is a normally constructed instance of this class or one of its subclasses.
    • Instance variable definitions don't have initializers. Instance variables are usually initialized by the constructor. An uninitialized instance variable has the value undefined.
  • A hidden method definition is a concise method that can only be directly invoked from inside the class body.
    • A hidden method definition looks exactly like a concise method except its name is prefixed by ->
    • A hidden method definition may be prefixed by the static keyword.
    • Here are some examples of hidden method definitions
    ->foo() {return super.bar()}
    get ->x() {return this->_x}  //a hidden accessor method
    set ->x(v) {this->_x = v}
    static ->from(itr) {return super.from(itr)}
    • Hidden method names are lexically scoped and visible to everything contained in a class body.
    • Within a class body, hidden methods are invoked using the -> operator like this:
    this->foo();
    obj->x = 5;   //invokes a hidden set accessor method
    this->foo.bind(this);  //access the function value of a hidden method
    • Hidden methods can be accessed/invoked with any object supplied to the left of the ->. It is up to the code of the method body whether or not a runtime error will occur if the this value is not an instance of the containing class or one of its subclasses. Access to instance variables that don't exist for the this value would, of course, throw.
  • It is an early error if the name occurring to the right of a -> operator is not the lexically visible name of a hidden method or instance variable.
  • No reflection on instance variables, instance variable names, hidden methods, or hidden method names
  • Lexically scoped instance variable and hidden method declarations introduce new names into a parallel scope (does not shadow non-hidden names)

Example

class Point {
   var x;    //or:  var x, y;
   var y;  
   
   ->brandcheck() {
      try {this->x} catch {return false};
      return true;
   }
       
   get x() {return this->x} //normal accessor properties
   get y() {return this->y}
   
   add(another) {  //a normal method property
      if (another.brandcheck()) 
        return new Point(this->x+another->x, this->y+another->y);
      else 
        return new Point(this->x+another.x, this->y+another.y);
   }
   
   constructor (x,y) {
      this->x = x;
      this->y = y;
   }
}

Technical Notes and Rationale

  • This proposal avoids using keywords or syntax that are already used by widely deployed JavaScript transpiler based extensions or dialects such as TypeScript. Transpilers can continue to support those extensions even when the target language includes features from this proposal.
  • var is repurposed to declare instance variables because its name is suggestive of "instance VARiable". Note that several early ES class proposals including the original ES4 circa 2000 proposals used var for this purpose.
  • Hidden names (instance variables and hidden methods) all exit in the same namespace so an instance variable may not have the same name as a hidden method.
  • The kind of thing a hidden name is bound to is based upon its declaration. the -> operator may statically determines from the supplied name whether it is accessing an instance variable, a hidden accessor method, or referencing a hidden method object.
  • Any concise method form may be used to define a hidden method. It's the -> prefix to the name that makes it a hidden method definition.
  • Hidden methods don't have any special semantics for their bodies. The Home object of a hidden method is the prototype object defined by the class unless the hidden method definition has the static modifier. In the static case, the Home object is the class' constructor function.
  • There is no particular reason why we couldn't have static instance variables But some people find the semantics of static instance variables unintuitive so it is simpler to not have to explain how they differ from the non-static instance variables.
  • Instance variable declrations do not have initializers to simplify things. Without them, we don't have to define their evaluaiton order or the scoping of the initializer expressions. People are already used to using constructors to initialize instance state.
  • It is an early error to assign to a hidden method reference (unless it is statically resolves to an accessor method)
  • Because hidden methods are statically resolvable and immutable, it is easy for an implementation to statically in-line them. No flow analysis or dynamic specialization required.
  • The var declarations of a class collectively and statically defined the "shape` of the hidden instance state introduced by that class definition. Subclass can add additional hidden state "shapes" but overall this is a step closer to objects with a fixed shape hidden shape determined at class definition time and should also make it easier to lift the internal checks needed on instance var accesses.

@zenparsing
Copy link
Owner

This is looking great - thanks!

I must admit a strong preference for the keyword here. It seems to just slide right in to the current class aesthetics. It "sings" to me. If we do end up going with "hidden", and we want a specific order, then I'm leaning towards "static hidden".

Bikeshedding aside, the only other issue I have has to do with the interaction between normal lexical scope and instance var names. Consider:

var x = 1;
class C {
  var x;
  constructor() {
    console.log(x);
  }
}
new C(); // Logs "1"

Similarly:

var x = 1;
class C {
  var x;
  constructor() {
    console.log(eval("x"));
  }
}
new C(); // Logs "1"

This seems surprising to me. Do you think we can use early errors or uninitialized bindings in the class body scope (or both!) to eliminate such cases?

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 3, 2018

Well, it would be no different if you had written:

let x = 1;   // <-- changed var to let
class C {
  var x;
  constructor() {
    console.log(x);
  }
}
new C(); // Logs "1"

or

class C {
  var x;
  constructor(x) { // <----- added parameter
    console.log(x);
  }
}
new C(1); // Logs "1"

These example helps illustrate that there are two key things that people who use class definitions have to learn:

  1. var as a class body element means something completely different form var in a statement list. (and it would probably be a good idea to stop using statement list var declarations)
  2. An identifier only binds to a hidden name if it immediately follows ->. In other contexts normal lexical or property binding is used for the identifier.

I don't believe the eval changes anything as strict mode direct evals evaluate in a new nested lexical scope. The more interesting questions is whether the following is valid or is it an early error?

var x = 1;
class C {
  var x;
  constructor() {
    console.log(eval("this->x"));
  }
}
new C(); // Logs "1"

Should this be legal? Probably. Eval has special early error rules for new.target, SuperProperty and SuperCall that ensures that they only occur in evals that are within an appropriate kind of method. Interestingly, it is less clearly that eval("continue;") etc. are adequately specified.

Regarding, hidden vs -> name prefix in concise methods. I think we need to get a bigger sample of opinions as it seems mostly like an esthetics based preference.

@zenparsing
Copy link
Owner

var as a class body element means something completely different form var in a statement list. (and it would probably be a good idea to stop using statement list var declarations)

Yes, this is the hangup. When I let go of my vestigial concept of var I can see it from your point of view entirely. The question, I think, is whether we can convince others to let go of their vestigial concept of var as well.

On the other hand, I'm not 100% convinced yet that we can't have it both ways. I think that given the history of var and the experience of other languages, unqualified access to instance vars can be pretty intuitive and some developers will appreciate the brevity.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 5, 2018

We could have hidden x; instead of var x; but it quickly leads to requests for unhidden x; or even just x; and with ASI x

The nice thing about the instance variable concept it is explicitly labeling x as a specific kind of thing rather then just emphasizing it's visibility while leaving what kind of thing x is implicit.

Even if we could make unqualified access to instance variables work in JS (and I very dubious) it's still a complication. To have any change with this proposal I think we have to focus on simplicity.

@zenparsing
Copy link
Owner

That's exactly my worry with using hidden for instance variables themselves: it's a slippery slope.

Also, from an aesthetic standpoint, I like the fact that with the current proposal it is easy to simply glance at a class definition and understand the internal storage requirements for instances, independent of any surface API or internal code organization.

@zenparsing
Copy link
Owner

@allenwb Clearly we should disallow hidden property names that are string literals, numeric literals, or computed property names.

But should we allow PropertyNames as hidden method names, or just identifiers?

class C {
  hidden delete() {}
  method() { this->delete() }
}

This seem fine to me (I think).

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 7, 2018

@zenparsing yes, fine with me too.

I guess, we could allow string and numeric literals as they are statically know values. But since we at least have to restrict computed property names I think it is probably best to go all the way and say a hidden names (both method and inst vars) be be a valid IdentifierName

@zenparsing
Copy link
Owner

zenparsing commented Feb 7, 2018

@allenwb This is a bit in the weeds, but I'm trying to work out the model more clearly:

In order to resolve hidden method names we'll need to internally resolve property names to property descriptors (because the hidden method could be an accessor).

From a specification point of view, do you think it makes sense to use lexical environments for storing these mappings? Currently, lexical environments are only used to resolve identifiers, and their values are "ECMAScript language values".

Another option would be to invent a new environment-like specification type that would carry the hidden method scope chain.

A more radical approach might be to re-use the existing object model: hidden methods could be stored on a "hidden object" whose prototype is the next level up in the hidden method scope chain.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 7, 2018

I'd avoid getting Property Descriptors involved.

I'd start with the work in the current "private fields" proposal that extends lexical environments with a parallel name space for private name bindings. (But, I actually haven't look at the algorithms of that proposal for a while, so it's possible it is no longer a good starting point).

I'd define the hidden name namespace, such that the bound value of a hidden name is a "hidden member descriptor". There are 3 kinds of HMDs:

Instance Variable: {id: definitionID, name: string}
hidden method: {name: string, value: function}
hidden accessor: (name: string, getter: function, setter: function)

Where definitionID is a unique value assigned to each class (or object literal) definition evaluation that defines instance variables. If we have static instance variables then there is a separate definitionId for the those.

Each ordinary object has an internal slot [[InstanceVariables]]. The value of that slot is a list whose elements are of the form {id: definitionID, vars: instanceVars}. instanceVars is a list whose elements are of the form {name: string, value: ECMAScript language value}. (note there are various way we could structure this, but I think this design leads implementation in the direction I want them to think about instance variables.

The semantics of obj->hiddenName is (if hiddenName is lexically resolvable) to create a new kind of Reference value, a hidden name reference). The base of the reference is obj and the reference name component is a Hidden Member Descriptor. The GetValue and SetValue operations on Reference Values are enhanced to use the HMD to access the appropriate value. Note that GetValue/SetValue using an Instance Variable HMD does a brand check of the id value against the id keys of the object's [[InstancVariables]] list.

@zenparsing
Copy link
Owner

Thanks, your suggestions have been extremely helpful!

I want to come back to this for a minute:

We could have hidden x; instead of var x; but it quickly leads to requests for unhidden x; or even just x; and with ASI x

The nice thing about the instance variable concept it is explicitly labeling x as a specific kind of thing rather then just emphasizing it's visibility while leaving what kind of thing x is implicit.

I agree 100% with you here. But I'm increasingly worried that when we present this idea to others they will be strongly attracted to hidden x;. In other words, using hidden as a field definition modifier.

Consider the integration of this proposal with TypeScript. One of the great features here is that hidden methods integrate so smoothly:

// TypeScript!
class Something {
  public foo: string;

  constructor() {
    this.foo = 'lkasjdf';
    this->helper();
  }

  hidden helper() {
    console.log('help');
  }
}

Now let's add instance variables:

// TypeScript!
class Something {
  var bar: boolean;
  public foo: string;

  constructor() {
    this.foo = 'lkasjdf';
    this->bar = true;
    this->helper();
  }

  hidden helper() {
    console.log('help');
  }
}

Not bad, but now let's try using hidden instead of var:

// TypeScript!
class Something {
  hidden bar: boolean;
  public foo: string;

  constructor() {
    this.foo = 'lkasjdf';
    this->bar = true;
    this->helper();
  }

  hidden helper() {
    console.log('help');
  }
}

They are going to love that.

But if we go that direction (using hidden for instance vars), then it seems like we'll quickly slide down the slope into public "fields" for JS. And then we'll slide down into instance var initializers. And then we'll slide down into "static hidden" fields and "static public" fields.

Of course, I don't think we should be designing for the benefit of TypeScript. (In fact, I find the amount of pressure being exerted on JS by TS quite troubling.) But the same arguments apply to developers using "fields" with Babel.

It seems to me that we have two options:

  • Accept "fields" as inevitable and use hidden for instance variables.
  • Stick with "var" and be prepared to argue strongly against "fields" as a design choice for JS.

Thoughts?

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 15, 2018

Stick with "var" and be prepared to argue strongly against "fields" as a design choice for JS.

I really want to firmly plant the concept that Instance variables are a fundamentally new concept (for JS) that enables strict encapsulation of state within objects.

Our slogan: Instance variables are not properties. This distinction is so important that we use the keyword var as a reinforcement when defining them.

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 15, 2018

I would really like to be able to strongly position that instance variables are a new fundamental part of the ES object model and are not exclusive tied to class declarations. We should be able to use them for strictly encapsulated state without using a class definition.

But, to make that argument I think we need to allow them to be defined within object literals. EG:

function Point(x,y) {
  return {
    var x: x,
    var y: y,
    get x() {return this->x},
    get y() {return this->y),
    //...
  }
}

seems like it should be roughly equivalent to:

class Point {
    var x;
    var y;
    constructor(x, y) {
       this->x = x;
       this->y = y;
    }
    get x() {return this->x}
    get y() {return this->y)
    //...
}

but they are really quite different in at least one important way which becomes apparent if you try to add this method to both formulations:

  plus(aPoint) {
      let newX = this->x + aPoint->x; 
      let newY = this->y + aPoint->y;
      //...
  }

This works for the class case but fails when invoking the plus method for the object literal case (assuming that hidden names in object literals have the extent of the immediately inclosing obj lit evaluation).

I suspect that this difference could be a significant foot-gun. (I've had this concern about the various lexically scoped class private proposals for a long time, but let it slip by because with classes it would really only arise with class expression whose use is fairly uncommon).

I find foot-gun disturbing enough that I'm inclined to completely leave object literals out of the classes 1.1 proposal. But I'm loath to throw away what I think is a strong positioning argument that instance variables are a fundamental feature of the ES object model and not just a feature of class definitions.

However, I just posted a little twitter rant about "kick the ball down the field" language design and that it often a symptom that some fundamental issues are being ignored. I also think that the current class enhancement proposals seriously suffers from that syndrome. So, I at least want to think about the problem before kicking the ball.

Here is the best solution I've come up with so far: the assignment of definitionIDs (c/f my up-thread semantics sketch) for object literals are "hoisted" to the enclosing function level (similarly to the scoping of this, super, and arguments) rather than assigning a new definitionID each time an object literal is evaluated. With that semantics, the above plus method would "just work" as the author likely intended in both an object literal and a class definition. Basically, the semantics would support the most likely use case and only needs to be understood by those who have other, less common use cases.

BTW, I'm not (yet) proposing that we do this sort of "hoisting" for class definition. I need to think about it more. However, I think it may also work just fine with them and would also address by class expression concerns.

@zenparsing
Copy link
Owner

Very interesting idea. I'm curious about this example:

function f() {
  return function g() {
    return {
      var x: 0,
      add(other) { this->x += other->x },
    };
  };
}

let a = f()();
let b = f()();

a.add(b); // Does this work, or throw?

Is the definitionID a dynamic property of the scope, and different for each function object, or is it a static property of the parse node representing the function?

I'm glad you brought up class expressions and I think you've identified a problem with the current encapsulation semantics. Check out the mixin pattern described here. The basic idea is:

let M = Base => class extends Base {
  // ...
};

class A {}
class B extends M(A) {}

It seems clear that private access will not work across different M results.

let M = Base => class extends Base {
  var x;
  constructor() { this->x = 1 }
  plus(other) { this->x += other->x }
};

class A {}
class B extends M(A) {}
class C extends M(A) {}

new B().plus(new C()); // Throws?

@allenwb
Copy link
Collaborator Author

allenwb commented Feb 16, 2018

a.add(b); // Does this work, or throw?

as I (intended to) described it above, it would throw because each invocation of function f would create a new function object g and definitionIDs would have the extent of their containing (non-arrow) function object. I have also considered the parse node association but for this thread went with the function level association as it provides a way to get per object literal definitionIDs (by wrapping { } in an IIFE) if that is what you really wanted.

I'm also aware (and very supportive) of this mix-in pattern and aware that it has the same problem. It's largely what I was thinking about as a "less common use case". In particular, the current private fields proposal has this exact same problem and nobody seems to care. Also, there is a work around for mix-ins:

class A {}
class AWithM extends M(A)  {}
class B extends AWithM {}
class C extends AWithM {}

new B().plus(new C());  //works as intended (assuming => doesn't create a new definitionID context

I believe that I briefly talked about this issue with @erights (Mark, are you listening??) sometime last year and suggested the parse node solution. My recollection was that Mark was very uncomfortable with any violation of the normal expectations of closure capture and I didn't pursue it further.

@zenparsing
Copy link
Owner

I don't have any strong opinions yet, but here are some random thoughts:

I would also (I think) be uncomfortable with a parse node variant. If I wanted to create a self-contained object graph factory:

function createWorld() {
  class A { /* ... */ }
  class B { /* ... */ }
  return { A, B };
}

let world1 = createWorld();
let world2 = createWorld();

let a1 = new world1.A();
let a2 = new world2.A();

then I would probably expect classes for different "worlds" to be incompatible with each other.

On the other hand, I'm worried that the "containing function" variant would be a little too subtle. I mean, it's easy enough to explain that this, arguments, and super go "through" arrow functions, but it might be a little more difficult to explain how definition IDs do too.

I sympathize with the desire to position instance variables as a fundamental new feature of the object model. They certainly are. But there's also, I think, a good reason why we don't need to provide this capability outside of class instances. For singleton object definitions, we can simply use closure to approximate internal slots: methods are per-instance rather than shared and "per-instance" state can be stored in local variables.

It's only when we want to share methods across instances using the prototype chain that the need for an instance variable syntax arises.

Again, just some random thoughts on a Friday afternoon.

@zenparsing
Copy link
Owner

I was looking at this comment on the static features repo, specifically:

private static fields and methods causing TypeErrors when accessed on subclasses

This seems to me to be another footgun related to having instance variables installed on singletons. Generally speaking, we don't want to provide a "private state" mechanism on objects that are intended to be used as prototypes for other objects. And I think objects created from object literals should probably be suitable for usage as prototypes for other objects.

To me, this seems like a pretty good argument against supporting instance variables on object literals and class constructors.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

(I'm posting before I absorb everything, so some posts may already be addressed. Here goes.)

I find the example at #7 (comment) compelling:

var x = 1;
class C {
  var x;
  constructor() {
    console.log(x);
  }
}
new C(); // Logs "1"

Why not

  keyword ->x;

for some appropriate keyword? At this stage of the thread, all the other private declarations are prefixed with a unary -> so why not private instance slots as well?

With this suggestion, the keyword could even be private without conflicting with TypeScript.

var x = 1;
class C {
  private ->x;
  constructor() {
    console.log(x);
  }
}
new C(); // Logs "1"

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

Where does hidden come from? Is there some history here I should know?

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

Accept "fields" as inevitable and use hidden for instance variables.

What "field" concept do you refer to? Is this distinct from both properties and private slots? Why is it inevitable?

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

Our slogan: Instance variables are not properties.

Just noting that the WeakMap-like model of private state makes that even clearer. By contrast, putting the state in so-called "internal properties" muddies exactly these waters.

The following text shows that we don't get to escape adding a new kind of internal bookkeeping thing either way. You invent definitionID and then store a map with these as keys. This is so much less natural that the transposed approach.

Doesn't it seem weird that proxies have a [[InstanceVariables]], and that these operations on it must not cause the proxy to trap? The semantics is right. The problem is that we explain it in a way that makes it seem weird.

Instance Variable: {id: definitionID, name: string}

Where definitionID is a unique value assigned to each class (or object literal) definition evaluation that defines instance variables. If we have static instance variables then there is a separate definitionId for the those.

Each ordinary object has an internal slot [[InstanceVariables]]. The value of that slot is a list whose elements are of the form {id: definitionID, vars: instanceVars}. instanceVars is a list whose elements are of the form {name: string, value: ECMAScript language value}.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

I believe that I briefly talked about this issue with @erights (Mark, are you listening??) sometime last year and suggested the parse node solution. My recollection was that Mark was very uncomfortable with any violation of the normal expectations of closure capture and I didn't pursue it further.

Sorry. I am now. Yes, I agree with both of your examples at #7 (comment) . We don't even need to use subclassing or parameterization to make the issue clear:

function m() {
  class M {
    var ->x;
    constructor() { this->x = 1 }
    plus(other) { this->x += other->x }
  };
  return new M();
}
m().plus(m());  // must throw

It must throw because the operands are instances of two different classes.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

I'll take this sub issue (per class or per parse node) as an opportunity to make a larger point.

No matter what we do, some people will expect the other. The principle of least surprise will still leave some people surprised. If we choose per class, then the code written with the expectation of per parse node will fail safe. If we choose per parse node, then code written with the expectation of per class may seem to work under testing, but fail unsafe when exploited. We should be on the lookout for such asymmetries.

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

@erights

Why not
keyword ->x;

I actually suggested that sort of prefixing for concise method, and Kevin convinced me to not go down that path. An of course, in usage, -> is a binary operator not a prefix.

Primary reason is that the community response to the #x prefix has been very negative and it appears to be not just about the # character. The whole idea of prefixing variables seems to be disliked. Fear that we are starting down a BASIC style prefixing route.

i originally suggested "secret" for the keyword with "hidden" as another possibility. We ultimately settled on "hidden" as a better word. Note that we are intentionally avoiding "private", "protected", etc because we don't want to clash with existing usage of those words by popular transpiler based extensions and dialects.

Also, we intentionally did not go with hidden x; to declare instance variables. We want the focus to be on the instance variable-ness of what is being declared. Being "hidden" from observation outside the class definition is one characteristic of instance variable but not the only one.

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

@erights

What "field" concept do you refer to?

The one in the current TC39 proposal of record. Where "field" means either an own property or a private instance slot. We are trying to simplify by not conflating those two things under one name. And not providing a new and redundant mechanism for defining and initializing instance own properties.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

I am fascinated by generalizing private state so that it can work for object literals. If we think of

class M {
  var ->x;
  foo() { this->x = this->x +1; }
}

as syntactic sugar for

let M = (() => { . // IIFE
  const %x% = new SlotMap();
  return class M {
    foo() { %x%.set(this, %x%.get(this) + 1); }
  };
})();

then we might generalize to consider

declaration F stuff {
  var ->x;
  return {
      foo() { this->x = this->x +1; }
  };
}

as syntactic sugar for

let F = (() => {  // IIFE
  const %x% = new SlotMap();
  return declaration F stuff {
    var ->x;
    return {
      foo() { %x%.set(this, %x%.get(this) + 1); }
    };
  };
})();

Suddenly, I feel good about var being the keyword ahead of the ->. The combination hoists upward past the enclosing declaration! This hoisting explanation of var ->x; would then painlessly allow:

function F(x) {
  return {
      var ->x = x;
      foo() { this->x = this->x +1; }
  };
}

since var ->x; has the same enclosing declaration. Even better

class Point {
  var ->x;
  var ->y;
  constructor(x, y) {
    this->x = x;
    this->y = y;
  }
}

could instead be written with initialization instead of assignment

class Point {
  constructor(x, y) {
    var ->x = x;
    var ->y = y;
  }
}

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

By contrast, putting the state in so-called "internal properties" muddies exactly these waters.

Note that this is terminology that we do not use. If you spot it tell us We consistently say "instance variable"

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

Primary reason is that the community response to the #x prefix has been very negative and it appears to be not just about the # character. The whole idea of prefixing variables seems to be disliked. Fear that we are starting down a BASIC style prefixing route.

It would be good to get some evidence on this. To my eyes, the # was painfully ugly. Since binary base->name is pervasive on use, I find unary ->name on declaration pleasant. It visually ties the two together without confusing them with each other.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

By contrast, putting the state in so-called "internal properties" muddies exactly these waters.

Note that this is terminology that we do not use. If you spot it tell us We consistently say "instance variable"

What is [[InstanceVariables]] ?

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

It would be good to get some evidence on this.

Read the multiple issue threads that have been filed against the current proposals with hundreds of supporting comments, and the continuous stream to twitter posts, and blog posts, and medium articles...

If you pay attention to these sorts of community channels it's all over the place.

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

Remember that many of those who complained, objected equally to not being able to use dot on use, like they can in statically typed languages or in TypeScript. Since we're committed to -> on use, we need to examine the delta force of the objections to the additional usage of -> on declarations.

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

What is [[InstanceVariables]] ?

It the attachment point for associating instance variables with an object. If you want to think in terms of a weak map model and inverted weak maps, it corresponds to each objects collection of inverted map entries.

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

Remember that many of those who complained, objected equally to not being able to use dot on use, like they can in statically typed languages or in TypeScript. Since we're committed to -> on use, we need to examine the delta force of the objections to the additional usage of -> on declarations.

One of the things we are trying to do with this proposal is seriously follow the max-min design path. Keep things as simple as possible, eliminate all not essential features and syntactic embellishments. Syntactically, var ->x; does not seem as minimal as var x;.

G'night....

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

In general, in the spec, when an object has associated with it something using the notation [[Foo]], we say that [[Foo]] is an internal property. Is [[InstanceVariables]] an internal property?

@erights
Copy link
Collaborator

erights commented Mar 12, 2018

I find the visual scope confusion at #7 (comment) caused by var x; to be terrible.

@zenparsing
Copy link
Owner

@erights

Thanks for all the great feedback!

is [[InstanceVariables]] an internal property?

In order to help us avoid getting confused about the meaning of "property", let's use "internal slot" for things like [[InstanceVariables]] and "property" for JS object properties.

The way we've currently specced it, [[InstanceVariables]] is indeed an internal slot on the base object. I agree with you that this specification choice tends to muddy our concept of per-instance encapsulated state. If we maintain our focus on WeakMap-like semantics, then I think that we should probably re-specify using a more Map-like mechanism.

Actually, I think using a more Map-like spec implementation might help JS educators in describing this feature.

I am fascinated by generalizing private state so that it can work for object literals.

Allen was also very interested in this idea, but I think I've convinced myself that we should not go there. I briefly mentioned my rationale in #7 (comment), but to restate:

Instance variables are not appropriate for objects that are intended to be used as prototypes for other objects, because access to instance variables is not supported through the prototype chain. This is fine for instances of classes, and matches the behavior of instances of the built-ins. But currently, objects created with the object literal syntax are expected to work as prototypes for other objects. I believe that it would be surprising to many if the addition of an instance variable to an object literal broke its usage as a prototype:

let obj = {
  var x = 1,
  m() { return this->x },
};

let child = Object.create(obj);
child.m(); // Throws, surprisingly?

I find the visual scope confusion at #7 (comment) caused by var x; to be terrible.

I'm curious to know your thoughts on #18, which would eliminate that scope confusion (but possibly create others) and also provide users with a way to elide repetitive this-> occurrences if they choose.

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

In general, in the spec, when an object has associated with it something using the notation [[Foo]], we say that [[Foo]] is an internal property. Is [[InstanceVariables]] an internal property?

No, we don't use that terminology any move, starting with ES6 we say that [[Foo]] is an internal slot. See 6.1.7.2.

After this proposal, we would probably call them "instance variables".

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 12, 2018

@erights

I find the visual scope confusion at #7 (comment) caused by var x; to be terrible.

In practice, I don't think that will occur very often. Programmer who know modern JS well enough to code a class with instance variables will also know that let is the modern way to declare lexical variables.

However, we could eliminate any such possibility, by defining a static semantic rules that says that it an early error to define a instance variable named x in a class definition that is lexically nested in a scope that has visibility of a legacy var x; variable declaration.

Such rules would add some complexity to the spec. and that is something we are trying to avoid. But it could be done. Part of what we are reacting to with this proposal in the growing conceptual and actual complexity of recent proposals. Essential complexity is, well, essential. But accumulating inessential complexity is detrimental. It would be terrible if someone actually wrote something like:

var x;
class foo {
  var x;
  get x() {return this->x}
}

But is it worth adding complexity (and actual runtime work) to detect and reject it?

@zenparsing
Copy link
Owner

Closing in preparation for public review (we want reviewers to focus on the proposal materials rather than long issue threads). Please feel free to continue discussion here or open a new issue for a specific topic.

@hax
Copy link
Contributor

hax commented Mar 15, 2018

@erights

Remember that many of those who complained, objected equally to not being able to use dot on use, like they can in statically typed languages or in TypeScript. Since we're committed to -> on use, we need to examine the delta force of the objections to the additional usage of -> on declarations.

As my observation , there are two factor in dislike of this.#priv

  1. JS do not use sigil
  2. # is the "wrong" token -- programmers only can imagine its usage as comment, macro, directive, literal delimiter, placeholder, but never part of identifier (This is why we want to exchange # and @)

I believe -> (or other alternatives like ::) is very different in

  1. It's not sigil, but operator which very common in the languages
  2. The usage of -> :: in other languages are close to property/name lookup which make them look "correct"

Note, :: as binding operator also suffer a minor issue: :: is more like name lookup (access static field or module exports), but the feedback from community is mainly positive though the proposal is near dead. Compare to #prive this may be the evidence how most hate sigil.

This is also why I think var ->x is not a good idea, it go backward to sigil.

BTW, I do a little prefer :: to ->, because

  1. :: convery "scope restriction", especially if we support x as the shortcut of this::x, most time you just need x, and only use this::x when you want to restrict the scope for unambiguity. This reduce the similarity of "property lookup" which . or -> convery
  2. Compare to ->, :: is easier to input in English keyboard. Consider instance variables / hidden methods would be used frequently, minor improvement is also important. (But if we allow shortcut for both instance var/hidden methods, this point can be ignored.)

@littledan
Copy link
Collaborator

The usage of -> :: in other languages are close to property/name lookup which make them look "correct"

Several comments from @allenwb and @zenparsing seem to be based around an idea that -> makes it more clear than .# that this is not property access. That distinction would help explain why the semantics are different from property access. Does this have the potential to be confusing, given @hax 's intuition above?

@allenwb
Copy link
Collaborator Author

allenwb commented Mar 15, 2018

@littledan
I don't think so. -> or :: is a form of lookup in other language but in those language neither has the same semantics that they give to .

That is all we were saying, -> is an accessor operator that is different from the . or [] property access operators. It is different in both what it accesses and how it does its lookup.

Regarding, using :: instead of ->. It was a close 2nd choice in our design discussions. I wouldn't be upset if we concluded there was a good reason to switch to it.

@Jamesernator
Copy link

Jamesernator commented Mar 16, 2018

I quite like the idea of closure-like variables instead of having to worry about things like "writable"/"configurable" (like-properties) that the current private fields proposal would allow.

It would be nice though if these are variable-like for them to be actually variable-like and allow let/const variables somehow. I wonder if class-parameters could be revived to do this e.g.:

class Vector(x, y) {
    const ->x = x
    const ->y = y

    /* No constructor */
    
    add(otherVector) {
        return new Vector(this->x + other->x, this->y + other->y);
    }

    get x() {
        return this->x
    }

    get y() {
        return this->y
    }
}

new Vector(3, 4);

Another example with a constructor as well:

class Adder extends HTMLElement {
    const ->shadowRoot = this.attachShadow({ mode: 'closed' })
    let ->count = 0

    constructor() {
        super();
        this->shadowRoot.innerHTML = someTemplate;
        this->shadowRoot.querySelector("#clickMe").onclick = _ => {
            this->count += 1
            this->render()
        }
        this->render()
    }

    hidden render() {
        const countElem = this->shadowRoot.querySelector("#countOut")
        countElem.innerHTML = this->count
    }
}

@hax
Copy link
Contributor

hax commented Mar 16, 2018

@Jamesernator
Do you mean use class-parameters replace constructor? Use two syntax do the same thing sounds not a good idea to me.

About const/let, see #25

@Jamesernator
Copy link

The main reason for suggested class-parameters is because const/let are traditionally block-scoped, const ->shadowRoot = this.attachShadow({ mode: 'closed' }) wouldn't allow the declaration of ->shadowRoot to appear in the top-level. Perhaps it's not actually that confusing though if it was in the constructor:

class Adder extends HTMLElement {
    constructor() {
        const ->shadowRoot = this.attachShadow({ mode: 'closed' })
    }

    hidden render() {
        // Is it confusing that `const ->shadowRoot` isn't block-scoped
        // to the constructor function? Probably not to be honest
        // after using it a couple times
        const countElem = this->shadowRoot.querySelector("#countOut")
    }
}

I'll continue discussion on #25 though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants