tc39 / proposal-class-fields

Orthogonally-informed combination of public and private fields proposals
https://arai-a.github.io/ecma262-compare/?pr=1668
1.72k stars 113 forks source link

Can we take a step back? #144

Closed rdking closed 5 years ago

rdking commented 6 years ago

When there is so much feedback on this proposal from those who are aware and choose to speak at all and from members of the TC39 board itself that there are several things wrong with this proposal that make it a less than adequate solution to the problem of adding data privacy to ES, isn't that a good enough indication that it may be time to step back and re-think?

How about, instead of trying to push through with class-fields, we come up with "class properties" instead? The general idea is simple. Objects have properties. Functions are objects. Classes are functions with a prototype object. Therefore classes should support declaring properties for both its function and its prototype.

Can we solve and add this to the language first, separately from all other concerns? No private, no protected or other intermediate. Just ordinary public and static properties. I'm beginning to come to the impression that until this much is solved, the path forward for a reasonably acceptable, non-future path blocking proposal won't easily reveal itself. Maybe we're trying to take too big of a jump all at once.

hax commented 6 years ago

It's not even half-baked yet, but somehow, it's in stage 3?

I talked to a guy in TC39, and as he/she described, many TC39 members do not much care about class private or related features, even they don't like it, they just feel it's none of our business and have no strong reason to deny it (because it has social cost). And, even there were some members care about it, this proposal have been already several years, so they just tired and lost the interest, and choose not enter the room. But TC39 use "consensus in the room"... So... this is what I called: the failure of the process.

mbrowne commented 6 years ago

I do not think that classes without public fields would stand on their own.

FWIW I agree with this. I'm just guessing, but I figure that it's unlikely classes 1.1 has any chance at all of being accepted by the committee or even the community instead of this proposal unless they add a concise syntax for declaring public properties to it. Private instance variables on their own are only a half solution especially when you consider how much existing code and frameworks rely on public properties.

hax commented 6 years ago

@bakkot

If you start from the point of view that any proposal should explicitly leave room for public fields, the classes 1.1 proposal makes much less sense.

If you think @protected #x = 1 is ok, then why @public var x = 1 is not ok?

hax commented 6 years ago

@bakkot

I don't think any other access control modifiers are likely to prove necessary;

Java/C#/TypeScript... basically most OO languages never agree with you.

And it's common in ES6+ using symbol to control access, which close to package-private/internal visibility in other languages.

hax commented 6 years ago

@mbrowne

when you consider how much existing code and frameworks rely on public properties.

If you are talking about properties generally, yes all js code rely on it, how can we not rely on it? So I don't know what's that mean.

If you are talking about babel/ts class {x=1} syntax, then it's just syntax sugar of moving initialization from constructor to class body, it's never "public field" which means redefinition semantic. These two things are very different. A syntax sugar like that is not a essential feature. And existing code and frameworks can always use Babel/TS for this sugar. There are tons of JSX in the world, do we need to put it in JS spec? But if it is desired, we could have a "property initialization" proposal, and classes 1.1 never against it!

mbrowne commented 6 years ago

A general question about dynamic access of private fields: doesn't this proposal leave room for that to be added in the future? I can imagine some new syntax for dynamically accessing private fields, e.g. this[#"x"]

shannon commented 6 years ago

@mbrowne that doesn't really fit with the whole # is part of the name. Also do we really need to add another syntax just to recover functionality that was lost?

shannon commented 6 years ago

Sorry to be clear I think this functionality is important I just think it would be better to reuse existing syntax or choose a single syntax that allows for both.

Edit: Made my comment less negative. Sorry tired

rdking commented 6 years ago

@mbrowne

this[#"x"]

Hate to tell you this, but that and several other possibilities were shot down by @bakkot & @ljharb for various reasons a year ago. As you probably already know, I proposed this#.x which they shot down for symmetry reasons alone. They both stated quite definitively that they weigh the value of the syntactic symmetry higher than the value of the functionality being lost in trade. (Guys, if I misstated your opinion on the matter, please correct this).

rdking commented 6 years ago

@bakkot

I do not think that classes without public fields would stand on their own.

"Public fields", no. Public properties, definitely. Let me humor your syntax for a moment, but without the public fields.

class PointXm2 {
  #x = 2;
  #y = 0;

  get x() { return x; }
  set x(v) { this.#x = v - v%2; }
  get y() { return y; }
  set y(v) { this.#y = v}
}

Seems like public properties would work just fine to me.

ljharb commented 6 years ago

Getter/setter pairs aren’t the same as data properties - they’re slower, and many style guide prohibit them due to the more complex mental model they impose. Whether you agree with this perspective or not is immaterial - but it does mean that the need is for own per-instance data properties, not getter/setter pairs - which is what the proposal provides.

rdking commented 6 years ago

@ljharb Actually, if I don't agree with something that is "needed", then by my own way of thinking, my opinion is in fact immaterial w/ respect to that need. However, I don't quite understand why

class Ex {
  x=1;
}

is needed when

class Ex {
  constructor() {
    this.x = 1;
  }
}

or in the case of this proposal

class Ex {
  constructor() {
    Object.defineProperty(this, 'x', {
      enumerable: true,
      writable: true,
      value: 1
    });
  }
}

already exists and is so much clearer as to the meaning?

rdking commented 6 years ago

@ljharb Let me be clear about the previous post. I could understand wanted perfectly. I don't understand needed at all.

ljharb commented 6 years ago

@rdking how do you differentiate "needed" versus "wanted"? Separately, what makes "wants" less important than "needs"?

One particular usefulness here is being able to omit the constructor, which in a subclass in particular can often be a source of bugs. Users don't always remember to (...args) and super(...args), when all they want is to define a public instance data property. For example, in React, the only purpose of defining a constructor in the first place is to initialize this.state, and any bound methods, all of which can be done instead as public fields which allows the constructor to be omitted.

rbuckton commented 6 years ago

@rdking:

However, I don't quite understand why […] is needed when […]

One major benefit of the class "public" field syntax is tied to decorators, i.e.:

// dependency injection
class MyService {
  @inject("urn:services:user")
  userService;

  @inject("urn:services:profile")
  profileService;
}

// advanced control over JSON serialization
class User {
  @serializeAs({ format: "iso8601" })
  createdDate = new Date();

  @serializeAs({ name: "name" })
  userName;
}

And these are just scratching the surface. Without a declaration, there is no place to attach these decorators other than the class itself. Without a declaration, decorators cannot insert themselves into the initialization logic (wrapping the initializer thunk) without having to wrap the entire class constructor.

mbrowne commented 6 years ago

this[#"x"]

Hate to tell you this, but that and several other possibilities were shot down by @bakkot & @ljharb for various reasons a year ago.

@bakkot @ljharb I'm not sure what "shot down" means here (and I don't know what thread that was discussed in). Are you against the idea of ever adding dynamic property access for private fields in the future, or are you just against adding such a feature to the current proposal?

mbrowne commented 6 years ago

Another major benefit of public fields is readability. IMO it's already a best practice in Babel and TypeScript to declare all your properties at the top of the class, even if they don't have a default value. The current best practice is of course to define them all in the constructor, but the constructor might be doing other things as well. Public data property/field declarations are a very useful shorthand for those of us who can't just switch to using only private fields overnight, e.g. React users, and even if we were designing the language from a blank slate I would say it's important to include them. Java and C# are just kidding themselves if they think using getters and setters ensures encapsulation: this is a matter of proper design, and if you just have a simple data property that needs to be get and set without any ceremony then a requirement to define a getter and setter just gets in the way.

Another consideration is that people are already using public property syntax in Flow and TypeScript. Since the syntax is already widely used, it's good to have the language natively support it and have an official spec, especially to specify whether it's doing [[Set]] or [[Define]]. (And BTW, I don't think it's completely outside the realm of possibility that JS could have some native support for optional type annotations some day, at least for base types like Number and Object. I remember the V8 team was experimenting with this at one point.)

littledan commented 6 years ago

I still think we could consider adding this.#["x"] in a follow-on proposal, personally.

rdking commented 6 years ago

@ljharb

how do you differentiate "needed" versus "wanted"?

"Needed" is an absolute requirement to achieve a particular goal. For instance, this proposal is pointless if it doesn't provide the needed total public inaccessibility of the prescribed new properties. "Wanted" is a desire that's a good to have as long as it doesn't cost too much. For instance, a language supported syntax for implementing private properties is only a want because it can be reasonably achieved using closures and WeakMap.

Separately, what makes "wants" less important than "needs"?

Really? Isn't this question just a little..... You "need" to sleep, but you "want" to watch tv. So, do you sacrifice your sleep to watch tv, or do you record the show so you can get your much needed rest and watch the show later? Sacrificing needs has far reaching and/or wide spread negative consequences. Sacrificing wants only feels bad momentarily, but you can usually get over it.

shannon commented 6 years ago

@littledan

I still think we could consider adding this.#["x"] in a follow-on proposal, personally.

Possibly, but would this.#.x === this.#['x'] be true? It's just more places that it wouldn't match the current well known syntax obj.foobar === obj['foobar']. this.#['x'] makes this.# look like an object. Which is actually what I was requesting in #75.

My concern with a follow on is it possibly won't be done (at least not for a very long time) because of all the same objections here. Then there won't be any way to fix it.

rdking commented 6 years ago

@rbuckton

One major benefit of the class "public" field syntax is tied to decorators...

I get where you're going with this, but this so-called benefit is actually a design flaw that leads to serious violations of encapsulation. @allenwb pointed this out in a different way back in June. What's more is that the implementation of this "field" design is a direct violation of the way objects are defined in general, and the way class works in specific.

Now let's be honest about this. Doing things the way that fits the existing language means that if public properties are added to class, those properties go on the prototype like everything else in class. (@ljharb I know you want to say that static members don't, but after thinking about it, I realized they do, too. They're applied to prototype.constructor, which is just another part of the prototype created along with it.)

If we ignore the foot-gun of doing this:

class Ex {
  x = {};
}

which most developers already know not to do anyway, then decorators can be applied properly just like you want. What's more is that I believe this particular foot-gun is not something to be avoided since it allows for nice little features like this:

class Ex {
  x = class Ex2 {};
}

allowing nested classes. That some people occasionally mistakenly misuse this feature is unfortunate. However, as I said before, this is easily caught in unit testing. That means it's not a foot-gun worth damaging the semantic structure of the language over.

Summary:

These are the "wants" and corresponding "needs" as I understand them for any proposal providing class properties.

ljharb commented 6 years ago

We need to keep it that way

Even if “a class is a prototype” were true - which it’s not, a class is a prototype and a constructor function - why would we need to keep it that way? I find it hard to believe that many people think about a class body in these terms.

There is a strong desire for own data properties, and to be able to omit the constructor for them. and for those properties not to be on the prototype - which mirrors “property usage in constructors” for many years, with or without this proposal.

I understand that you want these things to be true, but i don’t see how the wider JS community has these expectations.

mbrowne commented 6 years ago

I agree with @ljharb's most recent comment. The following was always the most standard convention prior to the introduction of classes (and people generally use ES6 classes in a way that still matches this):

function Demo() {
  this.x = 1
  this.arr = []
}
Demo.prototype.foo = function() {}

Much less frequently, I saw this equally valid approach:

function Demo() {
  this.arr = []
}
Demo.prototype.x = 1
// initializing to null instead of empty array to avoid
// sharing the same array object across all instances
Demo.prototype.arr = null
Demo.prototype.foo = function() {}

Version 1 is simpler to understand because it's more consistent. @rdking, your approach is also a valid way of using prototypes of course, but is yet more complex than version 2.

The history of the language and convention used by the great majority of users strongly argue in favor of version 1.

mbrowne commented 6 years ago

Aside: if we really want to establish how prototypes were meant to be used, we'd arguably have to go back to the Self language. In Self, objects inherited from two prototypes, not one: a data parent and a behavior parent. JS never had such a model. I guess we'd have to ask Brendan Eich to be sure of the original intention for how prototypes should be used in JS, but I don't think "put everything on the prototype and use the constructor only for setting default values (and possibly other initialization tasks)" is it...

rbuckton commented 6 years ago

@rdking:

class Ex {
 x = class Ex2 {};
}

If x (with a value of Ex2) were installed on the prototype, it would not match user expectations based on class design in other languages and would be a major footgun. I would expect new Ex().x !== new Ex().x since it should be allocating a fresh class expression each time the constructor is run (which is the current semantics).

If you want a single shared instance of Ex2, you would more commonly want to write it this way:

class Ex {
  static x = class Ex2 {};
}
new Ex.x();
slikts commented 6 years ago

Initializing properties on the instance has always been the normal pattern, both because it's more straightforward, and because changing object shape outside of the constructor can deoptimize lookups by setting the object to dictionary mode.

It's more straightforward since you can just provide the default as a fallback value: this.foo = foo || 123, and you initialize it in one place instead of both in the constructor and on the prototype.

ES6 class syntax reflects the idea that prototypes are just for method properties, because the syntax only supports method properties.

It's a given that everything that can be done (like setting non-method properties on prototypes) has been done by someone or even many people, but that doesn't make it the norm. In this case this whole tangent is weird.

mbrowne commented 6 years ago

Things could potentially be simplified by only allowing constant/static values in property initializers, as is the case in many other languages. That might be going too far; just saying it's an option.

mbrowne commented 6 years ago

In this case this whole tangent is weird.

Yes. It doesn't even seem that closely related to the central premise of this thread of having properties instead of fields. Properties can be either own properties or prototype properties; same for fields.

rdking commented 6 years ago

@ljharb Even if “a class is a prototype” were true - which it’s not, a class is a prototype and a constructor function - why would we need to keep it that way?

First, where does that constructor live? On the prototype! When is that constructor created? With the prototype! In other words, a class is a prototype (with a guaranteed constructor).

Second, we would need to keep it that way if we expect organic, ergonomic assumptions about the semantics of class to match the actual semantics. This is how you keep a mental model simple and easy to absorb.

rdking commented 6 years ago

@mbrowne

Version 1 is simpler to understand because it's more consistent. @rdking, your approach is also a valid way of using prototypes of course, but is yet more complex than version 2.

How do you figure that when my approach (assuming we allow data properties in the definition) is exactly identical to approach 2? This is what I've been saying! While I prefer the "no public data property" approach, I'm not one to force that down anyone's throat. If there really is a strong desire for public data properties, then they belong on the prototype. And you just wrote that this is an "equally valid approach". Thank you.

slikts commented 6 years ago

First, where does that constructor live? On the prototype!

This is wrong; the constructor property on the prototype is there just for convenience, but it's not used otherwise; instead, the prototype property on the constructor is what's used in construction.

mbrowne commented 6 years ago

@rdking

How do you figure that when my approach (assuming we allow data properties in the definition) is exactly identical to approach 2?

Most of your examples have included getters and setters; that's what I was referring to. I still think the simplest and best solution is for this:

class Demo {
  x = 1
  arr = []
}

To be equivalent to this:

class Demo {
  constructor() {
    this.x = 1
    this.arr = []
  }
}

...but I would be open to putting data properties on the prototype and on the instance (in the case of non-primitive default values) if there's a really strong reason to do so. (To be clear, this would match approach 2 in my above comment, which it seems is what you're advocating for.) But so far I remain unconvinced that it's worth the additional complication (and it sounds like there are potential performance issues too).

hax commented 6 years ago

@littledan

I still think we could consider adding this.#["x"] in a follow-on proposal, personally.

If this.#["x"], the consistent syntax must be this.#.x.

So do not use such fictitious follow-on to muddle through.

nicolo-ribaudo commented 6 years ago

⚠️ Personal opinion

I think that this.#foo -> this.#["foo"] looks much more nicer than this.#.foo -> this.#["foo"].

ljharb commented 6 years ago

@rdking the constructor happens to live on the prototype as well, but the class result is the constructor itself - it's that the prototype is attached to the constructor, not the other way around. The constructor, not the prototype, is the primary thing.

hax commented 6 years ago

@ljharb

they’re slower,

No they can easily inlined by engine.

and many style guide prohibit them due to the more complex mental model they impose.

Never see such style guide. Links?

Whether you agree with this perspective or not is immaterial

No, it's not immaterial. My coding style prefer getter/setter if we have real privacy, and I believe @rdking agree me too.

The community never have such "prohibit data property, use getter/setter instead" style guide just because we do not have real privacy solution with convenient syntax and good performance.

but it does mean that the need is for own per-instance data properties, not getter/setter pairs

So your inference of need for per-instance data prop is just false in logic.

ljharb commented 6 years ago

@hax i'm not sure how to argue that; in actual usage in all the browsers they are much slower, whether they're "inlined" or not. https://github.com/airbnb/javascript#accessors--no-getters-setters, for example, which is the most popular one in the JS community by a number of metrics (not that popularity is particularly important).

It's immaterial because whether you agree with it or not, empathy would require supporting the use case of folks who find getters/setters unacceptable.

rdking commented 6 years ago

@slikts You're free to believe that if you want, but if I'm wrong then why can't you extend a function with an undefined prototype? Answer: because the prototype is the important part. What was the point of class? To simplify the common process of prototype staging when creating constructor functions. There's nothing difficult about creating constructor functions, so there was never any need to simplify that part. It's all about the prototype.

ljharb commented 6 years ago

@rdking Object.setPrototypeOf(function () {}, null) works just fine? (I had to run function f() {} console.log(Object.getPrototypeOf(f) === Function.prototype); Object.setPrototypeOf(f, null); console.log(Object.getPrototypeOf(f) === Function.prototype) in the dev console to get the output i wanted, but there you go).

If you mean the .prototype property, then sure, you can't new something that doesn't have a .prototype - but you can't have a .prototype without a constructor function. That a prototype is necessary doesn't mean it's the primary piece.

rdking commented 6 years ago

@rbuckton

If x (with a value of Ex2) were installed on the prototype, it would not match user expectations based on class design in other languages and would be a major footgun. I would expect new Ex().x !== new Ex().x since it should be allocating a fresh class expression each time the constructor is run (which is the current semantics).

That's why assigning objects to the prototype is considered a well-known problem. That's also why I avoid it. I would tend to write that line as static x = class Ex2 {}. I would even be willing to do static #x = class Ex2 {} because instances of Ex may want to pass an Ex2 instance around, and all instances need to have the same definition, but might not want to let Ex2 be instantiated from outside.

In the end, I'm a strong advocate for not complicating class with the ability to set dynamic defaults. That should remain the domain of the constructor function. Otherwise, what's the point of the constructor? I get the desire to create a syntax that simplifies away the most simplistic of constructors, but don't break class and = to do it. Don't have the class definition modify anything that's not part of the definition. An instance is not part of the definition.

rdking commented 6 years ago

@ljharb A little disingenuous, don't you think?

Object.setPrototypeOf(function () {}, null) works just fine?

I didn't say a null prototype. I said an undefined prototype. What's more, I said extend a function, not replace the prototype of a function. Maybe it would've been more clear if I had instead said "constructor function". So let me re-state: If the staging the prototype isn't the point of class then why doesn't this work: class Ex extends undefined {}? The point is that prototypes are the reason the reason class even exists.

ljharb commented 6 years ago

extends null works though.

rdking commented 6 years ago

@ljharb

extends null works though.

It works because null is still an explicit reference to a constant object of no value. So if a prototype is null, it still has a prototype. undefined, otoh, is not even a reference to no value. It's just no reference at all! Hence, if a prototype is undefined, there is no prototype to reference, and therefore nothing to extend. That's why it doesn't work. Even a class that doesn't explicitly extend anything has an implicit extend Object.

mbrowne commented 6 years ago

This seems to have become primarily a thread for debating prototype vs. instance placement. I don't know if that means it would be good to start a new thread or just continue using this one since we seem to no longer be discussing other questions related to properties vs. fields. I just know that if you read the thread from the top it's kind of meandering...

rdking commented 6 years ago

@mbrowne I've noticed that too. My original intent wasn't to wind up in a narrow debate over a single issue, but rather to try to get an explicit expression of the requirements and a new evaluation of the possibilities. It's been a long time getting to this point, and there are those who would see this proposal pushed through just due to the fatigue of discussing the issues. That is definitely not a good place to be.

From the proponents of this proposal, I've often heard something like "everyone I've talked to thinks # is reasonable once I've explained it". Taken at face value, this seems like a blanket acceptance of the existing proposal. However, when I look at it, it seems to say this: "everyone I've talked to thinks # is reasonable, but only after I've explained it." Wording is crucial in such matters.

I'm curious as to what the initial, pre-explanation reactions were from these people. If the pre-explanation reactions were not favorable, then that speaks to many of the issues with the aesthetic of the syntax of this proposal that have been brought up. I also wonder what would happen if a focus group were brought together separate from the TC39 itself to examine the merits and pitfalls of each of the proposals presented to see if some new insights can be raised by people not so entrenched in the existing proposal.

mbrowne commented 6 years ago

I know I just suggested that this thread is getting sidetracked, so let me know if I should ask this in a new thread, but I have an honest question: what is the point of using a private property and getter/setter in the case where it's just behaving the same way as a public property? I don't see how it provides encapsulation in any way, so...

hax commented 6 years ago

@ljharb

One particular usefulness here is being able to omit the constructor

Check the classes 1.1 proposal, you will find it also have the "usefulness here is being able to omit the constructor". And it even avoid all unnecessary public property (use instance var instead) which is better for encapsulation.

ljharb commented 6 years ago

@hax i agree, it has that characteristic. However, being unable to declare necessary public properties is one reason that proposal is a nonstarter for me.

hax commented 6 years ago

@mbrowne

if you just have a simple data property that needs to be get and set without any ceremony then a requirement to define a getter and setter just gets in the way.

It's always possible you may need some "ceremony" in the future. Before that, a syntax sugar (or decorator) could auto generate getter/setter for you.

hax commented 6 years ago

@ljharb

https://github.com/airbnb/javascript#accessors--no-getters-setters, for example, which is the most popular one in the JS community by a number of metrics (not that popularity is particularly important).

Your coding style is popular and I think there are many good things I totally agree. But you prefer getX() {} setX() {} not get x() {} set x() {} is just weird. I would read it as you are using Java style instead of C# style, which ask for this.setX(1) instead of this.x = 1, which I never see any benefit. Could you explain what's the rationale of prefer Java-like style not C# style?

It's immaterial because whether you agree with it or not, empathy would require supporting the use case of folks who find getters/setters unacceptable.

It's very weird that they (you) refuse this.x = 1 to trigger setter, but accept this.setX(1).