tc39 / proposal-class-public-fields

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

instance property declarations need to be in constructor scope #2

Closed erights closed 8 years ago

erights commented 9 years ago

The proposal as of 9/17 places both instance property declarations and static property declarations inside the class body. For static properties, this is exactly right, as static properties are per-class and are initialized once when the class is initialized.

For instance properties, putting the declaration in the class body is exactly wrong, because instance properties are per-instance precisely in order to vary from one instance to another. The expression used to initialize an instance need to be in the scope of the constructor, as the initial value will often be calculated based on constructor parameters.

The most common response to this objection is that the constructor can re-initialize these properties by assignment. But this confuses initialization with mutation. A declarative property declaration syntax needs to be able to grow into the ability to declare const properties, i.e., non-writable, non-configurable data properties. Whether the ability to declare const properties is provided by syntax or by declaration, their unassignable nature prevents this re-initialization in the constructor.

To make this repair to the proposal, we of course need an alternate concrete syntax proposal. Here are some choices:

Overload the variable declaration syntax

The common ES6 way to give an instance properties is by assignment in the constructor body, such as:

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}

The first proposal would simply turn these assignments into declarative property initializations by prefixing them with let or const:

class Point {
    constructor(x, y) {
        const this.x = x;
        const this.y = y;
    }
}

This syntax is unambiguous, as it is currently illegal under the ES6 grammar.

Using the const keyword declares the property to be a non-writable, non-configurable data property. Using the let keyword makes a writable, non-configurable data property. Conceivably, using the var keyword would make a writable configurable data property, although there's not much point since assignment already does this. In all cases, these declarations could be decorated.

In a base class as above, perhaps all these declarations must come textually before the first non-declaration use of this, in order to prevent the instance from being observed before it is initialized. In a derived class constructor, perhaps all these declarations must come before the super call, while this is in a temporal dead zone, for the same reason. However, either of these requirements impedes refactoring ES6 code to turn instance assignment into initialization.

Use the reserved "public" keyword

This proposal is like the previous one, but using (and using up) the reserved "public" keyword rather than overloading the meaning of let and const:

class Point {
    constructor(x, y) {
        public this.x = x;
        public this.y = y;
    }
}

or perhaps

class Point {
    constructor(x, y) {
        public x = x;
        public y = y;
    }
}

By default, this makes writable non-configurable data properties. To declare a non-writable property instead, you'd use a decorator.

Since public, unlike let and const, is not already associated with a TDZ concept, the initialization-time considerations above do not have as much force. Nevertheless, observing an uninitialized instance is a hazard that these restrictions can help programmers avoid.

Another possible advantage of public is that private fields might then be declared with the same syntax but using the keyword private.

Use C++-like property initialization syntax

class Point {
    constructor(x, y)
      :x(x), y(y) {
    }
}

Personally I find it ugly. With decorators, they'd be even uglier. I list it because, given precedent, it should at least be mentioned as a candidate.

littledan commented 9 years ago

Would these public or let/const member declarations be allowed in other places besides the top level of the constructor? For example, could they be in a conditional?

erights commented 9 years ago

@littledan My inclination is to say "no". If you want to use control-flow (e.g., a conditional) to determine what the initial value is, where that control-flow code does not need access to this, just place those control-flow statements prior to the initialization. This works for let, const, and public. It works much less well for a C++-like syntax.

littledan commented 9 years ago

Would this be done by syntax (a new production for top-level StatementListItems in a constructor) or static semantics rules? Should you put these declarations before or after a call to super()? Can they come after other code in the constructor?

One nice thing about the prior proposal is that it follows all of these restrictions in the right way, without the risk of unintuitive syntax errors.

jeffmo commented 9 years ago

Would this be done by syntax (a new production for top-level StatementListItems in a constructor) or static semantics rules?

I'm inclined to say it needs to be syntax for two reasons: (a) It needs to be unambiguously clear that this is a declaration and part of the class's declared structure (and not just expando initialization) (b) [related to (a)] It needs to be possible for userland libraries, test frameworks, meta-programming utils, etc to introspect (at least) and ideally interact with the declared structure of a class.

(b) is also one reason I'm slightly concerned with moving property initializers into the constructor. It mingles the declared structure of the class with the constructor initialization logic making it difficult for outside utilities to make changes to this structure in "userland". This mingling is one of the primary problems this proposal aims to solve. Test utilities that wish to mock parts of a given class are one example of a use case where userland might need to adjust class structures at runtime.

One possible workaround for this could just be to thunk the initializer expression that sits in the constructor just like we're doing now -- allowing reflection APIs to reach in and mingle still.

It's not completely clear to me yet why fulfilling declared+uninitialized fields in the constructor is a problem, though?

rossberg commented 9 years ago

+1 to Mark's analysis and suggestion. It isn't super-pretty, but given the syntactic constraints of classes, it seems like the most adequate option.

@jeffmo, what do you mean by "fulfilling"? Mark noted that initialisation needs to be distinguished from mutation. So we would still need to invent special syntax for the initialisation of attributes (and tools would still potentially need to mess with that as well). At the same time, separating declaration from initialisation is more verbose because you have to enumerate each attribute twice. What's gained by that?

rossberg commented 9 years ago

FWIW, I strongly favour using the public keyword, because that (1) requires fewer syntactic hoops, (2) extends cleanly to private properties, and (3) also fits nicely with TypeScript-style property declaration in constructor parameters (constructor(public x, public y)).

jeffmo commented 9 years ago

@jeffmo, what do you mean by "fulfilling"? Mark noted that initialisation needs to be distinguished from mutation.

By fulfillment I meant what Mark referred to as mutation. You've restated his point here as well, but my question is why? It seems there is little benefit to the underlying machinery (no types, so predicting memory layout remains a problem)...

So what's the motivation for not separating the two given that a constructor is "guaranteed" (barring abrupt returns, etc) to run in any circumstance that a property initializer is guaranteed to run?

I agree a superficial benefit is that it is less verbose. Is that all?

we would still need to invent special syntax for the initialisation of attributes

Why would we need to do this?

rossberg commented 9 years ago

As Mark points out above, relying on mutation is incompatible with desirable future extensions, like const properties (which is a feature I assume you have some sympathies for).

jeffmo commented 9 years ago
function constant(target, name, descriptor) {
  return {
    ...descriptor,
    set: function(value) {
      var currDescriptor = Object.getOwnPropertyDescriptor(target, name);
      Object.defineProperty(target, name, {
          ...currDescriptor,
          configurable: false,
          writable: false,
          value,
      });
    },
  };
}

class Foo {
  @constant setOnce;

  constructor(injected) {
    this.setOnce = injected;
    this.setOnce = 42; // throws;
  }
}
rossberg commented 9 years ago

Ugh, okay. That is not quite the natural (and efficient) notion of write-only property that I believe anybody would want. It doesn't actually create immutable storage. And decorators are still way out anyway. (This approach would be impossible to type as well.)

jeffmo commented 9 years ago

@rossberg-chromium: I think anything decorator oriented is going to be difficult to statically type by general and conventional means.

If one wishes to type property decls, it's already useful to extend the syntax with type annotations. Extending further with decl prefix tags (or whatever) is still possible for systems like typescript or flow. In a similar vain, systems like sound-typescript could arguably provide runtime decorators like '@constant' that assert contracts known to the type checker. There are probably several other options here in the same vein.

domenic commented 9 years ago
class Point {
    constructor(x, y) {
        public this.x = x;
        public this.y = y;
    }
}

I could achieve the same affect with s/public//g. So this syntax is useless to me.

class Point {
    constructor(x, y) {
        public x = x;
        public y = y;
    }
}

public is longer than this., so I'll prefer the latter.

rwaldron commented 9 years ago

FWIW, I was typing (nearly) the exact same responses as @domenic, but had to return to taking notes before I finished. Thanks @domenic for the succinct response.

erights commented 9 years ago

@domenic, @rwaldron If it is useless to have a distinct declaration of the shape of an instance, then this whole proposal is useless. If (as I think we actually all agree) it is useful, then I do not understand this objection.

domenic commented 9 years ago

The proposal as-is saves me typing. (No need for constructor() { super(...arguments); /* actual initialization here */ } Yours adds typing.

erights commented 9 years ago

Is the purpose of this proposal merely to avoid some typing for an uncommon case? If that's it, I'd rather find a way to say "constructor" in fewer letters and be done with it. (Whether I would support such a proposal is another matter.)

zowers commented 9 years ago

c# has instance properties on class level, and that's very useful feature. instance properties can be initialized with new Klass() { instanceProp1=value1, instanceProp2=value2, }

jeffmo commented 8 years ago

@erights: Avoiding boilerplate is not the only purpose of this proposal but when it's possible to avoid, doing so is certainly appealing.

Currently there are 2 framework authors involved with this proposal (React and Ember) who can each cite prevalent patterns for their respective frameworks whereby non-constructor-dependent initialization will be common. The proposal shows one such example using React. I'm quite sure there are many other cases that will benefit equally as well such as auto-uuid initializers; And it is trivial to extrapolate the auto-uuid example into all sorts of less generic situations (csrf-token acquisition, registration of the instance while retaining a ref to the key in the registry, etc, etc).

Going back to the originally expressed concern, though:

A declarative property declaration syntax needs to be able to grow into the ability to declare const properties, i.e., non-writable, non-configurable data properties

Given the decorator described above, it seems clear that we can address the practicalities of single-initialization in userland in the interim at least. Moreover if we wish to include language level support for const properties at some point, a future proposal could ensure this by marking const fields non-configurable at the time the constructor evaluation is complete (thereby allowing configurability only during initialization and/or construction time). The current proposal also still offers the option of expansion to private properties by way of a similar syntax (a special #-prefix syntax perhaps, or maybe a private keyword). @wycats has shown how this can work in his big picture discussion.

After thinking this alternative through, however, it's become clear that there are some serious issues with it that just don't make sense. Namely the way that it places declarative descriptions about the class's prescriptive structure into an imperative statement-list position that executes at instantiation time. This introduces several bits of fall-out:

When do field declaration decorators run? (these need to run at class definition time) Where can field declarations be positioned with respect to super() inside the constructor? If they sit above super(), they cannot refer to this (this isn't allocated yet). Do we require that field definitions happen immediately after the super() call? What happens if some pre-initialization logic returns early (i.e. before the super())? In that case, did the field declarations count? It's a bit odd because they were added to the declarative definition of the class (observable via decorators + introspection), but yet they seem to be described conditionally? What happens if super() is itself called conditionally? What happens when a return or exception occurs in between or before field declaration statements come to execute?

The model here just doesn't make sense.

Ultimately we need a way to describe the fields on a class in terms of specified declarations that can be reflected and relied upon at instantiation time. Sometimes we wish to initialize those declarations with constructor-dependent data and other times not. The proposal as-is addresses both situations and does so in a familiar form that adds little complexity to the grammar and reduces boilerplate for a subset of use cases. I still find this to be the most compelling.

rossberg commented 8 years ago

Given the decorator described above https://github.com/jeffmo/es-class-properties/issues/2#issuecomment-141455960, it seems clear that we can address the practicalities of single-initialization in userland in the interim at least.

I don't think we can count on decorators landing in the language anytime soon, so they don't qualify as an interim solution. In fact, I believe (and hope) that we get const fields much earlier.

Moreover if we wish to include language level support for const properties

at some point, a future proposal could ensure this by marking const fields non-configurable at the time the constructor evaluation is complete (thereby allowing configurability only during initialization and/or construction time).

Such an approach would perhaps work, but also destroy half of the benefits of declarative declaration forms.

After thinking this alternative through, however, it's become clear that there are some serious issues with it that just don't make sense. Namely the way that it places declarative descriptions about the class's prescriptive structure into an imperative statement-list position that executes at instantiation time. This introduces several bits of fall-out:

When do field declaration decorators run? (these need to run at class definition time) Where can field declarations be positioned with respect to super() inside the constructor? If they sit above super(), they cannot refer to this (this isn't allocated yet). Do we require that field definitions happen immediately after the super() call? What happens if the pre-initialization logic returns early (i.e. before the super())? In that case, did the field declarations count? It's a bit odd because they were added to the declarative definition of the class (observable via decorators + introspection), but yet they seem to be described conditionally? What happens if super() is itself called conditionally? What happens when a return or abrupt completion occurs in between or before field declaration statements come to execute?

The model here just doesn't make sense.

Indeed, although I think part of the problem is also that we screwed up super. It shouldn't have been that "flexible".

jeffmo commented 8 years ago

I don't think we can count on decorators landing in the language anytime soon, so they don't qualify as an interim solution.

I am working closely with @wycats to ensure that these two proposals co-evolve. They are still separate for sake of modularity, but decorators are very much on a track to surface in a similar timespan as this one.

On Sep 29, 2015, at 3:06 AM, rossberg-chromium notifications@github.com wrote:

I don't think we can count on decorators landing in the language anytime soon, so they don't qualify as an interim solution.

PinkaminaDianePie commented 8 years ago

For all, who think what the main goal of this proposal is removing constructor word and saving few bytes of code - nope, main goal is providing declarative way for entire class description. With declarative class description we can run some logic on class declaration, providing it additional behavior or change something, what you can not do with simple mixins. This achieves with class decorators, whose target is class fields. Class decorators is not only tool for modifying field descriptors, it can be used as declarative API of frameworks, constructing application logic, based on your fields and its decorators. Good example of this is "Hibernate" - java framework and its ORM. In that framework main logic is based on annotations (java's analogue of decorators). You are only create some classes for your models and put some annotations, which grub all needed information and build DB schema and other stuff. And all your code is remains declarative, you dont need to write any imperative pieces of code for schema creation, you just need to describe your data models. Its very easy-readable and clean. With class fields and decorators you can write very clean frameworks in javascript. I have tried to do this for myself (with babel support of course) and realized that its a very powerfull feature. With class fields+decorators+async/await you can make frameworks such powerful as rails. For now on rails you can write a few times less code than on best javascript frameworks, but es7 should change this.

rossberg commented 8 years ago

I agree that getting to a declarative notion of field definitions should be the main motivation here. But in reply to the above, let me also point out that there is nothing declarative about decorators, even if their syntax makes them pretend that. In fact, decorators remove all declarativeness from classes.

Elephant-Vessel commented 8 years ago

Don't forget that this concept of "class" is something that is quite well used in many different programming languages and that it's generally valuable to keep the nature of these reusable and reoccurring concepts sticking to the de facto norm. And as far as I know, most commonly used class-based OOP-languages have the same structure as originally proposed by jeffmo. The default initialization value also tends to be limited to a compile-time constant value which prevents us from trying to initialize the field with data that would be runtime-dependent, where we can leave these dependencies on runtime that do exist to the constructor.

"because instance properties are per-instance precisely in order to vary from one instance to another". But in practice, it often makes sense to have a default value that can be changed per-instance if and/or when needed.

And note that the main value of field declaration in classes is not to have somewhere to initialize fields, but to provide both the developers and the tooling with a way to structure and communicate the nature of the class, and then it makes a lot of sense to treat declaration of both fields and method and both statics and dynamics in the same way, in the same place, in the source code to reduce unneeded complexity. We could separate the declaration and initialization completely and leave the later completely up to the constructor if we wanted to, in line with the original proposition as having the initialization optional, but we would still gain the communicative benefit of having a clear well-ordered definition of the nature of the class is one place. That we often can initialize them in the same place is probably just a thing of convenience.

b-strauss commented 8 years ago

JavaScript engines already optimize code based on the properties of an object created in it's constructor. I doubt EcmaScript will ever deprecate dynamic property setting, so these two ways to create properties (inside/outside of constructors) will need to work alongside each other anyway. Hence the main benefit I see in this proposal, as it currently stands, is the reduction of boilerplate code and easier detection, for the human eye as well as for tools like IDEs, of how an object looks like.

I am almost certain that the majority of developers, including me, would see no benefit in this code and would be too lazy to write it down.

class Point {
    constructor(x, y) {
        public this.x = x;
        public this.y = y;
    }
}

class Point {
    constructor(x, y) {
        public x = x;
        public y = y;
    }
}
erights commented 8 years ago

JavaScript engines already optimize code based on the properties of an object created in it's constructor.

Yes. The purpose of the various forms of this proposal are not optimizations, though it is worth pointing out that non-configurable properties are potentially more efficient that configurable ones.

I doubt EcmaScript will ever deprecate dynamic property setting,

Correct. Though with a good enough alternate, old practice may fade slowly over decades. We are seeing the beginnings of this now as the class pattern displaces the manual encoding of classes by writing functions and manually initializing prototype chains.

so these two ways to create properties (inside/outside of constructors) will need to work alongside each other anyway.

Here, you assume your conclusion -- that initialization happens outside constructors. I have heard no argument for that I find compelling.

Hence the main benefit I see in this proposal, as it currently stands, is the reduction of boilerplate code

The only argument I have heard that I understand is this reduction in boilerplate. But that only makes sense for classes without constructors. That in turn only makes sense of these properties are then "initialized" post construction by assignment rather than initialization. This turns what should have been a declarative initialization into an imperative mutation, which is not something we should encourage. It is bad for code clarity to both humans and tools.

and easier detection, for the human eye as well as for tools like IDEs, of how an object looks like.

As I understand the main proposal, the properties it creates are not even non-configurable, and so not any kind of reliable indicator of an object's shape. With the only meaningful initialization being post-construction, it also obscures the property's meaning.

michaelficarra commented 8 years ago

@erights

As I understand the main proposal, the properties it creates are not even non-configurable

You understand incorrectly. The properties are non-configurable.

erights commented 8 years ago

@michaelficarra Good, thanks.

b-strauss commented 8 years ago

The only argument I have heard that I understand is this reduction in boilerplate.

@jeffmo already raised several questions:

When do field declaration decorators run? (these need to run at class definition time) Where can field declarations be positioned with respect to super() inside the constructor? If they sit above super(), they cannot refer to this (this isn't allocated yet). Do we require that field definitions happen immediately after the super() call? What happens if some pre-initialization logic returns early (i.e. before the super())? In that case, did the field declarations count? It's a bit odd because they were added to the declarative definition of the class (observable via decorators + introspection), but yet they seem to be described conditionally? What happens if super() is itself called conditionally? What happens when a return or exception occurs in between or before field declaration statements come to execute?

Besides these I think there is also, like @Elephant-Vessel said, the problem of unfamiliarity which should not be overlooked. Most OO mainstream languages structure their classes this way. Putting the initializers inside the constructor will not only introduce several strange corner cases like mentioned above, it will make people wonder why they should use this syntax in the first place, if the good old this.x = 5; would seemingly do the same thing, with fewer characters, for them anyway.

One should also think about the popularity about something like TypeScript that already does this at the syntax level. They already have a large community and my guess is with the release of Angular2 it will grow even larger. I don't think this proposal should introduce something that is unfamiliar to so many people.

littledan commented 8 years ago

@michaelficarra What was the motivation for making the properties nonconfigurable?

rossberg commented 8 years ago

@jeffmo https://github.com/jeffmo already raised several questions:

When do field declaration decorators run? (these need to run at class definition time) Where can field declarations be positioned with respect to super() inside the constructor? If they sit above super(), they cannot refer to this (this isn't allocated yet). Do we require that field definitions happen immediately after the super() call? What happens if some pre-initialization logic returns early (i.e. before the super())? In that case, did the field declarations count? It's a bit odd because they were added to the declarative definition of the class (observable via decorators

  • introspection), but yet they seem to be described conditionally? What happens if super() is itself called conditionally? What happens when a return or exception occurs in between or before field declaration statements come to execute?

It's worth pointing out that much of this strangeness is due to super calls being overly permissive. I think we screwed that up. Maybe there is room to rectify some of that in the presence of property declarations, i.e., disallow some of the super() crazy liberties if property declarations are present.

erights commented 8 years ago

@rossberg-chromium

Maybe there is room to rectify some of that in the presence of property declarations, i.e., disallow some of the super() crazy liberties if property declarations are present.

Interesting. If the presence of private field declarations also caused these same super() crazinesses to be disallowed, perhaps the issues separating @allenwb and @zenparsing would also become non-issues. Since instance property declarations do result in non-configurable properties, if

were disallowed in the classes on the inheritance chain above a declaring class, then both declared instance properties and private fields could be allocated at fixed offsets in the object.

@rossberg-chromium Care to suggest something along these lines? Your recent experiences with what went well and badly in the StrongScript experiment should give us clues about what restrictions may be realistic.

erights commented 8 years ago

@littledan just had a suggestion in chat with me that could apply to both declared properties and private state. Rather than require that the proto chain above a declaring class not be mutated, go the other way. Any class declared as extending a declaring class must also have an immutable proto chain up to that declaring class. This is practically a much milder requirement. I like it.

littledan commented 8 years ago

I hope that whatever we go with for declaration syntax and semantics with respect to mutable prototypes, when the things get added, etc are the same between private state and property declarations. I definitely like the idea of making an immutable proto chain on classes using these features and their subclasses, and I think it could solve some of these problems, but we're left with super override, which might be harder to ban (at least subclassable builtins don't face the super override issue, so to the extent that what we do is motivated by consistency with them, proto-on-down freezing should be sufficient).

zenparsing commented 8 years ago

@erights Regarding private state, if we want the built-ins to be directly self-hostable, we'll need to make a breaking change so that subclasses are given an immutable proto. Also, don't forget about Reflect.construct.

erights commented 8 years ago

Hi @zenparsing I am studying your revised https://zenparsing.github.io/es-private-fields/ now. I am still entertaining both your and a subset of @allenwb 's pov on this ( https://github.com/wycats/javascript-private-state ) until I understand the implications better. (I don't mean to omit @wycats , Allen's co-author. I do only because I have talked to Allen about this and not to Yehuda.) Just to be clear to everyone, my comments immediately earlier in this thread, your response, and my comments below presume such a subset of an Allen-style treatment of state. However, I am not yet advocating either one.

@erights Regarding private state, if we want the built-ins to be directly self-hostable, we'll need to make a breaking change so that subclasses are given an immutable proto.

That is correct. I think this is a change we could get away with. We have successfully made technically breaking changes before, that were not practically breaking of actual non-test code.

Also, don't forget about Reflect.construct.

Say more, within an Allen-style treatment of state?

zenparsing commented 8 years ago

With Allen's original proposal, you have to be very careful to distinguish between initialized and uninitialized instances, because Reflect.construct can always be used to create uninitialized instances where an object has all of the necessary slots, but all of those slots are set to undefined.

class C {
    #x = 1;
}

Reflect.construct(Object, [], C); // { #x: undefined }

Now it's true that having an undefined field value will probably produce an error at some point when we attempt to use it within a method, but I would prefer an earlier TypeError on access if it's not the case that the relevant constructor has executed or is on the call stack.

Allen has suggested addressing this by making each field TDZ instead of undefined on creation. But at that point we are in effect no longer free of shape transitions anyway, which is one of the things that the static design was trying to accomplish.

I was hopeful early on that we could somehow restrict the power of Reflect.construct when private fields were at play (for instance, by throwing if the NewTarget argument has private fields associated with it), but that didn't seem feasible when I last considered it. If I remember correctly, such a restriction would break the equivalence between super() and return Reflect.construct(BaseClass, [], new.target).

fatfisz commented 8 years ago

May I suggest a little something that I haven't seen in this discussion yet:

class Foo {
  fields { // or any other keyword
    field1 = expression,
    field2 = expression,
    ...
  }

  method() {
  }  
  ...
}

I know this adds more chars than maybe is necessary, but this way the fields are separated from the methods, which can hint that they receive special treatment. Everything else stays the same as it is now (the structure can be figured out at parse time, etc.). What do you think @erights?

erights commented 8 years ago

@fatfisz This doesn't address my concern with this proposal. At the last tc39 meeting, @dtribble made an observation that completely flipped my opinion of this proposal. Even though property name lookup is not by lexical scope at all, there is still a lexical-scope-like intuition by which everyone intuitively understands any class syntax: The fields (properties, instance variables, ...) defined by the class are useful throughout the code of the class, i.e., the code of all the methods of the class. Thus, my suggestion of putting the declarations into the constructor, which is nested within the class body, is breaks the intuition, since the methods that would use these declared names do not visually seem to appear within a scope in which these names were defined.

Your suggestion a) has this problem, and b) does not solve the original problem which concerned me: that the initialization expressions will typically be data dependent on the constructor arguments.

fatfisz commented 8 years ago

Right, completely missed that.

littledan commented 8 years ago

@erights Based on @dtribble's argument, is this bug resolved?

erights commented 8 years ago

@littledan Good point! Closed.