Introduction
Reading current article's title and its short description, a lot of JavaScript experts will argue that ECMAScript specification does not mention named arguments and such feature has been implemented in many ways already. Anyway, neglecting such aspect, I am not trying to reinvent the wheel, I simply want to bring up a few interesting points, also useful in your daily JavaScript coding practice which are so often underutilized.
Background
As the aforementioned experts may add, changing JS built-in objects can give you some surprises and your work might accidentally be overwritten by other extensions, for example, if you add external libraries or change the version of one of them for good reasons. While these points are true and valid, I believe among driving motivations which could encourage developers to improve expertise, the willingness to embrace those intimidating features used to solve specific problems is often an underestimated good point of start for new explorative journeys. Also, in my case, what provided me more than a hint was a Sergey Alexandrovich Kryukov's article (and its follow-up) published recently here, which I would suggest to read to look at the problem from a different point of view.
Using the Code
Now I'm ready to show you how the main source code looks like, and to illustrate its usage through code samples created to explain the feature realization. Here below, you will find the source for argumentify
extension, which dismisses the function wrapper approach - probably necessary to deal with Object.seal
not reversible behavior - attaching named arguments straight to the function at runtime using the Object.defineProperty
feature introduced with ECMAScript 5 to modify JavaScript objects.
To prevent argument name clash, the hasOwnProperty
native method is used to check a property presence.
In any event, the functions are first-class citizens in JavaScript, thus they can have properties as any other object. We need these properties to represent the named formal arguments of the original target function. Writing the calling code, the developer may choose to define one or more named argument default values, or should assign values to these properties later on.
This is the complete implementation for argumentify:
"use strict";
Function.prototype.argumentify = function argumentify() {
var self = this,
argumentValues = Array.prototype.slice.call(arguments),
argumentNames = (self + "").match(/function[^(]*\(([^)]*)\)/)[1].split(/,\s*/);
if (!(!argumentNames || !argumentNames.length)) {
for (var i = 0, l = self.length; i < argumentNames.length; ++i) {
if (!self.hasOwnProperty(argumentNames[i]) || (self.$$arguments &&
self.$$arguments.names.indexOf(argumentNames[i]) > -1)) {
Object.defineProperty(self, argumentNames[i], {
get: function(index, length) {
return function() {
return argumentValues[index + length];
};
}.call(null, i, l),
set: function(index, length) {
return function(value) {
argumentValues[index + length] = value;
}
}.call(null, i, l),
configurable: true,
enumerable: false
});
self[argumentNames[i]] = argumentValues[i];
}
}
}
Object.defineProperty(self, "$$arguments", {
configurable: true,
enumerable: false,
value: {
names: argumentNames,
values: argumentValues
}
});
return self;
};
After the function is instantiated and augmented through argumentify
, the developer can execute the function logic calling invoke
method on that instance any number of times. The invoke
method behavior mimic the apply
native method, accepting two parameters respectively the this
context object and an object containing the named function arguments. So, if correctly supplied, the invoke
method does expect a second parameter typed as literal object passed through, hosting some properties representing the function parameter values, which at the moment of the call, can host all of the parameters required, or a part of them. And, in case of misspelled property name, the fault does not remain untrapped since the method fails early raising a TypeError
exception.
Here is the invoke
source code:
Function.prototype.invoke = function invoke(context) {
var i = 0, args, invokeArgs, $$arguments = this.$$arguments;
for (; $$arguments && i < $$arguments.names.length; i++)
(args || (args = [])).push(this[$$arguments.names[i]]);
invokeArgs = Array.prototype.slice.call(arguments, 1);
if (invokeArgs.length === 1 && invokeArgs[0].constructor === Object) {
var $args = invokeArgs[0];
for (var prop in $args) {
if ($args.hasOwnProperty(prop)) {
if ((i = $$arguments.names.indexOf(prop)) === -1) {
throw new TypeError("\"" + prop + "\" argument name is invalid");
} else {
args[i] = $args[prop];
}
}
}
}
return this.apply(context, args || invokeArgs);
};
And of course, all the arguments (properties) can be set to undefined
calling cleanUpNamedArguments
:
Function.prototype.cleanUpNamedArguments = function cleanUpNamedArguments(undefined) {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this[$$arguments.names[i]] = undefined;
};
Actually, to add ancillary features, such as those apt to observe the changes of one or more arguments, watchNamedArguments
and unWatchNamedArguments
has been implemented as mechanism allowing to trigger watch-blocks for the specified object instance/property.
The watch
/unwatch
methods and dependencies look like this:
if (!Function.prototype.watch) {
Object.defineProperty(Function.prototype, "watch", {
enumerable: false,
configurable: true,
writable: false,
value: function(prop, handler) {
var oldval = this[prop],
newval = oldval,
getter = function() {
return newval;
},
setter = function(val) {
oldval = newval;
return newval = handler.call(this, prop, oldval, val);
};
if (delete this[prop]) {
Object.defineProperty(this, prop, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
}
});
}
if (!Function.prototype.unwatch) {
Object.defineProperty(Function.prototype, "unwatch", {
enumerable: false,
configurable: true,
writable: false,
value: function(prop) {
var val = this[prop];
delete this[prop];
this[prop] = val;
}
});
}
Function.prototype.watchNamedArguments = function watchNamedArguments(callback) {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this.watch([$$arguments.names[i]], function (id, oldval, newval) {
callback.call(null, id, oldval, newval);
return newval;
});
};
Function.prototype.unwatchNamedArguments = function watchNamedArguments() {
var $$arguments = this.$$arguments;
for (var i = 0; $$arguments && i < $$arguments.names.length; i++)
this.unwatch([$$arguments.names[i]]);
};
As said, the argumentify
method has been conceived as a more JavaScript function friendly approach to named argument, and this does mean it is always ready to be called as many times as a developer needs an enhanced function instance without any sort of extra shield.
To see a complete example, let's consider the "ordinary" function using 3 arguments shown into Sergey's article:
var fn, fn0;
function f(first, medium, last) {
return first + medium + last;
}
try {
fn = f.argumentify(5, 10);
console.log("fn: " + (fn.last = 1, fn.invoke(null)));
console.log(fn.first);
console.log(fn.medium);
fn0 = f.argumentify(15, 20);
console.log("fn0: " + fn0.invoke(null, {last: 1}));
console.log(fn.first);
console.log(fn.medium);
} catch (ex) {
console.log(ex);
} finally {
fn.cleanUpNamedArguments();
}
Well, as you see nothing is explicitly checked up by the calling code, and although the argument names can be easily misspelled in both invoke
examples, it is worth noting the latter invoke
call may raise a TypeError
exception to notify a coding error. In the examples, the order of arguments can be changed also thanks to the fact that you can define default argument values for the omitted ones within argumentify
method call. On every function invocation, we may use invoke
method with different argument values, passing in the foremost argument as this
context or nullifying its value. This add a bit of negligible extra labor to function call coding, but, despite this, the JavaScript programmer may choose to stick with comma operator syntax or the more common, and validated against formal arguments list for mispelling, literal object syntax. In this case, both examples preferred to leave last argument set to undefined
as default value. Anyway, the real problem we are caring about here is readability and, of course, better maintenance of the codebase, especially when you have many duties to take care of and getting back to the same codebase after prolonged period of time becomes problematic.
The original function referenced by fn
and fn0
variables is the same as well as the property definitions attached to the function object. This is the main reason because within finally
block, cleanUpNamedArguments
is called only once.
At the same time, the misspell proof mechanism offered, paired with watching/unwatching mechanisms give the developer a barely complete foundation to work upon. In fact, the programming style shown in this article becomes more interesting when enriched with the following watching callback.
fn.watchNamedArguments(function(id, oldval, newval) {
console.log('o.' + id + ' changed from ' + oldval + ' to ' + newval);
});
Here, I used the common idea of taking a callback function to log out the named arguments activities. Please beware that only the first watching callback is triggered for the current proposed implementation.
ECMAScript 6 to the Rescue
ECMAScript 5 is the most adopted ECMAScript standard at the moment in all major browsers, anyway ECMAScript 6 introduced this possibility to define default parameter values: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters. Even if this syntax sugar addition may sound irrelevant, it could be a nice to have ECMAScript language improvement if you are among those developers begging for named arguments in JavaScript.
Points of Interest
Probably, there are two likely perplexing things about the presented approach. One of the two is the style of argument initialization, since I suggested the use of comma operator, while the other one regards the fact that the actual arguments apparently have nothing to do with the parameters passed in a regular function call statement.
Let's start to say that comma operator is not specific to JavaScript, and is available in other languages like C and C++ too. Being a binary operator inherited from C language, it is useful when the left-hand operand, which is generally an expression, has desired side effect required by the second operand. Anyway, to be exhaustive, the comma has a special meaning in var
statements. In fact, a variable declaration such as var a, b
refers to the two or more variables you are going to initialize and use in your code, which is not the case for an expression where the result is given by the last variable or instruction separated by comma operator. One obfuscated example of this could be like this:
var b = (1,2) > (4,8) ? 'bar' : (4,2) > (1,0) ? '' : 'baz';
The code smelling example leads to the result of a variabile intialization to ''
using an at first sight convulted sequence of steps which starts the resolution (1,2)
expression to 2
and (4,8)
expression to 8
, followed by the resolution of (4,2)
expression to 2
and (1,0)
expression to 1
. Then these transient values are used respectively as left-hand and right-hand for the greater-than evaluation feeding the two ?:
ternary operators leading to the mentioned ''
final result. A partially deobfuscation effect could be exemplified like this:
var b = 2 > 8 ? 'bar' : 2 > 0 ? '' : 'baz';
So, moving to the other potential perplexing point, it may seem that ignoring the arguments from the arguments list passed in the round brackets of the call statement is something that would look strange if you come from a different language. Considering that the developer writing a function call needs to have a concrete idea on its outcome, an important thing to think about, using the approach described in this article, is that the set of arguments may be passed as a set of property assignments, leaving the argument list to our disposal for others potential uses.
Let's see a couple of equivalent function call statements:
console.log(fn(1, -2, 3));
console.log((fn.first = 1, fn.medium = -2, fn.last = 3, fn.invoke(null)));
These two statements are equivalent as final result expected, and, despite anything to the contrary about calling invoke method with zero parameters writing fn.invoke()
, it is also important to bear in mind that f.first
is still a legit way to reference the first argument value because of the function name declared in our main example. This is a justified approach if you want to reuse function objects in order to avoid greedy GC operations, and you code in a way which is to create a single object once and update its named argument property values during argumentify call.
A different concern could be about Object.defineProperty
parameterization; so, I hope ECMAScript specification could help to clear up things especially about configurable
set to true in order to allow named argument default values further changes.
- 8.6.1 excerpt: "[[Configurable]]: If false, attemps to delete the property, change the property to be an accessor property, or change its attributes (other than [[Value]]) will fail."
In the same area of code design decision, as a matter of personal preference, I have decided to set enumerable
to false
; this way a for-in
loop going through all enumerable properties of an object, even those in the prototype chain, will skip named argument properties.
Conclusions
I have spent some time to come to the conclusion, matured through a few cycles of rewriting, that the simple technique I've described in this article can be even in part useful to other developer projects. Not only does it provide a way to govern control flow about passing function's arguments, it also could become a basis for thinking about a certain way to design JavaScript solutions in a more maintainable way.