New: proposal-safe-prototype

# Ranando King (6 years ago)

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

# Jordan Harband (6 years ago)

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.

# T.J. Crowder (6 years ago)

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

# Ranando King (6 years ago)

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
# Jordan Harband (6 years ago)

The behavior of everything currently used with new would need to be preserved, too - including class.

# T.J. Crowder (6 years ago)

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

# Ranando King (6 years ago)

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.

# T.J. Crowder (6 years ago)

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 of new.

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

# Ranando King (6 years ago)

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
# Jordan Harband (6 years ago)

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.

# Ranando King (6 years ago)

Is this to say that no new feature is allowed to introduce breaking changes in existing code?

# Ranando King (6 years ago)

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?

# Jordan Harband (6 years ago)

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.

# Ranando King (6 years ago)

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.
# Claude Pache (6 years ago)

Sorry, I didn’t read the thread thoroughly (so maybe I’ll repeat someone). But here are some serious issues with your proposal:


  1. 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; // ???

  1. 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 the RandomUserDefinedCollection class.
# Ranando King (6 years ago)

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.