Closed samreid closed 4 years ago
Why are you calling Array.prototype.splice.apply( this, arguments )
instead of super.splice( arguments )
?
Support construction via splice(), which invokes the sub-constructor
I'm unable to verify that AxonArray's constructor is called when splice
is called. What am I doing wrong?
I added a console.log
here, in AxonArray constructor:
if ( typeof options === 'number' ) {
console.log( `calling constructor with ${options}` );
super( options );
return;
}
I don't see that log message when exercising AxonArray splice
:
> var a = new phet.axon.AxonArray( { elements: [ 1, 2, 3 ] } )
undefined
> a.splice( 0, 1 )
AxonArray [1]
I tried the same code in natural-selection-main and I did see the console.log statement. Want to schedule a zoom call?
I also tried a debugger
and ran the axon unit tests, and it was triggered.
I do see the console.log now. My browser must've been caching (which has been happening chronically since updating to Chrome 84).
We definitely don't want to be calling new AxonArray( {number} )
in sim code. But I'm not sure what the best solution is for preventing that.
What is the answer to my question in https://github.com/phetsims/axon/issues/311#issuecomment-665177597?
Why are you calling
Array.prototype.splice.apply( this, arguments )
instead ofsuper.splice( arguments )
?
Why are you calling
Array.prototype.splice.apply( this, arguments )
instead ofsuper.splice( arguments )
?
We cannot use super.splice(arguments)
because it passes through the arguments object directly as a single argument (like call
), but we need them to be spread out. I think const deletedElements = super.splice(...arguments);
would have the same functional behavior, but that probably requires more CPU/heap compared to apply
. Therefore Array.prototype.splice.apply
seems preferable.
Do you want to bring it up for developer meeting about how to avoid new AxonArray( {number} )
in sim code?
Thanks for clarifying.
I still don't know what's best for preventing calls to new AxonArray( {number} )
. Of the 2 options you proposed in https://github.com/phetsims/axon/issues/311#issue-666642149, the lint rule seems easiest. But you should get other opinions.
For now, I have forbidden new AxonArray({number})
via a lint rule.
Public Service Announcement about AxonArray and not calling it with {number}
.
The PSA is actually that no one should be using AxonArray yet in production code, right?
It looks like there are other issues for developer meeting about the game plan for AxonArray, so this public service announcement will be a brief one about not calling it with {number}
.
Over in https://github.com/phetsims/natural-selection/issues/178, @samreid converted natural-selection to use AxonArray. In subclass BunnyArray extends AxonArray
, he added this same bit of code that we've been discussing here:
// Support construction via Array.prototype.splice.apply(), etc., which invoke the sub-constructor
if ( typeof options === 'number' ) {
super( options );
return;
}
Hopefully we can find a better solution, because having to duplicate this in all subclasses is very undesirable.
At today's meeting, @zepumph recommended overriding methods that return Array
so that we can assert that the argument is not a number, and that we can return Array
instances instead of the subtype?
@zepumph: Add unit tests that call every array function. To be defensive about whether a browser starts calling the sub-constructor.
It sounds promising, I'll go for it!
Also a reminder to make sure things like splice
don't call constructors (should be slice
instead).
Even splice
is calling the sub-constructor!
Here is a self-contained example of the same thing:
class MyArray extends Array {
constructor() {
super();
console.log( 'constructor called' );
}
}
console.log( 'creating myarray' );
const x = new MyArray();
console.log( 'calling splice' );
const y = x.splice( 0, 0 );
creating myarray constructor called calling splice constructor called
Why is the browser calling the sub-constructor during splice? Does this happen for other methods?
UPDATE: It is even called for map
(which makes sense) and filter
which does not.
class MyArray extends Array {
constructor() {
super();
console.log( 'constructor called' );
}
}
console.log( 'creating myarray' );
const x = new MyArray();
console.log( 'calling splice' );
const y = x.splice( 0, 0 );
console.log( 'calling map' );
const z = x.map( a => a );
console.log( 'calling filter' );
const ws = x.filter( a => true );
creating myarray
constructor called
calling splice
constructor called
calling map
constructor called
calling filter
constructor called
UPDATE: Splice and filter both return arrays, so it makes sense that Array tries to return the same type as the original Array.
array.copyWithin() array.entries() array.every() array.fill() array.find() array.findIndex() array.forEach() array.includes() array.indexOf() array.join() array.keys() array.lastIndexOf() array.pop() array.push() array.reduce() array.reduceRight() array.reverse() array.shift() array.some() array.sort() array.toLocaleString() array.toString() array.unshift() array.values()
array.concat() array.filter() array.flat() array.flatMap() array.map() array.slice() array.splice()
I tested in Firefox and Safari and found the behavior was the same as in Chrome (same methods call the sub-constructor). This is really puzzling to me, especially splice
which does not return a new array and is supposed to mutate the array in-place. What if some other browser also invokes the sub-constructor for another method? Can we efficiently + correctly reimplement each of these methods? We could correctly implement them by calling Array.from(this).method(...)
, but that is inefficient in CPU and heap. Shouldn't we rely on the browser implementation as much as possible?
It seems risky to choose a path that relies on a private implementation detail of these methods. And I cannot find a specification that describes how Array is supposed to behave with respect to calling subtype constructor. What if one Array method calls the sub-constructor, but not with a number argument?
Summarizing Options:
(a) Override methods that call the subtype constructor. Can we test that we got them all? Can we do this efficiently and accurately? What if some browser invokes the subtype constructor for all methods and we end up reimplementing the entire Array API?
(b) Allow some methods to call the subtype constructor. Can we identify these cases, and have them behave like a plain array instead of an AxonArray? At the meeting we decided we didn't want to pursue this, but that was before we knew that things like splice
call the sub-constructor.
(c) Investigate ways to swap out the prototype
before calling these suspicious methods, so it won't call the subtype constructor? Maybe @jonathanolson would have some good ideas in this area.
Extrapolating from our dev meeting consensus, it seems like we should continue pursuing (a).
I added these methods as a correct-but-inefficient placeholder:
// @public
concat( a ) { return Array.prototype.concat.apply( Array.from( this ), arguments ); }
// @public
filter( a ) { Array.prototype.filter.apply( Array.from( this ), arguments ); }
// @public
flat() {return Array.prototype.flat.apply( Array.from( this ), arguments );}
// @public
flatMap() {return Array.prototype.flatMap.apply( Array.from( this ), arguments );}
// @public
map() {return Array.prototype.map.apply( Array.from( this ), arguments );}
// @public
slice() {return Array.prototype.slice.apply( Array.from( this ), arguments );}
However, splice
must make notifications since it can add and remove elements. The same trick won't work there:
// @public
splice() {
const deletedElements = Array.prototype.splice.apply( Array.from(this), arguments );
// Gracefully support values created by axonArray.slice(), etc.
if ( this.lengthProperty ) {
this.lengthProperty.value = this.length;
for ( let i = 2; i < arguments.length; i++ ) {
this.elementAddedEmitter.emit( arguments[ i ] );
}
deletedElements.forEach( deletedElement => this.elementRemovedEmitter.emit( deletedElement ) );
}
return deletedElements;
}
...because splicing on a copy fails to remove elements from the primary copy. I can't think of a way around this without invoking the subconstructor or changing the API (like throwing an error during splice).
I'd like to brainstorm with @jonathanolson if there are other alternatives here.
Here's an option that runs a switcheroo and passes all unit tests
// @public
splice() {
const prototype = Object.getPrototypeOf( this );
Object.setPrototypeOf( this, Array.prototype );
const deletedElements = Array.prototype.splice.apply( this, arguments );
Object.setPrototypeOf( this, prototype );
// Gracefully support values created by axonArray.slice(), etc.
if ( this.lengthProperty ) {
this.lengthProperty.value = this.length;
for ( let i = 2; i < arguments.length; i++ ) {
this.elementAddedEmitter.emit( arguments[ i ] );
}
deletedElements.forEach( deletedElement => this.elementRemovedEmitter.emit( deletedElement ) );
}
return deletedElements;
}
However, there is an exciting warning on MDN that says this code is deoptimized and possibly sketchy:
After seeing all of these tradeoffs, I am reconsidering recommending that we detect when the AxonArray
sub-constructor is invoked, and allow it to peacefully exit, indicating that it is to be treated like a normal Array
.
The advantages of this proposal are: (a) implementations will be provided by the built-in Array type, and hence will be efficient and correct (b) we will not need to trigger any deoptimizations
The disadvantages of this proposal:
(a) We will need code to detect when the sub-constructor is invoked (more robust than checking numeric argument), and this will be in AxonArray and any of its subtypes
(b) We will need a way to indicate when an AxonArray has been constructed in this way, and client usages will have to treat it accordingly. For instance, instanceof AxonArray
checks wouldn't be enough, it would need to be instanceof AxonArray && array.hasAxonArrayFeatures
On the other hand, maybe we shouldn't be deterred by performance warnings until we observe that it is problematic in practice? But many AxonArray
instances will need to call splice
since it is the only way to remove elements. So maybe it means every AxonArray
would be deoptimized?
UPDATE: Goals for AxonArray included:
So even if performance tests right now indicate that setPrototypeOf
is not a problem, that doesn't guarantee that it will remain that way in all future browser implementations. I feel like allowing the AxonArray
subconstructor to be called is more like "taking matters into our own hands" and we can deal with the fallout in the simulation implementation side.
Summarizing Options: ...
(c) Bail on AxonArray altogether. Mention to the JavaScript working group that they really shouldn't have skipped that Object-Oriented Programming 101 class.
... similarly in https://github.com/phetsims/axon/issues/312#issuecomment-679317030, I said:
... If it were me, I'd bail completely on extending Array. When there are this many weird problems, composition is definitely favored over inheritance.
After doing a bit more diving into this, I'm trying to understand the use case for AxonArray over ObservableArray/Array. The doc says:
AxonArray adds the ability to observe when elements are added or removed from an Array. This was created as an alternative to ObservableArray with the distinguishing change that this extends Array and hence uses the native Array API.
but, what is the advantage? I don't feel like I can suggest options without understanding the purpose.
The PSA is actually that no one should be using AxonArray yet in production code, right?
I'm code-reviewing code that is using AxonArray, so this is coming up as part of https://github.com/phetsims/collision-lab/issues/153.
The advantages of this proposal are: (a) implementations will be provided by the built-in Array type, and hence will be efficient and correct (b) we will not need to trigger any deoptimizations
Subtyping Array potentially includes performance issues, right? Has performance been tested? I also see a list of things that isn't supported.
@brandonLi8 @jonathanolson I really don't think that AxonArray should be used in collision-lab. It has too many problems, and any advantages that it might have had over ObservableArray feel like they are evaporating.
@samreid one proposal could be to provide the Symbol.species
static getter.
With this:
static get [Symbol.species]() { return Array; }
filter
, map
, etc. would return Array and we shouldn't need to override the default browser implementation. Thoughts?
static get [Symbol.species]() { return Array; }
is exactly what we needed, thanks @brandonLi8! I committed it above. This allowed us to remove all of the workarounds related to calling the sub-constructor.
JavaScript continues to amaze me (and not in a good way).
I'm trying to understand the use case for AxonArray over ObservableArray/Array
Array
we automatically get a uniform API that is consistent with the built-in Array API.I'd also like to point out that
forEach
isn't the only method that exposes the underlying array which the user could modify. [...]That's the main reason why I avoid using those "convenience" methods of ObservableArray. I think it's much clearer to call
getArray()
orgetArrayCopy()
, then use the Array API directly.
Thanks to @brandonLi8, the problem regarding "Should new AxonArray({number}) fail?" has been addressed, and this issue is ready to close. However, there have been more general questions and comments by @pixelzoom and @jonathanolson which should be resolved or moved elsewhere first.
I prefer sticking to AxonArray in collision lab.
To add onto what @samreid said in https://github.com/phetsims/axon/issues/311#issuecomment-679429777...
I believe (but not fully sure without performance tests) that AxonArray is actually more performant than ObservableArray because it is able to utilize native methods from Array which are optimized. Regardless, collision lab doesn't utilize any large AxonArrays/ObservableArrays.
I also believe it is more natural to have AxonArray to be an actual array instead of wrapping an array via composition (which ObservableArray does). From the CRC:
- [ ] Prefer the most basic/restrictive type expression when defining APIs. For example, if a client only needs to know that a parameter is {Node}, don’t describe the parameter as {Rectangle}.
I think this applies to my usages of AxonArray in Collision Lab. In some APIs, it is not necessary to know that the passed-in array is an AxonArray or an ObservableArray, so I can define my API in terms of any Array. In other API's, I need to register a listener (or something specific to AxonArray), in which I define my API to take in the AxonArray.
I’ll look into this more tomorrow, including performance. The documentation also states that this is NOT API compatible with Array, since there are unsupported methods/getters/setters, so it seems unsafe in general to pass an AxonArray in place of an Array, no?
If those are the goals, I’m curious if Proxy has been tested (as it probably would have worse performance unfortunately), since it presumably wouldn’t suffer from any API compatibility drawbacks.
Looks OK to me, unassigning.
var arr = new axon.AxonArray();
arr.addItemAddedListener( item => console.log( `add ${item}` ) );
arr.addItemRemovedListener( item => console.log( `remove ${item}` ) );
arr.push( 1, 2, 3 ); // notifies
_.remove( arr, x => x === 2 ); // does not notify
_.pull( arr, 3 ); // does not notify
arr.length = 0; // does not notify (as expected)
Object.keys( arr )
includes ["elementAddedEmitter", "elementRemovedEmitter", "lengthProperty", "axonArrayPhetioObject", "phetioElementType"]
AxonArray.prototype.push.apply( this, options.elements )
can be this.push( ...options.elements )
? Array.prototype.something
can be super.something
, no?So overall: this looks like it can be a cleaner ObservableArray with restrictions! However it seems unsafe to treat it as a general Array, both because of API reasons, AND because of the deoptimizations we can trigger in that case.
@samreid, @brandonLi8, @pixelzoom thoughts?
Benchmarking committed above, should be testable with scenery/tests/benchmarking.html (there is other benchmarking code in that general location)
Results on my mac/chrome:
Creation#Array x 80,851,468 ops/sec ±2.68% (58 runs sampled)
Creation#ObservableArray x 26,569 ops/sec ±6.21% (63 runs sampled)
Creation#AxonArray x 27,056 ops/sec ±1.70% (63 runs sampled)
AssortedActions#Array x 718 ops/sec ±1.60% (61 runs sampled)
AssortedActions#ObservableArray x 71.20 ops/sec ±1.08% (53 runs sampled)
AssortedActions#AxonArray x 85.57 ops/sec ±0.85% (55 runs sampled)
Deopt#pure x 718 ops/sec ±0.36% (49 runs sampled)
Deopt#impure x 700 ops/sec ±2.78% (48 runs sampled)
Results on my Mac/Firefox:
Creation#Array x 10,962,687 ops/sec ±3.73% (55 runs sampled)
Creation#ObservableArray x 8,700 ops/sec ±6.42% (53 runs sampled)
Creation#AxonArray x 9,436 ops/sec ±2.47% (58 runs sampled)
AssortedActions#Array x 2,711 ops/sec ±2.26% (48 runs sampled)
AssortedActions#ObservableArray x 12.02 ops/sec ±2.02% (33 runs sampled)
AssortedActions#AxonArray x 15.80 ops/sec ±1.19% (29 runs sampled)
Deopt#pure x 2,274 ops/sec ±11.31% (17 runs sampled)
Deopt#impure x 1,978 ops/sec ±0.44% (14 runs sampled)
_.remove( arr, x => x === 2 ); // does not notify
Lodash unfortunately calls Array.prototype.splice(array)
instead of array.slice
.
- We don't need prototype references here?
AxonArray.prototype.push.apply( this, options.elements )
can bethis.push( ...options.elements )
?Array.prototype.something
can besuper.something
, no?
I was concerned that our Babel step would do something suboptimal for this.push(...options.elements)
. For instance, babel targeting es2015 converts
// A
AxonArray.prototype.push.apply( this, options.elements );
// B
this.push(...options.elements);
to
// A
AxonArray.prototype.push.apply(_assertThisInitialized(_this), options.elements);
// B
_this.push.apply(_this, _toConsumableArray(options.elements));
That being said, this.push(...options.elements)
does read nicer, and maybe prototype.push.apply
is a premature optimization?
Should we prototype a Proxy
-based solution that aims for 100% safety?
Should we prototype a Proxy-based solution that aims for 100% safety?
If that doesn't cause deoptimizations or performance loss, that would be great! I fear it's likely to cause performance loss, but could be worth a try. Probably worth some checks with performance.
I'm interested in prototyping that. I'll self assign, but also leave marked for developer meeting in case we have time to touch base and get a shared sense of what is acceptable on the performance/safety tradeoff spectrum.
Can you recommend how to proceed with Array + Proxy and subclassing? For instance, we have BunnyArray extends ObservableArray
and adds methods and attributes. But I don’t see a clear way to do that with Proxy
, which cannot be subclassed like class MyClass extends Proxy
. The "return a different object from the constructor" pattern appears to be used commonly as an alternative, but then I don't see how subclassing would work for that. Or should we say that subclassing would not be possible if we use Proxy?
UPDATE: here's my patch for when I return to this:
We would like to experiment with Proxy
to have full support for array.length=...
and array[3]=differentObject
. However, that will probably have the following disadvantages:
BunnyArray extends AxonArrayProxy
For developer meeting: are any of these dealbreakers? Does anyone know a way around (3)? - more detail on that in previous comment.
We discussed this at developer meeting today, and feel like Proxy
is worth investigation, as nothing above feels like a total "deal breaker", although it seems like a bummer to not be able to use inheritance.
We are most excited about 100% (or close to) api coverage of native Array. This will help convert arrays to this new type.
I'll pursue the Proxy-based approach in new issue #330.
On hold pending results in #330
From https://github.com/phetsims/axon/issues/308
When invoking
from splice, somehow this ends up invoking the subtype constructor with a number argument. That is, it calls the
AxonArray
constructor with a{number}
.Thus the constructor has to be documented like so:
Do we need a way to guard against simulations calling
new AxonArray(3)
? Right now that isn't caught. We cannot make that a shorthand fornew AxonArray({length:3})
because that would have different semantics for the subtype invocation.Other options:
new AxonArray({number})
AxonArray.constructor
. Throw an assertion error ifAxonArray({number})
is called with counter===0