Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement computed properties #1503

Merged
merged 10 commits into from
Jul 3, 2024
Merged

Conversation

andreabergia
Copy link
Contributor

@andreabergia andreabergia commented Jun 26, 2024

This (rather large) PR implements computed properties, i.e. you can do things like:

o = {
  [f(x, y, z)] = 42
}

A more obvious use case is something like o = { [Symbol.iterator]: () => { ... } }. Quite a few more test262 cases now pass (around 50).

I'm going to go into some details about the implementation to help the review, since there are a lot of changes. My apologies for the wall of text. 🙂

Closes #913

Background

Spec reference: https://tc39.es/ecma262/#sec-object-initializer

An important detail, per the spec, is that JS requires that the evaluation of the expressions for computed property keys and values are interleaved, e.g.:

x = 0;
o = {
    [++x]: ++x,
    [++x]: ++x,
};
JSON.stringify(o);

should print {"1":2,"3":4}.

Implementation notes

Parser

The changes to Parser are simple. The only noteworthy detail is that I have created a new subclass of AstNode called ComputedPropertyKey. This is useful for two things:

  • we want to treat [andrea] as a lookup of the name andrea in the scope, not a property called andrea in the object - having a wrapper node simplifies this;
  • it's useful when preparing the "encoded source".

IR Factory

In IRFactory the existing fragment of code was generating the array of property keys and store it in a property called OBJECT_IDS_PROP of the node. The new code also stores the computed properties in a new property OBJECT_IDS_COMPUTED_PROP. The two arrays have the same length, and there is a null element in the position of properties where we have a computed property, whose expression is at the corresponding index in the other array.

There are also some simple changes to handle the new ast node.

Interpreter

The existing code, for an object such as o = { x: 11, 2: 12 }, would generate the following bytecode:

[   5] REG_IND_C2 
[   6] LITERAL_NEW 
[   7] SHORTNUMBER 11
[  10] LITERAL_SET 
[  11] SHORTNUMBER 12
[  14] LITERAL_SET 
[  15] REG_IND_C0 
[  16] OBJECTLIT [x, 2]

Let's go through this:

  • first we have a LITERAL_NEW, which is pushing an array of length 2 (reading the index register) on the stack for the properties' values (not the keys!);
  • then we have a sequence of instructions to fill that array: push 11 and set it, push 12 and set it;
  • then we set the index register to 0, because the first entry in the literal array of the InterpreterData was used to store a java array containing the property keys (i.e. new Object[]{"x", 2})
  • and finally there is the instruction OBJECTLIT, which reads the property keys from the literal array and the property values from the stack and initializes the object.

(There is a small additional detail: there is a third array on the stack that records whether a property is standard, a setter, a getter, or a method. This was not touched by this PR).

However, this approach doesn't work anymore if we need to replace an entry in the property keys. Therefore, I have changed the bytecode to do the following:

  • first we push on the stack two arrays, one for the keys and one for values;
  • the keys array is pre-initialized with the literal, so that all non-computed properties are already filled;
  • then we loop over each entry:
    • if it has a computed key, we compute the expression and set it in the keys array;
    • otherwise we do nothing for the key, because it will already be present in the literal array and thus doesn't need to change;
    • next, we compute the value and put it in the values array;
  • finally, we build the object.

To implement this I have done some changes in the interpreter:

  • there is a new instruction LITERAL_KEYS, that pushes the literal array with the property keys onto the stack. It is followed by one byte (0/1) which specifies whether we have to copy the literal array or not (if we don't have computed properties we save an array copy and just push the one in the literal table, since we won't change it);
  • there is another new instruction named LITERAL_KEY_SET to replace an entry of the property keys array (which is assumed to be already on the stack), similar to the the existing LITERAL_SET;
  • finally, the OBJECTLIT instruction will now read both arrays from the stack.

Given this code:

function f(x) { return x; }
o = {
    a: 1,
    [f('x')]: 42,
    x2: f(2),
};

this is the generated bytecode:

[   8] REG_IND_C0 
[   9] LITERAL_KEYS [a, null, x2] true
[  11] REG_IND_C3 
[  12] LITERAL_NEW 
[  13] ONE 
[  14] LITERAL_SET 
[  15] LINE 4
[  18] REG_STR_C1 "f"
[  19] NAME_AND_THIS 
[  20] REG_STR_C2 "x"
[  21] STRING 
[  22] REG_IND_C1 
[  23] CALL 1
[  24] REG_IND_C1 
[  25] LITERAL_KEY_SET 
[  26] SHORTNUMBER 42
[  29] LITERAL_SET 
[  30] LINE 5
[  33] REG_STR_C1 "f"
[  34] NAME_AND_THIS 
[  35] SHORTNUMBER 2
[  38] REG_IND_C1 
[  39] CALL 1
[  40] LITERAL_SET 
[  41] OBJECTLIT 

You can see that we execute in order the expressions 1 (first value), then f(x) (second key), then 42 (second value), and finally the value f(2) (third value).

Compiled classes

For the compiled classes, the existing code would do something like this: for the JS fragment { x: 11, 2: 12 } the following java bytecode would be generated:

// Create array for keys
      25: iconst_2
      26: anewarray     #51                 // class java/lang/Object
// Fill key 0
      29: dup
      30: iconst_0
      31: ldc           #120                // String x
      33: aastore
// Fill key 1
      34: dup
      35: iconst_1
      36: iconst_2
      37: invokestatic  #124                // Method org/mozilla/javascript/ScriptRuntime.wrapInt:(I)Ljava/lang/Integer;
      40: aastore
// Create array for values
      41: iconst_2
      42: anewarray     #51                 // class java/lang/Object
// Fill value 0
      45: dup
      46: iconst_0
      47: getstatic     #128                // Field _k0:Ljava/lang/Integer;
      50: aastore
// Fill value 1
      51: dup
      52: iconst_1
      53: getstatic     #131                // Field _k1:Ljava/lang/Integer;
      56: aastore

It's pretty simple: we simply create an array with two elements, set the first one to the constant x and the second one to the constant 2. In general, the code would first fill the array with all the keys, and then the array with all the values, evaluating each key and value's expression. However, to implement the interleaved evaluation required by the spec, the changes in BodyCodegen are a bit bigger.

First, I split the implementation of an object literal and an array literal in two functions for clarity. Then, we create both arrays and fill them one entry at a time, so the code becomes something like:

create array keys
create array values
compute key #0
set in array keys at position 0
compute value #0
set in array values at position 0
compute key #1
set in array keys at position 1
compute value #1
set in array values at position 1
...

As an example, for the following JS:

function f(x) { return x; }
o = {
    a: 1,
    [f('x')]: 42,
    x2: f(2),
};

we get the following JVM bytecode:

// Create both arrays
      41: iconst_3
      42: anewarray     #49                 // class java/lang/Object
      45: iconst_3
      46: anewarray     #49                 // class java/lang/Object
// Prepare for entry 0
      49: dup2
// Set key 0
      50: iconst_0
      51: ldc           #130                // String a
      53: aastore
// Set value 0
      54: iconst_0
      55: getstatic     #134                // Field org/mozilla/javascript/optimizer/OptRuntime.oneObj:Ljava/lang/Double;
      58: aastore
// Prepare for entry 1
      59: dup2
// Set key 1
      60: iconst_1
      61: iconst_1
      62: anewarray     #49                 // class java/lang/Object
      65: dup
      66: iconst_0
      67: ldc           #84                 // String x
      69: aastore
      70: ldc           #76                 // String f
      72: aload_1
      73: aload_2
      74: invokestatic  #138                // Method org/mozilla/javascript/optimizer/OptRuntime.callName:([Ljava/lang/Object;Ljava/lang/String;Lorg/mozilla/javascript/Context;Lorg/mozilla/javascript/Scriptable;)Ljava/lang/Object;
      77: aastore
// Set value 1
      78: iconst_1
      79: getstatic     #142                // Field _k0:Ljava/lang/Integer;
      82: aastore
// Prepare for entry 2
      83: dup2
// Set key 2
      84: iconst_2
      85: ldc           #144                // String x2
      87: aastore
// Set value 2
      88: iconst_2
      89: iconst_1
      90: anewarray     #49                 // class java/lang/Object
      93: dup
      94: iconst_0
      95: getstatic     #147                // Field _k1:Ljava/lang/Integer;
      98: aastore
      99: ldc           #76                 // String f
     101: aload_1
     102: aload_2
     103: invokestatic  #138                // Method org/mozilla/javascript/optimizer/OptRuntime.callName:([Ljava/lang/Object;Ljava/lang/String;Lorg/mozilla/javascript/Context;Lorg/mozilla/javascript/Scriptable;)Ljava/lang/Object;
     106: aastore
// Swap key/value arrays on the stack
     107: swap

There is a special case for handling Rhino bug 757410 - if we are inside a generator, we instead do something like this:

compute key #0
compute value #0
...
compute key #n-1
compute value #n-1
create array keys
create array values
set in array values at position n-1
set in array keys at position n-1
...
set in array values at position 0
set in array keys at position 0

The reasons for this are explained in #70

Other changes

The last interesting change is the one in ScriptRuntime - the old code knew that a property key was always a string or an integer. Now, it can be any JS object, so we have to use ScriptRuntime.toString rather than just an instanceof String.

Further enhancements

There are some improvements that could be done on this, but they will come in separated PR. This one is big enough as it is. 🙂

  1. Currently we generate a literal array for the static keys. We could generate one also for the static values and use the same approach of filling only the "missing" values in interpreted mode.
  2. We could use the literal array that we have (at least for the keys, maybe also for the values if we introduce it) in compiled mode, by storing it in the constant pool and copying it before filling only the computed values, similarly to what we do in the interpreter.
  3. We should refactor the code to use just one array for both keys and values, to allow for better cache locality (and simplify the generated bytecode).

It has a bug: code like this doesn't work correctly:

```javascript
var i = 0;
var o = {
  [i++]: i++,
  [i++]: i++,
}
```

This is because this implementation does not evaluate keys and values
interleaved, as the spec says.
Accidentally broken bug 757410, working on it
I had run the update of test262 using Java 17, which makes some more
tests pass locally but not on CI. I have re-run it now under Java 11.
Should fix the CI run.
@gbrail
Copy link
Collaborator

gbrail commented Jun 26, 2024

Wow, this is a really important and complete PR! Thanks for all the details. I looked at the code and nothing jumped out at me, but give us all a few days to try this out and digest to see if anyone else has comments.

@gbrail
Copy link
Collaborator

gbrail commented Jun 26, 2024

I have one idea which might make things more complicated in the short run:

I've noticed that the code in ScriptRuntime that tries to optimize object literal creation is actually less efficient than just creating an object and setting properties. In other words, this:

let o = { a: 1, b: 2 };

is actually slower than:

let o = {}; o.a = 1; o.b = 2;

This will become more the case if I ever get any InvokeDynamic optimizations to have a good effect.

So at least in bytecode mode, do we really need all that? Or would it be easier for you to unwind the object literal creation bytecode to just create an object and set each field one at a time?

@p-bakker
Copy link
Collaborator

p-bakker commented Jun 26, 2024

The section in test262.properties related to computed properties needs to be uncommented (remove the trailing ~), so the tests are included:

~language/computed-property-names

Hopefully they'll all pass afterwards 😃

@andreabergia
Copy link
Contributor Author

I've noticed that the code in ScriptRuntime that tries to optimize object literal creation is actually less efficient than just creating an object and setting properties. [...]
So at least in bytecode mode, do we really need all that? Or would it be easier for you to unwind the object literal creation bytecode to just create an object and set each field one at a time?

IMHO the current strategy could also have some benefits - for example, we could create the slot map with a good initial size.

And, honestly, if we want to do that change, I'd rather to do it in a separate PR following this one. 🙂

@andreabergia
Copy link
Contributor Author

The section in test262.properties related to computed properties needs to be uncommented (remove the trailing ~), so the tests are included:

~language/computed-property-names

Hopefully they'll all pass afterwards 😃

Done! Most of the ones not passing are using class or super. The only "interesting" one is this:

var object = {
  *['a']() {
    yield 1;
    yield 2;
  }
};

But, the following also does not work, so it's not really related to this PR:

var object = {
  *a() {
    yield 1;
    yield 2;
  }
};

@p-bakker
Copy link
Collaborator

Yup, that generation syntax is another issue: #1413

I remember previous attempts to implement computed properties had challenges getting property order requirements from the spec correct. Any idea of that works correctly in your PR?

@gbrail
Copy link
Collaborator

gbrail commented Jun 30, 2024

Thinking more about the object initialization implemented in the runtime -- I actually get that now, I think that actually if we ever do anything with property maps like V8 (and I have a number of experiments in flight that do that), then leaving things the way they are are better, so that we can in the future optimize for the case when we know what properties to add and in which order.

@andreabergia
Copy link
Contributor Author

I remember previous attempts to implement computed properties had challenges getting property order requirements from the spec correct. Any idea of that works correctly in your PR?

Assuming you are talking about the fact that we need to "interleave" keys and values, it does. I.e. this does the same as v8 or any other js engine:

x = 0;
o = {
    [++x]: ++x,
    [++x]: ++x,
};

@gbrail
Copy link
Collaborator

gbrail commented Jul 1, 2024

@andreabergia how do you feel about this -- are you ready for another look and a possible merge, or do you plan to keep working on this and then ask for that later?

@andreabergia
Copy link
Contributor Author

@andreabergia how do you feel about this -- are you ready for another look and a possible merge, or do you plan to keep working on this and then ask for that later?

From my side, this is ready to be merged. 🙂 🚀

@gbrail
Copy link
Collaborator

gbrail commented Jul 1, 2024

It looks like a bunch of the test262 tests are triggering an IllegalArgumentException. This seems to be because something in the parser is triggering "Kit.codeBug". It's fine for invalid code to cause a RhinoException, but if we get to "codeBug" then we need to fix the parser even to handle illegal stuff without throwing the wrong kind of exception.

These don't show up as test failures because these tests are marked to fail anyway, but they show up as test output and are stack traces that we don't want to emulate. Can you check them out?

Stack trace here from
tests/test262/test/language/statements/generators/dstr/obj-ptrn-prop-eval-err.js

java.lang.IllegalStateException: FAILED ASSERTION: ts.cursor=83, ts.tokenBeg=82, currentToken=91
at org.mozilla.javascript.Kit.codeBug(Kit.java:369)
at org.mozilla.javascript.Parser.codeBug(Parser.java:4318)
at org.mozilla.javascript.Parser.destructuringObject(Parser.java:4158)
at org.mozilla.javascript.Parser.destructuringAssignmentHelper(Parser.java:4067)
at org.mozilla.javascript.Parser.createDestructuringAssignment(Parser.java:4036)
at org.mozilla.javascript.Parser.parseFunctionParams(Parser.java:863)
at org.mozilla.javascript.Parser.function(Parser.java:941)
at org.mozilla.javascript.Parser.function(Parser.java:907)
at org.mozilla.javascript.Parser.function(Parser.java:876)
at org.mozilla.javascript.Parser.parse(Parser.java:621)
at org.mozilla.javascript.Parser.parse(Parser.java:562)
at org.mozilla.javascript.Context.parse(Context.java:2510)
at org.mozilla.javascript.Context.compileImpl(Context.java:2441)
at org.mozilla.javascript.Context.compileString(Context.java:1404)
at org.mozilla.javascript.Context.compileString(Context.java:1392)
at org.mozilla.javascript.tools.shell.Main.processFileSecure(Main.java:575)
at org.mozilla.javascript.tools.shell.Main.processFile(Main.java:541)
at org.mozilla.javascript.tools.shell.Main.processSource(Main.java:514)
at org.mozilla.javascript.tools.shell.Main.processFiles(Main.java:180)
at org.mozilla.javascript.tools.shell.Main$IProxy.run(Main.java:98)
at org.mozilla.javascript.Context.call(Context.java:548)
at org.mozilla.javascript.ContextFactory.call(ContextFactory.java:475)
at org.mozilla.javascript.tools.shell.Main.exec(Main.java:164)
at org.mozilla.javascript.tools.shell.Main.main(Main.java:142)
Exception in thread "main" java.lang.IllegalStateException: FAILED ASSERTION: ts.cursor=83, ts.tokenBeg=82, currentToken=91
at org.mozilla.javascript.Kit.codeBug(Kit.java:369)
at org.mozilla.javascript.Parser.codeBug(Parser.java:4318)
at org.mozilla.javascript.Parser.destructuringObject(Parser.java:4158)
at org.mozilla.javascript.Parser.destructuringAssignmentHelper(Parser.java:4067)
at org.mozilla.javascript.Parser.createDestructuringAssignment(Parser.java:4036)
at org.mozilla.javascript.Parser.parseFunctionParams(Parser.java:863)
at org.mozilla.javascript.Parser.function(Parser.java:941)
at org.mozilla.javascript.Parser.function(Parser.java:907)
at org.mozilla.javascript.Parser.function(Parser.java:876)
at org.mozilla.javascript.Parser.parse(Parser.java:621)
at org.mozilla.javascript.Parser.parse(Parser.java:562)
at org.mozilla.javascript.Context.parse(Context.java:2510)
at org.mozilla.javascript.Context.compileImpl(Context.java:2441)
at org.mozilla.javascript.Context.compileString(Context.java:1404)
at org.mozilla.javascript.Context.compileString(Context.java:1392)
at org.mozilla.javascript.tools.shell.Main.processFileSecure(Main.java:575)
at org.mozilla.javascript.tools.shell.Main.processFile(Main.java:541)
at org.mozilla.javascript.tools.shell.Main.processSource(Main.java:514)
at org.mozilla.javascript.tools.shell.Main.processFiles(Main.java:180)
at org.mozilla.javascript.tools.shell.Main$IProxy.run(Main.java:98)
at org.mozilla.javascript.Context.call(Context.java:548)
at org.mozilla.javascript.ContextFactory.call(ContextFactory.java:475)
at org.mozilla.javascript.tools.shell.Main.exec(Main.java:164)
at org.mozilla.javascript.tools.shell.Main.main(Main.java:142)

@andreabergia
Copy link
Contributor Author

It looks like a bunch of the test262 tests are triggering an IllegalArgumentException. This seems to be because something in the parser is triggering "Kit.codeBug". It's fine for invalid code to cause a RhinoException, but if we get to "codeBug" then we need to fix the parser even to handle illegal stuff without throwing the wrong kind of exception.

These don't show up as test failures because these tests are marked to fail anyway, but they show up as test output and are stack traces that we don't want to emulate. Can you check them out?

Oh I missed that! I’ll take a look, thanks.

@p-bakker
Copy link
Collaborator

p-bakker commented Jul 1, 2024

Likely reason you missed this is because we don't print such exceptions atm, due to this. Once that one is fixed, we can enable printing unexpected exceptions while running tests (at least in the test262TestSuite)

We don't support it, but we want to give back a proper error message
rather than a generic assertion failed.
@andreabergia
Copy link
Contributor Author

It looks like a bunch of the test262 tests are triggering an IllegalArgumentException. This seems to be because something in the parser is triggering "Kit.codeBug". It's fine for invalid code to cause a RhinoException, but if we get to "codeBug" then we need to fix the parser even to handle illegal stuff without throwing the wrong kind of exception.

I think I fixed that, by just making the parser/code gen not throw an error, but a proper exception. The problem is in destructuring, supporting stuff like:

var { [a]: b } = {};
function({ [a]: b }) {};

I've tried to implement it, but it is quite complicated (I've hit issue #1074 too). And, in any case, I think the implementation can be considered good even without the support in destructuring.

@gbrail
Copy link
Collaborator

gbrail commented Jul 3, 2024

I think that this looks good and I'm happy to merge it. Thanks for doing all this!

@gbrail gbrail merged commit 94aa470 into mozilla:master Jul 3, 2024
3 checks passed
@p-bakker
Copy link
Collaborator

p-bakker commented Jul 3, 2024

Super!

@p-bakker p-bakker added this to the Release 1.7.16 milestone Jul 3, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support ES2015 Computed Property Names
3 participants