Skip to content

Discussion of Classes in MathJax

Davide P. Cervone edited this page Jun 4, 2016 · 3 revisions

Classes in MathJax as they are now

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 adds toSVG(). 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 for Point, say Point.id, that is incremented during each call to Point.getID(). A subclass ColoredPoint of Point might have a COLOR property that has named colors like ColoredPoint.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. The mi class has a shared property type that is set to mi, while the mfrac has type set to mfrac. All instances inherit this type 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 own type variable.)

  • Classes are instantiated by calling the class function to create an instance rather than using the new keyword (although one can use new if desired). For example, if the mfrac element is implemented via the MML.mfrac object, one creates an mfrac node via MML.mfrac(a,b), where a and b are the child nodes for the numerator and denominator. One need not use new MML.frac(a,b) (though one could). This corresponds to standard JavaScript technique of creating strings via String(...) and arrays via Array(...) rather than new String(...) or new 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.

Classes in version 3

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.

The proposal

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.

A potential implementation

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.