Two Headed ES6 Classes!

Owen Densmore
DailyJS
7 min readApr 18, 2017

--

I’m building a simulation framework for SimTable — software for “first responders” (police, fire, hazmat, etc). I need to support 100,000 or more objects (agents). Thats a LOT!

I also need to quickly and easily change “default” (or shared) properties of these objects, so that all “cops” agents default to large yellow squares while all “robbers” default to small red triangles.

An interesting combination of Class and Object.create() solved both problems by allowing two top properties layers for classes, not the usual one. Here’s the story.

Simple Objects

We’ll start looking at the size of simple objects, using Chrome DevTools. Go to an about:blank page. Open the JavaScript console and paste this into it:

a = new Array(1e6).fill(0) // A 1MB filled array for map to use
b = a.map((val, ix) => ({id: ix})) // (): treat {} as value, not fcn
c = a.map((val, ix) =>
({id: ix, shape: 'square', size: 10.5, color: 'green'}))

Looks like this:

Click Profiles, Heap Snapshot, then Take Shapshot. You’ll see a table. Use Retained Size to sort biggest first. Look under Window > Window. You’ll see our three arrays:

So a is 8MB, b is 40MB, c is 64MB! Even simple object arrays like c get large fast. And in real simulations, 20 or more properties per agent is common.

ES6 Classes

After that warm-up, we’ll move onto arrays of ES6 Classes. Refresh the page, then paste this into the console as before.

class Agent {
constructor(id, shape = 'square', size = 10.5, color = 'green') {
Object.assign(this, {id, shape, size, color}) // init this
this.x = 0; this.y = 0 // all agents have a location.
}
}
a = new Array(1e6).fill(0)
b = a.map((val, ix) => new Agent(ix))

Again, use Chrome’s Profiles Heap Snapshot.

The array of agents is now 80MB. Basically what you’d expect, a bit larger than our initial c object array which had two fewer properties per object.

The Prototype Stack

Let’s pause a bit to look more deeply at the structure of objects. Paste this function into the console to Print the Prototype Stack of objects:

function pps (obj) { // print prototype stack
let count = 1
while (obj) {
const okeys = Object.keys(obj)
const str = okeys.length > 0 ?
`[${okeys.join(', ')}]` : `[${obj.constructor.name}]`
console.log(`[${count++}]: ${str}`)
obj = Object.getPrototypeOf(obj)
}
}

Now look at one of the original objects and an instance of our Agent class:

pps({id: 10, shape: 'square', size: 10.5, color: 'green'})
[1]: [id, shape, size, color]
[2]: [Object]
pps(b[0]) // show the first Agent's proto stack in array b above
[1]: [id, shape, size, color, x, y]
[2]: [Agent]
[3]: [Object]

The Object example shows two levels and looks like this, when pasted into the console:

The top object actually contains the key/value pairs .. data. The second one is class Object which has the “methods’ that operate on the data. Methods are just functions that know about their associated data.

The Agent example shows a third level which contains the methods of Agent (just the constructor for now).

This is the object’s protocol stack, layers of functionality with a top-most object containing the data.

Two-Headed Classes

Now for the Two Headed Classes. The standard (one-headed) Agent has only one data layer.

[1]: [id, shape, size, color, x, y]
[2]: [Agent]
[3]: [Object]

But in a large simulation, there are only a few different shapes, sizes and colors. Is there a way to have these be “defaulted” or “shared”?

Yes. Just add a second data layer! We’ll do this by taking our first Agent in the b array and with Object.create() use b as the prototype of a new empty object. We’ll then “promote” the id property to that top layer:

two = Object.create(b[0]) // use an agent as prototype
two.id = 42 // “promote” id to top Object
console.log(b[0].id, two.id) // prints: 0 42
pps(two)
[1]: [id]
[2]: [id, shape, size, color, x, y]
[3]: [Agent]
[4]: [Object]

We’ve turned an Agent instance into a prototype for a new object, and set it’s id to 42, overriding the prototype’s value of 0. The proto stack shows that our new agent is has only an id property, leaving the common values as “defaults”.

Now use this idea to build a new array of these two-headed objects:

proto = new Agent(0) // new Agent for two-headed prototype
function newTwoHeaded (ix) {
const obj = Object.create(proto); obj.id = ix; return obj
}
c = a.map((val, ix) => newTwoHeaded(ix))
pps(c[0])
[1]: [id]
[2]: [id, shape, size, color, x, y]
[3]: [Agent]
[4]: [Object]

Using another Heap Snapshot, we see:

This shows a decline from 80MB for b to 64MB for c, not huge but shows we’re on the right track.

Finished Product

Now let’s make a “finished product”, with a more real-world example.

Start with a class designed to be the proto generator. It will be instantiated just once. We use the trick of using a static function that returns all the properties to be used by the prototype. This is a great way to show all the properties being used by a class too. Code is easier to read.

class AgentProto {
static defaults () {
return {
id: null,
x: 0,
y: 0,
shape: 'square',
size: 10.5,
color: 'red',
strokeColor: 'yellow',
hidden: false,
label: null,
labelOffset: [0, 0],
labelFont: '10px sans-serif',
labelColor: 'black'
}
}
constructor () {
Object.assign(this, AgentProto.defaults())
}
newAgent (id, x, y) {
const obj = Object.create(this)
return Object.assign(obj, {id, x, y})
}
setDefault (name, value) { this[name] = value }
getDefault (name) { return this[name] }
}

The number of properties here may seem extreme, but in a real simulation, many more properties is not unusual. We also include a utility to create new agents using this AgentProto instance, along with a getter/setter for defaults for the agents.

AgentProto is used like this:

agentProto = new AgentProto()
d = a.map((val, ix) => agentProto.newAgent(ix, ix/10, -ix/10))

How much a savings is this over not being two-headed? To see, we’ll simply make a new AgentProto per instance, creating an object with all properties for each instance.

function fatAgent (id, x, y) {
const a = new AgentProto()
return Object.assign(a, {id, x, y})
}
e = a.map((val, ix) => fatAgent(ix, ix/10, -ix/10))

The standard Class array is 184MB, while the Two Headed Class array is only 64MB or roughly 1/3 the size of the usual class approach. Nice.

Defaults

Besides reduced size, the defaults capability is quite useful in itself, allowing you to change the shape, for example, from the initial ‘square’ to ‘circle’, not for just one of the instances in the array, but for all of them.

d.every((agent) => agent.shape === 'square') // true
agentProto.setDefault('shape', 'circle')
d.every((agent) => agent.shape === 'square') // false
d.every((agent) => agent.shape === 'circle') // true

Overriding a single shape to a unique one would result in a false in both tests, yet setting either the default or the override to the same value would again result in all shapes being equal.

d[0].shape = 'triangle'
d.every((agent) => agent.shape === 'circle') // false
d[0].shape = 'circle'
d.every((agent) => agent.shape === 'circle') // true
d[0].shape = 'triangle'
d.every((agent) => agent.shape === 'triangle') // false
agentProto.setDefault('shape', 'triangle')
d.every((agent) => agent.shape === 'triangle') // true

This is far more powerful than classical Static properties, which are fixed, while defaults are static, only in the sense of shared, but modifiable.

Let’s illustrate our cops and robbers example mentioned above:

- cops: large yellow squares
- robbers: small red triangles

We’ll make a million of both, but now with different defaults:

cop = new AgentProto()
cop.setDefault('size', 15)
cop.setDefault('color', 'yellow')
cop.setDefault('shape', 'square')
cops = a.map((val, ix) => cop.newAgent(ix, ix/10, -ix/10))
robber = new AgentProto()
robber.setDefault('size', 5)
robber.setDefault('color', 'red')
robber.setDefault('shape', 'triangle')
robbers = a.map((val, ix) => robber.newAgent(ix, ix/10, -ix/10))

Both arrays are the 64MB, as before, we’re setting id, x, y for each.

But cops and robbers have different size, color and shape, yet not larger:

cop0 = cops[0] // first cop
console.log('cop0', cop0.size, cop0.color, cop0.shape)
robber0 = robbers[0] // first robber
console.log('robber0', robber0.size, robber0.color, robber0.shape)
cop0 15 yellow square // big yellow square
robber0 5 red triangle // small red triangle

And the values are the same for all cops/robbers:

cops.every((cop) => cop.shape === 'square') // true
robbers.every((robber) => robber.shape === 'triangle') // true

Summary

We started looking at how objects with many properties can get really large. We found that adding an extra “layer” to a Class instance with Object.create() provided a way to have only the unique values in the top layer, producing smaller objects.

But there is another very handy feature: “shared” or “default” values. By changing a value in the second layer of properties, they immediately become available in all instances of this class as seen by the cops & robbers example.

What do you think?

--

--