Pure functions
A function that meets the following two requirements is called pure:
- It always, when called with the same arguments, returns the same result.
- No side effects occur when the function is executed.
Let's consider an example:
function circleArea(radius){
return radius * radius * 3.14
}
If this function is passed the same value
radius
, it always returns the same result. At the same time, during the execution of the function, nothing outside of it changes, that is, it has no side effects. All this means that this is a pure function.
Here's another example:
let counter = (function(){
let initValue = 0
return function(){
initValue++;
return initValue
}
})()
Let's try this function in the browser console.
Testing the function in the browser console
As you can see, the function
counter
that implements the counter returns different results each time it is called. Therefore, it cannot be called pure.
And here is another example:
let femaleCounter = 0;
let maleCounter = 0;
function isMale(user){
if(user.sex = 'man'){
maleCounter++;
return true
}
return false
}
Shown here is a function
isMale
that, when passed the same argument, always returns the same result. But it has side effects. Namely, we are talking about changing a global variable maleCounter
. As a result, this function cannot be called pure.
βWhy are pure functions needed?
Why do we draw the line between regular and pure functions? The point is that pure functions have many strengths. Their use can improve the quality of the code. Let's talk about what the use of pure functions gives us.
1. The code of pure functions is clearer than the code of ordinary functions, it is easier to read
Each pure function is aimed at a specific task. It, called with the same input, always returns the same result. This greatly improves the readability of the code and makes it easier to document.
2. Pure functions lend themselves better to optimization when compiling their code
Suppose you have a piece of code like this:
for (int i = 0; i < 1000; i++){
console.log(fun(10));
}
If
fun
- this is a function that is not pure, then during the execution of this code, this function will have to be called fun(10)
1000 times.
And if it
fun
is a pure function, then the compiler can optimize the code. It might look something like this:
let result = fun(10)
for (int i = 0; i < 1000; i++){
console.log(result);
}
3. Pure functions are easier to test
Pure function tests should not be context sensitive. When writing unit tests for pure functions, they simply pass some input values ββto such functions and check what they return against certain requirements.
Here's a simple example. The pure function takes an array of numbers as an argument and adds 1 to each element of that array, returning a new array. Here's a shorthand representation:
const incrementNumbers = function(numbers){
// ...
}
To test such a function, it is enough to write a unit test that resembles the following:
let list = [1, 2, 3, 4, 5];
assert.equals(incrementNumbers(list), [2, 3, 4, 5, 6])
If a function is not pure, then to test it, you need to take into account many external factors that can affect its behavior. As a result, testing such a function will be more difficult than testing a pure one.
Higher-order functions.
A higher-order function is a function that has at least one of the following capabilities:
- It is capable of accepting other functions as arguments.
- It can return a function as the result of its work.
Using higher-order functions allows you to increase the flexibility of your code, helping you write more compact and efficient programs.
Let's say there is an array of integers. It is necessary to create on its basis a new array of the same length, but such, each element of which will represent the result of multiplying the corresponding element of the original array by two.
If you do not use the capabilities of higher-order functions, then the solution to this problem may look like this:
const arr1 = [1, 2, 3];
const arr2 = [];
for (let i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2);
}
If you think about the problem, it turns out that type objects
Array
in JavaScript have a method map()
. This method is called as map(callback)
. It creates a new array filled with the elements of the array for which it is called, processed with the function passed to it callback
.
This is how the solution to this problem using the method looks like
map()
:
const arr1 = [1, 2, 3];
const arr2 = arr1.map(function(item) {
return item * 2;
});
console.log(arr2);
Method
map()
is an example of a higher order function.
Correct use of higher-order functions helps to improve the quality of your code. In the following sections of this material, we will return to such functions more than once.
Caching function results
Let's say you have a pure function that looks like this:
function computed(str) {
// ,
console.log('2000s have passed')
// ,
return 'a result'
}
In order to improve the performance of the code, it will not hurt us to resort to caching the results of calculations performed in the function. When you call such a function with the same parameters with which it was already called, you will not have to perform the same calculations again. Instead, their results, previously stored in the cache, will be returned immediately.
How to equip a function with a cache? To do this, you can write a special function that can be used as a wrapper for the target function. We will give this special function a name
cached
. This function takes an objective function as an argument and returns a new function. In a function, cached
you can organize the caching of the results of a call to the function wrapped around it using a regular object ( Object
) or using an object that is a data structureMap
...
This is what the function code might look like
cached
:
function cached(fn){
// , fn.
const cache = Object.create(null);
// fn, .
return function cachedFn (str) {
// - fn
if ( !cache[str] ) {
let result = fn(str);
// , fn,
cache[str] = result;
}
return cache[str]
}
}
Here are the results of experimenting with this feature in the browser console.
Experimenting with a function whose results are cached
Lazy functions
In function bodies, there are usually some instructions for checking some conditions. Sometimes the conditions corresponding to them need to be checked only once. There is no point in checking them every time the function is called.
In such circumstances, you can improve the performance of the function by "deleting" these instructions after they are first executed. As a result, it turns out that the function, with its subsequent calls, will not have to perform checks, which will no longer be necessary. This will be the "lazy" function.
Suppose we want to write a function
foo
that always returns the object Date
created the first time that function is called. Please note that we need an object that was created exactly when the function was called for the first time.
Its code might look like this:
let fooFirstExecutedDate = null;
function foo() {
if ( fooFirstExecutedDate != null) {
return fooFirstExecutedDate;
} else {
fooFirstExecutedDate = new Date()
return fooFirstExecutedDate;
}
}
Every time this function is called, the condition must be checked. If this condition is very difficult, then calls to such a function will lead to a drop in program performance. This is where we can use the technique of creating "lazy" functions to optimize the code.
Namely, we can rewrite the function as follows:
var foo = function() {
var t = new Date();
foo = function() {
return t;
};
return foo();
}
After the first call to the function, we replace the original function with the new one. This new function returns the value
t
represented by the object Date
created the first time the function was called. As a result, no conditions need to be checked when calling such a function. This approach can improve the performance of your code.
This was a very simple conditional example. Let's now look at something closer to reality.
When attaching event handlers to DOM elements, you need to perform checks to ensure the solution is compatible with modern browsers and with IE:
function addEvent (type, el, fn) {
if (window.addEventListener) {
el.addEventListener(type, fn, false);
}
else if(window.attachEvent){
el.attachEvent('on' + type, fn);
}
}
It turns out that every time we call a function
addEvent
, a condition is checked in it, which is enough to check only once, the first time it is called. Let's make this function "lazy":
function addEvent (type, el, fn) {
if (window.addEventListener) {
addEvent = function (type, el, fn) {
el.addEventListener(type, fn, false);
}
} else if(window.attachEvent){
addEvent = function (type, el, fn) {
el.attachEvent('on' + type, fn);
}
}
addEvent(type, el, fn)
}
As a result, we can say that if a certain condition is checked in a function, which should be performed only once, then by applying the technique of creating "lazy" functions, you can optimize the code. Namely, optimization consists in the fact that after the first check of the condition, the original function is replaced with a new one, in which there are no more checks of conditions.
Currying functions
Currying is such a transformation of a function, after applying which a function that had to be called before passing several arguments to it at once turns into a function that can be called by passing the necessary arguments to it one at a time.
In other words, we are talking about the fact that a curried function, which requires several arguments to work correctly, is capable of accepting the first of them and returning a function that is capable of taking a second argument. This second function, in turn, returns a new function that takes a third argument and returns a new function. This will continue until the required number of arguments is passed to the function.
What is the use of this?
- Currying helps to avoid situations where a function needs to be called by passing the same argument over and over again.
- This technique helps to create higher-order functions. It is extremely useful for handling events.
- Thanks to currying, you can organize preliminary preparation of functions for performing certain actions, and then conveniently reuse such functions in your code.
Consider a simple function that adds the numbers passed to it. Let's call it
add
. It takes three operands as arguments and returns their sum:
function add(a,b,c){
return a + b + c;
}
Such a function can be called by passing it fewer arguments than it needs (although this will lead to the fact that it returns something completely different from what is expected of it). It can also be called with more arguments than provided for when it was created. In such a situation, "unnecessary" arguments will simply be ignored. Experimenting with a similar function might look like this:
add(1,2,3) --> 6
add(1,2) --> NaN
add(1,2,3,4) --> 6 // .
How to curry such a function?
Here is the code of the function
curry
that is meant to curry other functions:
function curry(fn) {
if (fn.length <= 1) return fn;
const generator = (...args) => {
if (fn.length === args.length) {
return fn(...args)
} else {
return (...args2) => {
return generator(...args, ...args2)
}
}
}
return generator
}
Here are the results of experimenting with this feature in the browser console.
Experimenting with curry in the browser console
Function composition
Suppose you need to write a function that, taking a string as input
bitfish
, returns a string HELLO, BITFISH
.
As you can see, this function serves two purposes:
- String concatenation.
- Converting the characters in the resulting string to uppercase.
This is what the code for such a function might look like:
let toUpperCase = function(x) { return x.toUpperCase(); };
let hello = function(x) { return 'HELLO, ' + x; };
let greet = function(x){
return hello(toUpperCase(x));
};
Let's experiment with it.
Testing a Function in the Browser Console
This task includes two subtasks that are organized as separate functions. As a result, the function code
greet
is quite simple. If it was necessary to perform more operations on strings, then the functiongreet
would contain a construction likefn3(fn2(fn1(fn0(x))))
.
Let's simplify the solution of the problem and write a function that composes other functions. Let's call it
compose
. Here is its code:
let compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
Now the function
greet
can be created using the function compose
:
let greet = compose(hello, toUpperCase);
greet('kevin');
Using a function
compose
to create a new function based on two existing ones implies creating a function that calls those functions from left to right. As a result, we get a compact code that is easy to read.
Now our function
compose
takes only two parameters. And we would like it to be able to accept any number of parameters.
A similar function, capable of accepting any number of parameters, is available in the well-known open source underscore library .
function compose() {
var args = arguments;
var start = args.length - 1;
return function() {
var i = start;
var result = args[start].apply(this, arguments);
while (i--) result = args[i].call(this, result);
return result;
};
};
By using function composition, you can make the logical relationships between functions more understandable, improve the readability of your code, and lay the foundation for future extensions and refactorings.
Do you use any special way of working with functions in your JavaScript projects?