applyFunctionArguments - argument injection technique in JavaScript

Tuesday, June 28, 2011 Posted by Ruslan Matveev
Hi all, it's been a while since I've updated the blog and many apologies for this. From now on I'll try to post something interesting at least once a week. Today I'm going to show describe you the thing that I call "argument injection" in JavaScript. This technique will let you to do some funny things that I'm going to describe below. But first things first and I'm going to start with the source code. It's going to be a bunch of functions that you can include into your own library / framework or application. Ready?
/**
 * List of the reserved words, cannnot be used as a function names.
 * @type {Array.<string>}
 * @constant
 */
var RESERVED_WORDS = [
        'break', 'else', 'new', 'var', 'case', 'finally',
        'return', 'void', 'catch', 'for', 'switch', 'while',
        'continue', 'function', 'this', 'with', 'default',
        'if', 'throw', 'delete', 'in', 'try', 'do', 'instanceof',
        'typeof', 'abstract', 'enum', 'int', 'short', 'boolean',
        'export', 'interface', 'static', 'byte', 'extends',
        'long', 'super', 'char', 'final', 'native', 'synchronized',
        'class', 'float', 'package', 'throws', 'const', 'goto',
        'private', 'transient', 'debugger', 'implements', 'protected',
        'volatile', 'double', 'import', 'public', 'null', 'true', 'false'
];
/**
 * Returns true if the value is undefined.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is undefined,
 *     otherwise false.
 */
function isUndefined(value) {
        return (typeof(value) === 'undefined');
}
/**
 * Returns true if the value is null.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is null, otherwise false.
 */
function isNull(value) {
        return (value === null);
}
/**
 * Returns true if the value is string.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is string, otherwise false.
 */
function isString(value) {
        return (typeof(value) === 'string');
}
/**
 * Returns true if the value is object.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is object, otherwise false.
 */
function isObject(value) {
        // return true only if the object is not null
        return (!isNull(value) && typeof(value) === 'object');
}
/**
 * Returns true if the value is array.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is array, otherwise false.
 */
function isArray(value) {
        // return true if value is object and array
        return (isObject(value) && value.constructor === Array);
}
/**
 * Returns true if the value is function.
 * @param {Variant} value Value to check.
 * @return {boolean} Returns true if the value is function, otherwise false.
 */
function isFunction(value) {
        return (typeof(value) === 'function');
}
/**
 * Returns true if the object is not empty.
 * @param {Object} object Object to check.
 * @return {boolean} Returns true if object contains properties,
 *     otherwise false.
 */
function objectIsEmpty(object) {
        // check if an argument is an object and contains some keys
        return (!isObject(object) || Object.keys(object).length === 0);
}
/**
 * Returns true if the value is valid JavaScript identifier.
 * @param {Variant} value Value to check.
 * @return {string} Returns true if the value is valid
 *     JavaScript identifier, otherwise false.
 */
function isIdentifier(value) {
        // check if the value is reserved word
        if (isString(value) && RESERVED_WORDS.indexOf(value) === -1) {
                // skip 'undefined' though it's valid identifier
                if (value !== 'undefined') {
                        // check value against identifier regexp pattern
                        return (/^[a-zA-Z_$]+[0-9a-zA-Z_$]*$/).test(value);
                }
        }
        // value is reseved word, return false
        return false;
}
/**
 * Executes a provided callback once per array element or object property.
 * @param {Object} object Array or object to process.
 * @param {Function} callback Function to execute for each
 *     element or property.
 * @param {Variant} thisObject this object that will be used as this
 *     while calling the callback function
 * @return {Variant} Original thisObject.
 */
function forEach(object, callback, thisObject) {
        // check if the given object is an array
        if (isArray(object)) {
                // use built-in Array.prototype.forEach
                object.forEach.call(
                        // array and callback
                        object, callback,
                        // thisObject or this if not specified
                        thisObject || this
                );
        } else {
                // check if we have an object or function
                if (isObject(object) || isFunction(object)) {
                        // walk over the properties
                        for (var key in object) {
                                // enumerate only own properties
                                if (object.hasOwnProperty(key)) {
                                        // execute callback for each object property
                                        callback.call(
                                                // thisObject or this if not specified
                                                thisObject || this,
                                                // simulate Array.prototype.forEach convention
                                                object[key], key, object
                                        );
                                }
                        }
                }
        }
        return thisObject;
}
/**
 * Returns a "flat" (one - dimensional) copy of the object.
 * Uses dot separated keys to represent the structure.
 * @param {Object} object Object to process.
 * @return {Object} One - dimensional copy of the object.
 */
function objectFlatten(object) {
        // initialize result
        var result = {};
        // initialize prefix
        var prefix = (arguments[1] || '');
        // walk over object properties
        forEach(object, function(value, name) {
                // check if we can process value
                if (!objectIsEmpty(value)) {
                        // process object's children
                        value = objectFlatten(value, name + '.');
                        // walk over object's children
                        forEach(value, function(value, name) {
                                // store value into result object
                                result[prefix + name] = value;
                        });
                } else {
                        // store value into result object
                        result[prefix + name] = value;
                }
        });
        // return result object
        return result;
}
/**
 * Returns an "unflatten" (multi - dimensional) copy of the object.
 * Uses dot separated keys to build the structure.
 * Uses empty objects to fill missing fragments.
 * @param {Object} object Object to process.
 * @return {Object} Multi - dimensional copy of the object.
 */
function objectUnFlatten(object) {
        // initialize result
        var result = {};
        // walk over the sorted list of object's keys
        forEach(Object.keys(object).sort(), function(key) {
                // initialize parent
                var parent = result;
                // walk over the name path
                forEach(key.split('.'), function(fragment, index, path) {
                        // check if we have a last key
                        if (index === path.length - 1) {
                                // initialize value
                                var value = object[key];
                                // check if we need to process value
                                if (isObject(value)) {
                                        // process value
                                        value = objectUnFlatten(value);
                                }
                                // check if we can add value to parent
                                if (isFunction(parent) ||
                                        isArray(parent) ||
                                        isObject(parent)) {
                                        // add fragment's value to parent
                                        parent[fragment] = value;
                                }
                        } else {
                                // check if we can switch parent
                                if (!isUndefined(parent[fragment])) {
                                        // switch parent
                                        parent = parent[fragment];
                                } else {
                                        // create parent and switch to it
                                        parent = parent[fragment] = {};
                                }
                        }
                });
        });
        // return result object
        return result;
}
/**
 * Extracts function body from the function object
 * and returns it as a string.
 * @param {Function} functionObject Function object to process.
 * @return {string} Function body as a string.
 */
function getFunctionBody(functionObject) {
        // check arguments
        if (!isFunction(functionObject)) return '';
        // convert functionObject to string
        var functionBody = functionObject.toString();
        // extract function body
        functionBody = functionBody.split('{').slice(1).join('{');
        functionBody = functionBody.split('}').slice(0, -1).join('}');
        // remove leading and trailing whitespaces
        functionBody = functionBody.replace(/^\s+/, '').replace(/\s+$/, '');
        // remove trailing semicolon
        functionBody = functionBody.replace(/;$/, '');
        // wrap function body into new line literals
        functionBody = ('\r\n' + functionBody + '\r\n');
        // return extracted body
        return functionBody;
}
/**
 * Retrieves the list of function arguments.
 * @param {Function} functionObject Function object to process.
 * @return {Array.<string>} Array of function arguments names.
 */
function getFunctionArguments(functionObject) {
        // check arguments
        if (!isFunction(functionObject)) return [];
        // how many arguments do we have
        var functionArity = functionObject.length;
        // convert functionObject to string
        var functionValue = functionObject.toString();
        // extract function arguments
        var functionArguments = functionValue.split(')', 2).shift();
        functionArguments = functionArguments.split('(', 2).pop();
        // return arguments names array
        return functionArguments.split(/\s?,\s?/, functionArity);
}
/**
 * Sets function arguments names.
 * @param {Function} functionObject Function object to process.
 * @param {Array.<string>} argumentsList Array with argument names.
 * @return {Function} New function object with updated arguments list.
 */
function setFunctionArguments(functionObject, argumentsList) {
        // check arguments
        if (!isFunction(functionObject)) functionObject = new Function();
        // set argumentsList to empty array in case if it's not set
        if (isUndefined(argumentsList)) argumentsList = [];
        // wrap argumentsList into array in case if it's not an array
        if (!isArray(argumentsList)) argumentsList = [argumentsList];
        // extract function body
        var functionBody = getFunctionBody(functionObject);
        // create and return new function with updated arguments list
        return new Function(argumentsList, functionBody);
}
/**
 * Calls a function with a given this and arguments provided as a map,
 * consists of name and value that will be used as an argument name and
 * argument value respectively.
 * @param {Function} functionObject Function to call.
 * @param {Object.<string, variant>} argumentsMap Map consists of argument
 *     name as a string and argument value, if name is numeric then
 *     it will be used to map original argument's name.
 * @param {Object} thisObject Object that will be used as this, while
 *     calling functionObject.
 * @return {Variant} Result of functionObject call as it's returned.
 */
function applyFunctionArguments(functionObject, argumentsMap, thisObject) {
        // initialize variables
        var argumentNames = [];
        var argumentValues = [];
        // get list of original arguments
        var argumentsList = getFunctionArguments(functionObject);
        // flatten and unflatten arguments map
        argumentsMap = objectFlatten(argumentsMap);
        argumentsMap = objectUnFlatten(argumentsMap);
        // walk over argumentsMap to build arguments
        forEach(argumentsMap, function(value, name) {
                // check if we can convert name to number
                var argumentIndex = parseInt(name, 10);
                // check if we can use original argument name
                if (!isNaN(argumentIndex) && argumentsList[argumentIndex]) {
                        // add argument name
                        argumentNames.push(argumentsList[argumentIndex]);
                        // add argument value
                        argumentValues.push(value);
                } else if (isIdentifier(name)) {
                        // add argument name
                        argumentNames.push(name);
                        // add argument value
                        argumentValues.push(value);
                }
        });
        // create evaluator function out of functionObject with new arguments
        var evaluator = setFunctionArguments(functionObject, argumentNames);
        // execute evaluator with new arguments and thisObject as this
        return evaluator.apply(thisObject, argumentValues);
}

Okay enough code, now I'm going to show you how to use it. Normally when you call a function, what do you expect as an arguments? Exactly what you've defined in the function header (in the following example is arg1, arg2, arg3):

// define a function
function foobar(arg1, arg2, arg3) {
        alert([arg1, arg2, arg3]);
}
// call a function
foobar(1, 2, 3);

But this is boring, isn't it? What if your function is generated as an anonymous function, and what if, ohh there is many many "what if", so that is why JavaScript has two methods defined on the function prototype that is suppose to manage all the different situations. These methods are: Function.prototype.call and Function.prototype.apply. Here is the short description that is taken from MDC:

Function.prototype.call - Calls a function with a given this value and arguments provided individually.
Function.prototype.applyCalls a function with a given this value and arguments provided as an array.

Okay so the difference is that one calls a function with the arguments that is listed as an arguments of the Function.prototype.call itself, when the other one is doing exactly the same, but allows you to pass the arguments as an array. So it's almost the same.

What I wanted to have - is full control over the function arguments, as well as their names. So that is why I've decided to invent my own way of calling the function, which includes: argument injection (you decide how your arguments is called while calling it), you specify arguments as a map (not as array) and additionally you can form a hierarchical structure our of your arguments. Too much information in one go? So let's look at the following example:

applyFunctionArguments(function() {
        alert([a, b, c, this]);
}, {
        'a': 10,
        'b': 'string',
        'c': [1, 2, 3]
}, 'Hi I\'m going to be "this" object this time');

So what we do here, is we specify an arguments as a map, and pass aditional argument which will act as "this" in the function's context. We can pass any kind of values (just as in original Function.prototype.call and Function.prototype.apply), but it gives us more flexibility to decide how we wanna call our arguments during the runtime. Additionally - you don't have to define argument names in the function header (look at it, header is empty). But that is not all, here is more complicated example:

applyFunctionArguments(function() {
        alert(document);
        alert(document.body);
        alert(document.body.innerHTML);
        alert(hello);
        alert(hello.world);
        alert(hello.world.of);
        alert(hello.world.of.javascript);
}, {
        'document': {
                'body': {
                        'innerHTML': 'hello world'
                }
        },
        'hello': {},
        'hello.world': {},
        'hello.world.of.javascript': '333333'
});

So the idea is that using "dot" separated map keys we can build a hierarchical structure out of our arguments. So you can have lots of fun with it (imagine for example what's gonna happen if you'll pass some jQuery object as a second argument of applyFunctionArguments?

Have fun with it!

Post a Comment