Let's Make a Framework: OO Part 2
Welcome to part 3 of Let’s Make a Framework wherein I discuss building a JavaScript framework suited to both command line and web development. This part completes the discussion of object oriented JavaScript support. In the last part, Classes, Inheritance, Extend I talked about Prototypal inheritance and classical object models. This part will conclude the discussion, explaining more about how turing.oo works, and will explore super().
If you haven’t been following along, I’ve been tagging these articles with lmaf so you can find them. The project we’re creating is called Turing and is available on GitHub: turing.js.
Class Creation in More Depth
Last week I was parading around initialize as if you were intimately familiar with prototype.js or Ruby. I apologise for that. In case I confused you, all you need to know is initialize is our way of saying call this method when you set up my class.
Turing’s Class.create method sets up classes. During the setup it defines a function that will be called when the class is instantiated. So when you say new, it will run initialize. The code behind this is very simple:
create: function() {
var methods = null,
parent = undefined,
klass = function() {
this.initialize.apply(this, arguments);
};
I sometimes feel like apply is magical, but it’s not really magic in the negative programmer sense of the word — it doesn’t hide too much from you. In this case it just calls your initialize method against the newly created class using the supplied arguments. Again, the arguments variable seems like magic… but that’s just a helpful variable JavaScript makes available when a function is called.
Because I’ve made this contract — that all objects will have initialize methods — we need to define one in cases where classes don’t specify initialize:
if (!klass.prototype.initialize)
klass.prototype.initialize = function(){};
Syntax Sugar * Extend === Mixin
It would be cool to be able to mix other object prototypes into our class. Ruby does this and I’ve often found it useful. The syntax could look like this:
var MixinUser = turing.Class({
include: User,
initialize: function(log) {
this.log = log;
}
});
Mixins should have some simple rules to ensure the resulting object compositions are sane:
- Methods should be included from the specified classes
- The
initializemethod should not be overwritten - Multiple includes should be possible
Since our classes are being run through turing.oo.create, we can easily look for an include property and include methods as required. Rather than including the bulk of this code in create, it should be in another mixin method in turing.oo to keep create readable.
To satisfy the rules above (which I’ve turned into unit tests), this pseudo-code is required:
mixin: function(klass, things) {
if "there are some valid things" {
if "the things are a class" {
"use turing.oo.extend to copy the methods over"
} else if "the things are an array" {
for "each class in the array" {
"use turing.oo.extend to copy the methods over"
}
}
}
},
Super
Last week I used an example that featured prototype.js’s super handling. Prototype allows classes to be extended using addMethods so it can track which methods have been added and provide access to overriden methods using $super.
I want to be able to inherit from a class and call methods that I override. Given a User class (from the fixtures/example_classes.js file), we can inherit from it to make a SuperUser:
var User = turing.Class({
initialize: function(name, age) {
this.name = name;
this.age = age;
},
login: function() {
return true;
},
toString: function() {
return "name: " + this.name + ", age: " + this.age;
}
});
var SuperUser = turing.Class(User, {
initialize: function() {
// Somehow call the parent's initialize
}
});
A test to make sure the parent’s initialize gets called is simple enough:
given('an inherited class that uses super', function() {
var superUser = new SuperUser('alex', 104);
should('have run super()', superUser.age).equals(104);
});
If I run this without a super implementation, I get this:
Given an inherited class that uses super
- should have run super(): 104 does not equal: undefined
To fix this all we need to do is call a previously defined method. Hey, it’s time for apply again!
var SuperUser = turing.Class(User, {
initialize: function() {
User.prototype.initialize.apply(this, arguments);
}
});
This isn’t perfect though. Most languages make their super implementation simpler for the caller — forcing people to use apply like this is unwieldy. One way around this is to make the parent class prototype available and add a super method to the class. The super method can simply use apply in the same manner. The only downside is you have to specify the method name:
var SuperUser = turing.Class(User, {
initialize: function() {
this.$super('initialize', arguments);
},
toString: function() {
return "SuperUser: " + this.$super('toString');
}
});
This is simple, lightweight, and easy to understand. The method name could be inferred by other means, but this would complicate the library beyond the scope of this article (meaning if you can do it and make it cross-browser then cool!)
Conclusion
Now we’ve got a simple, readable OO class. This will allow us to structure other parts of Turing in a reusable way. I hope the last two articles have demonstrated that JavaScript’s simplicity allows you to define your own behaviour for things that feel like fundamental language features.