-
Notifications
You must be signed in to change notification settings - Fork 113
Could we have avoided "fields"? #274
Comments
This seems like a great idea! |
It's a huge reason. But placing properties on the instance is the expected behavior for people migrating from imperative
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. |
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.
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. |
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". |
The language default is
There is no COW behavior… Mutate it once an every instance is screwed. Properties can be shadowed by instance properties.
How is this sane? Instance data does not belong on the prototype, only methods. |
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.
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?
Part of my stance is that (assuming
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. |
Absolutely not. Our responsibility is to design a language that works. Codifying something we know is harmful is the opposite of progress.
We've known about this since the Backbone days (2012 when I started using it), and Ember.Object days.
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.
Overshadowing is not COW. If
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.
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 Every single class based framework in popular use assigns data to the instance as the best
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. |
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);
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 Is there any C-derivative language that has this "feature"? |
... and yet, this proposal...
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.
I can accept that.
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.
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 😃. Further, you can't do
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:
If the first 2 reasons had been dealt with directly when
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 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.
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 |
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.
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.
This is specific to define semantics, and has nothing to do with prototype-COW or data-on-instance.
These are completely incompatible. There is no distinction between 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);
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. |
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
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.
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.
Now we're at the meat of it. Why must they do the same thing? Initially,
It's the simple fact that ES puts in effort to hide the fact that
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. |
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.
Because this is how the MOP is designed! The operation you're doing is a set
This is the culmination of years of discussions on different multiple ideas. There is not a better solution.
It is complete. Ideas that are not relevant to the problem space (like COW) are not included because we they're not relevant. |
Facts:
Conclusion:
Again, you're showing you don't fully understand the suggestion. It's not
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
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. 😄 |
that's very different from actually having
?? 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. |
FYI: 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. Also take note that attempting to naively use Which actually takes me back to another remark you wrote earlier:
In light of what I wrote above, class fields don't fit that mold. |
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. |
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 I'm not asking for prototypal properties to be the default over instance initializer properties though. 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. |
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
We have accessors in the spec, they've been there since ES6. Again, notice that |
@jridgewell By "accessor specs" I am pretty sure @rjgotten means "regular objects on the prototype, which accessors read from".
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. |
Wrong yet again. class Example {
#foo = "goes to instance";
}
Example.prototype.bar() {
console.log(`(private this).foo = ${this.#foo}`);
} That dog won't hunt. |
@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. |
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. |
At that point, what's the added value of people using In other words, if the idea behind:
-- 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... |
The added value of
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 |
Again with the differing perspectives. The 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. |
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. |
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 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? |
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. |
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. |
I don't think that they are just "mostly" syntax sugar: they are fully syntax sugar. Classes make the language easier to use, but don't add any new possibilities. |
@nicolo-ribaudo @ljharb said it right. They are "mostly" syntax sugar. There's a few things that are different about 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. |
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 |
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
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. |
@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 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. |
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. |
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. |
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 If instead,
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. |
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) |
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 |
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.
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.
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.
The text was updated successfully, but these errors were encountered: