Object-oriented JavaScript 2014-05-18
There are two viable design patterns for writing object-oriented JavaScript. The first of these patterns, prototype chaining, is built into the language but has downsides. The second approach avoids these downsides by adding additional library code and is exemplified by base2. I've been using a similar, but far more lightweight piece of code (only 40 lines of code and 2kb unminified) that improves on the snippet that John Resig presents in his recent book Secrets of the JavaScript Ninja. I'll briefly talk about the first pattern before discussing my version of "roll your own" JavaScript inheritance.
Inheritance through prototype chaining
Consider the following example of prototype chaining:
function Animal() { this.breathe = function() { console.log('Breathing'); }; }; function Mammal() { this.run = function() { console.log('Running'); }; }; Mammal.prototype = new Animal(); function Bat() { this.fly = function() { console.log('Flying'); }; this.run = function() { console.log('cannot run'); }; }; Bat.prototype = new Mammal(); var bat = new Bat(); bat.breathe(); // output: Breathing bat.run(); // output: cannot run bat.fly(); // output: Flying if (bat instanceof Bat) console.log('Bat'); // output: Bat if (bat instanceof Mammal) console.log('Mammal'); // output: Mammal if (bat instanceof Animal) console.log('Animal'); // output: Animal
Instances of the class Bat
inherit, as expected, the functionality
of superclasses, but this technique also has some downsides:
- Confusion can arise because a constructor is indistinguishable from any other function.
- 2 steps are required to implement the inheritance.
- There is no way to directly reference superclass methods.
Any JavaScript function can in fact be used as a constructor, although the result will certainly be useless if the function hasn't been built for that purpose. Code becomes easier to read, however, if you can immediately distinguish between the declaration of a function intended to be used functionally and a function intended to be used as a constructor.
Secondly, there's no syntax for specifying the inheritance hierarchy when you
declare the constructor for a derived class. You have to make that declaration in
a separate place (lines 13 and 24 above), and the declaration is itself prone to the
error of forgetting the new
operator when you create a subclass. It's actually kind of counter-intuitive
that it is not the class "Mammal" but rather one specific instance of that
class that is going to be the prototype for the derived class. But if you leave the new
out, your derived classes won't behave as expected.
Object-oriented programmers coming from Java or C++ would also expect to be
able to call superclass methods, which often need to be modified in subclasses. For example,
we might want to have a Dolphin
subclass of Mammal
that needs
to come up for air before breathing. So, it would be nice to have some kind of
syntax like:
function Dolphin() { this.breathe = function() { console.log("Surfacing"); super.breathe(); }; }; Dolphin.prototype = new Mammal();
Customized inheritance
We can resolve these difficulties with a custom implementation of inheritance:
var Extend = {}, initializing = false; // generic base class Extend.Base = function() {}; Extend.Base.extend = function extend(properties) { var _super = this.prototype, proto, name; initializing = true; proto = new this(); initializing = false; // leaving out check for presence of "_super" string for (name in properties) { proto[name] = typeof properties[name] === "function" && typeof _super[name] === "function" ? (function (name, fn) { var retFn = function () { var tmp = this._super, ret; this._super = _super[name]; ret = fn.apply(this, arguments); this._super = tmp; return ret; }; return retFn; })(name, properties[name]) : properties[name]; } function Class() { if (!initializing && this.init) { this.init.apply(this, arguments); } } Class.prototype = proto; Class.constructor = Class; Class.extend = extend; return Class; };
The code is very similar to what John Resig proposes in Secrets of the JavaScript Ninja, pp. 145f., but with 3 improvements:
- Rather than modifying JavaScript's native
Object
, this technique creates a separateExtend
object with the desired functionality. - Resig's use of
arguments.callee
is eliminated as this feature is deprecated in strict mode in the 5th edition of ECMAScript (ES5). - The check for function serialization, which is supported in all modern browsers, is removed. The check is expensive, and, although Resig says function serialization isn't supported universally, I have been unable to find any browser where it isn't supported (IE6 and mobile appear to support this feature). More details here.
The above code, wrapped in such a way that it can be run with or without require.js
, then allows the following
inheritance hierarchy:
var Animal = Extend.Base.extend({ breathe: function() { console.log('Breathing'); } }); var Mammal = Animal.extend({ run: function() { console.log('Running'); } }); var Dolphin = Mammal.extend({ breathe: function() { console.log('Surfacing'); this._super(); }, run: function() { console.log("Can't run"); } }); var flipper = new Dolphin(); flipper.breathe(); // output: Surfacing\ Breathing flipper.run(); // output: Can't run if (flipper instanceof Dolphin) { console.log('Dolphin'); } // ouput: Dolphin if (flipper instanceof Mammal) { console.log('Mammal'); } // ouput: Mammal if (flipper instanceof Animal) { console.log('Animal'); } // ouput: Animal
And that's the behavior we're looking for!
The complete extend.js code can be used without modification
and with no dependencies either as a require.js
module or as a standalone.