tc39 / proposal-class-public-fields

Stage 2 proposal for public class fields in ECMAScript
https://tc39.github.io/proposal-class-public-fields/
487 stars 25 forks source link

Syntactic ideas to try to clarify the different execution time and scoping #33

Open domenic opened 8 years ago

domenic commented 8 years ago

At the March TC39 meeting, I and others were able to articulate our misgivings about the current proposal. The obviously troublesome cases are things like:

class C {
  [this.bar] = this.bar;
  [baz] = baz;
}

where the left-hand side executes at a completely different time, and in a completely different scope, than the right-hand side. Myself in particular find the idea of two sides of an = sign having different bindings to be just too strange.

I think this could be helped with syntactic work to make it clear that the right-hand side is a "thunk" executing later and in a different scope. Here are some strawmen ideas:

  [baz] := baz // at least makes it clear this is not straightforward =
  [baz] := { return baz; } // very clear this is a thunk
  // or allow both, i.e. use arrow function body-esque rules?

  [baz] := do { baz; } // use do expressions as the thunk (allows omitting return)
  [baz] do= { baz; } // !?!?

  // or any of the above using different sigils, e.g.:
  [baz] <- { return baz; }
  [baz] <- { baz; }

I think this is particularly important in some of the common use cases, e.g. "method binding":

class C {
  method = (foo) => bar;
}

I have seen people do this just because they like arrow functions and want the concise body, not realizing that this is a significant semantic shift: removing the prototype property, and creating a new function instance every time a C is constructed. People's mental model, in other words, is that this is only evaluated once.

Contrast:

class C {
  method := { return (foo) => bar; }
}

Here it should be intuitively clear from looking at the code that there's a bit of code that will run, creating and returning a new arrow function every time. That's hugely beneficial.

ljharb commented 8 years ago

imo using a slightly modified = sigil that optionally also requires curly braces around the initialization expression seems totally legitimate to me.

The only concern i have with curly braces is, what does method := { a: b } do? I'd rather not see us carry forward the mistake that was made with arrow functions.

domenic commented 8 years ago

Yeah, perhaps making the curlies optional is not a good idea.

jeffmo commented 8 years ago

For the record: I'm not in complete agreement with the problem statement, but I am totally willing to explore and consider alternate syntax if it fits the same simplicity mold.

The only strawpeople I have concerns with are the ones with curlies -- namely that curlies are already overloaded in JS to represent both an object or a block. We're sort of after a block-like thing here, except that blocks are statement lists and not expressions (and we're specifically looking for an expression container).

So, of the proposed options you stated, baz := this.baz; or baz <- this.baz; seem like the best contenders.

:= is viable at a technical level, but with consideration of typecheckers like Flow/TypeScript/CloserCompiler it could get a little confusing for type annotation extensions:

baz: number := 42 (a bit noisy with the :) Alternatively: baz: number = 42 (but we're back to the original concerns)

This works pretty well, though: baz: number <- 42

domenic commented 8 years ago

We're sort of after a block-like thing here, except that blocks are statement lists and not expressions (and we're specifically looking for an expression container).

Can you explain why? The difference is minimal at best, given comma expressions.

jeffmo commented 8 years ago

We're looking for an expression to be evaluated to a value (and eventually assigned). Statement lists don't evaluate to a value, though.

If we had do-expressions, this would be a good space for them as you suggested (and I would love to have do-expressions for lots of reasons) but as of the last time they were discussed they were pretty contentious -- so I wouldn't want to block on that contention here.

domenic commented 8 years ago

Statement lists don't evaluate to a value, though.

Well, they do; that's what completion reform was about. But I agree that JS developers rarely see that (until do expressions advance), since eval usage is rare.

But I think using the return keyword, as in function bodies, certainly makes it explicit.

jeffmo commented 8 years ago

(PS: cc @zenparsing)

amasad commented 8 years ago

@jeffmo asked me to write my experience with this on twitter:

I have been naively using this feature for the past 6 months, mostly in React classes. I used it for:

I kinda knew that it is deferred execution (because how would it work otherwise ) but used it without thinking much about it and haven't ran into any problems. I also just did a scan over the codebase and a relevant piece of data is that the RHS is always pure expressions in my case, so that might explain how I didn't run into any wtfs using it.

jayphelps commented 8 years ago

My 2 cents:

method := { return (foo) => bar; } defeats most of the reason people use arrow functions in fields, terseness of autobind; now it's rather verbose compared to alternatives. As you mention, people sometimes don't realize using them creates a new instance of that function for each class instance, so in reality I'm not convinced it's something to be encouraged as a go-to since that could create unintuitive bugs for people, foo1.method !== foo2.method. I don't believe you're encouraging it per se, but just clarifying for drive-by readers.

I feel like the existing RHS behavior is a very normal behavior of class languages that most would realize/recognize if it were anything but an arrow function. e.g. foo = new Foo(); I think the discussion is a valid one to have, but given the current suggestions I would still land on the original proposed syntax.

I'm curious about why the LHS is evaluated separately and not along with the RHS? To me, that's the least intuitive in discussion. If that becomes a sticking point I'd like to see TC39 punt on computed properties for v1 as I had heard was the case.

neonsquare commented 8 years ago

I personally don't like the <- idea because I see to much clashing with things like generic syntax, JSX, "less-than-minus-X" a.s.o. I've to say though, that I do not really see a big problem with the status quo too.

The syntax - combined with arrow functions can always looks a bit strange IMO.

class Foo {
  method = x=>x
}

class Foo {
  method <- x=>x
}
class Foo {
  method := x=>x
}

Ok thats picking out this particular arrow function use case - but this seems to get common for event handlers because it solves the this binding issue:

class View {
  onClick = (ev)=> this.setState(...)
}

I proposed using a prefix - but I agree that it adds more boilerplate and doesn't make it look less like an assignment:

class Foo {
  instance onClick = (ev)=>this.setState()
}

it was just meant to stay in line to:

class Foo {
  static method = x=>x
}

I think it would be quite clear what it does though. "Set instance's onClick to...".

Using "thunks"

As outlined may be an option, but it also looks quite heavy to me. This whole thing is meant as syntactic sugar to make constructors less necessary. But if it adds even more fanfare than why bother at all?

class A {
   a = 42;
   b = "Hallo";
   c = 3.14;
   d = 3/4; 
}

class A {
   a := { return 42; };
   b := { return "Hallo" };
   c := { return 3.14; };
   d := { return 3/4 }; 
}

class A {
  constructor () {
    this.a = 42;
    this.b = "Hallo";
    this.c = 3.14;
    this.d = 3/4; 
  }
}

errhh... no I don't think those thunks are really a big improvement.... ;)

Crazy idea:

class A {
  this {
   a = 42;
   b = "Hallo";
   c = 3.14;
   c = 3/4; 
  }
}

???

I still think that the status quo is not that bad if such a feature is wanted...

ljharb commented 8 years ago

@jayphelps computed method properties are evaluated at class definition time - it would not be consistent to ever evaluate computed instance or static properties at any other time imo.

jayphelps commented 8 years ago

@ljharb I can absolutely see that argument, though fields are different as described in that their value is set at instantiation vs. methods at definition, so they're already inconsistent by necessity (obviously). I don't feel super strongly about it, just thought I'd mention what felt intuitive to me.

domenic commented 8 years ago

though fields are different as described in that their value is set at instantiation vs. methods at definition

That is precisely what this thread is trying to solve: make that inconsistency clear, by delimiting the code that runs at a different time in some outstanding way.

jayphelps commented 8 years ago

@domenic to me the equals sign by its nature inside of a class body denotes that already, but admittedly that's almost certainly because that's just how all the languages with classes I can recall do it. Is there any lang you're aware of that doesn't have this behavior?

domenic commented 8 years ago

@jayphelps all languages with classes that I've worked in (C++, C#, Java, JavaScript) do not have a syntax which uses = for initializers.

domenic commented 8 years ago

Again, I think it's bizarre to say that we want to introduce to JavaScript a context where

foo = foo

evaluates the first foo at a different time (and a different number of times) than the second foo.

neonsquare commented 8 years ago

I'm with @jayphelps here - to me the context of the class definition was enough to make this field initialization intuitively understandable. This whole class definition thing is one big pile of syntactic sugar anyways... ;)

neonsquare commented 8 years ago

@domenic: Well statements like v = v + 1 actually seemed quite bizarre to me for quite a long time for something that is really just: (setf v (+ v 1)) - but I'm comfortable with the thought that a lot of people adapted to = meaning something very different (an action) than equality (a relation).

Don't you think the thunk syntax would destroy the whole purpose of this syntax sugaring?

domenic commented 8 years ago

Don't you think the thunk syntax would destroy the whole purpose of this syntax sugaring?

I don't think so, no. It would make people more aware of the weight of what they're doing (causing code to be run and objects created on every initialization), which as I pointed out in my OP is pretty important for common cases like arrow functions. And it would clarify the scoping and runtime rules. A few extra characters to make the syntax sugaring, as you say, match the actual programming model, seems like a worthwhile investment.

pluma commented 8 years ago

Just a naive question: shouldn't you also take static properties into account in this discussion? Or are they intended to have different syntax because the assignment happens at different times?

neonsquare commented 8 years ago

@pluma Values of static properties are only evaluated once at class definition time - so both of the criticized aspects do not apply.

pluma commented 8 years ago

@neonsquare so the syntax (assuming := which @domenic proposed) would be

class A {
  static foo = 'hello';
  bar := 'world';
}

?

I think it's worth considering more full-fledged examples like these. I'd find the := syntax (as well as the <- syntax) quite jarring -- unless = without the static would be added as a way to assign to the prototype (which would break compatibility with current implementations of this proposal but allow replicating the behaviour of the function-with-prototype pattern).

It's important to avoid confusion, but it's also important not to fall into traps like PHP does where every new feature gets a new operator (leading to nonsense like having to use backslashes for namespaces because all the alternatives were already taken).

neonsquare commented 8 years ago

@pluma No @domenic 's proposed syntax would be:

class A {
  static foo = 'Hello';
  bar := { return 'world'; }
}

and perhaps variants like this when do expressions are there:

class A {
  static foo = 'Hello';
  bar := do { 'world'; }
}

I understand the reasoning behind making this "instance field initializers" look more heavy to remind people about the perceived computional complexity of them being evaluated on any instance creation. I can't help thinking about how they somehow look wrong to me. On the other side - I don't think that it is always necessary to cater for any misunderstanding a newbie might have. This is an "assignment within a class declaration" - that was enough speciality for me to assume special evaluation rules anyway. If there really is an argument about documenting whats going on - perhaps something like this would be more enlightening:

class A {
  prototype.foo = 'Hello';
  on new { this.bar = 'world'; }
}
neonsquare commented 8 years ago

Ok after much discussion the final syntax is:

class Foo {
  bar  ¯\_(ツ)_/¯ : 104
}

This syntax doesn't look like assignment at all, is enough fanfare to warn users about what they may do and should not collide with other features!

;-)

cesarandreu commented 8 years ago

I've been using the proposal for a couple months in my job, and I haven't seen any code in the wild that uses computed class fields, nor do we have any in our codebase. I had read about the field being evaluated at a different time, but hadn't paid it much attention. Most of my usage is with static props.

I apologize if this is an inappropriate question for the thread, but why not evaluate both sides at the same time? If you used something like this from the class body, it'd basically basically just be shifting stuff up one indentation level from the constructor.

class Foo {
  static abc = 'abc';
  this[Foo.abc] = true;
  this.foo = false;
  this.bar = (e) => console.log(e);
}

is sugar for

class Foo {
  constructor () {
    this[Foo.abc] = true;
    this.foo = false;
    this.bar = (e) => console.log(e);
  }
}
Foo.abc = 'abc'

Although I could also see how someone might find that confusing as well.

pluma commented 8 years ago

Frankly the entire point of having syntax for class fields is to be able to declare instance attributes and to avoid having to define constructors (with the entire this and super song and dance) to assign initial values to them. Forcing each assignment to happen inside yet another block-like construct kinda defeats the purpose.

If you make developers wrap each individual assignment in a block, nobody will use this feature.

What about less magic? Why not call them initializers and make them take functions?

class Foo {
  foo: () => 'hello'
  bar: initBar
}

as equivalent of

class Foo {
  constructor(...args) {
    super(...args);
    this.foo = (() => 'hello').call(this);
    this.bar = initBar.call(this);
  }
}
neonsquare commented 8 years ago

If you make developers wrap each individual assignment in a block, nobody will use this feature.

To some degree that is indeed the purpose. Users should not use this feature... without knowing that it may do more than they think (evaluation at each instantiation).

My personal opinion: If it would be only shortening of syntax and if the syntax would be that heavy, I may not really use it because writing a constructor is often shorter if there are multiple fields to initialize. Think of things like:

class Foo {
  constructor(a,b,c) {
     Object.assign(this,{a,b,c});
 }
}

But: Special Syntax like those field initializers is always an option for static analytics. It is a good place to annotate static type information (see Flow and TypeScript). Its restricted syntactical structure makes static analysis easier than with arbitrary initialization code.

So: a Special syntax has more purpose than just making the code shorter. Still: To me the originally proposed syntax is the best option so far. I personally do not see the instance evaluation time as a convincing reason to use heavy block syntax here. Maybe better variants are possible.

b-strauss commented 8 years ago

Although I read the whole issue I don't really understand the premise. Is the sole reason of this proposal to highlight to the developer that this is not their everyday variable binding?

Of all the strawmen ideas I prefer:

foo := 'bar';

Everything else seems a bit too verbose to me.

On a side note; Now that I see it I would love to have some sugar for implicit getters/setters in the style of Newspeak :smiley:

// implicitly generates getter
foo := 'bar';
// implicitly generates getter and setter
foo ::= 'bar';
ljharb commented 8 years ago

@b-strauss get foo() { return 'bar'; } - there's no need to use properties for that. Also, where would an implicit setter store its value? Let's not derail the thread with an unrelated proposal :-)

b-strauss commented 8 years ago

@ljharb Well if I had the syntax I would use it. Just because it's less to type :). Where the values would be stored would be an implementation detail. Everything would go through the accessors, like Dart does it. But you are right, let's not continue that discussion here. ;)

jayphelps commented 8 years ago

@pluma Using a colon has been pretty thoroughly discussed and mostly ruled out because statically-typed dialects of JS almost unanimously use it because it's a pretty standard syntax to do so in many languages.

class TheOne {
  firstName: string = 'Thomas';
  lastName: string = 'Anderson';
}

They could decide to appropriate the colon for a different purpose but it would be pretty controversial IMO, particularly since many are still holding out hope that TC39 will add static typing to JS natively though that's unlikely at the moment.

domenic commented 8 years ago

In an offline discussion @littledan reminded me of function default arguments, and how they already exhibit some of the non-obvious behaviors mentioned in the OP:

function f({ bar: [baz] = qux }) { ... }

in this example, baz and qux are both evaluated:

This convinced me that, with one small tweak, the current proposal's semantics can be used. The tweak is that the LHS and RHS should be evaluated in the same scope, i.e. the per-instance scope which (per other parts of this proposal) has access to this and super() as if they were in the constructor (but not arguments or the other arguments, so it is not the same scope as the constructor code). So, both sides are executed per-instance, instead of the LHS being executed per-class and the RHS being executed per-instance.

This addresses the counterintuitive LHS/RHS split and puts = back on the table for me. And the argument from analogy with function declarations/default parameters allows me to be comfortable with not introducing a new "thunk notation" with brackets or similar denoting the new scope and execution time.

(I still personally think it would be nice to make the syntax more "heavy", so that people realize that foo = () => bar is much less performant than foo() { return bar; }. But it's no longer an objection grounded in real concerns about language consistency---and in fact it'd be a bit inconsistent with function parameter defaults. So I can let that idea go and just write a lint rule or whatever.)

zenparsing commented 8 years ago

Evaluating the LHS per instance seems quite surprising to me, since property definitions seem like they are saying something about all instances of the class. They appear to be saying something declarative about instances.

ljharb commented 8 years ago

@domenic what about the inconsistency between:

[foo] = 'bar';
[foo]() { return 'bar'; }

In either case it seems like there's an inconsistency - it seems like we're deciding between whether it's more confusing to have the split be between properties and methods, or between LHS and RHS?

domenic commented 8 years ago

I agree there's some asymmetry either way, but IMO this proposal has already whole-heartedly bought into breaking the symmetry between property declarations and methods, since property declarations put properties on the instance instead of on the prototype.

ljharb commented 8 years ago

Indeed, that's a strong point in favor of evaluating the LHS in instance scope.

jeffmo commented 8 years ago

cc @wycats, who was the original advocate for computed field-names.

I originally only had identifier field-names -- mostly out of min-viable-product -- but he had asked that we include computed as he had some uses for them in mind, I believe.

zenparsing commented 8 years ago

@jeffmo I had also originally assumed only identifier-named properties, but I suppose it would be odd and incongruent to disallow symbol-named public property definitions.

jeffmo commented 8 years ago

@zenparsing: Good point.

To be clear, though: I wasn't necessarily suggesting omitting computed field-names -- only making sure @wycats could represent any upsides/downsides to evaluating LHS at instantiation time.

I personally would find this odd, but I'd probably want to think about it more. I'm not sure I could do a great job of representing the position of someone who plans on using computed field names at the moment :)

jayphelps commented 8 years ago

The biggest use case I want computed property names for is Symbols.

zenparsing commented 8 years ago

@jayphelps out of curiosity, can you show an example?

jayphelps commented 8 years ago

@zenparsing Contrived example:

import { $$meta } from './symbols';

class Foo {
  [$$meta] = {};

  set bar(value) {
    this[$$meta].bar = value;
  }
}

Other than that, computed property names for me are pretty rare. Even if we got private fields as spec'd I would still use Symbols because I often need "friends" to access them as well.

WebReflection commented 8 years ago

FWIW, I think instance as prefix is the best option: it's easy to refactor current code based on the old proposal, it requires pretty much zero effort to be recognized (as opposite to := or ::= or <- or others, since these have different meaning in other PLs) it doesn't need much explanation.

import { $$meta } from './symbols';

class Foo {
  instance [$$meta] = {};

  set bar(value) {
    this[$$meta].bar = value;
  }
}

If it doesn't work as prefix, it might work as "group"

class Foo {
  instance {
    [$$meta] = {}
    foo = 'bar'
    onClick = (e) => this.foo = e.detail.foo
  }
}

That being said, if this is just about avoiding construct I'm not sure why decorators wouldn't be an alternative.

@instance({
  [$$meta]: {},
  foo: 'bar',
  onClick(e) {
    this.foo = e.detail.foo;
  }
})
class Foo {
  constructor() { instance.setup(this); }
}

Although, the implementation could have to do some work to grant non primitives clones per each instance, and a missed setup could be a disaster so ... maybe latter one is not a good idea.

erights commented 8 years ago

I want to draw our attention to @sebmarkbage 's https://github.com/sebmarkbage/ecmascript-scoped-constructor-arguments . I like this direction, and it completely changes the conversation about initialization expressions. It would allow initialization expressions to make use of constructor arguments intuitively, while remaining in class-body position.

The translation of lexical capture to private fields would not be another way to express class-private instance variables. Rather, because it is in service of extending lexical capture intuitions, this internal use of private fields would be for instance-private instance variables. See the thread starting at https://github.com/tc39/proposal-private-fields/issues/14#issuecomment-216348883

WebReflection commented 8 years ago

@erights I'm not sure I understand your comment there. AFAICT @sebmarkbage proposal wouldn't solve any of the problems mentioned here, like how to setup a this.data = {} without writing it inside the constructor.

There are also some smelly situations such this one which would confuse on how scope worked until now for the last 20 years in JS (if it's outer, it's available).

I think is also quite redundant to write twice constructors arguments and yet that won't be a solution for properties definition at instance creation time.

Am I missing something?

arackaf commented 8 years ago

@domenic Am I correct in understanding that according to the current proposal, this

let foo = 'a';

class Blah {
  [foo] = foo
}

foo = 'b';

let b = new Blah();

console.log(b.a);

should output b (even though Babel understandably doesn't) since the LHS is evaluated at class-definition time, while the RHS is (obviously) evaluated whenever the class is instantiated, for that instance?

That does seem reasonable imo; it makes sense that those properties would be defined when the class is defined. That said, if giving that up, and having both sides evaluated at class instantiation time is what's needed to keep this syntax, I suppose that's the least bad of all options.

Again though, I'm not sure I see the problem, and I've been using this feature for some time now.

cc @jeffmo

hax commented 7 years ago

@domenic

all languages with classes that I've worked in (C++, C#, Java, JavaScript) do not have a syntax which uses = for initializers.

PHP ;) does

const x = '';
class Test {
        const x = x;
        public $x = x;
}

Though PHP only allow assign compile-time constant and no computed property at all, so the problem is not very notable.

pluma commented 7 years ago

In Python class attributes are also defined with =:

class Foo:
  some_list = []

Properties (with getters/setters) and class methods are just a special case of class attributes.

However as these are class attributes, they are evaluated at definition time.

EDIT: FWIW I have no more opinions on syntax because I recently exterminated my ego by using standard and then moving to prettier because it completely prevents me from having any opinions about formatting.