proposal-record-tuple icon indicating copy to clipboard operation
proposal-record-tuple copied to clipboard

Should we allow the wrapper object to have additional symbol properties

Open rricard opened this issue 5 years ago • 46 comments

While writing spec text we found out that we could either allow writing new arbitrary props to the exotic record boxing object or simply disallow them. The champion group would like to do the latter but if there are objections, we're interested to have them here.

Here is an example that we would want to disallow:

var o = Object(#{});
o.foo = true;
o.foo // => true

The main rationale against disallowing this would be that this is acceptable with a String for instance:

var o = Object("a");
o.foo = true;
o.foo // => true

This should be decided before Stage 3.

cc. @ljharb

rricard avatar Jun 30 '20 16:06 rricard

Why would you prefer to disallow it?

Primitives can be as exotic as desired, but it seems preferable to minimize the ways in which objects - even boxed primitives - are exotic.

ljharb avatar Jun 30 '20 16:06 ljharb

Additionally, if I can't set Symbols on a boxed Record object, then I can't opt them into any protocols, which is pretty important.

ljharb avatar Jun 30 '20 16:06 ljharb

To me it's in order to avoid any mistake of having a boxed record and starting to see I can mutate it. I don't see many people will wrap their Record into their boxing objects though and passing them so honestly I'm not against going back and changing this. I'd still like to wait next week to hear @littledan's opinion on this however.

rricard avatar Jun 30 '20 16:06 rricard

Additionally, if I can't set Symbols on a boxed Record object, then I can't opt them into any protocols, which is pretty important.

That might do it to reverse that decision, still invoking @rickbutton for a second champion opinion

rricard avatar Jun 30 '20 16:06 rricard

I don't really have a strong opinion on this either way. I can see the argument against allowing adding string properties to a boxed Record, for the same reason that boxed Records have a null prototype - that we don't want to allow Records to surface string-properties that don't come from the [[RecordData]] itself. However, attaching symbols to boxed records seems like a reasonable desire to me.

There was some previous discussion about these ideas in this thread: https://github.com/tc39/proposal-record-tuple/issues/71#issuecomment-633894383 (although, not for new-properties on a boxed Record, but instead for the prototype).

rickbutton avatar Jun 30 '20 17:06 rickbutton

Personally, I'm pretty opposed to permitting additional properties on Record wrappers. The domain of Records is their data properties, and it would be rather broken to have to think about whether the property is on the Record or its wrapper. Being frozen is not very exotic, and it corresponds to the normal mental model around Records and Tuples, so it's unsurprising.

@ljharb Could you explain the part about presence in protocols? It's very hard for me to understand how this would be useful, given that the underlying Record will not have the symbol properties, and the Record wrapper will not act like Records in key ways (e.g., === is broken).

littledan avatar Jul 06 '20 21:07 littledan

That's a fair point that it'd be awkward to make a wrapper object just to install Symbols, and I just realized, and filed #142, about the seeming current inability to extract a primitive Record from a boxed Record object - which would be strictly necessary, both for "boxing the primitive for the purposes of protocol participation, and in general. I think it might make sense to hold off further discussion on this issue until that's resolved, since I'm somewhat convinced that the only outcome of it is Record.prototype, which would require exoticness so that string properties on boxed objects weren't possible, and then adding custom Symbols to a boxed object or to Record.prototype would seemingly Just Work.

ljharb avatar Jul 08 '20 06:07 ljharb

and then adding custom Symbols to a boxed object or to Record.prototype would seemingly Just Work.

I think this comment is mixing together two different things. One question is whether Record and Tuple wrappers are frozen, and another thing is whether we defer to Record.prototype on access of Symbol-keyed properties. Mechanically, they're unrelated. I think it's important that Record and Tuple wrappers are frozen, but I'm open to Record property access deferring to Record.prototype for Symbol properties.

littledan avatar Jul 14 '20 11:07 littledan

If Record.prototype is a mutable object, then the use cases for mutating an individual wrapper are certainly fewer - but i don't see why these should be the only primitive wrapper objects that are frozen, and there still are use cases where I'd want to opt a wrapper into a protocol without opting in every wrapper object.

ljharb avatar Jul 14 '20 22:07 ljharb

It is still possible to opt-in an immutable wrapped Record into a protocol with a Proxy.

rickbutton avatar Jul 14 '20 23:07 rickbutton

No, it’s not, because the Proxy wouldn’t have the internal slots of a Record, so unboxing would fail.

ljharb avatar Jul 15 '20 03:07 ljharb

That's true, it wouldn't have the internal slots of a Record. But a Record wrapper that you could add other properties to wouldn't act like a Record, e.g., with ===. Overall, I'm still having trouble understanding when you'd want to use this construct.

littledan avatar Jul 20 '20 11:07 littledan

Right, but wrapped[Symbol.toPrimitive]() is a cheap call, and would work with ===, while wrapped could still contain extra data via regular properties.

ljharb avatar Jul 20 '20 18:07 ljharb

Could you give an example of how this would use in practice? I can imagine a program using this corner of the language, but I can't understand why anyone would want to write it.

If you're going to explicitly unwrap it for some operations, it's unclear to me what the problem is with Proxy-wrapping.

littledan avatar Jul 20 '20 19:07 littledan

You can unwrap a boxed primitive; you can't unwrap a Proxy around one (because Record.prototype[Symbol.toPrimitive].call(proxy) would always throw.

I don't have a concrete example to offer - i'm coming from a position of maximizing composability and consistency with the way primitives and objects and internal slots already work. I'm also thinking vaguely in terms of what tricks might be employed by a R&T polyfill, and I don't want to discover too-late that something that I'd expect to work, doesn't :-)

ljharb avatar Jul 20 '20 20:07 ljharb

Yes, I can see how the difference is observable. I'd like to understand more what you'd like to compose this with. I agree that it's important to follow typical patterns in the language, and I think "behaving like a frozen object" achieves that. I don't think Records and Tuples need to slot into all existing cases which expect mutable, extensible objects, since the whole point of Records and Tuples is to be deeply immutable, but I'm open to being convinced with examples until Stage 3.

littledan avatar Jul 21 '20 18:07 littledan

I remain confused by the arguments above for allowing additional properties on Box wrappers. I think we should conclude that Box wrappers are also prevent-extensions, like Record and Tuple wrappers.

Edit: Oh, wow, I completely misunderstood this thread when re-skimming it. Anyway, I still agree with my previous comments above 😇 and would prefer to settle this issue on the frozen-wrappers side. Note that if we move to identityless objects, the question is moot--they must be frozen.

littledan avatar Aug 09 '21 08:08 littledan

In the context of R&T as identity-less objects, as @littledan mentions the question would be moot. There would be no object wrapper, the R/T would be the object, which would be frozen and prevent any extension.

In the context of identity-less objects I was suggesting that proxies of R&T would behave like regular objects. However I don't think that would solve @ljharb's use case with adding properties to a wrapper of R/T, since with a frozen target, all properties would need to exist on the shadow target. However I'm still not sure I understand the motivation to extend a R/T that way.

mhofman avatar Aug 12 '21 15:08 mhofman

After implementing R&T in SpiderMonkey, I'm in favor of letting the object wrappers to be mutable (as they are for other primitives).

There are many places where the spec calls ToObject on a generic ECMAScript value, and this means that object wrappers are frequent. They can be optimized away in hot paths, but optimizing them away everywhere introduces a complexity that might not be worth the performance improvement.

When implementing R&T wrappers, I used the same approach already used for string wrappers (since strings are the only existing primitive with own properties): Object(tuple) returns an empty object, and its properties are lazily defined only when you try to observe them.

For example, this is how the wrapper object internally transitions between the different shapes:

let o = Object(#{ x: 1, y: 2, z: 3 }); // o is {}
o.x; // o is { x: 1 }
o.w; // o is { x: 1 }
delete o.y; // o is { x: 1, y: 2 }

This is not observable from JS since properties appear as soon as you try to observe them, but gives a big advantage by making Object(record) an O(1) operation rather than an O(n) operation, since it doesn't require to eagerly copy all the properties.

If object wrappers were not extensible, this would be harder to accomplish. It's possible to internally define new properties and skip the "is it extensible?" check, but there are optimizations for non-extensible objects that would stop being safe (since you cannot rely anymore on the non-extensibility to be sure that it won't get new properties).

nicolo-ribaudo avatar Nov 12 '21 16:11 nicolo-ribaudo

What would the following do:

let o = Object(#{ x: 1, y: 2, z: 3 });
Object.defineProperty(o, 'w', {value: 0, enumerable: true});

If modeled like other object wrappers it'd be allowed, right?

mhofman avatar Nov 12 '21 19:11 mhofman

Yes, I'm proposing that it should work.

nicolo-ribaudo avatar Nov 12 '21 20:11 nicolo-ribaudo

It’s an object; it’d be strange if it’s not allowed.

ljharb avatar Nov 12 '21 20:11 ljharb

How do we ensure the following:

const r = #{ x: 1 };
Object(r).y = 2;
assert.throws(() => r.y = 2);

I know that strings behaves that way, but I'm unclear what in the spec confers that behavior.

mhofman avatar Nov 12 '21 23:11 mhofman

I don't understand exactly why it works for strings, but I plan to copy the same behavior.

nicolo-ribaudo avatar Nov 12 '21 23:11 nicolo-ribaudo

In what sense does it "work" for strings?

$ eshost -sx 'const s = "foo", p = 1; Object(s)[p] = "x"; s[p] = "x"; print("no error")'
#### ChakraCore, engine262, GraalJS, Hermes, JavaScriptCore, Moddable XS, SpiderMonkey, V8
no error

gibson042 avatar Nov 13 '21 00:11 gibson042

Sorry I should have said strict mode is important:

$ eshost -sx '(function() {"use strict"; const s = "foo", p = 3; Object(s)[p] = "x"; s[p] = "x"; print("no error")})()'
#### ChakraCore

TypeError: Assignment to read-only properties is not allowed in strict mode

#### engine262

TypeError: Cannot set property '3' on 'foo'

#### Hermes, V8

TypeError: Cannot create property '3' on string 'foo'

#### JavaScriptCore

TypeError: Attempted to assign to readonly property.

#### Moddable XS

TypeError: ?: set 0: not extensible

#### SpiderMonkey

TypeError: can't assign to property 3 on "foo": not an object

I would have assumed String Exotic object [[DefineOwnProperty]] had a check for the this value being the string primitive or an object wrapper, but that doesn't seem to be it, so I'm not sure.

Edit: I assumed wrong and the check is done earlier in OrdinarySetWithOwnDescriptor step 2.b. If Type(Receiver) is not Object, return false.

mhofman avatar Nov 13 '21 00:11 mhofman

I think it might be in the actual assignment semantics, but I’m not certain. Either way, I’d assume assigning to any non-nullish primitive behaved identically as any other, in sloppy mode and strict mode.

ljharb avatar Nov 13 '21 01:11 ljharb

@nicolo-ribaudo could you clarify your implementation feedback?

The current spec text doesn't seem to allow #{ x: 1 }.y = 2 whether in strict mode or not, neither does it allow Object.defineProperty on a record wrapper object (unless same description of course).

Why would [[Set]] and [[DefineOwnProperty]] operations that allow those be better for implementations? Like proxy objects that lazily fetch their properties on access, you should be able to lazily surface the properties of the record wrappers while disallowing the actual operations that modifies the properties?

@ljharb while I agree it'd be more consistent with other primitives, I'm concerned the [[Set]] semantics in non-strict mode are a foot gun for this new primitive type (we shouldn't be bound to sloppy compatibility on this). And it'd probably be weird to disallow [[Set]] but to allow [[DefineOwnProperty]], but that's more debatable. Also, I have even less of an opinion whether the restriction should only apply to string properties, or to symbol properties as well.

mhofman avatar Nov 13 '21 01:11 mhofman

To be fair, i wouldn't object to a new primitive behaving strictly even in sloppy mode, since that seems in keeping with the spirit of strict mode - altho that might break code that assumes such assignment will never throw.

ljharb avatar Nov 13 '21 01:11 ljharb

Arguably the following is still a sloppy mode footgun:

const r = #{ x: 1 };
const s = Symbol();
r[s] = 'foo';

So while having [[Set]] disallow any assignment but allow [[DefineOwnProperty]] to define symbols might be weird, it probably is the only sensible approach if you want to allow symbols on wrapper objects. I still find it questionable one would want to defineOwnProperty of strings properties on record wrapper though.

mhofman avatar Nov 13 '21 01:11 mhofman