Saturday, September 17, 2011

Subclassing an array in JavaScript

To implement the following C# code in JavaScript, the more common solution is to subclass an array, which comes with few problems.
public class PersonList : List<Person> {
    public string Name;
    public PersonList(string name){
        this.Name = name;
    }
    public int GetSumYears() {
        var total = 0;
        foreach (var p in this)
            total += p.BirthDate.Year;
        return total;
    }
}
Among other thing a bug in IE8. I personally focus on ECMAScript 5, so this is not my concern for this post, I also want to use ECMAScript 5, property get.

A blog post by Juriy Zaytsev, How ECMAScript 5 still does not allow to subclass an array will tell you every thing about it.

I also like to mention a post from Andrea Giammarchi Habemus Array ... unlocked length in IE8, subclassed Array for every browser, which proposes a solution to implement a Stack object which I re-used to implement my List() object.

This post is my implementation of the C# List<T> in JavaScript. First here is the kind of code I want to be able to write.

The source code is now part of my library fJs.lib on github.
var l = new List();
for(var i=0; i<5; i++){
    l.add(i);
}
l.addRange(5, 6, 7, 8, 9);
print(l.toString()); // 0,1,2,3,4,5,6,7,8,9

l.removeAt(0);
l.remove(9);
print(l.toString()); // 1,2,3,4,5,6,7,8

var l2 = l.filter(function(v){ return v % 2 == 0; });
print(l2.toString()); // 2,4,6,8

var l3 = l.map(function(v){ return v*v; });
print(l3.toString()); // 1,4,9,16,25,36,49,64,81,81

print(l instanceof List);  // true
print(l instanceof Array); // true
My first idea was to start with something like that
function List() {
    var
        _list = [];

    _list.add = function(v){
        this.push(v);
    }
    return _list;
}
var l = new List();
print(l instanceof Array); // true
print(l instanceof List);  // false
But the object returned by the List() constructor is not an instance of List, which is confusing. So I re-used the Stack implementation of Andrea Giammarchi which solve this problem. Creating the object is a little bit more complex, but then adding the methods remain simple. Here is the List object properties and methods.
List
  properties:
     count
  method:
 
     add
     addRange
     all
     any
     concat
     contains
     exists
     findAll
     findIndex
     remove
     removeAll
     removeAt
     reverse
     
     filter
     map
The source code
Here is the source code with some unit tests. I ran the code on NodeJS.
//
// Class List a match for the C# .NET List<T> - Frederic Torres 2011
// Mit Style License
//
// based on
// - Andrea Giammarchi's Stack
//      http://webreflection.blogspot.com/2008/05/habemus-array-unlocked-length-in-ie8.html
// - How ECMAScript 5 still does not allow to subclass an array
//      http://perfectionkills.com/how-ecmascript-5-still-does-not-allow-to-subclass-an-array/
//
var List = (function(){
    var
        MAX_SIGNED_INT_VALUE = Math.pow(2, 32) - 1;

    function __isFunction(f) {

        return typeof f === 'function';
    }
    function __toUint32(value) {

        return value >>> 0;
    }
    function __isInt(v){

        return String(__toUint32(v)) === v;
    }
    function __removeAt(that, index) {

        that.splice(index ,1);
    }
    function _list(length) {

        if (arguments.length === 1 && typeof length === "number") {
            this.length = -1 < length && length === length << 1 >> 1 ? length : this.push(length);
        }
        else if (arguments.length) {
            this.push.apply(this, arguments);
        }
        Object.defineProperty(this, "count", {

            get: function(){ return this.length; },
        });
    }
    function _array() { };
    _array.prototype           = [];

    _list.prototype            = new _array();
    _list.prototype.length     = 0;

    _list.prototype.toString   = function () {

        return this.slice(0).toString();
    }
    _list.prototype.add = function (v) {

        this.push(v);
    }
    _list.prototype.addRange = function () {
        var i;
        for(i=0; i < arguments.length; i++)
            this.add(arguments[i]);
    };
   _list.prototype.clear = function (v) {

        this.length = 0;
    }
   _list.prototype.removeAt = function (index) {
        if(this.count==0)
            throw new Error("Cannot removeAt from empty List");

        if(index>=0 && index < this.count)
            __removeAt(this, index);
        else
            throw new Error("invalid index "+index+" for List");
    }
   _list.prototype.remove = function (val) {
        var
            elementRemoved = 0,
            index = this.indexOf(val);

        if(index===-1)
            return 0;

        while(index!==-1){
            __removeAt(this, index);
            elementRemoved++;
            index = this.indexOf(val);
        }
        return elementRemoved;
    }
    _list.prototype.contains = function (val) {

        return this.indexOf(val) !== -1;
    }
   _list.prototype.concat = function (l) {
        var
            i;
        for(i in this)
            if(__isInt(i))
                this.add(l[i]);
    }
   _list.prototype.findIndex = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("exists() requires a function as parameter");

        for(i in this)
            if((__isInt(i))&&(lambda(this[i])))
                r.add(i);

        return r;
    }
   _list.prototype.exists = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("exists() requires a function as parameter");

        for(i in this)
            if((__isInt(i)) && (lambda(this[i])))
                return true;

        return false;
    }
   _list.prototype.removeAll = function (lambda) {
        var
            goOn = true,
            i;

        if(!__isFunction(lambda))
            throw new Error("exists() requires a function as parameter");

        while(goOn){
            goOn = false;
            for(i in this){
                if((__isInt(i)) && (lambda(this[i]))) {
                    __removeAt(this, i);
                    goOn = true;
                    break;
                }
            }
        }
    }
   _list.prototype.all = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("exists() requires a function as parameter");

        for(i in this)
            if((__isInt(i)) && (!lambda(this[i])))
                return false;
        return true;
    }
   _list.prototype.any = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("exists() requires a function as parameter");

        for(i in this)
            if((__isInt(i)) && (lambda(this[i])))
                    return true;
        return false;
    }
    _list.prototype.reverse = function () {
        var
            values = [],
            i;

        for(i in this)
            if(__isInt(i))
                values.unshift(this[i]);

        this.clear();

        this.push.apply(this, values);
    }
   _list.prototype.filter = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("filter() requires a function as parameter");

        for(i in this)
            if(__isInt(i))
                if(lambda(this[i]))
                    r.add(this[i]);
        return r;
    }
    _list.prototype.findAll = function (lambda) {
        // Just to have the same .net method
        return this.filter(lambda);
    }
   _list.prototype.map = function (lambda) {
        var
            i,
            r = new List();

        if(!__isFunction(lambda))
            throw new Error("map() requires a function as parameter");

        for(i in this)
            if(__isInt(i))
               r.add(lambda(this[i]));
        return r;
    }
    _list.prototype.constructor = _list;
    return _list;
})();

The unit tests.
For now it is the unit tests of the poor until I include this in my own JavaScript library and probably add it on github.
function isNodeJs() {
    return (typeof require === "function" && typeof Buffer === "function" && typeof Buffer.byteLength === "function" && typeof Buffer.prototype !== "undefined" && typeof Buffer.prototype.write === "function");
}
function print(s){
    console.log(s);
}

if(isNodeJs()){

    var Assert = {
        isTrue      : function(e){ if(!e) throw new Error("IsTrue failed"); },
        isFalse     : function(e){ if(!!e) throw new Error("IsTrue failed"); },
        areEqual    : function(v1,v2){ if(v1!==v2) throw new Error("expected:"+v1+" is not equal to actual:"+v2); }
    }

    var l = new List(1, 2, 3);
    Assert.areEqual(3, l.count);
    Assert.areEqual("1,2,3", l.toString());

    var l = new List();
    Assert.areEqual(0, l.count);
    l.add(1);
    l.add(2);
    l.add(3);
    Assert.areEqual("1,2,3", l.toString());
    Assert.areEqual(3, l.count);

    var l = new List(1, 2, 3);
    l.removeAt(1);
    Assert.areEqual(2, l.count);
    Assert.areEqual("1,3", l.toString());

    var l = new List(1, 2, 3);
    Assert.areEqual(1,l.remove(2));
    Assert.areEqual(2, l.count);
    Assert.areEqual("1,3", l.toString());

    var l = new List(1, 2, 3, 1, 1);
    Assert.areEqual(3,l.remove(1));
    Assert.areEqual(2, l.count);
    Assert.areEqual("2,3", l.toString());

    var l1 = new List(1, 2, 3);
    var l2 = new List(4, 5, 6);
    l1.concat(l2);
    print(l1.toString());
    Assert.areEqual("1,2,3,4,5,6", l1.toString());

    var l = new List(1, 2, 3);
    Assert.isTrue(l instanceof List);
    Assert.isTrue(l instanceof Array);

    var l = new List(1, 2, 3, 4);
    var ll = l.filter(function(v){ return v % 2 == 0; });
    Assert.areEqual("2,4", ll.toString());

    var l = new List(1, 2, 3, 4);
    var ll = l.map(function(v){ return v*v; });
    Assert.areEqual("1,4,9,16", ll.toString());

    var l = new List();
    l.addRange(1, 2, 3);
    Assert.areEqual("1,2,3", l.toString());

    var l = new List(1, 2, 3);
    l.clear();
    Assert.areEqual(0, l.count);
    Assert.areEqual("", l.toString());
    l.add(1);
    Assert.areEqual("1", l.toString());
    Assert.areEqual(1, l.count);
    l.addRange(2, 3, 4);
    Assert.areEqual("1,2,3,4", l.toString());

    var l = new List(1, 2, 3);
    Assert.isTrue(l.contains(2));
    Assert.isFalse(l.contains(12));

    var l = new List(1, 2, 3);
    Assert.isTrue( l.exists(function(v){ return v == 2;  }));
    Assert.isFalse(l.exists(function(v){ return v == 12; }));

    var l = new List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    l.name='fred';
    Assert.isTrue ( l.all(function(v){ return v > 0;  }));
    Assert.isFalse( l.all(function(v){ return v > 5;  }));

    var l = new List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    l.name='fred';
    Assert.isTrue ( l.any(function(v){ return v > 0;  }));
    Assert.isFalse( l.any(function(v){ return v > 15;  }));

    var l = new List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    Assert.areEqual( "1,3,5,7,9", l.findIndex(function(v){ return v % 2 == 0; }).toString() );

    var l = new List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    l.removeAll(function(v){ return v % 2 == 0; });
    Assert.areEqual( "1,3,5,7,9", l.toString() );

    var l = new List(1, 2, 3, 4, 5);
    l.reverse();
    Assert.areEqual( "5,4,3,2,1", l.toString() );

    print("---------------------");
    var l = new List();
    for(var i=0; i < 5; i++){
        l.add(i);
    }
    l.addRange(5, 6, 7, 8, 9);
    print(l.toString()); // 0,1,2,3,4,5,6,7,8,9

    l.removeAt(0);
    l.remove(9);
    print(l.toString()); // 1,2,3,4,5,6,7,8

    var l2 = l.filter(function(v){ return v % 2 == 0; });
    print(l2.toString()); // 2,4,6,8

    var l3 = l.map(function(v){ return v*v; });
    print(l3.toString()); // 1,4,9,16,25,36,49,64,81,81

    print(l instanceof List);  // true
    print(l instanceof Array); // true

}

No comments:

Post a Comment