CSS-live.ru

ES6: улучшения объекта Number изнутри

Перевод статьи ES6 Number Improvements in Depth с сайта ponyfoo.com, опубликовано на css-live.ru с разрешения автора — Николаса Беваквы.

Привет! Рад, что вы подоспели вовремя для ES6 — «Снова в школу» — изнутри. Не слышали об этом? Тогда загляните в краткую историю инструментария ES6. Затем изучите деструктирование, литералы шаблона, стрелочные функции, оператор расширения и оставшиеся параметры, улучшения в литералах объекта, новый «сахарок» — классы поверх прототипов, let, const и «Временную мёртвую зону», а также итераторы, генераторы, символы,  объекты Map, WeakMaps, Sets и WeakSets,  прокси, ловушки прокси, ещё о ловушках и отражение. Ну а сегодня поговорим об улучшениях объекта Number.

Как и в прошлых статьях, рекомендую вам установить Babel и повторять за мной, копируя примеры с помощью интерактивной оболочки REPL, либо командной строки babel-node и файла. Это поможет гораздо лучше усвоить идеи, обсуждаемые в серии. Если вы не из тех, кто любит устанавливать что-либо на свой компьютер, то вам есть смысл залезть на CodePen и кликнуть иконку с шестерёнкой для JavaScript — у него есть препроцессор Babel, который с лёгкостью позволяет опробовать ES6. Ещё одна довольно полезная альтернатива, это использовать онлайновый REPL для Babel — он показывает скомпилированный ES5-код справа от ES6-кода, чтобы быстро сравнить.

Пока мы не начали, позвольте беззастенчиво попросить вашей поддержки, если вы наслаждаетесь моей серией «ES6 изнутри». Ваши взносы пойдут на хостинг сайту, мне на еду, на то, чтоб я не отставал от графика, и на то, чтобы Pony Foo оставался поистине источником джаваскриптовых вкусняшек.

Спасибо, что выслушали, а теперь перейдём к улучшениям объекта Number! На самом деле, эти изменения не зависят от того, что мы уже рассмотрели — хотя я бы настоятельно рекомендовал ознакомиться со статьями из этой серии. Пришло время для объекта Number.

Улучшения объекта Number в ES6

Для объекта числа (Number) ES6 приготовил изрядное число новинок — как вам каламбур? Вначале предлагаю посмотреть список функций, о которых мы поговорим. Сегодня мы рассмотрим все следующие изменения в объекте Number.

GrB4w7R

Двоичные и восьмеричные литералы

До ES6 двоичные представления целых чисел проще всего было бы передать в parseInt с основанием 2.

parseInt('101', 2)
// <- 5

В ES6 для представления двоичных целочисленных литералов также подойдёт префикс 0b. Можно и 0B, но я советую придерживаться нижнего регистра.

console.log(0b001)
// <- 1
console.log(0b010)
// <- 2
console.log(0b011)
// <- 3
console.log(0b100)
// <- 4

То же касается и восьмеричных литералов. В ES3 parseInt интерпретировал строки цифр, начинающиеся с 0, в качестве восьмеричного значения. Поэтому, если не указать основание 10, сразу же начинались странности — и вскоре все взяли за правило его указывать.

parseInt('01')
// <- 1
parseInt('08')
// <- 0
parseInt('8')
// <- 8

А вот ES5 уже избавился от восьмеричной интерпретации в parseInt — хотя в целях обратной совместимости основание всё-таки желательно указывать. Если нужен восьмеричный результат, основание 8 вам в помощь.

parseInt('100', '8')
// <- 64

Теперь, в ES6, появился префикс 0o для восьмеричных литералов. Можно конечно использовать и 0O, но в некоторых шрифтах эти символы легко перепутать, так что лучше придерживайтесь нотации 0o.

console.log(0o010)
// <- 8
console.log(0o100)
// <- 64

Правда, восьмеричные литералы вряд ли появятся в ваших веб-приложениях в ближайшее время, поэтому не стоит слишком переживать из-за, казалось бы, странного выбора (ради читаемости шрифта) префикса 0o. К тому же, многие используют редакторы, которые без проблем различают 0o, 0O, 00, OO и oo.

buOiD1I

Если вы сейчас порываетесь недоуменно спросить «А что же насчёт шестнадцатеричных?» — не переживайте, они уже были частью языка в ES5 и их можно смело использовать. 16-ричные литералы записываются с префиксом 0x, либо 0X.

console.log(0x0ff)
// <- 255
console.log(0xf00)
// <- 3840

Ладно, надоели эти числовые литералы. Обсудим первые четыре новых метода NumberNumber.isNaN, Number.isFinite, Number.parseInt и Number.parseFloat — уже были в ES5, но в глобальном пространстве имён. Кроме того, у методов Number есть небольшое отличие — перед получением результата они не приводят нечисловые значения к числам.

Number.isNaN

Этот метод практически идентичен глобальному методу isNaN в ES5. Number.isNaN проверяет, является ли указанное значение NaN или нет. Этот вопрос отличается от «Является ли это числом?»

В кратком примере ниже показано, что всё, что не NaN, при передаче в Number.isNaN вернёт false, а передав в него NaN, мы получим true.

Number.isNaN(123)
// <- false, целые числа не являются NaN
Number.isNaN(Infinity)
// <- false, бесконечность не является NaN
Number.isNaN('ponyfoo')
// <- false, 'ponyfoo' не NaN
Number.isNaN(NaN)
// <- true, NaN является NaN
Number.isNaN('pony'/'foo')
// <- true, 'pony'/'foo' является NaN, NaN является NaN

Метод global.isNaN в ES5, наоборот, приводит переданные ему нечисловые значение перед сравнением с NaN. И результаты от этого сильно различаются. Результаты в примере ниже противоречивы, поскольку, в отличие от Number.isNaN, isNaN берёт переданное ему значение и преобразует его сначала через Number.

isNaN('ponyfoo')
// <- true, поскольку Number('ponyfoo') является NaN
isNaN(new Date())
// <- true

Хотя Number.isNaN и точнее, чем его глобальный аналог isNaN, поскольку он ничего не приводит, он по-прежнему может путать людей по ряду причин.

  1. global.isNaN перед сравнением приводит подаваемое ему на вход значение через Number(value)
  2. Number.isNaN не приводит
  3. Ни Number.isNaN, ни global.isNaN не отвечают на вопрос «Это не число?»
  4. Они отвечают, будет ли value — или Number(value) — является NaN

Чаще всего нам просто нужно знать, определяется ли значение как число — typeof NaN === 'number' — и число ли это? Метод ниже это и делает. Заметье, что благодаря проверке типа это сработает как с global.isNaN, так и с Number.isNaN. Всё, для чего typeof возвращает значение 'number — число, кроме NaN, так что мы удаляем их во избежание ложных срабатываний!

function isNumber (value) {
  return typeof value === 'number' && !Number.isNaN(value)
}

Этот метод вычисляет, является ли что-то настоящим числом или нет. Вот некоторые примеры того, что представляет (или не представляет) собой настоящие числа в JavaScript.

isNumber(1)
// <- true
isNumber(Infinity)
// <- true
isNumber(NaN)
// <- false
isNumber('ponyfoo')
// <- false
isNumber(new Date())
// <- false

Кстати, насчет isNumber, неужели в самом языке нет чего-то подобного? В какой-то мере.

Number.isFinite

Малоизвестный метод isFinite появился ещё в ES3 и он возвращает, совпадает ли указанное значение со всем кроме: Infinity, -Infinity и NaN.

Угадаете разницу между global.isFinite и Number.isFinite?

Верно! Метод global.isFinite приводит значения с помощью Number(value), а Number.isFinite — нет. Вот несколько примеров с global.isFinite. В этом случае значения, которые можно привести к числам, отличным от NaN, рассматриваются как конечные числа — даже несмотря на то, что по факту числами они не являются!

В большинстве случаев isFinite будет достаточно, как и isNaN, но если дело касается нечисловых значений, он начинает капризничать и производить неожиданные результаты, поскольку приводит value к числам.

isFinite(NaN)
// <- false
isFinite(Infinity)
// <- false
isFinite(-Infinity)
// <- false
isFinite(null)
// <- true, поскольку Number(null) равно 0
isFinite('10')
// <- true, поскольку Number('10') равно 10

Number.isFinite во всех отношениях безопаснее, поскольку не занимается лишними приведениями. А чтобы значение приводилось к его числовому представлению, всегда есть Number.isFinite(Number(value)).

Number.isFinite(NaN)
// <- false
Number.isFinite(Infinity)
// <- false
Number.isFinite(-Infinity)
// <- false
Number.isFinite(null)
// <- false
Number.isFinite(0)
// <- true

Опять же, расхождение бесполезно для языка, но Number.isFinite однозначно полезнее isFinite. Создание полифилла для версии Number.isFinite в основном сводится к проверке типа.

Number.isFinite = function (value) {
  return typeof value === 'number' && isFinite(value)
}

Number.parseInt

Этот метод идентичен parseInt. Это вообще одно и то же.

console.log(Number.parseInt === parseInt)
// <- true

Но тем не менее метод parseInt всё равно вносит определенную путаницу — даже при том, что он не изменился, в этом-то и проблема. До ES6, parseInt поддерживал нотацию 16-ричного литерала в строках. Даже необязательно указывать основание, parseInt определяет это, основываясь на префиксе 0x.

parseInt('0xf00')
// <- 3840
parseInt('0xf00', 16)
// <- 3840

Если жёстко вписать другое основание — и это ещё одна причина так и сделатьparseInt проигнорирует все символы, начиная с первого нецифрового.

parseInt('0xf00', 10)
// <- 0
parseInt('5xf00', 10)
// <- 5, иллюстрация того, что здесь нет никакого специального подхода

Пока всё хорошо. Почему мне хочется, чтобы parseInt отказался от 0x в шестнадцатеричных строках? Звучит неплохо, хотя вы можете возразить, что это слишком, и, видимо, окажитесь правы.

parseInt('0b011')
// <- 0
parseInt('0b011', 2)
// <- 0
parseInt('0o800')
// <- 0
parseInt('0o800', 8)
// <- 0

Теперь мы сами должны избавляться от префикса перед parseInt. И не забывайте жёстко прописанное основание!

parseInt('0b011'.slice(2), 2)
// <- 3
parseInt('0o110'.slice(2), 8)
// <- 72

А ещё удивительнее то, что метод Number прекрасно умеет приводить эти строки к правильным числам.

Number('0b011')
// <- 3
Number('0o110')
// <- 72

Непонятно, из каких соображений они оставили Number.parseInt идентичным parseInt. Лично я сделал бы так, чтобы этот метод работал так же, как Number — мог приводить строковые записи двоичных и восьмеричных чисел с соответствующим десятичным числам.

Возможно, для этого пришлось бы намного сильнее переделать parseInt, чем просто отказаться от приведения входного значения к числу, как мы видели в случае Number.isNaN и Number.isFinite. Но здесь я могу лишь гадать.

Number.parseFloat

Аналогично parseInt, parseFloat остался без изменений.

Number.parseFloat === parseFloat
// <- true

Однако, у parseFloat и так ничего не менялось в отношении шестнадцатеричных литералов, так что это фактически единственный метод, который не вводит никакой путаницы, разве что только перенесён в Number для полноты картины.

Number.isInteger

Это новый метод в ES6. Он возвращает true, если указанное значение является конечным числом, у которой нет дробной части.

console.log(Number.isInteger(Infinity))
// <- false
console.log(Number.isInteger(-Infinity))
// <- false
console.log(Number.isInteger(NaN))
// <- false
console.log(Number.isInteger(null))
// <- false
console.log(Number.isInteger(0))
// <- true
console.log(Number.isInteger(-10))
// <- true
console.log(Number.isInteger(10.3))
// <- false

Если хотите взглянуть на полифилл для isInteger, посмотрите на кусок кода ниже. Оператор деления по модулю возвращает остаток от деления одинаковых операндов, т.е. фактически: дробную часть. Если это 0, значит число является целым.

Number.isInteger = function (value) {
  return Number.isFinite(value) && value % 1 === 0
}

Арифметические операции с плавающей запятой давно известны некоторыми своими несуразностями. Что ещё за Number.EPSILON?

Number.EPSILON

Пожалуй, отвечу на этот вопрос с помощью куска кода.

Number.EPSILON
// <- 2.220446049250313e-16, постойте, что?
Number.EPSILON.toFixed(20)
// <- '0.00000000000000022204', понял

Хорошо, значит, Number.EPSILONэто чертовски малое число. И что с этого? Помните, что операция суммы с плавающей точкой бессмысленна? Уверен, что этот канонический пример освежит вашу память.

0.1 + 0.2
// <- 0.30000000000000004
0.1 + 0.2 === 0.3
// <- false

И ещё разок.

0.1 + 0.2 - 0.3
// <- 5.551115123125783e-17, какого чёрта?
5.551115123125783e-17.toFixed(20)
// <- '0.00000000000000005551', понял

И что? Используйте Number.EPSILON, чтобы выяснить, достаточно ли мала разница для попадания в категорию «арифметика с плавающей точкой нелепа, а разница пренебрежима».

5.551115123125783e-17 < Number.EPSILON
// <- true

Этому можно верить? Ну, 0.00000000000000005551 и правда меньше 0.00000000000000022204. В смысле, вы мне не верите? Вот же они рядом.

0.00000000000000005551
0.00000000000000022204

Видите? Number.EPSILON больше разницы. Можно использовать Number.EPSILON в качестве допустимой погрешности из-за операций округления арифметики с плавающей точкой.

Таким образом, в куске кода ниже выясняется, находится ли результат операции с плавающей точкой в пределах допустимой погрешности. Мы используем Math.abs, чтобы порядок слева и справа не имел значения. Другими словами, withinErrorMargin(left, right) вернёт тот же результат, что и withinErrorMargin(right, left).

function withinErrorMargin (left, right) {
  return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1 + 0.2, 0.3)
// <- true
withinErrorMargin(0.2 + 0.2, 0.3)
// <- false

Сделать-то так можно, но, пожалуй, ни к чему такие сложности, если только вам не приходится иметь дело с очень низкоуровневой математикой. В этом случае легче внедрить в проект библиотеку mathjs.

И ещё кое-что важное: есть ещё один странный аспект представления числа в JavaScript. Целые числа тоже не все можно представить точно.

Number.MAX_SAFE_INTEGER

Это самое большое целое число, которое можно безопасно и точно представить в JavaScript, или любом языке, который представляет целые числа, используя плавающую точку, в соответствии со стандартом IEEE-754. Код ниже показывает, насколько это число большое. Если нужно справиться с ещё большим числом, тогда я бы снова предложил mathjs, либо другой язык для задач с массой вычислений.

Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1
// <- true
Number.MAX_SAFE_INTEGER === 9007199254740991
// <- true

И знаете, как говорится — если есть максимум…

Number.MIN_SAFE_INTEGER

Правильно, так никто не говорит. Однако, несмотря на это есть Number.MIN_SAFE_INTEGER — и это значение Number.MAX_SAFE_INTEGER со знаком минус.

Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER
// <- true
Number.MIN_SAFE_INTEGER === -9007199254740991
// <- true

Как именно использовать эти две константы, спросите вы? В случае переполнения не требуется реализовывать собственный метод withinErrorMargin, как пришлось делать для точных операций с дробями. Вместо этого есть Number.isSafeInteger.

Number.isSafeInteger

Этот метод возвращает true для любого целого числа в диапазоне [MIN_SAFE_INTEGER, MAX_SAFE_INTEGER]. К тому же, здесь нет приведения типов. Чтобы этот метод вернул true, входное значение должно быть целым числом в пределах вышеупомянутых границ. Вот исчерпывающий набор примеров, чтобы понять это.

Number.isSafeInteger('a')
// <- false
Number.isSafeInteger(null)
// <- false
Number.isSafeInteger(NaN)
// <- false
Number.isSafeInteger(Infinity)
// <- false
Number.isSafeInteger(-Infinity)
// <- false
Number.isSafeInteger(Number.MIN_SAFE_INTEGER - 1)
// <- false
Number.isSafeInteger(Number.MIN_SAFE_INTEGER)
// <- true
Number.isSafeInteger(1)
// <- true
Number.isSafeInteger(1.2)
// <- false
Number.isSafeInteger(Number.MAX_SAFE_INTEGER)
// <- true
Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1)
// <- false

Как отмечает в своей статье про числа ES6 доктор Аксель Раушмайер, чтобы проверить, находится ли результат операции в пределах границ, нужно проверить не только результат, но и оба операнда. Причина в том, что один операнд (или оба) может находиться за пределами границ, а результат быть «безопасным» (но неправильным). Аналогично, и сам результат может находиться за пределами границ, поэтому и нужна проверка левого oперанда, правого операнда и результата выражения, чтобы быть уверенными в результате.

Во всех примерах ниже результат неправильный. Вот первый пример, где оба операнда безопасны, хотя сам результат — нет.

Number.isSafeInteger(9007199254740000)
// <- true
Number.isSafeInteger(993)
// <- true
Number.isSafeInteger(9007199254740000 + 993)
// <- false
9007199254740000 + 993
// <- 9007199254740992, должен быть  9007199254740993

В этом примере один из операндов не был в диапазоне, поэтому нельзя доверять точности результата.

Number.isSafeInteger(9007199254740993)
// <- false
Number.isSafeInteger(990)
// <- true
Number.isSafeInteger(9007199254740993 + 990)
// <- false
9007199254740993 + 990
// <-  9007199254741982, должно быть  9007199254741983

Заметьте, что в примере выше результат вычитания находился бы в пределах границ и был бы также не точным.

Number.isSafeInteger(9007199254740993)
// <- false
Number.isSafeInteger(990)
// <- true
Number.isSafeInteger(9007199254740993 - 990)
// <- true
9007199254740993 - 990
// <-  9007199254740002, должен быть 9007199254740003

Не так уж и сложно представить случай, где оба операнда выходят за границы, но результат рассматривается как «безопасный», хотя на самом деле он неправильный.

Number.isSafeInteger(9007199254740993)
// <- false
Number.isSafeInteger(9007199254740995)
// <- false
Number.isSafeInteger(9007199254740993 - 9007199254740995)
// <- true
9007199254740993 - 9007199254740995
// <- -4, должно быть -2

Как видите, можно сделать вывод, что единственный способ убедиться в корректности операции — это следующий метод. Если нет гарантии, что и оба операнда, и результат операции лежат в нужных пределах, то этот результат может быть неточным, это непорядок. В таких случаях лучше всего воспользоваться оператором throw и предусмотреть подходящий обработчик этого исключения, но это зависит от конкретной программы. Главное тут — действительно отловить такие вот хитрые баги.

function trusty (left, right, result) {
  if (
    Number.isSafeInteger(left) &&
    Number.isSafeInteger(right) &&
    Number.isSafeInteger(result)
  ) {
    return result
  }
  throw new RangeError('Операции нельзя доверять!')
}

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

trusty(9007199254740000, 993, 9007199254740000 + 993)
/*9007199254740000 + 993 — небезопасное значение*/
// <- RangeError: Операции нельзя доверять!
trusty(9007199254740993, 990, 9007199254740993 + 990)
/*9007199254740993 и 9007199254740993 + 990 — небезопасные значения*/
// <- RangeError: Операции нельзя доверять!
trusty(9007199254740993, 990, 9007199254740993 - 990)
/*9007199254740993 — небезопасное значение*/
// <- RangeError: Операции нельзя доверять!
trusty(9007199254740993, 9007199254740995, 9007199254740993 - 9007199254740995)
/*9007199254740993 и 9007199254740995 — небезопасные значения*/
// <- RangeError: Операции нельзя доверять!
trusty(1, 2, 3)
// <- 3

Не думаю, что я вернусь к плавающим точкам в ближайшее время. Хватит уже с меня.

Заключение

Хаки для защиты от ошибок округления и переполнения, хотя некоторые из них и полезны, не решают суть проблемы: математика со стандартом IEEE-754 сложная.

Сегодня JavaScript работает на всех устройствах и браузерах, так что было бы неплохо реализовать лучший стандарт в придачу к IEEE-754. Примерно год назад Дуглас Крокфорд придумал DEC64, но мнения варьировались от «это гениально!» до «это работа сумашедшего» — хотя мне кажется, что это нормально, когда речь заходит о большинстве материалов Крокфорда.

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

P.S. Это тоже может быть интересно:

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Получать новые комментарии по электронной почте. Вы можете подписаться без комментирования.