Wednesday, December 5, 2007

Wrapping JavaScript New Constructor with Variable Arguments

I wanted to wrap all of my library functions with a logging utility. However, as JavaScript functions can also be used as constructors, I ran into a problem.

Convention dictates that if a function name begins with a capital letter, then it is a constructor. Fine, so I can determine which functions are constructors. Good. I still want to wrap the constructors, however.

Working through it, I saw that calling new on a function that returns a value will actually return that value. Thus, my wrapper could call new on the enclosed function:


function wrap(f) {
return function () {
//custom logic per invocation
return new f();
};
}

function Cool () {};
Hot = wrapper(Cool);

assertTrue( new Hot() instanceof Cool); //passes
assertFalse( new Hot() instanceof wrapper); //passes


Good.

Now, what to do about the parameters to the constructor Cool?

Luckily, functions have a length attribute. I could write


function wrap (f){
return function () {
switch(Math.min(f.length, arguments.length)) {
case 0: return new f();
case 1: return new f(arguments[0]);
case 2: return new f(arguments[0], arguments[1]);
...
default: throw 'doh!'
}
};
}


I don't like that - what happens if the constructor actually took variable arguments? Would anybody really want to use a library as hacky as this? This approach is embarrassing.

The basic hope is that we could use arguments and apply or call (why are they both there again?), except those don't really do anything about the identity of the object. However, that's fine: we can just provide it:


function wrap (f) {
return function () {
f.apply(this, arguments);
this.__proto__ = f.prototype;
return this;
};
}

function Cool (t) { this.temp = 5; }
Hot = wrap(Cool);

var c = new Cool(5);
var h = new Hot(50);

assertTrue(h.temp == 50);
assertTrue(h instanceof Cool)
assertFalse(h instanceof Hot)


There is one slight subtlety to the above:


h = Hot.apply({}, [50]);
c = Cool.apply({}, [5]);
assertTrue(h)
assertFalse(c)


Cool has no return, so when invoked as a function, it returns undefined, but when invoked as a constructor, returns an object. Hot does have a return in all uses as it cannot really determine what the usage was and we always return an object.

Thus, if you wrap constructor, use it as a function, and count on it returning undefined, you will get the incorrect behaviour and cannot use the above method. The style police should probably revoke your dynamic language usage rights as well.

We can do slightly better, however:

function wrap (f,l){
return function (){
var lbl = l ? l : arguments.callee + '';
if (lbl.charAt(10) >= 'A' || lbl.charAt(10) <= 'Z') {
//constructor
} else {
//function
}
};
}

function gbl () {}
var lcl = function () {};
var a = wrap(gbl);
var b = wrap(lcl, 'local');


The above sets us up for sending out warnings. If we knew that constructing an object caused no side-effects, in the case of an undefined return on the else case, we may want to emit a warning, etc. We can keep tweaking and optimizing, but ultimately, I think this exposes a slight gap in the semantics of JS1.5 (or reveals what it means to write 'new'). Of course, in AS3, the same problem arises with the Class object. I don't know if that's fixed in ES4.

Now only if I could get blogger to correctly format code...

Edit 12/05/07:

Instead of returning the wrapped function right away, in case some code may write "new Hot(5) instanceof Hot", you should also set "Hot.prototype = f.prototype" (you could do this within wrap).

1 comment:

Mark Bessey said...

I found this post the other day while I was trying to do some clever function wrapping, and wanted to pass along an additional consideration.

When wrapping a constructor, you may need to set __proto__ before applying the original constructor function.

If the constructor function makes any calls to other methods in the class, it'll fail if the __proto__ property isn't properly set.

Whether calling methods from the constructor is a good idea or not is open to debate, of course, but it does happen.