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

Could we have avoided "fields"? #274

Open rdking opened 5 years ago

rdking commented 5 years ago

I wish Stage 3 was still a time when TC39 is open making major changes to a proposal. However, since it isn't, I can only ask these questions in hindsight.

  1. Is the prototype foot-gun the primary reason for avoiding the prototype?
  2. If so, was any consideration given to a syntax for avoiding the issue directly?

Here's what I'm thinking: suppose the syntax being used for public fields was instead used for prototype properties. I can think of several different approaches that solve the problem directly.

  1. Use a keyword to specify delayed initialization:

    class Ex {
    inst object = {};
    }
  2. Use an operator to specify that the initializer is an initialization function:

    class Ex {
    object => {};
    }
  3. Use a helper pseudo-function to wrap the initializer in an initialization function:

    class Ex {
    object = class.inst({});
    }
  4. Use a decorator to manage the initialization:

    class Ex {
    @inst object = {};
    }

All in all, any of these would successfully avoid the foot-gun without incurring any of the issues that will exist as a result of the current proposal. All 4 are relatively simple to implement. The only negative result of such a shift would be that private fields would need to be reworked. However, even that has a few solutions. But right now, what I'm looking for is an evaluation of whether or not this kind of idea presents a viable solution to the foot-gun, and if such a solution would have been enough to warrant dropping the "fields" concept (barring the issue of private) should it have been presented before Stage 3.

fabiosantoscode commented 5 years ago

This seems like a great idea!

jridgewell commented 5 years ago

Is the prototype foot-gun the primary reason for avoiding the prototype?

It's a huge reason. But placing properties on the instance is the expected behavior for people migrating from imperative this.x = 1 in the constructor.

If so, was any consideration given to a syntax for avoiding the issue directly?

Your suggestion here is to let people do the bad thing by default, and have them opt into the good behavior? That's backwards. If you want to shoot yourself in the foot, you can already do it with the good-by-default semantics:

const data = {};

class FootGun {
  data = data;
}

Let me be super blunt about this: The committee will only accept a proposal that installs a fresh object onto the instance. No one on the committee will take a proposal that breaks this requirement seriously, so it's waisting our time to hash this out again.

rdking commented 5 years ago

Your suggestion here is to let people do the bad thing by default, and have them opt into the good behavior? That's backwards.

Agreed. However, it's already the language default. At the same time, as long as the foot-gun can still be enabled, I could care less whether or not it's the default.

But placing properties on the instance is the expected behavior for people migrating from imperative this.x = 1 in the constructor.

My question here is simply "Why?". The copy-on-write(CoW) semantics of prototypes guarantees that any primitive on the prototype is immediately set on the instance object as soon as it is changed. The only thing that's ever been missing is CoW semantics for objects on prototypes. Unless I'm missing something, if all non-primitive initializers defaulted to constructor initialization, or even if all initializations defaulted to constructor initialization, but the definitions were placed on the prototype, not only would we have sanity back in the inheritance chain, but we'd also satisfy this curious desire for instance properties.

So I'm left with wondering why this path is not the chosen one given that it seemingly satisfies the requirements without breaking anything unnecessarily.

rdking commented 5 years ago

In case it isn't clear, I'm not trying to re-hash old arguments. I am very much aware of the fact that no one in TC39 has any intention of amending any of the excessively numerous issues with this proposal. That's not my goal. I just want to understand the details of how ...this ... became the "best we can come up with".

jridgewell commented 5 years ago

Agreed. However, it's already the language default.

The language default is this.data = {} in a constructor. Patching data onto the prototype is the anti-pattern.

My question here is simply "Why?". The copy-on-write(CoW) semantics of prototypes guarantees that any primitive on the prototype is immediately set on the instance object as soon as it is changed.

There is no COW behavior… Mutate it once an every instance is screwed.

Properties can be shadowed by instance properties. this.x = 1 and this.x.prop = 1 are to very different behaviors.

not only would we have sanity back in the inheritance chain, but we'd also satisfy this curious desire for instance properties.

How is this sane? Instance data does not belong on the prototype, only methods.

rdking commented 5 years ago

The language default is this.data = {} in a constructor. Patching data onto the prototype is the anti-pattern.

The language default is:

The notion of an anti-pattern is both secondary and temporary as it is something using developers decide and not something encoded into the language itself. The notion of coding a language to avoid these so-called anti-patterns is a mistake as what is considered an anti-pattern today can easily re-surface as a very useful pattern, and what is considered best practice today can easily plummet into anti-pattern status. As someone who's been using JS since it was first released, I've seen this happen a few times. Not only in JS, but in many different languages.

There is no COW behavior… Mutate it once an every instance is screwed.

I'm assuming you're talking about for objects, as any attempt to change a non-nested prototype value causes the new value to be written to the instance instead of the prototype. Or maybe you don't view this as CoW behavior?

Properties can be shadowed by instance properties. this.x = 1 and this.x.prop = 1 are to very different behaviors.

Part of my stance is that (assuming x is on the prototype object), not only is this true, but it's not the best of defaults. When an object serves as the prototype of an instance, any write attempt to the object's properties while using the instance as a receiver should cause that property to be written to the instance. This already happens. I also hold that something should be done to cause the same thing to happen even if a property of a property of the prototype is written to under the same circumstances. Put another way, writes through an instance to any part of the prototype should force the property on the prototype to be copied to the instance and the modification redirected to that new property.

How is this sane? Instance data does not belong on the prototype, only methods.

  1. The data elements of a class definition should specify the default values for the those elements.
  2. Should any instance need to change the value of any part of one of those elements, it should do so in its own instance data and shadow the prototype.

This is the sanity. No instance object should ever need to keep a copy of data that has never been modified from the defaults. Should any modification occur, then that modification should belong only to the instance. The only thing that makes this difficult is that writes to an object on the prototype don't redirect the write to the instance object, hence the foot-gun.

Put another way, other than objects, having data on the prototype does not preclude the object from having or creating instance data when it needs a different value than the default. This is how objects have always worked in JS/ES. All of the so-called "best practices" that have been built up in communities like React exist primarily because the foot-gun has and continues to do so much damage that developers have decided to completely avoid any risk despite the costs, and seemingly without attempting to remedy the issue directly.


Please understand that I'm not trying to convince you to accept or even understand my stance, as I'm sure you either can't or won't, and probably for reasons that I could accept if I knew them. But that is also the point of my post. TC39 has reasons for their (IMO) peculiar choices. I just want to understand them. If I can do that, then if/when this proposal lands, maybe I can accept it for what it is, rather than continuously decry it as an unfortunate waste of really good effort.

jridgewell commented 5 years ago

The notion of an anti-pattern is both secondary

Absolutely not. Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

and temporary as it is something using developers decide

We've known about this since the Backbone days (2012 when I started using it), and Ember.Object days.

and not something encoded into the language itself. The notion of coding a language to avoid these so-called anti-patterns is a mistake as what is considered an anti-pattern today can easily re-surface as a very useful pattern, and what is considered best practice today can easily plummet into anti-pattern status.

We're coding it into the language. This is an anti-pattern. The footgun still exists if you really want it, as I've shown.

Or maybe you don't view this as CoW behavior?

Overshadowing is not COW. If prototype.data = {x: 1}, and I do this.data = {}, my new data object doesn't have an x property. The same thing applies when the prototype property was a primitive value.

Put another way, writes through an instance to any part of the prototype should force the property on the prototype to be copied to the instance and the modification redirected to that new property.

That's where you wrong, there is no copy here. This is installing a new property with the same name and a new value. Designing a new feature to do a copy isn't the cowpath we're paving here. We have years of class usage telling us people understand assignment to instances (that have a prototype), and that assignment doesn't copy.

Should any instance need to change the value of any part of one of those elements, it should do so in its own instance data and shadow the prototype.

Data shouldn't exist on the prototype. If it were to exist on the prototype, we know (via years of bugs) that they will just do this.data.x = 1.

Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

that developers have decided to completely avoid any risk despite the costs, and seemingly without attempting to remedy the issue directly.

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

There is no upside to installing them on the prototype, and the downside is bugs.


I don't even understand why this needs to be debated. We don't live in a world with some hypothetical COW semantics, and that's not going to change. Designing fields as if we do would be a disservice to JS developers.

jridgewell commented 5 years ago

Let's just pretend this is a good idea for a bit:

const v = Cow({ x: 'x', y: 'y' });

// Assignment is COW, so `v.x = 1` won't write to v.
v.x = 1;
assert(v.x === 'x');
assert(v.y === 'y');

// So we know the assignment must return a new object.
const v2 = (v.x = 1); 
// BTW, `const v2 = v.x = 1` won't work and that can't be changed.
// It would be the same as `const v2 = 1`. Footguns…

assert(v2.x === 1);
assert(v2.y === 'y');

So now, to have a COW prototype property:

class FootGun {
  // Pretend this is installed on the prototype
  data = Cow({ x: 'x', y: 'y' });
}

const fg = new FootGun();

// This won't work, because we know the assignment returns a new object.
fg.data.x = 1;
assert(fg.data.x === 'x');

// So, we have to do:
fg.data = (fg.data.x = 1);

fg.data.x = 1 has no knowledge that data is a property of fg (as far as the spec is concerned, this is the same operation as data.x = 1), so it couldn't ever do the auto-write to fg for you.

So now we have an even worse situation where our COW isn't writing our assignments at all. Because no one is going to remember to do fg.data = (fg.data.x = 1). They're just going to do fg.data.x = 1, and that'll return a new Cow({ x: 1, y: 'y' }) object.

Is there any C-derivative language that has this "feature"?

rdking commented 5 years ago

Absolutely not. Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

... and yet, this proposal...

We've known about this since the Backbone days (2012 when I started using it), and Ember.Object days.

But what you started calling a foot-gun since that time had been common practice for quite some time, so much so that many actually found a good way to use it. That's why there's code out there you can't afford to break by fixing the foot-gun directly.

Overshadowing is not COW.

I can accept that.

That's where you wrong, there is no copy here.

I just did a test and surprised myself. I was under the mistaken impression that the new property on the instance received the attributes of the old. Until just now, I never noticed these attributes weren't being copied. My mistake.

Data shouldn't exist on the prototype. If it were to exist on the prototype, we know (via years of bugs) that they will just do this.data.x = 1.

Despite my error, this statement still seems wrong. Early documentation about prototypes described them as templates, or a means of sharing existing content between multiple objects. That's also where my CoW understanding came from, so take it with a grain of salt :smiley:. Further, you can't do this.data.x = 1 if this.data is a primitive, or if this.data.x is non-writable, or if this.data is frozen, or any of several other limitations.

Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

Because (IMO) you've been too focused on the "what" and not the "why". There's 3 simple reasons why the "best practice" is what it is:

  1. The foot-gun exists.
  2. There's no direct syntax support for declaring data in a class.
  3. The next best practical and logical option that still generally gets you close enough is to set them in the constructor.

If the first 2 reasons had been dealt with directly when class was being developed, I do not believe 3 would have ever become a best practice. But this only partially answers the question. The other part is because since [[Define]] is the mode-du-jour, inheritance is broken in a peculiar way and requires developers to be even more careful lest they find themselves hunting for a foot-gun that leaves even less clues than the one you're trying so hard to avoid. Inheritance is a major piece of one of the 3 core concepts behind the concept of a class. If you cripple that in any way, you've done serious damage to its usability and usefulness.

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

Are you really ignoring the "base overrides descendant" problem? Even if you want to describe it as an edge case, it shouldn't be ignored.

I don't even understand why this needs to be debated.

This is not a debate. I'm challenging myself by challenging you. This is me trying to understand TC39's decisions and why they differ so greatly from how I would have chosen. I've already found 1 error in my understanding thanks to you, but that 1 error is no where near sufficient enough to explain such a large deviation. There's got to be other rational reasons, other things I either don't know or have a wrong understanding of.

Let's just pretend this is a good idea for a bit:

First, since I was wrong, let's call it initialize-on-write (IoW) since the instance object is initialized with the new property when you overwrite a value on the prototype. Second, that example isn't the idea. Its more like this:

const v = IoW(Object.create({ x: 'x', y: 'y', z: { a: 'a' } }));

// Assignment is IoW, so `v.?(.?) = 1` will only write to v.
v.x = 1;
//assert(v.x === 'x'); //throws
assert(v.x === 1);
assert(v.y === 'y');
v.z.a = 'alpha';
assert(v.z !== v.__proto__.z);
assert(v.z.a === 'alpha');
assert(v.__proto__.z.a === 'a');

The idea follows this kind of logic:

let proto = {
   a: {}
};
let x = IoW(Object.create(proto));

//All 3 of these directly change proto
proto.a.alpha = 1;
x.__proto__.a.beta = 2;
Object.getPrototypeOf(x).a.gamma = 3;
assert(!x.hasOwnProperty("a"));

//This, however, initializes x with a new copy of `proto.a` 
//and changes that new property instead of the prototype.
x.a.delta = 4;
assert(x.hasOwnProperty(a));
assert(x.a.hasOwnProperty(alpha));
assert(x.a.hasOwnProperty(beta));
assert(x.a.hasOwnProperty(gamma));
assert(x.a.hasOwnProperty(delta));
assert(x.a.alpha === 1);
assert(x.a.beta === 2);
assert(x.a.gamma === 3);
assert(x.a.delta === 4);

The idea is that since the prototype interface of an object is not itself a property of the object, it should not allow [[Set]], [[Delete]], or [[DefineProperty]] calls to cross it. You can look here to see a Proxy-based, 90% solution. Can't seem to get around Proxy invariants for the seal/freeze cases. If I was thinking on the same lines as what you're showing in that second post, I would have found those same reasons and dropped the idea a long time ago, or it would have eventually mutated by a series of corrections into what I'm trying to show you now.

jridgewell commented 5 years ago

common practice for quite some time, so much so that many actually found a good way to use it. That's why there's code out there you can't afford to break by fixing the foot-gun directly.

This is incorrect. It can't be fixed because it would fundamentally change the language. There is no precedent for COW behavior you're asking for in any language I'm aware of.

Further, you can't do this.data.x = 1 if this.data is a primitive, or if this.data.x is non-writable, or if this.data is frozen, or any of several other limitations.

Because immutable data has no bug associated with it. That's why it's ok to store methods on the prototype, the function's code (not the function instance) is immutable. Setting properties on the method itself is strange, but almost never changes the function's running code.

Are you really ignoring the "base overrides descendant" problem? Even if you want to describe it as an edge case, it shouldn't be ignored.

This is specific to define semantics, and has nothing to do with prototype-COW or data-on-instance.

// Assignment is IoW, so v.?(.?) = 1 will only write to v. v.x = 1; assert(v.x === 1); //This, however, initializes x with a new copy of proto.a //and changes that new property instead of the prototype. x.a.delta = 4; assert(x.a.delta === 4);

These are completely incompatible. There is no distinction between v.x = 1 and a.delta = 4, the must do the same thing. Again, x.a.delta is completely unaware that a is a property of x.

So, if assignment were to actually mutate the object, we don't have COW anymore.

// Given mutation
new X().a.delta = 4;

assert(x.a.delta === 4);

// We have the same footgun, again.
assert(new X().a.delta === 4);

I've already found 1 error in my understanding thanks to you, but that 1 error is no where near sufficient enough to explain such a large deviation. There's got to be other rational reasons, other things I either don't know or have a wrong understanding of.

Do you understand how much time it takes to respond to your multiple issue threads? It's ad nauseam, with reopening discussions we've already settled on.

It's not an appropriate use of our time to work out pie-in-the-sky ideas, especially when it slows down progress on an already-stable, completely unrelated proposal.

rdking commented 5 years ago

It can't be fixed because it would fundamentally change the language.

We agree here, but it's not like there aren't available approaches to make this an "opt-in". It's also not like with the addition of data declarations in class, that the option becomes the default in the presence of those declarations. It's no different than class forcing "use strict".

There is no precedent for COW behavior you're asking for in any language I'm aware of.

Me either, but neither has any other language I'm aware of been in this particular predicament. Class templates are immutable in every language I can think of that supports them. That's why I think the prototype interface should be responsible for protecting the attached object unless that object is directly addressed.

This is specific to define semantics, and has nothing to do with prototype-COW or data-on-instance.

That's only half-true. It's specific to define semantics when used on the instance and not the prototype. When define semantics are used on the prototype, that problem doesn't occur, and you're only left with the well known, remediable foot-gun.

There is no distinction between v.x = 1 and a.delta = 4, the must do the same thing.

Now we're at the meat of it. Why must they do the same thing? Initially, x.hasOwnProperty("a") === false. This means the engine has to do extra work to retrieve a from the prototype object. Now imagine, if that extra work included returning a reference that the language treaded as === a but additionally:

It's the simple fact that ES puts in effort to hide the fact that a is not an own property of x unless you specifically ask that makes this a viable possibility.

Do you understand how much time it takes to respond to your multiple issue threads?

Yes, I do. And that's why I am so appreciative when one of you takes the time to respond (even if it doesn't always seem like it from the way I write). And while I understand the frustration you must be experiencing going over what is to you a closed issue, I'm sure you can likewise see similar frustration by those of us who see this proposal as being more damaging than useful. Even for as thorough as I'm certain you all were when making these decisions, I still think you either glossed over or failed to notice certain possibilities that would have rendered a technically better result. Almost as though it was intended to be proof, you have shown multiple times that you did not understand the concept I was trying to show you. That may be my fault, and if it is, I apologize for not being clear enough.

This is the reason I kept asking for over a year for someone to post a complete set of the requirements behind the decisions. The FAQ is not that complete set, not even in its current form. Had that been available, I am almost absolutely certain that I would have either been quieter, or managed to convince one of you that a serious miscalculation had been made... or maybe even both. As a programmer, it's always been my nature to try and stop problems I see coming before they arrive. The cost of this proposal is higher than the benefit it provides, even if not by much. However, there are ways to lower that cost without giving up on any of the requirements I'm aware of. That's why I'm confused and keep raising questions.

jridgewell commented 5 years ago

It's also not like with the addition of data declarations in class, that the option becomes the default in the presence of those declarations. It's no different than class forcing "use strict".

It's very different than either of these, it changes the fundamental MOP operations the language is built on. You're designing a brand new, not-JS language.

Now we're at the meat of it. Why must they do the same thing?

Because this is how the MOP is designed! The operation you're doing is a set a.x = 1 (aka, SET(base = a, property = x, value = 1). The get operations x.a is already done, nothing can change that. To be able to propagate a parent reference in this would completely change the way object operations are performed.

I still think you either glossed over or failed to notice certain possibilities that would have rendered a technically better result.

This is the culmination of years of discussions on different multiple ideas. There is not a better solution.

This is the reason I kept asking for over a year for someone to post a complete set of the requirements behind the decisions. The FAQ is not that complete set, not even in its current form.

It is complete. Ideas that are not relevant to the problem space (like COW) are not included because we they're not relevant.

rdking commented 5 years ago

Because this is how the MOP is designed!

Facts:

Conclusion: Still not worth it???

The operation you're doing is a set a.x = 1 (aka, SET(base = a, property = x, value = 1). The get operations x.a is already done, nothing can change that.

Again, you're showing you don't fully understand the suggestion. It's not [[Set]], but rather [[Get]] that would be altered, or more precisely OrdinaryGet(O, P, Receiver)

  1. Assert: IsPropertyKey(P) is true.
  2. Let desc be ? O.[GetOwnProperty].
  3. If desc is undefined, then a. Let parent be ? O.[[GetPrototypeOf]](). b. If parent is null, return undefined. c. Let value be ? parent.[[Get]](P, Receiver). d. Return GetObjectReference(value, Receiver);
  4. If IsDataDescriptor(desc) is true, return desc.[[Value]].
  5. Assert: IsAccessorDescriptor(desc) is true.
  6. Let getter be desc.[[Get]].
  7. If getter is undefined, return undefined.
  8. Return ? Call(getter, Receiver).

An ObjectReference is an exotic object that encapsulates 2 objects, one used as a data source for reading, the other used as a data sink for writing. All modifying Essential Internal Methods other than [[GetPrototypeOf]] are directed toward the data sink. All other operations are directed toward the data source. An object reference is always === to its data source. On retrieval of an Object (any object, array, or function) property from an ObjectReference, the value is likewise wrapped in an ObjectReference with the data sink being the originating ObjectReference. Thus, a write to a nested property of an Object reference causes a cascade, shallow copying each ObjectReference to its data sink until a non-ObjectReference data sink is reached.

GetObjectReceiver checks to see if Receiver is flagged for prototype protection. If it is not, Receiver is returned. Otherwise a new ObjectReference is created, with value as the data source and Receiver as the data sync, and returned.

I'm not sure I can be much clearer than that. If it's still not a good idea or too much of a change, or you still don't quite get it, then oh well. At least I tried. 😄

a-ejs commented 5 years ago

If you want to shoot yourself in the foot, you can already do it with the good-by-default semantics

that's very different from actually having data on the prototype, especially with fields using [[Define]].

There is no downside with the current design's installing data on the instance, and the upside is that everyone will understand it.

There is no upside to installing them on the prototype, and the downside is bugs.

?? this is obviously not true, there are countless downsides and upsides to both mentioned just in 'issues' section of this repo.

You're implying that anything that isn't a function is 'data', and vice versa, however that is not always the case.

In the end, 'class fields' bring very little benefit but have a huge cost. I can understand your position on prototype fields (even though I don't agree with it), but that doesn't mean that the opposite (instance fields) should be added to the language.

rjgotten commented 5 years ago

@jridgewell Every single class based framework in popular use assigns data to the instance as the best practice. Is there any example that does what you're suggesting? If not, why would we design a new feature that breaks with what we know is the best practice?

FYI: https://v5.canjs.com/doc/can-define/map/map.html

This is the CanJS framework's core means to build observable data models. It declares specs including type converters, getters/setters; serializers; etc. on the prototype chain and then instantiates those as getter/setter on individual instances.

As observability hooks involve a substantial cost to spin up, the initial accessors are lazy and the real observable access logic isn't hooked up until first access of the property. This requires the specs to stick around and be accessible (and referenceable) via the prototype chain, while lazy dummy accessors are already present on the instances.

And ofcourse: having the property specs present on the prototype is necessary to inherit them to further subclasses, rather than the (instance-specific) accessors created from the specs.


This probably is far from the only framework which takes such an approach. (This practice has been around for a long time.)

Also take note that attempting to naively use class __ extends __ on constructor/prototype based 'classes' from such frameworks -- which is bound to happen at one point with developers less versed in the nitty-gritty details of ES classes and class fields -- will lead to awkward bugs and developer headache. On the part of the developer using the framework as well as the developer maintaining such a framework, who will end up having to deal with false positive bug reports and support questions for those cases.

Which actually takes me back to another remark you wrote earlier:

Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.

In light of what I wrote above, class fields don't fit that mold. They fly counter to the entire notion of the prototype chain and introduce further incompatibility between Constructors and Classes. They widen the schism between new and existing code-bases; libraries and frameworks and further contribute to rising debt and baggage by requiring a strict duality of separated ES class and prototypal code, rather than being able to harmonize the two.

jridgewell commented 5 years ago

This is the CanJS framework's core means to build observable data models. It declares specs including type converters, getters/setters; serializers; etc. on the prototype chain and then instantiates those as getter/setter on individual instances.

The accessors are stored on prototype, but the data isn't. @rdking is suggesting we store the data on the prototype.

const MyType = DefineMap.extend({ prop: 'string' });

Object.getOwnPropertyDescriptor(MyType.prototype, 'prop');
// => { get: f(), set: f() }

const m = new MyType({ prop: 'foo' });
Object.getOwnPropertyDescriptor(m, '_data');
// => { value: { prop: 'foo' } }

This is very different than OP's proposal.

rjgotten commented 5 years ago

The accessors are stored on prototype, but the data isn't.

But the accessor specs are data. They're plain objects following a particular duck-typed interface. Regardless of whether data is per-instance 'user' data or that data is prototypal 'framework' data shared by all instances, it's still just data.

And this type of pattern pre-existing in constructor/prototype based code is impossible to hook into with class __ extends __ due to avoiding the prototype. Said avoidance of the prototype 'foot-gun' is as much a practical problem here as it seems to be an ideological one for @rdking.

I'm not asking for prototypal properties to be the default over instance initializer properties though. Instance initializer will probably be by far the more common case moving forward in modern codebases.

But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

class Example {
  foo = "goes to instance";
  proto bar = "goes to prototype";
}

Or any other syntactical means to accomplish the same where instance properties are the preferred path.

jridgewell commented 5 years ago

But the accessor specs are data.

They are very different. Accessors are immutable functions, the same as regular methods, and we place them on the prototype because sharing them has no downside but has a significant upside (reduced memory).

Accessors are just a fancy way of having this.setFoo(value) and this.getFoo() methods under a unified this.foo property name.

And this type of pattern pre-existing in constructor/prototype based code is impossible to hook into with class extends due to avoiding the prototype But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

We have accessors in the spec, they've been there since ES6.

Again, notice that this._data isn't an accessor. It's an instance property, as it should be.

bakkot commented 5 years ago

@jridgewell By "accessor specs" I am pretty sure @rjgotten means "regular objects on the prototype, which accessors read from".

@rjgotten

But really; would it have been so bad to introduce syntax so you could also have prototype properties? E.g.

Yes, it would have. It's too big of a footgun for the language to encourage it with explicit syntactic support.

It's still easy enough to do it if you really want to:

class Example {
  foo = "goes to instance";
}
Example.prototype.bar = "goes to prototype";

but it is important that prototype-placed data properties be harder to reach for than regular properties or accessors or methods, because they are much more likely to trip people up.

rdking commented 5 years ago

@bakkot

It's still easy enough to do it if you really want to:

Wrong yet again.

class Example {
  #foo = "goes to instance";
}
Example.prototype.bar() {
   console.log(`(private this).foo = ${this.#foo}`);
}

That dog won't hunt.

bakkot commented 5 years ago

@rdking If you want to put a method on the prototype, we have syntax for that:

class Example {
  #foo = "goes to instance";
  bar() {
     console.log(`(private this).foo = ${this.#foo}`);
  }
}

It is specifically the case of putting data properties on the prototype which (intentionally) lacks explicit syntactic support.

rdking commented 5 years ago

Here's the part that's getting to me. Nothing in ES before this point made any real distinction regarding what could be stored in a prototype object. Absolutely nothing. Now, however, due to the fact that some developers are so appalled by the

now suddenly TC39 is taking steps to perform type discrimination in ES, a weakly typed language. The simple fact that functions are 1st class objects in ES means that a function IS data. Try code written like this:

class Example {
  get someProp() { return Object.getOwnPropertyDescriptor(this, someProp).get.someProp; }
  set someProp(v) { Object.getOwnPropertyDescriptor(this, someProp).get.someProp = v; }
}

Perfectly valid code, yes? Data lives in an object on the prototype, yes? Functions are data. The mere existence of static properties is proof of this. It feels as though 2 different philosophies are being preached at the same time with this proposal. I'm lacking the understanding of how T39 can see this and know this, yet still claim it doesn't matter.

rjgotten commented 5 years ago

It's still easy enough to do it if you really want to:

class Example {
  foo = "goes to instance";
}
Example.prototype.bar = "goes to prototype";

At that point, what's the added value of people using class as opposed to whatever class-like inheritance scheme is built-in to the prototype/constructor based framework they're already using? The transition path towards adoption of classes will literally take more effort at that point than just to keep using constructors.

In other words, if the idea behind:

it is important that prototype-placed data properties be harder to reach for than regular properties or accessors or methods, because they are much more likely to trip people up.

-- is that it should discourage complexity of prototypal code over class-based code, then making that transition towards classes harder is going to backfire on you...

bakkot commented 5 years ago

At that point, what's the added value of people using class as opposed to whatever class-like inheritance scheme is built-in to the prototype/constructor based framework they're already using?

The added value of class is for the overwhelming majority of users who do not need to put data properties on the prototype.

is that it should discourage complexity of prototypal code over class-based code

It is not to discourage prototypal code. (Classes are prototype-based; they are mostly just declarative sugar.) The point is to discourage specifically putting data properties on the prototype, which is a thing very few libraries or codebases are currently doing and which most that are have lived to regret.

I accept that some people who are currently putting data properties on the prototype may avoid class because it does not have an easy affordance for doing so. It is more important to me that new users of the language not be misled into thinking that's a good idea in typical circumstances.

rdking commented 5 years ago

Again with the differing perspectives. The class keyword already forces us into strict mode. Why not force in a fix for the prototype problem when data properties are used? It's already simple enough to avoid the prototype if that's what's desired. Making it any easier doesn't solve any problems.

Making it so that prototype properties are safely implemented wouldn't fundamentally alter the language any more than forced strict mode does. Yet doing so would encourage creation of classes that always work properly regardless of what's in them. Between the two choices, I don't understand why you'd choose to break something while trying to get other's to avoid a pitfall, when you can simply fix the pitfall for the one case where people can fall in while you've got the opportunity to do so.

ljharb commented 5 years ago

Making it so that prototype properties are safely implemented wouldn't fundamentally alter the language any more than forced strict mode does.

It very much would; strict mode alters the behavior of your code, much like syntax sugar; what you suggest would alter how other code interacts with objects your code produces.

rdking commented 5 years ago

That was true of some prior suggestions of mine, but not of the suggestions I made in this thread. Go back and read the OP.

Now, if declaring a class property placed that property on the prototype in a way that the engine would monitor so that a change would trigger re-creation of the initializer that sets the instance-specific copy, then we'd both have what we want. The rule of law for the class would be the prototype, and every instance would have its own copy.

This would fix many of the issues I have with this proposal without forcing you to give up on anything you want. No more weird breaks around inheritance. That's a good thing, right?

rdking commented 5 years ago

@ljharb

what you suggest would alter how other code interacts with objects your code produces.

Btw, class-fields already does this by disturbing the inheritance process. If this is your criteria for exclusion due to fundamental alteration, then this proposal is already something that should be excluded. Any code using this proposal has a potentially negative impact on any other code that uses it. The suggestion in the previous post can remedy this.

rjgotten commented 5 years ago

(Classes are prototype-based; they are mostly just declarative sugar.)

Keyword: mostly

It's that 'mostly' where the opposition to this proposal for class fields is coming from...

Exceptional cases and gotchas originating from maintaining a dual inheritance mechanism with slightly different semantics -- those sound like a bigger long-term foot-gun to me than what's currently being worked around - not solved - by the proposal.

nicolo-ribaudo commented 5 years ago

I don't think that they are just "mostly" syntax sugar: they are fully syntax sugar. If classes added a real new functionality to the language, it wouldn't be possible to properly transpile them.

Classes make the language easier to use, but don't add any new possibilities.

rdking commented 5 years ago

@nicolo-ribaudo @ljharb said it right. They are "mostly" syntax sugar. There's a few things that are different about class that just don't happen when using a regular constructor function. Those are all internal details though. One of the biggest is that for some class Foo, Foo(); cannot even call the constructor at all if not triggered by new. In the transpiled version, Foo can indeed be called. However, the first code executed is if (!new.target) throw. That doesn't change that Foo was actually entered.

Just because the final semantics are the same doesn't mean the same approach was taken to get there. That's why "mostly" is the most accurate way to put it.

rjgotten commented 5 years ago

I don't think that they are just "mostly" syntax sugar: they are fully syntax sugar.

No, to qualify as 'fully' sugar the semantics need to be functionally the same as normal prototypal inheritance and constructor functions. A nicer coat of paint on top of the ground layer. And they aren't. There are already semantic differences between classes and constructors. E.g. one can be called as a regular function without new and become a factory; the other cannot. Class fields are going to drive a deeper wedge.

nicolo-ribaudo commented 5 years ago

I think that

class Foo {
  constructor(PARAMS) {
    BODY;
  }
}

is 100% equivalent to

let Foo = function() {
  if (!new.target) throw new TypeError;
  return ((PARAMS) => {
    BODY;
  })(...arguments);
}
Object.defineProperty(Foo, "prototype", { writable: false });

If you call them as Foo(), 1) Both will throw a TypeError, neither one can be used as a factory. 2) Both will throw before evaluating any expression in the function parameters, effectively not entering what is used as the constructor function.

That said, I don't think that @rdking's comment about "Foo is entered anyway" disqualifies it from being syntax sugar, because there isn't anything in the specification which makes it observable.

ljharb commented 5 years ago

@nicolo-ribaudo new.target didn’t exist before class, so you can’t use it as a transpilation target; but also, extending builtins is not possible without class extends. You’re correct that new.target offers non-class constructors the “throw on call” functionality; but not the “install builtins’ internal slots on the instance”, which can only be otherwise achieved with the return override trick and Object.setPrototypeOf or __proto__, neither of which existed in the standard prior to class.

However, whether it’s 100% or 99.9999% syntax sugar doesn’t alter the discussion around class fields, as the difference is exceedingly minor and doesn’t have any relevance to the specific things some people have taken exception to.

rjgotten commented 5 years ago

The thing I'm taking exception to is specifically that currently the gap is minor; but this proposal is going to make it bigger due to how it handles non-function members.

But maybe the whole debate can be side-stepped in a different way: don't call this a proposal for class fields. Call it what it is: a proposal for instance field initializers. Stop the false assumption that ES classes support non-function members, like prototypes do.

ljharb commented 5 years ago

the word “fields” already includes “is an instance initializer”; non-function members are a subset of “prototype properties”, which this feature isn’t named.

Prototypes (and objects) support a number of things that there’s no class syntax for, like enumerability, nonwritability, and nonconfigurability, as well as prototype data properties.

rdking commented 5 years ago

@ljharb

Prototypes (and objects) support a number of things that there’s no class syntax for, like enumerability, nonwritability, and nonconfigurability, as well as prototype data properties.

All of which are shortcomings of the current proposal but not incompatible with it. One of the problems I have with fields is that they are supposedly part of the class but do not exist until instantiation. That means that unlike everything else in a class up to this point, there's no way to change what the instantiation will produce without re-defining the class. This takes away from the highly useful dynamic nature of a class.

If instead, class worked by:

Then proceeded to run the then current initializers on construction of an instance as is the case with the current proposal, then we'd both have everything we want with no real reason to object, and no odd issues like base classes overriding derived classes.

ljharb commented 5 years ago

This is also true of contents of the constructor body, which class instance fields act as if they are part of (conceptually, ofc, since constructor arguments and a few other things aren’t present, but please don’t get in the weeds on this)

rdking commented 5 years ago

@ljharb

This is also true of contents of the constructor body, which class instance fields act as if they are part of...

We agree here. Maybe from this you can understand my point of view. What a factory does to the instance is not guaranteed to be part of every instance because the factory can contain conditional logic. As such, what a constructor adds to/removes from an instance cannot be considered part of the class, only part of the instance. By contrast, a class definition should define things that are guaranteed to initially be part of every instance of the class, even if the constructor later removes it or hides it. So from where I sit, "class fields" is a conceptual and syntactic oxymoron.

I have no problems with you wanting all members of a class to appear on the instance object so you can use the Object.<someMethod> tools reliably. However, that's just not a good enough reason to implement an approach that violates the fundamental principles of a "class" while trying to enhance class. This is especially true when, as I pointed out in my previous post, there's no reason why we can't both have what we need.