6. Advanced working with functions
JavaScript gives exceptional flexibility when dealing with functions. They can be passed around, used as objects, and now we'll see how to forward calls between them and decorate them.
Let's say we have a function slow(x)
which is CPU-heavy, but its results are stable. In other words, for the same x
it always returns the same result.
If the function is called often, we may want to cache (remember) the results to avoid spending extra-time on recalculations.
But instead of adding that functionality into slow()
we'll create a wrapper function, that adds caching. As we'll see, there are many benefits of doing so.
Here's the code, and explanations follow:
function slow(x) {
// there can be a heavy CPU-intensive job here
alert(`Called with ${x}`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) { // if there's such key in cache
return cache.get(x); // read the result from it
}
let result = func(x); // otherwise call func
cache.set(x, result); // and cache (remember) the result
return result;
};
}
slow = cachingDecorator(slow);
alert( slow(1) ); // slow(1) is cached and the result returned
alert( "Again: " + slow(1) ); // slow(1) result returned from cache
alert( slow(2) ); // slow(2) is cached and the result returned
alert( "Again: " + slow(2) ); // slow(2) result returned from cache
In the code above cachingDecorator
is a decorator: a special function that takes another function and alters its behavior.
The idea is that we can call cachingDecorator
for any function, and it will return the caching wrapper. That's great, because we can have many functions that could use such a feature, and all we need to do is to apply cachingDecorator
to them.
By separating caching from the main function code we also keep the main code simpler.
The result of cachingDecorator(func)
is a "wrapper": function(x)
that "wraps" the call of func(x)
into caching logic:
From an outside code, the wrapped slow
function still does the same. It just got a caching aspect added to its behavior.
To summarize, there are several benefits of using a separate cachingDecorator
instead of altering the code of slow
itself:
cachingDecorator
is reusable. We can apply it to another function.slow
itself (if there was any).The caching decorator mentioned above is not suited to work with object methods.
For instance, in the code below worker.slow()
stops working after the decoration:
// we'll make worker.slow caching
let worker = {
someMethod() {
return 1;
},
slow(x) {
// scary CPU-heavy task here
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
// same code as before
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
*!*
let result = func(x); // (**)
*/!*
cache.set(x, result);
return result;
};
}
alert( worker.slow(1) ); // the original method works
worker.slow = cachingDecorator(worker.slow); // now make it caching
*!*
alert( worker.slow(2) ); // Whoops! Error: Cannot read property 'someMethod' of undefined
*/!*
The error occurs in the line (*)
that tries to access this.someMethod
and fails. Can you see why?
The reason is that the wrapper calls the original function as func(x)
in the line (**)
. And, when called like that, the function gets this = undefined
.
We would observe a similar symptom if we tried to run:
let func = worker.slow;
func(2);
So, the wrapper passes the call to the original method, but without the context this
. Hence the error.
Let's fix it.
There's a special built-in function method func.call(context, ...args) that allows to call a function explicitly setting this
.
The syntax is:
func.call(context, arg1, arg2, ...)
It runs func
providing the first argument as this
, and the next as the arguments.
To put it simply, these two calls do almost the same:
func(1, 2, 3);
func.call(obj, 1, 2, 3)
They both call func
with arguments 1
, 2
and 3
. The only difference is that func.call
also sets this
to obj
.
As an example, in the code below we call sayHi
in the context of different objects: sayHi.call(user)
runs sayHi
providing this=user
, and the next line sets this=admin
:
function sayHi() {
alert(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };
// use call to pass different objects as "this"
sayHi.call( user ); // John
sayHi.call( admin ); // Admin
And here we use call
to call say
with the given context and phrase:
function say(phrase) {
alert(this.name + ': ' + phrase);
}
let user = { name: "John" };
// user becomes this, and "Hello" becomes the first argument
say.call( user, "Hello" ); // John: Hello
In our case, we can use call
in the wrapper to pass the context to the original function:
let worker = {
someMethod() {
return 1;
},
slow(x) {
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
*!*
let result = func.call(this, x); // "this" is passed correctly now
*/!*
cache.set(x, result);
return result;
};
}
worker.slow = cachingDecorator(worker.slow); // now make it caching
alert( worker.slow(2) ); // works
alert( worker.slow(2) ); // works, doesn't call the original (cached)
Now everything is fine.
To make it all clear, let's see more deeply how this
is passed along:
worker.slow
is now the wrapper function (x) { ... }
.worker.slow(2)
is executed, the wrapper gets 2
as an argument and this=worker
(it's the object before dot).func.call(this, x)
passes the current this
(=worker
) and the current argument (=2
) to the original method.Now let's make cachingDecorator
even more universal. Till now it was working only with single-argument functions.
Now how to cache the multi-argument worker.slow
method?
let worker = {
slow(min, max) {
return min + max; // scary CPU-hogger is assumed
}
};
// should remember same-argument calls
worker.slow = cachingDecorator(worker.slow);
Previously, for a single argument x
we could just cache.set(x, result)
to save the result and cache.get(x)
to retrieve it. But now we need to remember the result for a combination of arguments (min,max)
. The native Map
takes single value only as the key.
There are many solutions possible:
cache.set(min)
will be a Map
that stores the pair (max, result)
. So we can get result
as cache.get(min).get(max)
."min,max"
as the Map
key. For flexibility, we can allow to provide a hashing function for the decorator, that knows how to make one value from many.For many practical applications, the 3rd variant is good enough, so we'll stick to it.
Also we need to pass not just x
, but all arguments in func.call
. Let's recall that in a function()
we can get a pseudo-array of its arguments as arguments
, so func.call(this, x)
should be replaced with func.call(this, ...arguments)
.
Here's a more powerful cachingDecorator
:
let worker = {
slow(min, max) {
alert(`Called with ${min},${max}`);
return min + max;
}
};
function cachingDecorator(func, hash) {
let cache = new Map();
return function() {
*!*
let key = hash(arguments); // (*)
*/!*
if (cache.has(key)) {
return cache.get(key);
}
*!*
let result = func.call(this, ...arguments); // (**)
*/!*
cache.set(key, result);
return result;
};
}
function hash(args) {
return args[0] + ',' + args[1];
}
worker.slow = cachingDecorator(worker.slow, hash);
alert( worker.slow(3, 5) ); // works
alert( "Again " + worker.slow(3, 5) ); // same (cached)
Now it works with any number of arguments (though the hash function would also need to be adjusted to allow any number of arguments. An interesting way to handle this will be covered below).
There are two changes:
(*)
it calls hash
to create a single key from arguments
. Here we use a simple "joining" function that turns arguments (3, 5)
into the key "3,5"
. More complex cases may require other hashing functions.(**)
uses func.call(this, ...arguments)
to pass both the context and all arguments the wrapper got (not just the first one) to the original function.Instead of func.call(this, ...arguments)
we could use func.apply(this, arguments)
.
The syntax of built-in method func.apply is:
func.apply(context, args)
It runs the func
setting this=context
and using an array-like object args
as the list of arguments.
The only syntax difference between call
and apply
is that call
expects a list of arguments, while apply
takes an array-like object with them.
So these two calls are almost equivalent:
func.call(context, ...args);
func.apply(context, args);
They perform the same call of func
with given context and arguments.
There's only a subtle difference regarding args
:
...
allows to pass iterable args
as the list to call
.apply
accepts only array-like args
....And for objects that are both iterable and array-like, such as a real array, we can use any of them, but apply
will probably be faster, because most JavaScript engines internally optimize it better.
Passing all arguments along with the context to another function is called call forwarding.
That's the simplest form of it:
let wrapper = function() {
return func.apply(this, arguments);
};
When an external code calls such wrapper
, it is indistinguishable from the call of the original function func
.
Now let's make one more minor improvement in the hashing function:
function hash(args) {
return args[0] + ',' + args[1];
}
As of now, it works only on two arguments. It would be better if it could glue any number of args
.
The natural solution would be to use arr.join method:
function hash(args) {
return args.join();
}
...Unfortunately, that won't work. Because we are calling hash(arguments)
, and arguments
object is both iterable and array-like, but not a real array.
So calling join
on it would fail, as we can see below:
function hash() {
*!*
alert( arguments.join() ); // Error: arguments.join is not a function
*/!*
}
hash(1, 2);
Still, there's an easy way to use array join:
function hash() {
*!*
alert( [].join.call(arguments) ); // 1,2
*/!*
}
hash(1, 2);
The trick is called method borrowing.
We take (borrow) a join method from a regular array ([].join
) and use [].join.call
to run it in the context of arguments
.
Why does it work?
That's because the internal algorithm of the native method arr.join(glue)
is very simple.
Taken from the specification almost "as-is":
glue
be the first argument or, if no arguments, then a comma ","
.result
be an empty string.this[0]
to result
.glue
and this[1]
.glue
and this[2]
.this.length
items are glued.result
.So, technically it takes this
and joins this[0]
, this[1]
...etc together. It's intentionally written in a way that allows any array-like this
(not a coincidence, many methods follow this practice). That's why it also works with this=arguments
.
It is generally safe to replace a function or a method with a decorated one, except for one little thing. If the original function had properties on it, like func.calledCount
or whatever, then the decorated one will not provide them. Because that is a wrapper. So one needs to be careful if one uses them.
E.g. in the example above if slow
function had any properties on it, then cachingDecorator(slow)
is a wrapper without them.
Some decorators may provide their own properties. E.g. a decorator may count how many times a function was invoked and how much time it took, and expose this information via wrapper properties.
There exists a way to create decorators that keep access to function properties, but this requires using a special Proxy
object to wrap a function. We'll discuss it later in the article info:proxy#proxy-apply.
Decorator is a wrapper around a function that alters its behavior. The main job is still carried out by the function.
Decorators can be seen as "features" or "aspects" that can be added to a function. We can add one or add many. And all this without changing its code!
To implement cachingDecorator
, we studied methods:
func
with given context and arguments.func
passing context
as this
and array-like args
into a list of arguments.The generic call forwarding is usually done with apply
:
let wrapper = function() {
return original.apply(this, arguments);
};
We also saw an example of method borrowing when we take a method from an object and call
it in the context of another object. It is quite common to take array methods and apply them to arguments
. The alternative is to use rest parameters object that is a real array.
There are many decorators there in the wild. Check how well you got them by solving the tasks of this chapter.