Pull to refresh

Опасности необязательных аргументов в JavaScript

Reading time 7 min
Views 13K
Original author: Аллен Вирфс-Брок
Моя последняя тема про “минус ноль” вызвала много интереса. По этому сегодня я собираюсь описать ещё одну особенность JavaScript, на что меня так же вдохновил твит:
Без попытки повторить это в браузере, что вернет следующий код?
["1", "2", "3"].map(parseInt);


Это был хитрый вопрос. Возможно некоторые программисты ожидали, что это выражение вернёт массив [1, 2, 3], но не тут то было. Почему? Что же мы в действительности получим? Я не стал запускать код и сразу полез в спецификацию ECMAScript 5. Согласно спецификации ответ очевиден:
[1, NaN, NaN]


После этого я всё-таки запустил пример в браузере и результат был идентичным. Перед тем как я объясню почему, вы, возможно, захотите остановиться на этом и проверить сможете ли понять в чём дело.

Ок, вот объяснение. parseInt является встроенной функцией, которая предназначена для того, чтобы парсить строку с цифрами и вернуть её как число. То есть, функция, вызванная:
var n = parseInt ("123");

должна присвоить числовое значение 123 локальной переменной n.

Вы так же должны знать, что если строка не может быть разпарсена как число, то parseInt вернёт как результат значение NaN. NaN (аббревиатура «Not a number»), является значением, которое выявляет ошибку в переводе строки в число. По этому следующая строчка:
var x = parseInt("xyz");

присваивает значение NaN переменной x.

map — встроенный метод Array в ECMAScript 5, был доступен во многих браузерах. Он проходит каждый элемент массива и вызывает функцию аргумента единожды для каждого элемента, передавая значение элемента как аргумент. Из результатов функции он создаёт новый массив. Например, для этой строки
[1,2,3].map(function (value) {return value+1});

он вернёт новый массив [2,3,4]. Наверное, вполне привычно что такая функция, как parseInt передаётся в метод «map» и это вполне корректно.

Опираясь на основы parseInt и map становится ясно, что оригинал выражения предназначался для того, чтобы из массива числовых строк вернуть соответствующие массивы, содержащие числовые значения каждой строки. Почему это не работает? Чтобы найти ответ на этот вопрос необходимо более детально рассмотреть определения parseInt и map.

Согласно спецификации parseInt допускает два аргумента. Первым аргументом является строка, которая должна быть разобрана, а вторым — система счисления числа, по которому оно должно быть разобраным. И так, parseInt("ffff",16) вернёт 65535, в то время как parseInt("ffff",8) вернёт NaN, потому что «ffff» не парсится как восьмеричное число. Если второй аргумент отсутствует или равен 0, по умолчанию в качестве системы счисления принимается десятеричная, по этому parseInt("12",10), parseInt("12") и parseInt("12", 0) возвратят число 12.

А теперь смотрим внимательно на спецификацию метода «map» и какие значения он передает первому своему аргументу — «callbackfn». В спецификации сказано «функция callbackfn вызывается с тремя аргументами: значением элемента, индексом элемента и объектом, с которым мы работали». Это значит, что вместо вызовов parseInt, которые выглядят так:

parseInt("1")
parseInt("2")
parseInt("3")


мы получим три вызова, которые будут выглядеть следующим образом:
parseInt("1", 0, theArray)
parseInt("2", 1, theArray)
parseInt("3", 2, theArray)


где theArray это оригинальный массив ["1","2","3"].

JavaScript функции, как правило, игнорируют лишние аргументы и parseInt применяет только первых два аргумента, по этому нам не придётся беспокоиться о аргументе theArray в этих вызовах. Но как на счёт второго аргумента? В первом вызове второй аргумент равен 0, что означает, как мы знаем, применение десятеричной системы счисления, по этому parseInt("1", 0) вернёт 1. Второй вызов принимает 1 как аргумент, обозначающий систему сисления. В спецификации чётко прописано что произойдёт в этом случае. Если система счисления не равна нулю и менее, чем 2 функция возвращает NaN, даже не анализируя строку.

Третий вызов принимает 2, как аргумент системы счисления. Это значит, что строка, предназначенная для конвертирования должна быть бинарным числом, содержащим только «0» и «1». Пункт 11 спецификации parseInt утверждает, что функция парсит строку слева направо до первого невалидного символа. Первый символ строки — «3» так же не является валидной цифрой в двоичной системе счисления, по этому подстрока не содержит чисел для разбора. В таком случае, согласно пункту 12, функция возвращает NaN. И так, результат трёх вызовов — 1, NaN и NaN.

Программист оригинального выражения делает, как минимум, одну из двух возможных ошибок, которые вызывают этот баг. Во-1, он либо забывает, либо просто не знает, что parseInt принимает в качестве необязательного второй аргумент. Во-2, он либо забывает, либо просто не знает, что map вызывает callbackfn с помощью трёх аргументов. Чаще, это комбинация двух ошибок. В наиболее используемом варианте использования parseInt проходит только один аргумент и большинство функций, переданных в метод «map» используют только первый аргумент, по этому можно легко забыть о том, что дополнительные аргументы возможны в обоих случаях.

Вот пример как можно переписать оригинальное выражение, чтобы избежать проблем. Используйте:

["1","2","3"].map(function(value) {return parseInt(value)})


вместо:

["1","2","3"].map(parseInt)


То есть, callbackfn вызывает parseInt только для одного аргумента. Более многословно и менее элегантно.

После того, как я написал в твиттере об этом, началось обсуждение по поводу того, как расширить JavaScript для того, чтобы избежать этой проблемы или, хотя бы, сделать изменения менее громоздкими.
Angus Croll (@angusTweets) предложил использовать конструктор Number в качестве callbackfn взамен parseInt. Число вызванное таким образом так же будет парсить аргумент строки, как десятичное число. Number, вызванный таким образом тоже парсит первый аргумент на основе десятеричной системы счисления, но не обращает внимания на второй аргумент.

@__DavidFlanagan предположил добавить метод mapValues который передает только один аргумент в callbackfn. Однако, ECMAScript 5 имеет семь различных Array методов, которые работают подобно map, по этому нам придётся добавить и их.

Я предположил возможность добавления метода, который может выглядеть как-то так:
Function.prototype.only=function(numberOfArgs) {
   var self=this; //the original function
   return function() {
      return self.apply(this,[].slice.call(arguments,0,numberOfArgs))
   }
}


Это функция высшего порядка, которая принимает функцию за аргумент и возвращает новую функцию, которая вызывает оригинальную функцию, но с ограниченым числом аргументов. Оригинальное выражение может быть записано так:
["1","2","3"].map(parseInt.only(1))

который чуть подробнее, но при этом сохраняет свою элегантность

Это привело к дальнейшей дискуссии о карринге (частичном применении функции) в JavaScript. Частичное применение функции берёт функцию, которая требует определённое количество аргументов и производит новую функцию, которая требует меньше аргументов. Этот метод – пример функции, выполняющей частичное применение функции. Ведь был метод Function.prototype.bind добавлен в ES5. Нужны ли JavaScript дополнительные методы? Например, метод bindRight, который устанавливает элементы, которые находятся правее. Возможно, но какие элементы будут считаться таковыми, когда допускается непостоянное число аргументов? Вероятно, bindStartingAt, которая использует позицию аргумента будет лучшим вариантом для JavaScript.

Однако, дискуссии о расширении далеки от сути проблемы. Для того, чтобы использовать любую из них для начала нужно знать о несовпадении опциональных аргументов map и parseInt. Существует много решений этой проблемы, но если вы не усведомлены о ней, то ни одно из предложеных решений не поможет. Эта проблема, кажется, появляется скорее из-за неверного дизайна API и, в связи с этим, возникают вопросы, касающиеся соответствия использования опциональных аргументов в JavaSript.

Поддержка опциональных аргументов может упростить дизайн API путём сокращения общего количества функций API и позволяя многим пользователям думать только о самых распространённых случаях. Как мы можем видеть выше, это упрощение может быть причиной проблем, когда функции объединяются неожиданным образом. В этом примере мы видим, что существует два разных применения опциональных аргументов.

В первом случае, опциональные аргументы рассматриваются со стороны вызывающей функции(метод map, который вызывает callbackfn). Во втором случае – со стороны вызываемой функции(callbackfn, который вызывается методом map). Дизайн parseInt предполагает что вызывающий знает, что он вызывает parseInt и надлежаще выбрал текущий аргумент. Второй аргумент является опциональным для вызывающего. Если программист хочет использовать систему счисления по умолчанию, то он может игнорировать этот аргумент. Однако, текущая спецификация parseInt определяет поведение функции зависимо от количества и значений аргументов.

Другой вариант использования освещает ситуацию с точки зрения вызывающей функции. Она не знает какую конкретную функцию вызывает и потому всегда передает одно и то же количество аргументов.
Спецификация map чётко определяет, что этот метод всегда будет передавать три аргумента для любой функции callbackfn. По скольку вызывающая функция не знает определения вызываемой функции и не знает какая именно информация ей потребуется, map рассматривает всю существующую информацию как аргументы. Возможно вызываемая функция будет игнорировать те аргументы, которые ей не нужны. В этом случае второй и третий аргументы являются опциональными с точки зрения вызываемой функции.
Оба случая — это примеры корректного применения опциональных аргументов, но когда они объеденяются — случается fail. Опциональные аргументы вызываемой и вызывающей функции редко совпадают. Можно использовать такие функции высшего порядка, как методы bind или only, чтобы исправить несовпадения, но они будут полезны, только если программист знает о этой пробелме. Дизайнеры JavaScript API должны иметь это ввиду и каждый Javascript-программист должен быть уверен на счёт того, что он передаёт в качестве callbackFn
Update 1: Спасибо Ангусу Кроллу за отличное решение с map(Number).
Tags:
Hubs:
+86
Comments 67
Comments Comments 67

Articles