New: proposal-safe-prototype
Regarding modes, you may wish to read esdiscuss.org/topic/no-more-modes which indicates the general desire to never again add any pragmas like strict mode.
From the proposal:
.,..some of which attempt to obviate the use of the prototype for its intended purpose as the template for an instantiation by new...
I wouldn't say that's "the intended purpose" of a prototype at all. That's more a class-centric view of prototypes. Prototypical inheritance is different from class-based inheritance. Prototypes aren't templates, they're living objects.
If you want every instance of a class to have an object property that you can modify in a per-instance way, make that object property an object backed by a prototype:
class Example {
constructor() {
this.x = Object.create(this.x);
}
}
Example.prototype.x = {
y: 0
};
const e1 = new Example();
const e2 = new Example();
console.log(e1.x === e2.x); // false
console.log(e1.x.hasOwnProperty("y")); // false
console.log(e2.x.hasOwnProperty("y")); // false
console.log(e1.x.y); // 0
console.log(e2.x.y); // 0
e1.x.y = 1;
console.log(e1.x.hasOwnProperty("y")); // true
console.log(e2.x.hasOwnProperty("y")); // false
console.log(e1.x.y); // 1
console.log(e2.x.y); // 0
Once decorators are up and running, doing this could be a decorator, though I submit it's an edge case.
-- T.J. Crowder
I've always understood the purpose of a prototype to be a means of
including pre-defined data and behavior in an object. Classes are just a
convenient means of defining those things in a prototype-oriented language,
and from a class POV, the prototype serves the role of the class template.
You did just make me think of something though. In the interest of not
adding another pragma, this feature could, and probably should be limited
to just the prototypes of objects created with the new
keyword. This
would leave objects created manually alone, preserving the old behavior for
object literals and factory functions.
As for your suggestion, that doesn't work when the element being changed is on a sub-property of the object.
class Example {
constructor() {
this.x = Object.create(this.x);
}
}
Example.prototype.x = {
y: {
a: 0,
b: 1,
c: 2
}
};
const e1 = new Example();
const e2 = new Example();
console.log(e1.x === e2.x); // false
console.log(e1.x.hasOwnProperty("y")); // false
console.log(e2.x.hasOwnProperty("y")); // false
console.log(e1.x.y.c); // 2
console.log(e2.x.y.c); // 2
e1.x.y.c = 4;
console.log(e1.x.hasOwnProperty("y")); // false
console.log(e2.x.hasOwnProperty("y")); // false
console.log(e1.x.y); // 4
console.log(e2.x.y); // 4
The behavior of everything currently used with new
would need to be
preserved, too - including class
.
On Tue, Nov 27, 2018 at 3:24 AM Ranando King <kingmph at gmail.com> wrote:
...and from a class POV, the prototype serves the role of the class template...
Not in a prototypical language. Again, prototypes are living objects (even though lots of code doesn't make use of that fact). Code that does isn't uncommon, and can't just suddenly work differently.
As Jordan said, new
can't be changed in an incompatible way.
As for your suggestion, that doesn't work when the element being changed is on a sub-property of the object.
Depending on the use case, you just repeat the pattern, or use a Proxy, or...
-- T.J. Crowder
Not in a prototypical language. Again, prototypes are living objects (even though lots of code doesn't make use of that fact).
The fact that the prototype is a 1st class, (usually) mutable object
doesn't change the fact that it is a template. Templates come in many
forms, not all of which are of the "rubber stamp" variety. The design of ES
is such that for any given object, all properties of its prototype behave
as though they are own properties (var a = {__proto__: {b: 42}};
-> a.b === 42;), even though they're not (
Object.keys(a).length === 0`). Further,
any attempt to change the top level value of any prototype property through
the object causes an own property to be created on the object instead of
modifying the prototype. It is this behavior that I'm referencing when I
say that a prototype serves the role of a template for classes in a
prototype-oriented language. When you want a property to exist in every
instance of the class, you can either manually create the property in the
constructor, or you can put it on the prototype and rest assured that every
new instance will behave as though it has its own copy (unless the value is
an object).
As for changing new
in an incompatible way, doesn't represent a
significant or incompatible change in the behavior of new
. The only thing
different new
will do is mark an internal slot on the newly created
object, flagging it to use the new behavior when accessing __proto__
.
There is no other behavioral change for new
. The real behavior of this
proposal happens when accessing an object data property from the flagged
object's prototype.
Depending on the use case, you just repeat the pattern, or use a Proxy,
or...
Let's say you've got some heavily nested structure on a prototype property. Repeating this process for every nested object in the structure could be excessively time consuming and certainly an exercise in tedium to write. What if the language did it for you?
- On one extreme, you get the public-fields proposal. That just introduces new foot guns for very little gain.
- In the middle, you've got values applied to the prototype, and then applied again to the instance in the constructor. That blows the point of having mutable prototypes.
- On the other extreme, you put properties on the prototype only. Then have the language treat changes to the object property's structure or data as a cue to copy.
This proposal is essentially what you're describing, except carried out by
language itself using data and structure mutations as the trigger. In all
honesty, I would really like to use your Object.create
approach instead
of Object.assign
, but since the object property may have a structure
including needed prototypes, and the original cannot be modified, that idea
won't work. The problem with your approach is that it doesn't account for
cases where the sub-property is on a prototype of a sub-object.
On Tue, Nov 27, 2018 at 3:34 PM Ranando King <kingmph at gmail.com> wrote:
The fact that the prototype is a 1st class, (usually) mutable object doesn't change the fact that it is a template.
It fundamentally does, calling prototypes templates rather short-changes them. Again, they're live objects:
class Example {
}
const e = new Example();
console.log(e.foo); // undefined
Example.prototype.foo = "bar";
console.log(e.foo); // "bar"
(jsfiddle.net/pot8cdq6) A template wouldn't demonstrate that sort of behavior. Perhaps it's just a semantic point, though.
As for changing
new
in an incompatible way, doesn't represent a significant or incompatible change in the behavior ofnew
.
Of course it does. If it didn't, it wouldn't solve the problem you describe wanting to solve. Or was there some opt-in (other than the pragma) that I missed? The problem you describe is perfectly valid current code:
class Example {
}
Example.prototype.sharedObject = {
counter: 0
};
const e1 = new Example();
const e2 = new Example();
console.log(e2.sharedObject.counter); // 0
++e1.sharedObject.counter;
console.log(e2.sharedObject.counter); // 1
(jsfiddle.net/m49jsxof) Not something you'd want to do often, but perfectly valid and I expect there are use cases for it, which changing it would break.
Re the rest: Yes, it's complicated to solve for nested properties. But again, you just repeat the pattern, and/or use a Proxy; you can certainly preserve prototypes as needed. The way in which you do so will vary dramatically depending on what your use case is and how much you want to copy, etc.
I certainly don't see adding new semantics to new
. I could see a library
function setting things up for you, but I think the patterns would be so
project-specific that it's unlikely to go into the standard library.
I'll step back at this point.
Best,
-- T.J. Crowder
Not something you'd want to do often...
Or ever. This is the foot-gun behavior. The same result can be achieved with a simple factory class.
class Example {
//Don't use "this". It was flagged to use the updated prototype behavior.
return Object.create(Example.prototype);
}
Example.prototype.sharedObject = {
counter: 0
};
const e1 = new Example();
const e2 = new Example();
console.log(e2.sharedObject.counter); // 0
++e1.sharedObject.counter;
console.log(e2.sharedObject.counter); // 1
This is what I meant when I said that the existing behavior isn't lost. There are still plenty of ways to achieve the foot-gun behavior if that is what's desired. What this proposal seeks is a means of making the most common path foot-gun free.
Besides, a cleaner result can be achieved by using a static property.
class Example {
}
Example.counter = 0;
const e1 = new Example();
const e2 = new Example();
console.log(e2.constructor.counter); // 0
++e1.constructor.counter;
console.log(e2.constructor.counter); // 1
What I meant by "preserve existing behavior" is that all current code must retain the footgun. Any chance must only apply to new code that explicitly opts in to it.
Is this to say that no new feature is allowed to introduce breaking changes in existing code?
What if, instead of having new
introduce a flag on an internal slot, we
define a new "well known" Symbol:
Symbol.SafeProto = new Symbol("SafeProto");
such that if this Symbol is a property of an object being used as a prototype (regardless of its value), then that prototype object exhibits the new behavior. Will that suffice?
Correct, existing code used with existing code can't break. It's totally fine for existing code that works with objects produced by new code to break.
Ok. I just updated the proposal.
Since T.J. felt so strongly about the use of the word "template", I removed
it from the proposal. No need to raise unnecessary ire.
Since using new
to trigger the behavior is a no-go just like pragmas, I
introduced 2 new well known Symbols.
Symbol.SafeProto = Symbol("SafeProto")
to mark objects that should exhibit the new behavior.Symbol.UnsafeProto = Symbol("UnsafeProto")
to mark objects that must not exhibit the new behavior.
Sorry, I didn’t read the thread thoroughly (so maybe I’ll repeat someone). But here are some serious issues with your proposal:
- Assignment of properties deep in the object hierarchy split in two instructions:
var a = { __proto__: { foo: { bar: 2 } } } with safe-prototype;
var b = a.foo;
b.bar = 3;
Should the last line trigger copy-on-write around a.__proto__
?
If you say ”no”, it means that the meaning of the code is changed unexpectedly with an apparent innocuous code refactoring.
If you say ”yes”, it is dreadful action-at-distance, because the two last lines of code may be very distant, indeed even in two distinct files. — It would be less an issue if prototypes were, say, just abstract templates. But they are not, they are concrete objects, so:
var proto = { foo: { bar: 2 } };
var a1 = { __proto__: proto } with safe-prototype;
var a2 = { __proto__: proto } with safe-prototype;
var b1 = a1.foo;
var b2 = a2.foo
b1 === b2; // true
b1.bar = 3;
b1 === b2; // ???
- Object mutations unrelated to the value of its properties:
var a = { __proto__: { foo: new Map([ [ 'bar', 2 ] ]) } } with safe-prototype;
a.foo.set('bar', 3);
Unless you have deep knowledge of the internal state and/or the implementation of the modified object (which you don’t have in case of a user-implementated one), you cannot reliably detect when an object is mutated.
Note the subtle difference between:
[].push(1)
— You will detect object mutation, because it will add a concrete property on the Array object.(new Set).add(1)
— You won’t detect mutation, because only internal state of the Set object is modified.(new RandomUserDefinedCollection).put(1)
— That depends on the implementation of theRandomUserDefinedCollection
class.
Should the last line trigger copy-on-write around
a.__proto__
?
Yes. I get that you think of this as "dreadful action-at-a-distance", but
that kind of thing already happens any time the primitive value (including
root-level object references) of a property on the prototype changes. The
intent of this proposal is to treat the whole of the object that exists on
a property of __proto__
as if it were a primitive. Any attempt to change
it in part or whole forces it to become an instance-specific value first.
The technique used in the example of this proposal ensures that object
reference in b
isn't invalidated in the process.
The net result is that you don't get to accidentally mutate a prototype. If you want to change some value in a prototype, get access to it directly first. Using your example, do this:
var a = { __proto__: { foo: { bar: 2 } } } with safe-prototype;
var b = Object.getPrototypeOf(a).foo;
b.bar = 3;
This would skip the new behavior, causing the prototype to be modified directly.
Unless you have deep knowledge of the internal state and/or the
implementation of the modified object (which you don’t have in case of a user-implementated one), you cannot reliably detect when an object is mutated.
I accounted for that with Symbol.UnsafeProto. All prototypes of native functions will have this flag on them, preventing any attempt at the new behavior. Further, any behavior that cannot be caught by a Membrane is beyond the scope of this effort. Given the insanely flexible nature of ES, it's unrealistic to thing that 100% of all possible implementation details can be tracked. However, what this proposal seeks to provide is elimination of the foot-gun for the most common cases. Even in non-interpreted languages, some objects are impossible to clone completely due to the use of external resources. This can't be helped.
For objects having such complicated implementations, the developer will always be able to add Symbol.UnsafeProto to ensure that no attempt to duplicate it ever occurs. Proper use of the 2 symbols will ensure that whenever reasonable, the foot-gun cannot appear.
The general idea is to eliminate the foot-gun of objects on a prototype by providing copy on write semantics to properties on the prototype that contain an object. The property must be a data property. Accessors and functions are ignored.
rdking/proposal