Object-oriented Patterns in JavaScript

Jesse Hallett — @hallettj

NodePDX 2012

http://sitr.us/talks/oo-patterns

Multiple inheritance - How does it work?

Multiple inheritance is good, but there is no good way to do it.

Steve Cook, summarizing a panel from OOPSLA ‘87

Crafty

var player = Crafty.e('2D, Canvas, Collision,
                        Massive, Inertia, Player')
    .attr({ x: 300, y: 300, w: 25, h: 25 })
    .setMass(1);

var asteroid = Crafty.e('2D, Canvas, Collision,
                        Massive, Deadly')
    .attr({ x: 500, y: 500, w: 50, h: 50 })
    .setMass(100);
    

Crafty

Crafty.c('Deadly', {
    init: function() {
        if (!this.hasComponent('Collision')) {
            this.addComponent('Collision');
        }

        this.onHit('player', function() {
            Crafty.scene('gameover');
        });
    }
});

Crafty.c('Collision', {
    /* ... */
    onHit: function (comp, fn) {
        this.bind('EnterFrame', function () {
            var hitdata = this.hit(comp);
            if (hitdata) {
                fn.call(this, hitdata);
            }
        });
        return this;
    },
    /* ... */
});
    
Crafty.c('Massive', {
    mass: function(mass) {
        var self = this;
        this.bind('EnterFrame', function() {
            Crafty('Massive').each(function() {
                var other = this,
                    d = distance(other, self),
                    u = unitVector(other, self),
                    accel = vecMult(G * mass /
                                    (d*d), u);

                if (other.has('Inertia')) {
                    other.accelerate(accel);
                }
            });
        });
    }
});

Crafty.c('Inertia', {
    /* ... */
    accelerate: function(vec) {
        this._vector.x += vec.x;
        this._vector.y += vec.y;
    },
    /* ... */
});
        

traits.js

var Trait = require('traits').Trait;

var TEquality = Trait({
    equals: Trait.required,
    notEquals: function(x) { return !this.equals(x); }
});

function TCircle(center, radius) {
    return Trait.compose(
        TEquality,
        Trait({
            center: center,
            radius: radius,
            area: function {
                return Math.PI * this.radius * this.radius;
            },
            equals: function(c) {
                return c.center.x == this.center.x &&
                       c.center.y == this.center.y &&
                       c.radius == this.radius;
            },
        })
    )
}
    

Crafty with traits

function makePlayer(pos, mass) {
    var TPlayer = Trait.compose(
        T2D(pos), TCanvas, TCollision,
        TMassive(mass), TInertia(0, 0)
    );
    return Trait.create(Object.prototype, TPlayer);
}

function TInertia(initVecX, initVecY) {
    var vector = { x: initVecX, y: initVecY };

    Trait({
        init: function() {
            this.bind('EnterFrame', function() {
                this.setPosX(this.getPosX + vector.x);
                this.setPosY(this.getPosY + vector.y);
            });
        },

        setPosX: Trait.required,
        setPosY: Trait.required,

        accelerate: function(vec) {
            vector.x += vec.x;
            vector.y += vec.y;
        },
        /* ... */
    });
}
    

traits.js — conflicts

function TColor(red, green, blue) {
    return Trait.compose(TEquality, Trait({
        red: red, green: green, blue: blue,
        equals: function(col) {
            return col.red == red &&
                   col.green == green &&
                   col.blue == blue;
        }
    }));
}

function TCircleWithColor(center, radius, rgb) {
    return Trait.compose(
        TEquality,
        TColor(rgb.red, rgb.green, rgb.blue),
        Trait({
            center: center,
            radius: radius,
            area: function {
                return Math.PI * this.radius * this.radius;
            },
            equals: function(c) {
                return c.center.x == this.center.x &&
                       c.center.y == this.center.y &&
                       c.radius == this.radius;
            }
        })
    );
}
    

traits.js — conflicts

function TColor(red, green, blue) {
    return Trait.compose(TEquality, Trait({
        red: red, green: green, blue: blue,
        equals: function(col) {
            return col.red == red &&
                   col.green == green &&
                   col.blue == blue;
        }
    }));
}

function TCircleWithColor(center, radius, rgb) {
    return Trait.compose(
        TEquality,
        TColor(rgb.red, rgb.green, rgb.blue),
        Trait({
            center: center,
            radius: radius,
            area: function {
                return Math.PI * this.radius * this.radius;
            },
            equals: function(c) {
                return c.center.x == this.center.x &&
                       c.center.y == this.center.y &&
                       c.radius == this.radius;
            }
        })
    );
}
    

traits.js — conflicts

function TCircleWithColor(center, radius, rgb) {
    return Trait.compose(

        Trait.resolve({
            equals: 'equalCircles'
        }, TCircle(center, radius)),

        Trait.resolve({
            equals: 'equalColors'
        }, TColor(rgb.red, rgb.green, rgb.blue)),

        Trait({
            equals: function(c) {
                return this.equalCircles(c) &&
                    this.equalColors(c);
            }
        })

    );
}
    

traits.js — conflicts

function checkPalette(color) {
    var officialColors = [burgundy, mauve,
                            burntUmber, white];
    return officialColors.some(function(official) {
        color.equals(official);
    });
}

checkPalette(circleA);  // whoops!
    

Crafty with traits

function CraftyCompose(/* traits */) {
    var traits = Array.prototype.slice.call(arguments),
    var inits = traits.filter(function(t) {
        return !!t.init;
    }).map(function(t) {
        return t.init;
    });
    var resolved = traits.map(function(t) {
        return t.resolve({ init: undefined });
    }
    var composed = Trait.compose.apply(Trait, resolved);
    var withInit = Trait.compose(composed, Trait({
        init: function() {
            var self = this;
            inits.forEach(function(init) {
                init.call(self);
            });
        }
    });
    return withInit;
}

function makePlayer(pos, mass) {
    var TPlayer = CraftyCompose(
        T2D(pos), TCanvas, TCollision,
        TMassive(mass), TInertia(0, 0));
    var player = Trait.create(Object.prototype, TPlayer);
    player.init();
    return player;
}
    

Another conflict

var TColor = function(red, green, blue) {
    return Trait({
        red: red, green: green, blue: blue,

        add: function(col) {
            return Trait.create(Object.prototype, TColor(
                Math.max(255, red + col.red),
                Math.max(255, green + col.green),
                Math.max(255, blue + col.blue)
            ));
        }
    }));
}

function TCircle(center, radius) {
    return Trait({
        center: center,
        radius: radius,
        area: function {
            return Math.PI * this.radius * this.radius
        },

        add: function(s) {
            return Trait.create(Object.prototype,
                TCircle(center, radius + s))
        }
    });
}
    

Another conflict

function TCircleWithColor(center, radius, rgb) {
    return Trait.compose(
        Trait.resolve({
            add: 'addCircles'
        }, TCircle(center, radius)),

        Trait.resolve({
            add: 'addColors'
        }, TColor({
            red: rgb.red, green: rgb.green, blue: rgb.blue
        })),
    );
}

function CircleWithColor(center, radius, rgb) {
    return Trait.create(Object.prototype,
        TCircleWithColor(center, radius, rgb));
}
    

Fragile types

function dropHighlight(circle, color) {
    var highlight = circle.add(5);
    var lightened = color.add({
        red: 50, green: 50, blue: 50
    });
    draw([circle, color], [highlight, lightened]);
}

var myCircle = CircleWithColor({ x: 0, y: 0 }, 100, {
    red: 100, green: 0, blue: 0
});

dropHighlight(myCircle, myCircle);  // whoops!
    

Separating behavior and state

// color.js
function add(x, y) {
    return new Color(
        Math.max(255, x.red + y.red),
        Math.max(255, x.green + y.green),
        Math.max(255, x.blue + y.blue)
    );
}

function Color(red, green, blue) {
    this.red = red; this.green = green; this.blue = blue;
}

exports.add = add;
exports.Color = Color;

// circle.js
function add(circle, s) {
    return new Circle(circle.center, circle.radius + s);
}

function Circle(center, radius) {
    this.center = center;
    this.radius = radius;
}

exports.add = add;
exports.Circle = Circle;
    

Separating behavior and state

var Color = require('color');
var Circle = require('circle');

function dropHighlight(circle, color) {
    var highlight = Circle.add(circle, 5);
    var lightened = Color.add(color,
                                new Color(50, 50, 50));
    draw([circle, color], [highlight, lightened]);
}

var myCircle = {
    center: { x: 0, y: 0 },
    radius: 100,
    red: 100,
    green: 0,
    blue: 0
};

dropHighlight(myCircle, myCircle);  // it's ok!
    

What about polymorphism?

var TEnum = Trait({
    forEach: Trait.required,
    reduce: function(f, init) {
        var r = init;
        this.forEach(function(e) { r = f(r, e); });
        return r;
    },
    map: function(f) {
        return this.reduce(function(r, e) {
            r.push(f(e));
        }, []);
    },
    some: function(p) {
        return this.reduce(function(r, e) {
            return r || p(e);
        }, false);
    },
    // and so forth
});
    

What about polymorphism?

var ObjectEnum = require('ObjectEnum');
var StringEnum = require('StringEnum');
var ArrayLike = require('ArrayLikeEnum');
var Color = require('Color');

ObjectEnum.map({ x: 0, y: 1 }, function(pair) {
    return [pair[0], pair[1] + 1];
});

StringEnum.map("hello, world", function(c) {
    return c.toUpperCase();
});

(function() {
    ArrayLike.forEach(arguments, function(n) {
        console.log(n);
    });
})(1, 2, 3);

Color.some(new Color(255, 0, 0), function(c) {
    return c > 0
});
    

Protocols

// enum.js
var Protocol = require('protocol').Protocol;

var Enum = exports = Protocol({
    forEach: Protocol.required,

    reduce: function(obj, f, init) {
        var r = init;
        Enum.forEach(obj, function(e) { r = f(r, e); });
        return r;
    },

    map: function(obj, f) {
        return Enum.reduce(obj, function(r, e) {
            r.push(f(e));
        }, []);
    },

    some: function(obj, p) {
        return Enum.reduce(obj, function(r, e) {
            return r || p(e);
        }, false);
    },

    // and so forth
});
    

Protocols

var extend = require('protocol').extend;
var Enum = require('enum');
var Color = require('Color');

extend(Object, Enum, {
    forEach: function(obj, f) {
        Object.keys.forEach(function(k) {
            f([k, obj[k]]);
        });
    }
});

extend(Color, Enum, {
    forEach: function(obj, f) {
        f(obj.red);
        f(obj.green);
        f(obj.blue);
    });
});
    

Etiquette.js

github.com/hallettj/etiquette.js

function Protocol(signatures) {
    var proto = this; impls = {};  // { Type: Function }

    function findImpl(object) {
        var constructor = object.constructor;
        var impl = impls[constructor];
        if (impl) { return impl; }
        else if (constructor &&
                 constructor.prototype &&
                 constructor.prototype !== Object) {
            return findImpl(constructor.prototype);
        }
    }

    Object.keys(signatures).forEach(function(funcName) {
        proto[funcName] = function(obj) {
            var impl=findImpl(obj), func=impl[funcName];
            if (func) {
                return func.apply(null, arguments);
            } else if (signatures[funcName]) {
                return signatures[funcName]
                            .apply(null, arguments);
            } else {
                throw "no implementation: '"+funcName+"'";
            }
        };
    });
    proto.impls = impls;
    return proto;
}

function extend(type, protocol, impls) {
    protocol.impls[type] = impls;
}
    

Etiquette.js usage

var Ord = Protocol({
    lt: function(a, b) { return Ord.compare(a, b) < 0; },
    gt: function(a, b) { return Ord.compare(a, b) > 0; },
    eq: function(a, b) { return Ord.compare(a, b) === 0; },
    gte: function(a, b) { return Ord.gt(a, b) ||
                                    Ord.eq(a, b); },
    lte: function(a, b) { return Ord.lt(a, b) ||
                                    Ord.eq(a, b); },
    compare: false
});

extend(String, Ord, {
    compare: function(a, b) {
        return a.length - b.length;
    }
});

Ord.compare("foo", "bar");  // 0
Ord.eq("foo", "bar");  // true
    

References

Image credits