monomorph
v2.3.0
Published
Monomorph
Readme
Monomorph is a Typescript and Javascript library for performance-critical applications. Monomorph was originally developed by Hamza Kubba to power Bounce, a free & open source physics library for the web. Monomorph also powers MECS, a free & open source ECS library.
With Monomorph, you can easily create classes that:
- have built-in object pooling to minimize garbage collection
- can nest and/or reference monomorph classes
- have built-in performant serialization/deserialization methods for data (including handling nesting and references), as well as other utility methods like .copy() which copies including nested object values.
- keep more methods monomorphic, avoiding potential slowdowns of up to 60x
- avoid repetitive boilerplate
- are strongly typed, when using Typescript
How it works
In a nutshell, Monomorph dynamically creates the JS code for a class based on the schema you provide, then you extend that class. Each generated class has unique helper methods that are not inherited, which avoids performance drops that result from class inheritance in JS. Read "Keep in Monomorphic" for more info about monomorphism, why it matters, and how this library helps.
Install into your project
npm install monomorphUsage
Basic class schema (JavaScript)
Let's say we want to create a Vector class, which has x and y numbers on it. This is what that would look like in
import { createClass, NumberType } from 'monomorph';
const vectorProps = {
x: 0,
y: 0,
// ^^ this is shorthand for:
// y: NumberType(0)
};
// note the double function call, the first () is required to set up generics in TypeSscript, but unfortunately still needed in JS
class Vector extends createClass()(vectorProps) {
/* your own methods here, for example distanceTo() */
distanceTo(otherVector) {
const dx = this.x - otherVector.x;
const dy = this.y - otherVector.y;
return Math.sqrt(dx * dx + dy * dy);
}
}import { createClass, NumberType, props } from 'monomorph';
const vectorProps = props({
x: 0,
y: 0,
// ^^ this is shorthand for:
// y: NumberType(0)
});
// note the double function call, the first () is required to set up generics in TypeSscript
class Vector extends createClass<Vector>()(vectorProps) {
/* your own methods here, for example distanceTo() */
distanceTo(otherVector: Vector) {
const dx = this.x - otherVector.x;
const dy = this.y - otherVector.y;
return Math.sqrt(dx * dx + dy * dy);
}
}Using Monomorph classes
// Vector defined above
const v1 = Vector.create(); // x and y are 0, based on the defaults defined in the schema
const v2 = Vector.create({ x: 1, y: 2 });
const v3 = Vector.create({ x: 3 }); // y is 0
v1.x = 5;
v1.reset(); // built-in method, now v1 is reset to the defaults defined in the schema (x:0, y:0)
v1.copy(v2); // built-in method, now v1 has the same values as v2, but they are still different objects
const distance = v1.distanceTo(v3); // custom method defined aboveObject pooling
Monomorph automatically creates a Pool class for classes you create with it. For example, with the Vector class defined above, Vector.Pool is the pool class. This is how you use it:
// Vector defined above
// this creates a pool of vectors
const vectorPool = new Vector.Pool();
// v1 is created inside vectorPool
const v1 = Vector.create({ x: 1, y: 2 }, vectorPool);
// v2 is created in the same pool as v1
const v2 = v1.createInPool({ x: 3, y: 4 });
// alternate syntax
const v3 = vectorPool.create({ x: 5, y: 6 });
for (const vector of vectorPool) {
// loops over v1, v2, v3
}
// v2 is returned to the pool
v2.destroy();
for (const vector of vectorPool) {
// because we used v2.destroy(), this now loops over v1, v3
}
// creating v4 now will automatically reuse the v2 object
// however v4 will be reset to the defaults, { x: 0, y: 0 }
const v4 = vectorPool.create();
for (const vector of vectorPool) {
// loops over v1, v4, v3
}Nested objects (JavaScript)
Monomorph supports its classes being composed of other monomorph classes in a hierarchy. Child objects automatically get created when the parent is created or reused
import { createClass, ChildType } from 'monomorph';
// Vector as defined above in the "Basic class schema" section
const boundsProps = {
min: Vector,
max: Vector,
// ^^ this is shorthand for:
// max: ChildType(Vector),
};
class Bounds extends createClass()(boundsProps) {}
const bounds = Bounds.create({
min: { x: -1, y: -2 },
max: { x: 10, y: 5 },
});
// now, bounds.min and bounds.max are automatically instances of the Vector classimport { createClass, ChildType, props } from 'monomorph';
// Vector as defined above in the "Basic class schema" section
const boundsProps = props({
min: Vector,
max: Vector,
// ^^ this is shorthand for:
// max: ChildType(Vector),
});
class Bounds extends createClass<Bounds>()(boundsProps) {}
const bounds = Bounds.create({
min: { x: -1, y: -2 },
max: { x: 10, y: 5 },
});
// now, bounds.min and bounds.max are automatically instances of the Vector classReferences (JavaScript)
To reference another monormorph instance without it being dependent on this one, there is ReferenceType() and LazyReferenceType(). They function the same in practice, however LazyReferenceType() allows you to have circular dependencies, including references to instances of the same class.
const listItemProps = {
vector: ReferenceType(Vector),
next: LazyReferenceType(() => ListItem),
};
class ListItem extends createClass()(listItemProps) {}
const myVector = Vector.create();
const listItem = ListItem.create({
vector: myVector,
});
console.log(listItem.next); // null
console.log(listItem.vector === myVector); // true
const myVector2 = Vector.create({ x: 1, y: 2 });
const listItem2 = ListItem.create({
vector: myVector2,
});
console.log(listItem2.next); // null
console.log(listItem2.vector === myVector2); // true
console.log(listItem2.vector === listItem.vector); // false
listItem.next = listItem2;
console.log(listItem.next); // listItem2
console.log(listItem.next.vector === myVector2); // true
listItem2.destroy();
// ReferenceType and LazyReferenceType automatically handle destroyed objects
// this works even if the object is reused, as reference types automatically track versioning
console.log(listItem.next); // null
const listItemProps = props({
vector: ReferenceType(Vector),
// unfortunately this is quite an unpleasant workaround to get proper typing for
// lazy references, until Typescript supports recursive/circular types better
next: LazyReferenceType((() => ListItem) as () => never) as PropertyDefinitionReference<ListItem | null, true>,
});
class ListItem extends createClass<ListItem>()(listItemProps) {}
const myVector = Vector.create();
const listItem = ListItem.create({
vector: myVector,
});
console.log(listItem.next); // null
console.log(listItem.vector === myVector); // true
const myVector2 = Vector.create({ x: 1, y: 2 });
const listItem2 = ListItem.create({
vector: myVector2,
});
console.log(listItem2.next); // null
console.log(listItem2.vector === myVector2); // true
console.log(listItem2.vector === listItem.vector); // false
listItem.next = listItem2;
console.log(listItem.next); // listItem2
console.log(listItem.next.vector === myVector2); // true
listItem2.destroy();
// ReferenceType and LazyReferenceType automatically handle destroyed objects
// this works even if the object is reused, as reference types automatically track versioning
console.log(listItem.next); // nullCustom Types
For various reasons, sometimes you want a field in your schema to be a non-monomorph class. For example, you might want to reference an HTML element, or use a TypedArray. This is what the CustomType is for, and it allows you to customize what happens to the field at various points in the instance's lifecycle, for example you may have some custom handling of the field when the instance is created or destroyed. Currently, the best way to see how to use custom types is to see the related tests in tests/customType.test.ts.
Serializing/Deserializing
// Bounds and Vector as defined above
const bounds = Bounds.create({
min: { x: -1, y: -2 },
max: { x: 10, y: 5 },
});
const array = new Float64Array(); // can also be Float32Array or a basic js Array []
bounds.toArray(array); // toArray and fromArray are automagically generated methods
const bounds2 = Bounds.create(); // create with default values
bounds2.fromArray(array); // now bounds2 has the same values as bounds
const aabb3 = Bounds.createFromArray(array); // same as bounds2 in one step
// Pools can also be serialized/deserialized this way
const vectorPool = new Vector.Pool();
const boundsPool = new Bounds.Pool();
const vectorPool2 = new Vector.Pool();
const boundsPool2 = new Bounds.Pool();
let startOffset = 0;
const array = [];
startOffset = vectorPool.toArray(array, startOffset);
startOffset = boundsPool.toArray(array, startOffset);
startOffset = 0;
startOffset = vectorPool2.fromArray(array, startOffset);
startOffset = boundsPool2.fromArray(array, startOffset);
// now, vectorPool2 has the same number of instances with the same data as vectorPool, and
// likewise for boundsPool2 copying boundsPool
// serializing and deserializing references is necessarily more complex,
// see `tests/serialization.test.ts` for in-depth examplesAPI summary/reference
[!NOTE]
Parameters ending with
?below means those parameters are optional, meaning they can be omitted, and they can receiveundefinedas a value to skip them. E.g.MyClass.create(undefined, myPool).
Classes created using Monomorph have the following static methods:
| Method | Description |
| --- | --- |
| TheClass.create(dataObj?, pool?) | Creates an instance of this class and returns it. dataObj is, by default, expected to be a JSON representation of the expected tree of data. If a pool is provided, the instance is created inside it. Automatically reuses destroyed instances from the pool if available, calling .reset(dataObj?) on them. Returns the new instance. |
| TheClass.createFromArray(sourceArrayOfNumbers, startOffset?, pool?) | Creates an instance of this class from an array of numbers, and returns it (create followed by fromArray). See instance.fromArray() for details. If a pool is provided as the last parameter, the instance is created inside it. Returns the new instance. |
Instances created using Monomorph automatically have the following methods and properties (in addition to properties derived from your schema):
| Method | Description |
| --- | --- |
| instance.copy(otherInstance) | Copy otherInstance into instance, recursively. Returns this. |
| instance.set(dataObj) | Set only the fields specified in dataObj on instance. Recursive, but only updates fields that are defined on dataObj. Returns this. |
| instance.reset(dataObj?) | Sets the fields specified in dataObj on instance. Recursive. Any fields that are missing from dataObj are reset to the defaults. Returns this. |
| instance.destroy() | Puts instance back into its pool for future reuse. Returns this. |
| instance.isDestroyed() | Returns true if instance is currently destroyed, false otherwise. Note that if that instance is reused, this method would return false (i.e. not destroyed). Returns boolean. |
| instance.toArray(targetArrayOfNumbers, startOffset?) | Encodes this instance (recursively, including all nested types) into numbers. targetArrayOfNumbers is expected to be a Float64Array, Float32Array or a JS array (created with [] syntax or new Array()). startOffset is where in the target array this instance should start (0 by default). Returns a number indicating the next index to write to (i.e. you can use it as startOffset to a future toArray() call). |
| instance.fromArray(sourceArrayOfNumbers, startOffset?) | Populates this instance (recursively, including all nested types) from the data in an array of numbers. sourceArrayOfNumbers is expected to be a Float64Array, Float32Array or a JS array (created with [] syntax or new Array()), that was populated using toArray(). startOffset is where in the source array this instance's data starts (0 by default). Returns a number indicating the next index to read from (i.e. you can use it as startOffset to a future fromArray() call). |
| instance.fromArrayNoReferences(sourceArrayOfNumbers, startOffset?) | Used for handling circular references, this is like .fromArray(), but skips setting reference fields (ReferencyType, LazyReferenceType). See tests/serialization.test.ts for in-depth examples. |
| instance.fromArrayOnlyReferences(sourceArrayOfNumbers, startOffset?) | Used for handling circular references, this is like .fromArray(), but skips setting fields that are NOT references. See tests/serialization.test.ts for in-depth examples. |
| Property | Description |
| --- | --- |
| instance.pool | The pool this instance is in, or null if it is not in a pool. |
| instance.poolIndex | Usually for internal use. The index of this instance in its pool, or -1 if it is not in a pool. |
| instance.poolVersion | Usually for internal use. This is a number that increments whenever this instance is destroyed, as well as when it is reused, so you can determine if a reference is stale (handled automatically by ReferenceType, ReferenceListType, LazyReferenceType and LazyReferenceListType). |
The TheClass.Pool class has the following methods and properties:
| Method | Description |
| --- | --- |
| constructor(maxLength?) | Pools are created with normal new syntax, e.g. const pool = new TheClass.Pool(). If maxLength is specified, the pool will not grow past that number of instances, otherwise maxLength defaults to Infinity. |
| pool.create(dataObj?) | Creates an instance of TheClass inside this pool. This is like calling TheClass.create(dataObj, pool). Returns the new instance. |
| pool.toArray(targetArrayOfNumbers, startOffset?) | Like instance.toArray but for an entire pool. A pool created from this array will have the same instances, including empty instances, inside it. |
| pool.fromArray(sourceArrayOfNumbers, startOffset?) | Like instance.fromArray but for an entire pool. |
| pool.fromArrayNoReferences(sourceArrayOfNumbers, startOffset?) | Like instance.fromArrayNoReferences but for an entire pool. |
| pool.fromArrayOnlyReferences(sourceArrayOfNumbers, startOffset?) | Like instance.fromArrayOnlyReferences but for an entire pool. |
| Property | Description |
| --- | --- |
| pool.length | The current number of instances in this pool that are not destroyed. |
| pool.maxLength | The maximum number of instances allowed in this pool. |
| pool.array | Usually for internal use. The array that has the instances, including destroyed ones and potential nulls. |
| pool.freeIndices | Usually for internal use. An array that has the indices instances of the instances that are currently destroyed and can be reused. |
Additional and advanced examples
There are many examples written in Typescript in the tests folder, including CustomType, ReferenceListType and others, as well as handling the serialization and deserialization of pools and references, including circular references.
Roadmap
- More official benchmarks; basic benchmarks were added in version 1.0.1 but more extensive benchmarks would be good
- Fixing bugs: while Monomorph is heavily used in our physics library Bounce, there may be bugs around edge cases and untested complex combinations. Please file an issue if you run into problems
- More supported types: Strings are partially implemented and not considered fully supported yet, other types will be added based on requests and code contributions
- Want to see something here? Please create an issue on codeberg
How to contribute
If you like this project and would like to support our work, please consider contributing code via pull requests, or donating via open collective. Contributions are greatly appreciated!
