In this part of the series,
we allow storing state inside class instances in EasyScript,
our simplified JavaScript implementation.
In order to support that,
we need to add the notion of property writes,
the this
keyword, and constructors, to the language.
In order to support these features, we need to introduce two changes to the ANTLR grammar for EasyScript:
- We add a new
expr1
production (with the labelPropertyWriteExpr1
) that represents property writes. - We add a new
expr6
production that represents thethis
keyword.
Property writes are in many ways a mirror of property reads.
We introduce a
new class, PropertyWriteExprNode
,
that represents the PropertyWriteExpr1
grammar production from above,
and is the equivalent of PropertyReadExprNode
that we've seen since
part 10.
It simply delegates to the
new CommonWritePropertyNode
class
that is the equivalent of CommonReadPropertyNode
that we've had since
part 11.
The existing
ArrayIndexWriteExprNode
class
also delegates to CommonWritePropertyNode
,
but has to make sure that if the index evaluates to not a number,
it's converted to a Java string first
(if the index turns out to be a TruffleString
,
in code like myObj['myProp'] = value
,
it uses the same trick that ArrayIndexReadExprNode
used in
part 11
to cache the first two Java String
s resulting from converting TruffleString
s).
CommonWritePropertyNode
itself is quite simple,
and uses the
writeMember
message from the InteropLibrary
to perform the actual property writes.
The handling of the writeMember()
interop library message is in the new
JavaScriptObject
class
(which is just the renamed
ClassInstanceObject
from the previous part
to reflect its more generic nature).
Writing a property simply means saving it using the
dynamic object library.
Note that performing writes also changes the logic of reads - instead of always delegating to the prototype, like in the previous part, we now need to check first whether the object itself contains that property (if it does, it shadows the one from the prototype).
Since property writes can be performed on any object,
not only on class instances,
we make the classes for the built-in objects,
FunctionObject
and ArrayObject
,
extend JavaScriptObject
,
in order not to duplicate the logic of writing properties.
Speaking of ArrayObject
, it needs to make sure it handles writing the length
property, which is special for arrays:
you can only write a non-negative integer value to it
(any attempt to write a negative integer, or a non-integer, will result in an error),
and if the value written is different from the current length of the array,
it gets resized to match the value written.
We handle that in ArrayObject
by exporting the writeMember
message as a static nested class,
instead of a method
(similarly like with methods,
either the name of the class must be equal to the (capitalized) name of the message from the library,
or you can use the name
attribute of @ExportMessage
, and then the class can have any name).
Inside that class, we can write @Specialization
methods,
similarly like we can in Node classes
(with the one significant difference that the specializations inside the message class must be static
,
and thus must take the object the message is being invoked on as the first argument).
Since now all objects extend JavaScript
object,
we must provide not only a Shape
when creating them,
but also an instance of
ClassPrototypeObject
,
which is unchanged from the previous part.
Since arrays and functions all have their own prototype,
we introduce
a class, ShapesAndPrototypes
,
grouping all of them, alongside the required Shapes, in one place.
We make an instance of this new class available from the
TruffleLanguage
context
instance.
Naturally, we create an instance of the TruffleLanguage
context class in the
TruffleLanguage
implementation for this part.
Then, ShapesAndPrototypes
can be used, through EasyScriptLanguageContext
, in
ArrayLiteralExprNode
and FuncDeclStmtNode
.
The this
keyword is implemented in the
ThisExprNode
class
by simply reading the first argument from the VirtualFrame
.
Reserving the first argument for this
has wide-ranging repercussions on how function and method calls work in our language.
First, we need to offset all arguments for user-defined functions and methods by one,
to leave the argument with index 0
reserved for this
.
In EasyScript, we do it directly
in the parser,
in the parseSubroutineDecl()
method.
Second, we need to also offset the arguments for the built-in functions
(not for built-in methods though, since those already have an explicit argument for this
),
and we do that in the
TruffleLanguage
class.
Finally, and most importantly, we need to adjust the code that performs function and method calls,
to pass the this
argument.
We add a new argument to the
executeDispatch()
method of FunctionDispatchNode
that represents the receiver of a method call,
and make sure it's the first element of the array of arguments passed to the CallTarget
representing a given function or method.
Where do we get that argument from?
We add two new methods to the root of our expression Node hierarchy,
EasyScriptExprNode
,
evaluateAsReceiver()
and evaluateAsFunction()
.
The vast majority of Node classes in EasyScript use the default implementation of these methods
(which are simply to return undefined
, and delegate to executeGeneric()
, respectively),
the only exception are the property read Nodes.
These two new methods are only called from the
FunctionCallExprNode
class,
which uses the value returned from evaluateAsReceiver()
as the receiver
argument it passes to FunctionDispatchNode.executeDispatch()
.
The property read Nodes are the only Nodes that override the default implementations of
evaluateAsReceiver()
and evaluateAsFunction()
.
PropertyReadExprNode
implements evaluateAsReceiver()
by executing only the target expression,
and evaluateAsFunction()
by delegating to its one specialization method
(in part 12,
that specialization used a @Cached
argument of type CommonReadPropertyNode
,
but since we won't have an instance of CommonReadPropertyNode
available in evaluateAsFunction()
,
we switch to using a field annotated with @Child
,
and initializing the field with an explicitly created instance of CommonReadPropertyNode
).
But while that is easy to implement for PropertyReadExprNode
,
since it only has a single specialization,
ArrayIndexReadExprNode
in part 12
has 4 specializations.
So, how can we implement evaluateAsFunction()
in that case?
We use a trick: we create a
static inner Node inside ArrayIndexReadExprNode
,
and move all specializations to that class
(in fact, we have to add a fifth specialization in this part,
to convert a non-int, non-string index to a string before delegating to CommonReadPropertyNode
,
same as we did in ArrayIndexWriteExprNode
).
With that change, ArrayIndexReadExprNode
now only contains a single specialization that delegates to the inner Node,
and we can implement evaluateAsFunction()
in a similar way as we did for PropertyReadExprNode
,
by calling that one specialization.
Finally, since we changed the way we resolve targets of method calls,
we no longer need the complicated caching we used in
ReadTruffleStringPropertyNode
since part 11.
So, we can change ReadTruffleStringPropertyNode
to read properties directly from the string prototype
(we don't have to worry about instance properties shadowing the ones from the prototype,
since strings are immutable in JavaScript).
And for the last feature, we just need to make a small change to the
NewExprNode
class
to check whether the prototype of a given class has a method with the name "constructor",
and if it does, call it using FunctionDispatchNode
.
We introduce a simple benchmark that calls an instance method of a user-defined class in a loop, to make sure our implementation is efficient. We have two variants of the benchmark: one that uses direct property accesses, and another that uses indexed property accesses.
Here are the results when running the benchmark on my laptop:
Benchmark Mode Cnt Score Error Units
CounterThisBenchmark.count_with_this_in_for_direct_ezs avgt 5 577.478 ± 36.396 us/op
CounterThisBenchmark.count_with_this_in_for_direct_js avgt 5 571.999 ± 21.203 us/op
CounterThisBenchmark.count_with_this_in_for_indexed_ezs avgt 5 579.777 ± 31.468 us/op
CounterThisBenchmark.count_with_this_in_for_indexed_js avgt 5 576.204 ± 25.755 us/op
Direct and indexed property access have the same performance, both in EasyScript, and in the GraalVM JavaScript implementation.
In addition to the benchmark, there are some unit tests that validate the fields functionality works as expected.