-
Notifications
You must be signed in to change notification settings - Fork 209
Discussion of Classes in MathJax
The object-oriented programming code used in MathJax was one of the first pieces of code written for the project back in the summer of 2008. In those days, there were not many choices for libraries to do this, and none of the ones available at the time did what I wanted, so I ended up writing my own.
Some of the requirements that I had were:
-
The inheritance had to be "live", so that if new properties were added to a class after instances of the class had already been created (or subclasses of it had been declared), the instances and subclasses would get those new properties automatically.
This was critical to the plug-in structure used in MathJax, where different components would add methods to the classes to implement their features. For example, the HTML-CSS output jax adds a
toHTML()
method to the internal MathML objects that implements the output for each node type. Similarly, the SVG output jax addstoSVG()
. If those components are not loaded, those methods are not defined.This requirement ruled out libraries that operated by copying the class properties to instances or subclasses at the time they are created (this is the technique often used by libraries that implement mixins, for example).
-
The subclasses needed to be able to access the super class and its methods, and that should not require much overhead either in creating the classes or making the calls. (I.e., the methods shoudl not need to have wrapper functions to impement it.)
-
The classes shoud be able to have class methods and properties, as well as instance methods and properties. For example, a
Point
class might have a method for obtaining a unique ID for a point (e.g.,Point.getID()
) that uses a counter that is a class property forPoint
, sayPoint.id
, that is incremented during each call toPoint.getID()
. A subclassColoredPoint
ofPoint
might have aCOLOR
property that has named colors likeColoredPoint.COLOR.RED
,ColoredPoint.COLOR.BLUE
, etc. -
Classes could have properties that are shared among all instances of the class. For example, the internal MathML classes each have a
type
property that indicates the node type. Themi
class has a shared propertytype
that is set tomi
, while themfrac
hastype
set tomfrac
. All instances inherit thistype
property; it does not have to be set during instantiation, and only one copy of the string"mi"
or"mfrac"
is needed (as opposed to each instance having its own property and copy of the string, as when each instance set its owntype
variable.) -
Classes are instantiated by calling the class function to create an instance rather than using the
new
keyword (although one can usenew
if desired). For example, if themfrac
element is implemented via theMML.mfrac
object, one creates anmfrac
node viaMML.mfrac(a,b)
, wherea
andb
are the child nodes for the numerator and denominator. One need not usenew MML.frac(a,b)
(though one could). This corresponds to standard JavaScript technique of creating strings viaString(...)
and arrays viaArray(...)
rather thannew String(...)
ornew Array(...)
. (This is the factory function approach to object creation.)
It may be that these requirements will no longer apply in version 3.0, but they have been very useful and well-used features of the current MathJax code.
To implement these, MathJax currently has a base class from which others inherit the basic OOP properties, and all the classes used in MathJax are subclasses of that. To create a subclass, one provides two objects: one that gives the properties and methods for the instances of the class, and one that gives the properties and methods of the class itself. Instances are initialized via an Init()
method, if provided, and there are several techniques for accessing the super class within a subclass.
For version 3, we have discusses using ES6-style classes and class inheritance. There are several features of ES6 classes that make the items above harder to accomplish. First, they do not provide factory functions, but require the new
keyword (you have to make your own factory functions if you want to work that way). Second, the class declarations don't allow for shared data, only shared methods. This is because sharing arrays or other objects can lead to "unexpected" results (namely that what looks like a instance variable is actually shared among all the instances, so changes in one represent changes in all instances). But for data that represents defaults or that are common to all instances, it is very convenient to have those be shared properties, and if handled properly, this works very well (as it currently does in MathJax). [I personally don't believe in restricting a useful feature simply because careless people misuse it.] Third, to make class methods and data, one must add these by hand.
I would like to see how much of the existing functionality can be maintained while still using ES6 classes. To do this, I suggest some functions to make managing classes a little easier.
I would like to recover the ability to use factory functions to create instances of an object, and also the ability to add instance data that is shared (as in the current MathJax model). Finally, I'd like to make it easier to set the class methods and properties by including their definitisons at the same time that the class is defined, rather than adding them in afterward. This would make the creation of the classes more atomic. To that end, I propose the use of a function to create objects; it would accept an ES6 class and build a factory function for it. It would also accept an object that contains data properties to add to the class prototype, and an object of methods and properties to add to the class itself.
Here is an example:
let Point = OBJECT(
class {
constructor(x,y) {
this.x = x;
this.y = y;
}
toString() {
return `(${this.x},${this.y})`;
}
},
null,
{
n: 0,
getID() {return ++this.n}
}
);
This creates a Point
object that takes two values (x
and y
) and creates an object containing them. The object stringifies as (x,y)
. Thre are no share instance data properties (the null
) and there is a Point.getID()
method that returns a number to use as in id.
With this defintion, one would be able to do
var p = Point(2,5);
console.log("p = "+p);
to obtain p = (2,5)
as the output. It is also possible to do new Point(2,5)
for those who prefer that style.
At this point, you can use
console.log(Point.getID());
console.log(Point.getID());
console.log(Point.getID());
to get
1
2
3
as the output.
To create a subclass, one could do
let ColoredPoint = OBJECT(
class extends CLASSOF(Point) {
constructor(x,y,c) {
super(x,y);
this.c = (c || this.defaultColor);
}
toString() {
return super.toString() + " in color " + this.c;
}
},
{
defaultColor: "black"
},
{
COLOR: {
RED: "red",
GREEN: "green",
BLUE: "blue",
BLACK: "black"
}
}
);
This makes a point with an associated color. There is a default color that is shared by all the points and is used to initialize the instance color if one isn't given. There is also a class property COLOR
that provides access to predefined colors. So
var p1 = ColoredPoint(2,5);
console.log("p1 = "+p1);
var p2 = ColoredPoint(-3,1,ColoredPoint.COLOR.BLUE);
console.log("p1 = "+p2);
would produce
p1 = (2,5) in color black
p2 = (-3,1) in color blue
In the definition of ColoredPoint
, we needed to extend the class of the Point
object; but Point
is not the ES6 class used to define it, it is the factory function used to create the Point
objects. Since we want to extend the underlying ES6 object, we used CLASSOF(Point)
to obtain it.
One can include getters and setters in the objets passed to OBJECT()
as well. For example:
let ColoredPoint = OBJECT(
class extends CLASSOF(Point) {
constructor(x,y,c) {
super(x,y);
this.c = (c || this.defaultColor);
}
toString() {
return super.toString() + " in color " + this.c;
}
},
{
defaultColor: "black",
get COLOR() {return CLASSOF(this).COLOR}
},
{
COLOR: {
RED: "red",
GREEN: "green",
BLUE: "blue",
BLACK: "black"
}
}
);
This creates a getter for COLOR
that returns the ColoredPoint.COLOR
object. That means that this.COLOR.RED
could be used in the methods of a ColoredPoint
to access the color values. Note that CLASSOF()
can be used on an instance object to get the class for that object (so as to access ColoredPoint
and its methods).
This is just an example, however, as it would probably be better to do
let ColoredPoint = OBJECT(
class extends CLASSOF(Point) {
constructor(x,y,c) {
super(x,y);
this.c = (c || this.defaultColor);
}
toString() {
return super.toString() + " in color " + this.c;
}
},
null,
{
COLOR: {
RED: "red",
GREEN: "green",
BLUE: "blue",
BLACK: "black"
}
}
);
ColoredPoint.prototype.COLOR = ColoredPoint.COLOR;
ColoredPoint.prototype.defaultColor = ColoredPoint.COLOR.BLACK;
or
const COLOR = {
RED: "red",
GREEN: "green",
BLUE: "blue",
BLACK: "black"
};
let ColoredPoint = OBJECT(
class extends CLASSOF(Point) {
constructor(x,y,c) {
super(x,y);
this.c = (c || this.defaultColor);
}
toString() {
return super.toString() + " in color " + this.c;
}
},
{
defaultColor: COLOR.BLACK,
COLOR: COLOR
},
{
COLOR: COLOR
}
);
rather than use a getter just to access a constant object.
The OBJECT
and CLASSOF
functions are not hard to implement. Here is one version that you can use to expermient with.
const CREATE = Symbol("create");
const CLASSOF = function (object) {
if (object.prototype) return object.prototype.constructor;
return object.constructor[CREATE];
}
const ASSIGN = function (obj,def) {
let props = [];
Object.keys(def).forEach(key => {
props[key] = Object.getOwnPropertyDescriptor(def,key)
});
Object.getOwnPropertySymbols(def).forEach(sym => {
let prop = Object.getOwnPropertyDescriptor(def,sym);
if (prop.enumerable) props[sym] = prop;
});
Object.defineProperties(obj,props);
return obj;
}
const AUGMENT = function (def,classdef) {
if (def) ASSIGN(this.prototype,def);
if (classdef) ASSIGN(this,classdef);
};
const OBJECT = function (object,def,classdef) {
let create = function (...args) {return new object(...args)};
if (object[CREATE]) ASSIGN(create,object[CREATE]);
create.prototype = object.prototype;
create.Augment = AUGMENT;
create.Augment(def,classdef);
object[CREATE] = create;
return create;
};
The CREATE
, ASSIGN
and AUGMENT
functions are for internal use. I haven't made this into a module, which would hide those. Note that each class gets an Augment()
method that can be used to add new instance and class methods and properties. The current MathJax uses that extensively, but that may be re-evaluated for version 3.
In this implementation, subclasses inherit the class properties of their parent classes. This is done via copying, not prototypal inheritance (I could not find a way to do this other than mutating the create
function by setting the __proto__
object, but while this works, Firefox says this has dire consequences on performance; more on that in a separate post). So in the examples above, ColoredPoint.getID()
can be used to get an ID for a point. Note that, as it stands, the Point
and ColoredPoint
objects have two separate and unrelates sequences of numbers because both have the n
property, and getID()
uses this.n
. If you wanted the two to share the same sequence, getID()
in Point
could be defined as
getID() {return ++Point.n}
so that both classes use the n
from the Point
object instead. It all depends on the requirements for the id.