Extend primitives without prototyping them

I am working on a pretty ugly library that lets you do some strange things. Having a graph you can map a set of collections in a chain-like style and when you alter a value to be altered in the whole system.

The problem came when the end type is a JS primitive.

In my case after making the graph with the values and objects I can do something like this:

CHAIN.components[0].value = 20; 

components is a filter function over the graph's nodes using setters and getters. If there is only one node filtered in components the default value set by the user would be available without doing this: CHAIN.components.value = 20; But rather this: CHAIN.components = 20;

Now the problem is that the node could have other methods or properties besides a default ( which in my case is set on value.

How can i use the setters and getters on Number object without hacking in the Number.prototype, because CHAIN.components is now a Number ( if it's not a primitive i've made it work in an unobtrusive way ), but when i want to call the CHAIN.components.func() there is a problem because i would have to append to the Number.prototype the func every time i make a set or get on the components and then delete it.

Do you have another idea for accomplishing this kind of behavior?

You wanted code so here it is:

/*jslint nomen: true, sloppy: true*/
GRID.modules.OHM || Object.extend(GRID.modules, ( function() {
    var Node, Nodes, Ohm, num_proto = Number.prototype.__clone(), str_proto = String.prototype.__clone();
    Node = function(uid) {
        var UID = uid;
        this.getUID = function() {
            return UID;
        };
    };
    Nodes = function() {
        var stack = [];
        this.add = function(id, val) {
            var n = new Node(stack.length);
            val.id = id;
            Object.extend(n, val);
            stack.push(n);
            return n.getUID();
        };
        this.getById = function(id) {
            return stack.filter(function(v) {
                var a = id || v.id;
                return (v.id === a);
            });
        };
        this.getByUID = function(UID) {
            return stack[UID];
        };
        this.get = function(callback) {
            !Object.isString(callback) || ( callback = [callback]);
            var f = Object.isFunction(callback) ? callback : (Object.isArray(callback) ? function(k) {
                return (callback.indexOf(k.id) >= 0);
            } : function(k) {
                return true;
            });
            return stack.filter(f);
        };
    };
    Ohm = function(n) {
        var graph = n || (new Nodes()), filters = {}, __nodes = {}, addGS = function(obj, name, conf, binder) {
            var alfa = {};
            Object.extend(alfa, conf);
            if (!alfa.get) {
                alfa.get = function() {
                    var a = this.g.getById(this.p);
                    return a.length === 1 ? a[0] : a;
                }.bind(binder);
            } else {
                alfa.get = alfa.get.bind(binder);
            }
            if (!alfa.set) {
                alfa.set = function(value) {
                    this.g.getById(this.p).forEach(function(k) {
                        Object.extend(k, value);
                        return true;
                    });
                }.bind(binder);
            } else {
                alfa.set = alfa.set.bind(binder);
            }
            Object.defineProperty(obj, name, alfa);
        }, add = function(id, node) {
            if (__nodes.hasOwnProperty(id)) {
                addGS(__nodes, id, {
                    enumerable : true
                }, {
                    t : this,
                    p : id,
                    g : graph
                });
            }
            return graph.add(id, node || {});
        };
        Object.extend(this, {
            add : function() {
                add.apply(this, arguments);
            },
            map : function(name, f, that) {
                var n = name, filterer = ['add', 'map', '__all'];
                n = Object.isFunction(n) ? name.apply(that, arguments.slice(3)) : n;
                if (filterer.indexOf(n.toLowerCase()) >= 0) {
                    console.log("You can't map over a basic property of object !!! Please read the freakin' manual.");
                    return null;
                }
                if (!filters.hasOwnProperty(n)) {
                    filters[n] = new Ohm(graph);
                    addGS(this, n, {
                        get : function() {
                            this.g.get(this.f).forEach(function(v, key, arr) {
                                var temp, binder;
                                if (arr.length !== 1) {
                                    if (!this.filt.hasOwnProperty(v.id)) {
                                        addGS(this.filt, v.id, {
                                            set : function(value) {
                                                this.t.g.getById(this.p).filter(this.t.f).forEach(function(k) {
                                                    Object.extend(k, value);
                                                });
                                            },
                                            get : function() {
                                                var a = this.t.g.getById(this.p).filter(this.t.f);
                                                return a.length === 1 ? a[0] : a;
                                            }
                                        }, {
                                            t : this,
                                            p : v.id
                                        });
                                        (key !== arr.length - 1) || Object.extend(this.filt, this.g.get(this.f));
                                    }
                                } else {
                                    if (Object.isFunction(v.__new__)) {
                                        v.__default = function() {
                                            return Object.extend((new this.__new__(arguments)), this);
                                        };
                                    }
                                    if (!Object.isUndefined(v.__default)) {
                                        temp = this.filt;
                                        this.filt = Object.isFunction(v.__default) ? v.__default.bind(v) : v.__default;
                                        if (Object.isNumber(this.filt) || Object.isString(this.filt)) {
                                            var prot = Object.isNumber(this.filt) ? Number : String;
                                            for (var i in temp) {
                                                if (temp.hasOwnProperty(i) && !prot.prototype.hasOwnProperty(i)) {
                                                    var bin = {
                                                        t : temp,
                                                        m : i,
                                                        p : prot,

                                                    };
                                                    Object.defineProperty(prot.prototype, i, {
                                                        set : function(value) {
                                                            Object.defineProperty(this.p.prototype, this.m, {
                                                                configurable : true, // defaults to false
                                                                writable : false,
                                                                value : 1
                                                            });
                                                            delete this.p.prototype[this.m];
                                                            this.t[this.m] = value;
                                                        }.bind(bin),
                                                        get : function() {
                                                            Object.defineProperty(this.p.prototype, this.m, {
                                                                configurable : true, // defaults to false
                                                                writable : false,
                                                                value : 1
                                                            });
                                                            delete this.p.prototype[this.m];
                                                            return this.t[this.m];
                                                        }.bind(bin),
                                                        enumerable : true,
                                                        configurable : true
                                                    });
                                                }
                                            }
                                        } else {
                                            Object.extend(this.filt, temp);
                                        }
                                    }
                                    if (Object.isNumber(this.filt) || Object.isString(this.filt)) {
                                        var prot = Object.isNumber(this.filt) ? Number : String;
                                        for (var i in v) {
                                            if (v.hasOwnProperty(i) && !prot.prototype.hasOwnProperty(i)) {
                                                var bin = {
                                                    t : v,
                                                    m : i,
                                                    p : prot,

                                                };
                                                Object.defineProperty(prot.prototype, i, {
                                                    set : function(value) {
                                                        Object.defineProperty(this.p.prototype, this.m, {
                                                            configurable : true, // defaults to false
                                                            writable : false,
                                                            value : 1
                                                        });
                                                        delete this.p.prototype[this.m];
                                                        this.t[this.m] = value;
                                                    }.bind(bin),
                                                    get : function() {
                                                        Object.defineProperty(this.p.prototype, this.m, {
                                                            configurable : true, // defaults to false
                                                            writable : false,
                                                            value : 1
                                                        });
                                                        delete this.p.prototype[this.m];
                                                        return this.t[this.m];
                                                    }.bind(bin),
                                                    enumerable : true,
                                                    configurable : true
                                                });
                                            }
                                        }
                                    } else {
                                        Object.extend(this.filt, v);
                                    }
                                }
                            }, this);
                            return this.filt;
                        },
                        set : function(value) {
                            this.g.get(this.f).forEach(function(k) {
                                Object.extend(k, value);
                            });
                        }
                    }, {
                        t : this,
                        f : f,
                        g : graph,
                        filt : filters[n]
                    });
                }
            }
        }, true, true);
        addGS(this, '__all', {
            get : function() {
                var a = this.g.getById();
                Object.extend(__nodes, a.length === 1 ? a[0] : a);
                return __nodes;
            },
            enumerable : true
        }, {
            t : this,
            p : null,
            g : graph
        });
    };
    window['Ω'] = Ohm;
    return {
        OHM : Ohm,
    };
}()));

And now the demo:

var c = new Ω();
c.add('ann', {
__default : 58,
blah : 98,
ceva : function()
{
    console.log('asd');
}
});
c.add('ann2',{
    __default: function(){
       console.log('hello');
    },
    abc: 78,
    dce: function(){
       console.log(' world');
    }
};
c.add('b2', {
__new__ : function() {
    this.init = function() {
        this.id = 86;
    };
    this.mer = function() {
        console.log(this);
    };
},
els : 'asadar'
});
c.map('b2', function(k) {
return k.id === 'b2';
});
c.map('ann', function(k) {
return k.id === 'ann';
});
c.map('ann2', function(k) {
return k.id === 'ann2';
});
console.log(c.ann); // returns 58 ( the __default value )
console.log(c.ann.blah); // returns 98
console.log(c.ann.blah.blah); // undefined
console.log(c.ann2);  // function()
c.ann2(); // prints out 'hello'
c.ann2.cde(); // prints out 'world'
c.ann2 = 60;
console.log(c.ann2); // 60
console.log(c.ann2.cde()); // prints out 'world'

This code works, but the part where i have to use the Number or String prototype bothers me. Do you have another way of doing this?

The reason is to do something that someone said it could be done in PHP but not JS, this guy had recently been working with me on WebGL shaders and hated that he had to write 700 lines of code to use multiple effect combined with FBO instead of 100 which would took him with a similar tool like this one which was written in PHP. So yes i KNOW the accessors on primitive prototypes is a hack but how can i make it different without having to use valueOf if the chain end object is a primitive?


Solution 1:

OK, now I've read the attached code (but could not follow all of it). Lacks some comments, but I won't bitch about that, my own codes are not better as I don't expect anybody to read or understand them :-)

You are right, the part where you extend the native prototypes is scary. From what I understand, you define an accessor property on the Number or String prototype before returning a number / string. Both on getting and setting, the accessor property is overwritten by a data property (???), then the whole property is deleted, before you store / return the value. This seems to be a clever hack to allow custom properties on primitive values, yet:

  • There is a high risk of collisions. It might be possible to lower that by using the this value as a key in a lookup table (to differentiate (5).x from (3).x), but it still can't be fully avoided.
  • Properties that remove themselves on accessing / setting are extremely unintuitive. Avoid that.
  • arbitrarily changing accessor properties on the prototype of primitives are costly. That's 4 performance-contras combined. No engine will be able to optimize this. And you seem to use them quite often.

If you really needed this (I still didn't get your reason), I'd use a variant with a lookup-table. It should reduce collisions (not sure how your code handled them), does not change the accessor properties ones they're defined and is therefore more persistent (though it might leak then):

// Let's call this
// PRIMITIVE PROXIES
// as they proxy real objects behind primitive values
var proxy = _.map( { // some map that works on Objects
    string: String.prototype,
    number: Number.prototype
}, function closure(type, proto) {
    var table = {};
    function setupProperty(prop) {
        if (prop in proto) return; // ah, we already proxied this kind of object
        Object.defineProperty(proto, prop, {
            configurable:true, // for deleting
            get: function getter() {
                // "this" is the primitive value
                if (!this in table)
                    return undefined;
                return table[this][prop]; // get prop from obj
            },
            set: function setter(val) {
                if (this in table)
                    table[this][prop] = val; // pass val to obj
            }
        });
    }
    return {
        create: function createProxy(prim, obj) {
            if (prim in table) // we already did create a proxy on this primitive
                return; // let's abort. You might continue to overwrite
            table[prim] = obj;
            Object.getOwnPropertyNames(obj).forEach(setupProperty);
            return prim; // the new "proxy"
        },
        move: function moveName(from, to) {
            if (to in table) return false;
            table[to] = table[from];
            delete table[from];
            return true;
        }
    };
});
proxy.create = function(prim, obj) {
    return proxy[typeof prim].create(prim, obj);
};
proxy.move = function(from, to) {
    return proxy[typeof from].create(from, to);
};
// USAGE:
// proxy.create works just like Object.extend
> var c = {ann: 58},
>     o = {blah: 98};
> proxy.create(c.ann, o);
> 58..blah
98
> c.ann.blah
98
> (58).blah = 60;
> o
{blah: 60}
> var num = c.ann; // 58
> c.ann.blah = function(){return "Hello"};
> num.blah()
"Hello"
> proxy.move(c.ann, c.ann = 78);
> c.ann
78
> (58).blah
undefined
> c.ann.blah()
"Hello"
> // getters/setters for properties are global:
> c.ann.blub = "something"; // does not work, there is no getter
> c.ann.blub
undefined
> proxy.create(58, {blub: "foo"})
> c.ann.blub // still returns
undefined
> c.ann.blub = "bar"; // but can be set now
> (58).blub + (78).blub
"foobar"
> // infinite lookup loops are possible:
> proxy.create("loop", {x:"loop"});
> "loop" === "loop".x
true
> "loop".x.x.x.….x
"loop"

However, there is one thing you will never be able to work around:

Unlike objects, primitive values are not unique; they have no identity.

You will never be able to distinguish c.ann from 58, or "loop" from "loop".x, and so both will have a property or not. This is not a good premise to build an API upon.

So, I still recommend to use Number and String objects. You don't need to subclass them (as shown in my previous answer), as you don't seem to have (m)any methods on them, so you can easily build them:

c.ann = new Number(58);
c.ann.blah = 98;
return c;

There should be hardly a difference, expect for the typeof operator. Could you maybe add some more examples that use the __default value?

but how can I make it different without having to use valueOf if the chain end object is a primitive?

To answer this simple question: That guy was right, it cannot be done in JavaScript without hacking native prototypes. And you are right, the hack is quite ugly :-)

Solution 2:

Don't make it a primitive number, make it an object that behaves like a number. You could use a Number object and extend that with your custom properties (see https://stackoverflow.com/a/9338486/1048572), but why not subclass Number in general? It only needs to preserve a valueOf method that returns the number:

function MyNumber(n) {
    this.value = Number(n);
}
MyNumber.prototype = Object.create(Number.prototype, {constructor:{value:MyNumber}});
MyNumber.prototype.valueOf = function() {
    return this.value;
};
// OK, we have to overwrite those methods as they don't work on subclasses of Number
["toExponential", "toFixed", "toLocaleString", "toPrecision", "toString"].forEach(function(name) {
    MyNumber.prototype[name] = function() {
        return Number.prototype[name].apply(new Number(this.value), arguments);
    };
});

// now extend the prototype with other properties, e.g. chaining methods

Of course, you might need to make components a setter that transforms a given number (or Number) into a MyNumber:

var actualNumber = new MyNumber;
Object.defineProperty(MyFabulousChainingThing, "components", {
    get: function() { return actualNumber; },
    set: function(x) { actualNumber.value = Number(x); }
});