js-classes-1.1 icon indicating copy to clipboard operation
js-classes-1.1 copied to clipboard

Why no initializers?

Open ljharb opened this issue 7 years ago • 58 comments

One of the very valuable aspects of the current class fields proposal is that it makes it very ergonomic and easy to avoid having to write a constructor at all.

Being forced to write the constructor (the current state of things) allows the common footguns of forgetting to call super, of forgetting to call it with the proper arguments, etc - the default constructor's behavior of constructor(...args) { super(...args); }, in practice, is not intuitive enough for most people to properly replicate when they have to add a constructor (for the purpose of adding instance fields).

ljharb avatar Mar 12 '18 18:03 ljharb

Initializers add non-essential complexity. The natural scoping of such initializers is different from the scoping currently used in class bodies for computed property name expressions. Supporting that means in a sequence like:

class C {
   ...
   [this.name]() {...}
   var v = this.name();
   ...
}

The expression this.name will have two different meanings. This difference also suggests that different initialization orders have to be used for computed property names and for initializers.

All of that complexity is just not necessary. JS programmers already know how to initialize instance state. They do it by performing assignments within the body of a constructor. This well entrenched pattern works just as well for instance variables as it does for instance own properties.

Similarly, JS developers well understand the role of constructors. We don't think that the complexity that would have to be introduced to make constructors unnecessary in a limited set of use cases (note, there is no parameterization of initializers) is justified.

allenwb avatar Mar 12 '18 20:03 allenwb

JS programmers already know how to initialize instance state. They do it by performing assignments within the body of a constructor.

I think that a huge fraction of people who would think of themselves as JS programmers believe that the way you initialize instance state is class A { x = 0; }.

bakkot avatar Mar 12 '18 20:03 bakkot

I think that a huge fraction of people who would think of themselves as JS programmers believe that the way you initialize instance state is class A { x = 0; }.

In which standard edition of ECMAScript can you do that?

Note that we have explicitly stayed away from that syntax so that dialects such as TS and other transpilers based extensions can continue to support such syntax.

allenwb avatar Mar 12 '18 21:03 allenwb

In which standard edition of ECMAScript can you do that?

Notice my careful phrasing of "people who would think of themselves as JS programmers".

bakkot avatar Mar 12 '18 21:03 bakkot

Initialisers often help code be more self-documenting. Yes you can have descriptive names and comments - but a value next to them adds to that.

Additionally banning initialisers from the declaration of instance variables would mean that in order to initialise everything all such variables would be listed twice.

In some large/complex classes with a lot of instance variables this could make the code unnecessarily long/less clear/more effort to update.

rhuanjl avatar Mar 12 '18 22:03 rhuanjl

On this issue - I bet 95% of devs using plain initializers (class C { x = 10; }) would be happy to leave out computed name expressions (class F { [this.dont] = 'nooo'}).

@allenwb

As for TypeScript concerns - sounds like we have the next MooTools 👊 😿

justsml avatar Mar 13 '18 14:03 justsml

If you have initializers then you have to decide something that I think has no correct answer.

If you evaluate the initialization expression once during class setup and copy that value to each instance, that may be "too early", increases static startup cost, and creates enormous traps when the initializer is mutable (e.g. var arr = []). ~50% of devs will tell you this is the wrong behavior.

If you evaluate the initialization expression once per class instance, that may be "too often", and needlessly incurs cost when the initializer is a complex evaluation of an unchanging value. ~50% of devs will tell you this is the wrong behavior. It's also a trap because initializers observing virtual behavior [1] will presumably be running before any derived class constructors finish; people get this wrong all the time. See https://stackoverflow.com/questions/43595943/why-are-derived-class-property-values-not-seen-in-the-base-class-constructor / https://github.com/Microsoft/TypeScript/issues/1617

If initializers exist then the syntax needs to clearly imply which of these happen; I don't think it does (and thus think initializers should not exist)

RyanCavanaugh avatar Mar 14 '18 00:03 RyanCavanaugh

@RyanCavanaugh,

evaluate the initialization expression once during class setup and copy that value to each instance [...] ~50% of devs will tell you this is the wrong behavior

I would be very surprised to learn it's anything like that large a fraction.

It's also a trap because initializers observing virtual behavior [1] will presumably be running before any derived class constructors finish

Not having initializers does not save you. The following, as spec'd in this proposal, throws:

class Base {
  constructor() {
    this.init();
  }
  init(){}
}
class Derived extends Base {
  var m;
  init(){
    this->m = 0;
  }
}
new Derived;

because at the time that Derived.prototype.init is called, this does not yet have m.

Nor can this be trivially resolved by adding fields to this before invoking super, since the super call can return arbitrary objects.

bakkot avatar Mar 14 '18 01:03 bakkot

The following, as spec'd in this proposal, throws

As it should. Both the superclass coder is at fault for using an over-ridable method for instance initialization and the subclass coder is at fault for putting post construction dependencies into a over-ridden method that (hopefully) has been documented as being called during superclass construction. If you write subclasses you need to know the subclass extension contract of your superclass. But easily done correctly:

class Base {
  constructor() {
    this->init();
  }
  hidden init(){}
}
class Derived extends Base {
  var m;
  constructor() {
     super();
     this->init();
  }
  hidden init(){
    this->m = 0;
  }
}
new Derived;

allenwb avatar Mar 14 '18 02:03 allenwb

@allenwb

In which standard edition of ECMAScript can you do that?

This has been Stage 3 for ages, and has been the de facto way to initialize class state in at least one major JS framework (React). Not only is it shocking to see TC39 members argue for abandoning these features which are widely used, but removing the primary feature of class fields: initializers.

I must be missing something obvious, but what is the difference between

class C {
  x = 12;
}

and

class C {
  constructor() {
    this.x = 12;
  }
}

(besides the added boilerplate)? The entire point of these class field, for a pretty sizable number of developers, was to avoid constructor boilerplate.

As others have said, this proposal seems to take a big step backward in usefulness, while creating significant churn in the JS community in the process. I struggle to see the point in any of it.

arackaf avatar Mar 14 '18 02:03 arackaf

@arackaf Also, the second one might invoke setters, and you have to be careful to correctly forward your arguments to super.

Jessidhia avatar Mar 14 '18 02:03 Jessidhia

@Kovensky - ah! Thank you - yes, I now remember a change to class fields whereby the

x = 12

would, iirc, now shadow an inherited field, rather than invoke the setter. Thank you!

arackaf avatar Mar 14 '18 02:03 arackaf

I must be missing something obvious, but what is the difference between

Complexity. Which you guys are demonstrating my trying to figure out the interaction between the initializer and inherited fields.

Understanding linear execution in a constructor method is easy.

allenwb avatar Mar 14 '18 02:03 allenwb

I see the declarative field as simpler from a mental model standpoint, as a declared field is just a field in your instance, and it avoids for you all the things you'd have to be careful with if you had to write it in the constructor instead.

Not only do you get to give it an initial value, you also get to avoid potential superclass prototype/instance setters, and you don't have to write the rest/spread arguments boilerplate to forward the correct constructor arguments to super.

Jessidhia avatar Mar 14 '18 02:03 Jessidhia

Which you guys are demonstrating my trying to figure out the interaction between the initializer and inherited fields.

I already understood that, I had just forgotten since it comes up literally never. And for those rare cases of deep inheritance chains with inherited properties, the class field initializer shadows the inherited property as most developers would expect (which is why, I assume, that change was made).

The initializers on class fields solved a significant problem many developers face. I really hope TC39 thinks better than to remove it under the (frankly patronizing) assumption that the status quo is too "complex."

arackaf avatar Mar 14 '18 02:03 arackaf

@Kovensky

a declared field is just a field in your instance

What is a "field"? It isn't a concept I remember reading about in any JavaScript book. Since you didn't say public field or private field I assume you are generalize over both. What are the semantics that both have in common that are relevant here?

allenwb avatar Mar 14 '18 02:03 allenwb

@arackaf

I already understood that, I had just forgotten since it comes up literally never.

Again, a sign of lurking complexity. Also not this isn't just about what JS programmer can understand and remember. Behind of this there is implementation complexity and language design complexity.

the class field initializer shadows the inherited property as most developers would expect (which is why, I assume, that change was made).

I assume you are referring to the fact that initializers for public fields (ie, own properties) set the property value using [[DefineOwnProperty]] rather than [[Set]]. But what happens it the initializer expression itself refers to an inherited get accessor. Is it visible or not? Do you know?

This is all a form of complexity. When you make common cases look simple by masking underlying mechanism you don't provide any foundation for reasoning about what is going on when you encounter a uncommon case.

allenwb avatar Mar 14 '18 02:03 allenwb

I think what’s missing here is that there are use cases for which almost any complexity might be warranted. Features for the language are not evaluated solely on complexity; that’s just one of many considerations that come into play.

This proposal heavily prioritizes avoiding complexity while heavily deprioritizing desired use cases, years of committee consensus and work, years of existing babel codebases proving out the feasibility and desirability of the current proposals (and demonstrating that a very large number of JS programmers seem to intuitively understand how initializers work without explanation), and while overlooking very large consistency and feature holes in the ES6 design that the current proposals fill but this proposal fails to fully satisfy.

ljharb avatar Mar 14 '18 03:03 ljharb

Behind of this there is implementation complexity

Did implementation concerns not get ironed out prior to this getting to Stage 3?

and language design complexity

All language features have hidden "complexity" (what I would call trivia") lurking behind the scenes. I was just reading Axel's Exploring ES2018 and ES2019. I wonder how many JS devs don't know the difference between object spread, and Object.assign, as it pertains to property setters vs defining new properties, and yet manage to use this feature productively nonetheless.

arackaf avatar Mar 14 '18 03:03 arackaf

@arackaf ftr, stage 3 is when implementation concerns are intended to get ironed out, not stage 2.

ljharb avatar Mar 14 '18 03:03 ljharb

@ljharb thank you for clarifying. I have heard of the Chrome team objecting to proposals prior to reaching Stage 3, but I'll take you word on that in the general case.

arackaf avatar Mar 14 '18 03:03 arackaf

Being a browser doesn’t exclude objecting at earlier stages :-) certainly any known implementation issues would be brought up as early as possible; it’s just expected that stage 3 is when engines actually implement it, and thus are able to bring concrete feedback to the champion/committee.

ljharb avatar Mar 14 '18 03:03 ljharb

@ljharb @arackaf

I understand that this proposal seems very disruptive from the point of view of people who are used to thinking of public fields (with initializers) as "the way it's done", and I sympathize.

Of course, it must be noted that lots of React developers think of other non-standard JS extensions as the "way it's done" as well.

A clarifying question: are you objecting to the lack of initializers on instance variable declarations (as the thread title indicates) or more generally to the lack of public fields?

zenparsing avatar Mar 14 '18 03:03 zenparsing

Of course, it must be noted that lots of React developers think of other non-standard JS extensions as the "way it's done" as well

We're well aware that JSX is non-standard, and that we'll always need tooling to support it. The concern is that these class features have been dragging along for literally years, and now, just as instance fields are starting to get implemented, and decorators were about to hit stage 3, you all want to roll this back to the drawing board in order to fix problems which don't in reality exist, while making these features less useful in the process.

And yes, I understood this thread to be about initializers on instance fields, ie

class C {
  x = 0;
  inc = () => this.x++;
}

The use of initializers is, I think it's accurate to say, the most common reason devs reach for class instance fields.

arackaf avatar Mar 14 '18 03:03 arackaf

@zenparsing I’m specifically objecting here to the lack of initializers; but related, to the inability to omit the constructor, which is a very critical feature of the current proposals. I don’t care if they’re called “fields” or not, i care that i can omit the constructor and define a per-instance initializer expression. If this proposal can offer that, then I’d feel it’s at least be a viable alternative - as of now, it is not one imo.

ljharb avatar Mar 14 '18 03:03 ljharb

While I understand @allenwb 's concern of complexity, I also understand initialization is a very wanted feature, especially for those (like, React users) who do not use deep inheritance at all.

Is it possible we can advance this proposal, but keep the door open for future support initialization? I think a controversial but important feature like initialization deserves a separate proposal.

hax avatar Mar 14 '18 13:03 hax

@hax

Is it possible we can advance this proposal, but keep the door open for future support initialization? I think a controversial but important feature like initialization deserves a separate proposal.

The door is always open.

Also, there is nothing preventing a trasnspiler based support for adding initializers to instance variable definitions including generating the necessary constructor if one is not present. Having the features in those proposal built-in to engines provides a more solid and consistant base for such transpiler-based features then what we have today.

allenwb avatar Mar 14 '18 15:03 allenwb

Also, there is nothing preventing a trasnspiler based support for adding initializers to instance variable definitions including generating the necessary constructor if one is not present.

That's what the TypeScript compiler, and Babel transforms do today. The value in having these features supported natively is that we don't (wouldn't) need those tools anymore. A language proposal which needs tooling support in order to satisfy essential use cases seems inherently flawed.

arackaf avatar Mar 14 '18 15:03 arackaf

@arackaf You always need TS for static type system, and you always need Babel for JSX support.

satisfy essential use cases seems inherently flawed

We should agree JSX/static type system is very very important for React/TS users, which definitely have "essential use cases"! So if JS do not support JSX/static type system, it seems JS will always "inherently flawed"!?

On the other side, you ignore the possible complexity of initialization because you may never use deep inheritance and will unlikely meet the edge cases.

I don't think it's a fair argument which treat initialization usage as "essential" because of React heavy usage, and on the other side treat confusion risk as "not essential" because React discourage of inheritance.

With the same logic, other users who rely on deep inheritance will treat confusion risk as "essential" and initialization convenience as "non-essential".

NOTE, I'm not against the idea of initialization. Some teams in my company also use React heavily and myself is a TypeScript lover and I always use initialization AMAP.

I just think we don't need to land all features in one time. As my understanding, we still can add initializer in the future. So this should not be the block of this proposal. The main advantage of this proposal IMO is it solves the big issue of #priv syntax, which can save the whole community from disorder.

hax avatar Mar 14 '18 17:03 hax

We can save the discussion of how terrible deep inheritance hierarchies is for another day, and just note that, if people choose to do that, they're free to bypass field initializers altogether, if needed.

I'm not saying everyone needs to use initializers; that would be absurd. But the counterargument here seems to be that nobody should be able to use initializers, because some people may get confused, which is equally absurd.

arackaf avatar Mar 14 '18 17:03 arackaf