This is a deep dive into the inner workings of JavaScript engines and how they handle arithmetic operations on primitive and complex objects. We'll go through ordinary and exotic objects, the standard semantics, references, abstract operations, internal methods, and finally how to implement objects that benefit from arithmetic operators.
If you haven't read my previous articles make sure to!
AdditiveExpressions
Before we start, let me note that JavaScript doesn't support operator overloading in the general sense like C++ does for example, but it provides deep workings that allow us to define special methods that are used in arithmetic operations, like Java's toString
!
5 + 8;
Let's start with this simple arithmetic addition AdditiveExpression : AdditiveExpression + MultiplicativeExpression
. The standard defines the steps for an addition operation:
1. Let lref be the result of evaluating AdditiveExpression.
2. Let lval be ? GetValue(lref).
3. Let rref be the result of evaluating MultiplicativeExpression.
4. Let rval be ? GetValue(rref).
5. Let lprim be ? ToPrimitive(lval).
6. Let rprim be ? ToPrimitive(rval).
7. If Type(lprim) is String or Type(rprim) is String, then
a. Let lstr be ? ToString(lprim).
b. Let rstr be ? ToString(rprim).
c. Return the string-concatenation of lstr and rstr.
8. Let lnum be ? ToNumeric(lprim).
9. Let rnum be ? ToNumeric(rprim).
10. If Type(lnum) is different from Type(rnum), throw a TypeError exception.
11. Let T be Type(lnum).
12. Return T::add(lnum, rnum).
Pretty daunting right? Let's dumb it down!
Semantics
The standard defines any additive operation as the result of two operands, l
and r
, being left
, and right
respectively. It also attaches other semantic descriptors like ref
, val
, prim
, str
, and num
to refer to Reference
, Value
, Primitive
, String
, and Numeric
values respectively.
JavaScript Engine References
The standard operates using References
. References are special objects/variables that reference other variables in memory. This is to save resources so instead of copying a variable every time the engine needs it, it can just reference it, which is more memory and performance efficient. This Reference
type can be dereferenced to get the actual value by using the GetValue(V)
method.
The GetValue(V)
method itself has an algorithm of its own. I've dumbed it down without going too deep as follows:
1. If V is not a reference, return it.
2. If V is invalid reference (as in using a variable that doesn't exist), throw ReferenceError.
3. Else return value.
Exotic and Ordinary Objects
In JavaScript, an exotic object is an object that contains behaviour that goes above and beyond the language itself. These objects require internal methods that are enclosed in double square brackets [[ ]]
. Think Array, Proxy, Arguments, and Module for example. The JavaScript engine does a lot of magic using internal methods to work with those objects. You cannot completely replicate this magic using just JavaScript.
Ordinary objects are normal objects that you can build using JavaScript code.
Primitives and Abstract Operations
Primitives in JavaScript are the most basic values that can be represented in the engine directly. This includes booleans, strings, numbers, and others. The standard defines primitive helpers called Abstract Operations. These helper functions allow the engine to directly manipulate values such as add two numbers, subtract, and others. Each primitive type has its own set of helpers.
Now that we have a basic understanding of how things in the EcmaScript world work let's dive into addition.
1. Let lref be the result of evaluating AdditiveExpression.
2. Let lval be ? GetValue(lref).
3. Let rref be the result of evaluating MultiplicativeExpression.
4. Let rval be ? GetValue(rref).
Up until the forth step all we do is just dereference the references we have. Now we have two values, lval
and rval
.
5. Let lprim be ? ToPrimitive(lval).
6. Let rprim be ? ToPrimitive(rval).
We now turn these values into primitives so we can operate on them easily on the engine level. The abstract operation ToPrimitive
converts its input argument to a non-Object type. It has a somewhat long algorithm.
ToPrimitive and @@toPrimitive
ToPrimitive
takes two parameters, the value you wish to turn into a primitive, and a Hint PreferredType
. This Hint helps ToPrimitive
determine the Target type.
When ToPrimitive is called with no hint, then it generally behaves as if the hint were Number. However, objects may over-ride this behaviour by defining a @@toPrimitive method. Of the objects defined in this specification only Date objects (see 20.4.4.45) and Symbol objects (see19.4.3.5) over-ride the default ToPrimitive behaviour. Date objects treat no hint as if the hint were String.
Meaning that if Hint is not present the function falls back to "number" for all objects except Date
, which defines Hint
as "string". This is one of the reasons Date is an exotic object. Date also defines more internal methods to help with serializing it to JSON.
Ignoring unimportant steps, the ToPrimitive
algorithm is:
2. If Type(input) is Object, then
d. Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
e. If exoticToPrim is not undefined, then
i. Let result be ? Call(exoticToPrim, input, « hint »).
ii. If Type(result) is not Object, return result.
iii. Else throw a TypeError exception.
f. If hint is "default", set hint to "number".
g. Return ? OrdinaryToPrimitive(input, hint).
3. Return input
The key here is @@toPrimitive
at 2.d. Remember what we said about Ordinary and Exotic objects? @@ToPrimitive
is an internal method defined only on some exotic objects that control how the object is turned into a primitive. If this method is defined on the object we're working with (Date and Symbol), it'll be called and the result will be returned. Otherwise we'll resort to OrdinaryToPrimitive
.
OrdinaryToPrimitive
OrdinaryToPrimtive
bears the responsibility of turning ordinary objects into primitives. We're getting close now! It does the following:
3. If hint is "string", then
a. Let methodNames be « "toString", "valueOf" ».
4. Else,
a. Let methodNames be « "valueOf", "toString" ».
5. For each name in methodNames in List order, do
a. Let method be ? Get(O, name).
b. If IsCallable(method) is true, then
i. Let result be ? Call(method, O).
ii. If Type(result) is not Object, return result.
6. Throw a TypeError exception.
We define a list of method names to call in order. This list can either be [toString, valueOf]
if the hint is "string" (as in string concatenation), or [valueOf, toString]
if hint is "number" (as in number addition). We then execute this list and return the value of whichever method we find first, in the same order.
We've now called ToPrimitive
on both operands and have two primitives to add together. The algorithm for AdditiveExpression
continues:
7. If Type(lprim) is String or Type(rprim) is String, then
a. Let lstr be ? ToString(lprim).
b. Let rstr be ? ToString(rprim).
c. Return the string-concatenation of lstr and rstr.
8. Let lnum be ? ToNumeric(lprim).
9. Let rnum be ? ToNumeric(rprim).
10. If Type(lnum) is different from Type(rnum), throw a TypeError exception.
11. Let T be Type(lnum).
12. Return T::add(lnum, rnum)
We see that if any of the primitives is a string then we convert both of them to strings and concatenate them. Otherwise we convert them to numbers and use the Abstract Operations defined on Number primitives, specifically add(lnum, rnum)
.
Now we have a pretty good understanding of how addition works! But we're talking about operator overloading! Remember what I said about OrdinaryToPrimitive
? OrdinaryToPrimitive
looks for toString and valueOf on objects depending on the operation. Which means we can just define them on our custom objects and use arithmetic operators with them!
Operator Overloading Custom Objects
Let's start by defining an object A
that doesn't implement these methods:
const A = {
value: 5,
}
If we try to do arithmetic operations on this object we'll get strings all the time.
A + 5 // [object Object]5
A - 5 // NaN
A * 5 // NaN
A / 5 // NaN
Now let's implement these methods on this object:
const A = {
value: 5,
valueOf: function(){
return this.value;
},
toString: function(){
return String(this.value)
}
}
Arithmetic operations should now work flawlessly!
A + 5 // 10
A - 5 // 0
A * 5 // 25
A / 5 // 1
So now we can not only just define some methods to use operator overload on our objects, but we also deeply understand how JavaScript engines do it!
If you liked this article don't forget to love this post! If you found any issues with this article or have questions don't hesitate to comment them! Thanks for reading! You can follow me on Twitter, or read more of my content here or on my personal blog!