My last topic about “minus zero” caused a lot of interest. So today I’m going to describe another feature of JavaScript, which was also inspired by the tweet:
Without trying to repeat this in the browser, what will the following code return?
["1", "2", "3"].map(parseInt);
It was a tricky question. Perhaps some programmers expected that this expression would return an array [1, 2, 3], but it was not there. Why? What do we actually get? I did not start the code and immediately got into the ECMAScript 5 specification. According to the specification, the answer is obvious:
[1, NaN, NaN]
After that, I still ran the example in the browser and the result was identical. Before I explain why, you might want to stop there and see if you can figure out what's wrong.
Ok, here is the explanation. parseInt is a built-in function that is designed to parse a string with numbers and return it as a number. That is, a function called:
var n = parseInt ("123");
must assign the numeric value 123 to the local variable n.
')
You should also know that if a string cannot be parsed as a number, then parseInt will return NaN as the result. NaN (abbreviation "Not a number") is a value that identifies an error in the translation of a string into a number. Therefore, the following line:
var x = parseInt("xyz");
sets NaN to x.
map - the built-in Array method in ECMAScript 5, has been available in many browsers. It passes each element of the array and calls the argument function once for each element, passing the value of the element as an argument. From the function results, it creates a new array. For example, for this line
[1,2,3].map(function (value) {return value+1});
it will return a new array [2,3,4]. Probably, it’s quite usual that such a function as parseInt is passed to the “map” method and this is quite correct.
Based on the bases of parseInt and map, it becomes clear that the original expression was intended to return from the array of numeric strings the corresponding arrays containing the numerical values of each string. Why is this not working? To find the answer to this question, it is necessary to consider in more detail the definitions of parseInt and map.
According to the specification,
parseInt allows two arguments. The first argument is a string that should be parsed, and the second is the number system by which it should be parsed. And so,
parseInt("ffff",16)
returns 65535, while
parseInt("ffff",8)
returns NaN, because "ffff" does not parse as an octal number. If the second argument is missing or equal to 0, the default is decimal as the number system, so
parseInt("12",10)
,
parseInt("12")
and
parseInt("12", 0)
will return the number 12.
And now we look carefully at the specification of the “map” method and what values it conveys to its first argument, “callbackfn”. The specification says "the callbackfn function is called with three arguments: the value of the element, the index of the element and the object with which we worked." This means that instead of calling parseInt, which look like this:
parseInt("1") parseInt("2") parseInt("3")
we get three calls that will look like this:
parseInt("1", 0, theArray) parseInt("2", 1, theArray) parseInt("3", 2, theArray)
where theArray is the original array
["1","2","3"]
.
JavaScript functions, as a rule, ignore unnecessary arguments and parseInt applies only the first two arguments, so we don’t have to worry about theArray argument in these calls. But what about the second argument? In the first call, the second argument is 0, which means, as we know, the use of the decimal number system, so
parseInt("1", 0)
returns 1. The second call takes 1 as an argument for the sysytem system. The specification clearly states what happens in this case. If the number system is not equal to zero and less than 2, the function returns NaN, without even analyzing the string.
The third call takes 2 as the number system argument. This means that the string to be converted must be a binary number containing only “0” and “1”. Paragraph 11 of the parseInt specification states that the function parses the string from left to right before the first invalid character. The first character of the string - “3” is also not a valid digit in the binary number system; therefore, the substring does not contain numbers to parse. In this case, according to clause 12, the function returns NaN. And so, the result of the three calls is 1, NaN and NaN.
The programmer of the original expression makes at least one of two possible errors that cause this bug. First, he either forgets or simply does not know that parseInt accepts the second argument as an optional one. Secondly, he either forgets or simply does not know that the map calls the callbackfn with the help of three arguments. More often, it is a combination of two mistakes. In the most commonly used use of parseInt, only one argument passes and most of the functions passed to the map method use only the first argument, so you can easily forget that additional arguments are possible in both cases.
Here is an example of how to rewrite the original expression to avoid problems. Use:
["1","2","3"].map(function(value) {return parseInt(value)})
instead:
["1","2","3"].map(parseInt)
That is, callbackfn calls parseInt for only one argument. More verbose and less elegant.
After I tweeted about this, a discussion began on how to extend JavaScript in order to avoid this problem or, at least, to make the changes less cumbersome.
Angus Croll (@angusTweets) suggested using the Number constructor as a callbackfn instead of parseInt. The number called in this way will also parse the argument of the string as a decimal number. Number, called in this way, also parses the first argument based on the decimal number system, but does not pay attention to the second argument.
@__DavidFlanagan suggested adding a mapValues method that passes only one argument to callbackfn. However, ECMAScript 5 has seven different Array methods that work like a map, so we have to add them.
I suggested the possibility of adding a method that might look something like this:
Function.prototype.only=function(numberOfArgs) { var self=this;
This
is a higher order function that takes a function as an argument and returns a new function that calls the original function, but with a limited number of arguments. The original expression can be written like this:
["1","2","3"].map(parseInt.only(1))
which is a little more detailed, but retains its elegance
This led to further discussion about
currying (partial application of the function) in JavaScript. Partial application of a function takes a function that requires a certain number of arguments and produces a new function that requires fewer arguments. This method is an example of a function that performs partial application of a function. After all, the
Function.prototype.bind
method was added to ES5. Does JavaScript need additional methods? For example, the
bindRight
method, which sets the elements that are to the right. Perhaps, but what elements will be considered as such when a non-constant number of arguments is allowed? Probably bindStartingAt, which uses the position of the argument would be the best option for javascript.
However, discussions about expansion are far from the essence of the problem. In order to use any of them, you first need to know about the discrepancy between the optional map and parseInt arguments. There are many solutions to this problem, but if you are not aware of it, then none of the proposed solutions will help. This problem appears to be more likely due to an incorrect API design and, therefore, questions arise regarding the consistency of using optional arguments in JavaScript.
Supporting optional arguments can simplify the design of an API by reducing the total number of API functions and allowing many users to think only about the most common cases. As we can see above, this simplification can cause problems when functions are combined in unexpected ways. In this example, we see that there are two different uses of optional arguments.
In the first case, optional arguments are considered from the side of the calling function (the map method, which calls callbackfn). In the second case, from the side of the called function (callbackfn, which is called by the map method). The parseInt design assumes that the caller knows that he is calling parseInt and has properly selected the current argument. The second argument is optional for the caller. If the programmer wants to use the default number system, then he can ignore this argument. However, the current parseInt specification defines the behavior of the function depending on the number and values of the arguments.
Another use case covers the situation from the point of view of the calling function. It does not know what specific function it calls and therefore always passes the same number of arguments.
The map specification clearly states that this method will always pass three arguments for any callbackfn function. Since the calling function does not know the definition of the called function and does not know what information it will need, the map treats all existing information as arguments. Perhaps the called function will ignore those arguments that it does not need. In this case, the second and third arguments are optional from the point of view of the called function.
Both cases are examples of the correct use of optional arguments, but when they merge, fail happens. The optional arguments of the called and calling functions rarely match. You can use higher-order functions such as
bind
or
only
methods to correct inconsistencies, but they will be useful only if the programmer knows about this space. JavaScript API designers should keep this in mind and every Javascript programmer needs to be confident about what he sends as a callbackFn
Update 1: Thanks to Angus Kroll for a great solution with
map(Number)
.