Every new version of JavaScript adds some extra goodies that make programming easier. EcmaScript 5 added some much needed methods to the Array
data type, and, while you can find resources which teach you how to use these methods, they typically omit a discussion on using them with anything other than a boring, custom function.
All of the array extras ignore holes in arrays.
The new array methods added in ES5 are usually referred to as Array Extras. They ease the process of working with arrays by providing methods to perform common operations. Here is an almost complete list of the new methods:
Array.prototype.map
Array.prototype.reduce
Array.prototype.reduceRight
Array.prototype.filter
Array.prototype.forEach
Array.prototype.every
Array.prototype.some
Array.prototype.indexOf
and Array.prototype.lastIndexOf
are also part of that list, but this tutorial will only discuss the above seven methods.
What They Told You
These methods are fairly simple to use. They execute a function that you supply as their first argument, for every element in the array. Typically, the supplied function should have three parameters: the element, the element's index, and the whole array. Here are a few examples:
[1, 2, 3].map(function(elem, index, arr){ return elem * elem; }); //returns [1, 4, 9] [1, 2, 3, 4, 5].filter(function(elem, index, arr){ return elem % 2 === 0; }); //returns [2, 4] [1, 2, 3, 4, 5].some(function(elem, index, arr){ return elem >= 3; }); //returns true [1, 2, 3, 4, 5].every(function(elem, index, arr){ return elem >= 3; }); //returns false
The reduce
and reduceRight
methods have a different parameter list. As their names suggest, they reduce an array to a single value. The initial value of the result defaults to the first element in the array, but you can pass a second argument to these methods to serve as the initial value.
The callback function for these methods accepts four arguments. The current state is the first argument, and the remaining arguments are the element, index, and array. The following snippets demonstrate the usage of these two methods:
[1, 2, 3, 4, 5].reduce(function(sum, elem, index, arr){ return sum + elem; }); //returns 15 [1, 2, 3, 4, 5].reduce(function(sum, elem, index, arr){ return sum + elem; }, 10); //returns 25
But you probably already knew all of this, didn't you? So let's move onto something you may not be familiar with.
Functional Programming to the Rescue
It's surprising that more people don't know this: you don't have to create a new function and pass it to .map()
and friends. Even better, you can pass built-in functions, such as parseFloat
with no wrapper required!
["1", "2", "3", "4"].map(parseFloat); //returns [1, 2, 3, 4]
Note that some functions won't work as expected. For example, parseInt
accepts a radix as a second argument. Now remember that the element's index is passed to the function as a second argument. So what will the following return?
["1", "2", "3", "4"].map(parseInt);
Exactly: [1, NaN, NaN, NaN]
. As an explanation: base 0 is ignored; so, the first value gets parsed as expected. The following bases don't include the number passed as the first argument (eg. base 2 doesn't include 3), which leads to NaN
s. So make sure to check the Mozilla Developer Network upfront before using a function and you'll be good to go.
Pro-tip: You can even use built-in constructors as arguments, as they aren't required to be called with new
. As a result, a simple conversion to a boolean value can be done using Boolean
, like this:
["yes", 0, "no", "", "true", "false"].filter(Boolean); //returns ["yes", "no", "true", "false"]
A couple of other nice functions are encodeURIComponent
, Date.parse
(note that you can't use the Date
constructor as it always returns the current date when called without new
), Array.isArray
and JSON.parse
.
Don't Forget to .apply()
While using built-in functions as arguments for array methods may make for a nice syntax, you should also remember that you can pass an array as the second argument of Function.prototype.apply
. This is handy, when calling methods, like Math.max
or String.fromCharCode
. Both functions accept a variable number of arguments, so you'll need to wrap them in a function when using the array extras. So instead of:
var arr = [1, 2, 4, 5, 3]; var max = arr.reduce(function(a, b) { return Math.max(a, b); });
You can write the following:
var arr = [1, 2, 4, 5, 3]; var max = Math.max.apply(null, arr);
This code also comes with a nice performance benefit. As a side-note: In EcmaScript 6, you'll be able to simply write:
var arr = [1, 2, 4, 5, 3]; var max = Math.max(…arr); //THIS CURRENTLY DOESN'T WORK!
Hole-less Arrays
All of the array extras ignore holes in arrays. An example:
var a = ["hello", , , , , "world"]; //a[1] to a[4] aren't defined var count = a.reduce(function(count){ return count + 1; }, 0); console.log(count); // 2
This behavior probably comes with a performance benefit, but there are cases when it can be a real pain in the butt. One such example might be when you need an array of random numbers; it isn't possible to simply write this:
var randomNums = new Array(5).map(Math.random);
But remember that you can call all native constructors without new
. And another useful tidbit: Function.prototype.apply
doesn't ignore holes. Combining these, this code returns the correct result:
var randomNums = Array.apply(null, new Array(5)).map(Math.random);
The Unknown Second Argument
Most of the above is known and used by many programmers on a regular basis. What most of them don't know (or at least don't use) is the second argument of most of the array extras (only the reduce*
functions don't support it).
Using the second argument, you can pass a this
value to the function. As a result, you're able to use prototype
-methods. For example, filtering an array with a regular expression becomes a one-liner:
["foo", "bar", "baz"].filter(RegExp.prototype.test, /^b/); //returns ["bar", "baz"]
Also, checking if an object has certain properties becomes a cinch:
["foo", "isArray", "create"].some(Object.prototype.hasOwnProperty, Object); //returns true (because of Object.create)
In the end, you can use every method you would like to:
//lets do something crazy [ function(a) { return a * a; }, function(b) { return b * b * b; } ] .map(Array.prototype.map, [1, 2, 3]); //returns [[1, 4, 9], [1, 8, 27]]
This becomes insane when using Function.prototype.call
. Watch this:
[" foo ", "\n\tbar", "\r\nbaz\t "].map(Function.prototype.call, String.prototype.trim); //returns ["foo", "bar", "baz"] [true, 0, null, []].map(Function.prototype.call, Object.prototype.toString); //returns ["[object Boolean]", "[object Number]", "[object Null]", "[object Array]"]
Of course, to please your inner geek, you can also use Function.prototype.call
as the second parameter. When doing so, every element of the array is called with its index as the first argument and the whole array as the second:
[function(index, arr){ //whatever you might want to do with it }].forEach(Function.prototype.call, Function.prototype.call);
Lets Build Something Useful
With all that said, let's build a simple calculator. We only want to support the basic operators (+
, -
, *
, /
), and we need to respect operator procedure. So, multiplication (*
) and division (/
) need to be evaluated before addition (+
) and subtraction (-
).
Firstly, we define a function that accepts a string representing the calculation as the first and only argument.
function calculate (calculation) {
In the function body, we start converting the calculation into an array by using a regular expression. Then, we ensure that we parsed the whole calculation by joining the parts using Array.prototype.join
and comparing the result with the original calculation.
var parts = calculation.match( // digits |operators|whitespace /(?:\-?[\d\.]+)|[-\+\*\/]|\s+/g ); if( calculation !== parts.join("") ) { throw new Error("couldn't parse calculation") }
After that, we call String.prototype.trim
for each element to eliminate white-space. Then, we filter the array and remove falsey elements (ie:f empty strings).
parts = parts.map(Function.prototype.call, String.prototype.trim); parts = parts.filter(Boolean);
Now, we build a separate array that contains parsed numbers.
var nums = parts.map(parseFloat);
You can pass built-in functions such as
parseFloat
with no wrapper required!
At this point, the easiest way to continue is a simple for
-loop. Within it, we build another array (named processed
) with multiplication and division already applied. The basic idea is to reduce every operation to an addition, so that the last step becomes pretty trivial.
We check every element of the nums
array to ensure it's not NaN
; if it's not a number, then it's an operator. The easiest way to do this is by taking advantage of the fact that, in JavaScript, NaN !== NaN
. When we find a number, we add it to the result array. When we find an operator, we apply it. We skip addition operations and only change the sign of the next number for subtraction.
Multiplication and division need to be calculated using the two surrounding numbers. Because we already appended the previous number to the array, it needs to be removed using Array.prototype.pop
. The result of the calculation gets appended to the result array, ready to be added.
var processed = []; for(var i = 0; i < parts.length; i++){ if( nums[i] === nums[i] ){ processed.push( nums[i] ); } else { switch( parts[i] ) { case "+": continue; //ignore case "-": processed.push(nums[++i] * -1); break; case "*": processed.push(processed.pop() * nums[++i]); break; case "/": processed.push(processed.pop() / nums[++i]); break; default: throw new Error("unknown operation: " + parts[i]); } } }
The last step is fairly easy: We just add all numbers and return our final result.
return processed.reduce(function(result, elem){ return result + elem; });
The completed function should look like so:
function calculate (calculation) { //build an array containing the individual parts var parts = calculation.match( // digits |operators|whitespace /(?:\-?[\d\.]+)|[-\+\*\/]|\s+/g ); //test if everything was matched if( calculation !== parts.join("") ) { throw new Error("couldn't parse calculation") } //remove all whitespace parts = parts.map(Function.prototype.call, String.prototype.trim); parts = parts.filter(Boolean); //build a separate array containing parsed numbers var nums = parts.map(parseFloat); //build another array with all operations reduced to additions var processed = []; for(var i = 0; i < parts.length; i++){ if( nums[i] === nums[i] ){ //nums[i] isn't NaN processed.push( nums[i] ); } else { switch( parts[i] ) { case "+": continue; //ignore case "-": processed.push(nums[++i] * -1); break; case "*": processed.push(processed.pop() * nums[++i]); break; case "/": processed.push(processed.pop() / nums[++i]); break; default: throw new Error("unknown operation: " + parts[i]); } } } //add all numbers and return the result return processed.reduce(function(result, elem){ return result + elem; }); }
Okay, so let's test it:
calculate(" 2 + 2.5 * 2 ") // returns 7 calculate("12 / 6 + 4 * 3") // returns 14
It appears to be working! There are still some edge-cases that aren't handled, such as operator-first calculations or numbers containing multiple dots. Support for parenthesis would be nice, but we won't worry about digging into more details in this simple example.
Wrapping Up
While ES5's array extras might, at first, appear to be fairly trivial, they reveal quite a bit of depth, once you give them a chance. Suddenly, functional programming in JavaScript becomes more than callback hell and spaghetti code. Realizing this out was a real eye-opener for me and influenced my way of writing programs.
Of course, as seen above, there are always cases where you'd want to instead use a regular loop. But, and that's the nice part, you don't need to.
Comments