On a project I'm working on, I came across a strange requirement (which I'll explain in a bit) and most importantly, it needed to be performant. After some testing I concluded that going with the obvious native faster solution wasn't actually the faster solution...
To best explain the requirement, let's bring in the traditional example of cars
class Car{
constructor(topSpeed, color, name){
this.position = 0
this.topSpeed = topSpeed
this.color = color
this.name = name
}
}
let firstCar = new Car(110, 'red', 'Honda Civic')
Unfortunately, as simple and elegant as this looks, it may not be the best idea. Can you see why? Well when I want to implement logic, the Honda's engine may work differently from a Ford, and so I would be forced to write a really large switch statement for each car type.
No problem! This kind of problem has been addressed before, just create a HondaCivic class that extends the Car class.
class Car{
constructor(topSpeed, color, name){
this.position = 0
this.topSpeed = topSpeed
this.color = color
this.name = name
}
}
class HondaCivic extends Car{
constructor(color){
super(110, color, 'Honda Civic')
}
startUp(){
//Logic to start our honda
}
}
Another problem arises, if we wanted to track 1000+ honda civics, we would have 1000+ times the name 'Honda Civic'
in memory, as well as any other properties of the Honda civic family. If you've ever used function-style constructors before, you'd know there's a simple solution to that too:
class Car{
constructor(color){
this.position = 0
this.color = color
}
}
class HondaCivic extends Car{
startUp(){
//...
}
}
//Instead of defining name & topSpeed on instances
//we define it on the prototype
HondaCivic.prototype.name = 'Honda Civic'
HondaCivic.prototype.topSpeed = 110
let firstCar = new HondaCivic('red')
console.log(firstCar.topSpeed == 110) //true
The big issue
Now let's say we want to test our car around a track
function testCar(car){
let seconds = 0
//run car around a 100km track while counting seconds
while(car.position < 100 * 1000){
car.position += car.topSpeed
seconds++
}
return seconds
}
Now, don't get me wrong, this function works fine and performs about as good as you can get, or does it?
Let's time how fast js can execute this function
Neat! I'm sure no one would need to race their car 150 million times per second around a track
Let's try adding more cars
class FordMondeo extends Car{...}
FordMondeo.prototype.topSpeed = 120
FordMondeo.prototype.name = 'Ford Mondeo'
class BmwM4 extends Car{...}
...
After we've added 4 cars and we've tested them all, we go back to our Honda civic and run the function again
What??
When we ran our test earlier, we got 156 million ops/s, now we only get 110. What happened?
Earlier, the testCar
function was optimized to recieve exclusively HondaCivic
objects. Now that we've tested a bunch of more cars, the Javascript engine can no longer be sure what car the function will recieve.
As such, it no longer knows for sure that the car we're testing has the same properties with the same type. But hang on, all the cars we tested did have the same properties and even the same types.
Well, that's the magic of optimizations, we never know for sure what's going on under the hood or why it does something that way. There may be a reason, or it may just be a silly mistake from V8's developers. ¯\(ツ)/¯
The solution
Enough explaining and complaining, is there a way to get around this so fundamental issue?
As it turns out, yeah and it's really simple:
Avoid needing multiple classes: don't use the prototype
What if, instead of creating a new class with different properties & methods, we put them in an object, a CarDefinition
that we can attach to each car?
class Car{
constructor(definition, color){
this.props = definition
this.position = 0
this.color = color
}
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}
let firstCar = new Car(HondaCivic, 'red')
console.log(firstCar.props.topSpeed == 110) //true
Now that we only use 1 class Car
for all types of cars, testCar
will stay optimized, as it should.
But, you may complain, I don't want to have to access .props
every time, I'm a programmer, and programmers are lazy
Don't worry, I got you covered. Let's add some getters for a shortcut
class Car{
constructor(definition, color){
this.props = definition
this.position = 0
this.color = color
}
get topSpeed(){return this.props.topSpeed}
get name(){return this.props.name}
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}
let firstCar = new Car(HondaCivic, 'red')
console.log(firstCar.topSpeed == 110) //true
Now, we can use our object just as before, without having to worry about props
Performance of getters
Now our example seems a bit more complex, we've got an extra object props
and every time we want to get a property, we'll actually be implicitly calling the getter trap. Shouldn't this make our code actually much slower than before? Well, turns out here most Javascript engines are actually smart and inline our function.
When a function is inlined, it's basically removed, and replaced with its contents. Javascript will essentially optimize our code to something like this:
class Car{
constructor(definition, color){
this.props = definition
this.position = 0
this.color = color
}
//These 2 functions are "inlined"
//get topSpeed(){return this.props.topSpeed}
//get name(){return this.props.name}
}
const HondaCivic = {name: 'Honda Civic', topSpeed: 110}
let firstCar = new Car(HondaCivic, 'red')
let _this = firstCar
//Note how our getter access was replaced with the actual function body
console.log((_this.props.topSpeed) == 110)
Typically, the engine will decide whether or not a function can be inlined depending on how big it is, and what variables it accesses. After some testing, I could confirm that all major browsers will inline our little getter. 👍
Final test
Typical approach
class Car{
constructor(color){
this.position = 0
this.color = color
}
}
class HondaCivic extends Car{}
HondaCivic.prototype.topSpeed = 110
HondaCivic.prototype.name = 'Honda Civic'
class FordMondeo extends Car{}
FordMondeo.prototype.topSpeed = 120
FordMondeo.prototype.name = 'Ford Mondeo'
class BmwM4 extends Car{}
BmwM4.prototype.topSpeed = 130
BmwM4.prototype.name = 'BMW M4'
class Lamborghini extends Car{}
Lamborghini.prototype.topSpeed = 160
Lamborghini.prototype.name = 'Lamborghini'
class AnotherCar extends Car{}
AnotherCar.prototype.topSpeed = 999
AnotherCar.prototype.name = 'run out of ideas'
let firstCar = new HondaCivic('red')
function testCar(car, trackLength){
let seconds = 0
//run car around a 100km track while counting seconds
while(car.position < trackLength){
car.position += car.topSpeed
seconds++
}
return seconds
}
testCar(firstCar, 100000)
testCar(new FordMondeo('grey'), 100000)
testCar(new BmwM4('green'), 100000)
testCar(new Lamborghini('blue'), 100000)
testCar(new AnotherCar('white'), 100000)
Test case:
testCar(firstCar,100000)
Result 110-111 million ops/s
Our solution
class Car{
constructor(definition, color){
this.props = definition
this.position = 0
this.color = color
}
get topSpeed(){return this.props.topSpeed}
get name(){return this.props.name}
}
const HondaCivic = {topSpeed: 110, name: 'Honda Civic'}
const FordMondeo = {topSpeed: 120, name: 'Ford Mondeo'}
const BmwM4 = {topSpeed: 130, name: 'BMW M4'}
const Lamborghini = {topSpeed: 160, name: 'Lamborghini'}
const AnotherCar = {topSpeed: 999, name: 'run out of ideas'}
let firstCar = new Car(HondaCivic, 'red')
function testCar(car, trackLength){
let seconds = 0
//run car around a 100km track while counting seconds
while(car.position < trackLength){
car.position += car.topSpeed
seconds++
}
return seconds
}
testCar(firstCar, 100000)
testCar(new Car(FordMondeo, 'grey'), 100000)
testCar(new Car(BmwM4, 'green'), 100000)
testCar(new Car(Lamborghini, 'blue'), 100000)
testCar(new Car(AnotherCar, 'white'), 100000)
Test (same):
testCar(firstCar,100000)
Result: 156 million ops/s
Top comments (0)