Эта статья была первоначально опубликована на
Я обнаружил ошибку округления в Number().toFixed()
каждой среде JavaScript, которую я пробовал (Chrome, Firefox, Internet Explorer, Brave и Node.js). Исправление удивительно просто. Читай дальше…
Разогреть
Я нашел эту версию ошибки округления в toFixed()
Intl.NumberFormat#format()
(1.015).toFixed(2) // returns "1.01" instead of "1.02"
Неудачный тест находится в строке 42 здесь . Я пропустил его до декабря 2017 года, и это подстегнуло меня проверить другие проблемы.
Смотрите мои твиты об этом:
Отчеты об ошибках
Существует долгая история отчетов об ошибках, связанных с ошибками округления с использованием toFixed()
Вот краткий пример вопросов StackOverflow об этой проблеме:
В общем, они указывают на ошибку для значения, но ни один из них не сообщает о диапазоне или шаблоне значений, возвращающих ошибочные результаты (по крайней мере, ни одно из найденных мной, возможно, я что-то пропустил). Это оставляет программистам возможность сосредоточиться на малом, не видя большего паттерна. Я не виню их за это.
В поисках картины
Неожиданные результаты, основанные на вводе, должны возникать из общего шаблона ввода. Поэтому вместо того, чтобы рассматривать спецификацию Number().toFixed()
, я сосредоточился на тестировании с серией значений, чтобы определить, где ошибка обнаруживается в каждой серии.
Тестовая функция
Я создал следующую тестовую функцию для выполнения toFixed()
maxValue
fraction
fixed
toFixed()
fraction
function test({fraction, maxValue}) {
// Happy side-effect: `toString()` removes trailing zeroes.
fraction = fraction.toString()
var fixLength = fraction.split('.')[1].length - 1
// All this to create the expectedFraction message...
var last = Number(fraction.charAt(fraction.length - 1))
var fixDigit = Number(fraction.charAt(fraction.length - 2))
last >= 5 && (fixDigit = fixDigit + 1)
// Replace last two digits with single `fixDigit`
var expectedFraction = fraction.replace(/[\d]{2,2}$/, fixDigit)
return Array(maxValue).fill(0)
.map(function(ignoreValue, index) {
return index + 1
})
.filter(function(integer) {
// Compares 1.015 to 1.0151 b/c fixing by more than one decimal place rounds correctly.
var number = integer + Number(fraction) // number 1.015
var actual = number.toFixed(fixLength) // string "1.015"
var expected = Number(number + '1').toFixed(fixLength) // string "1.0151"
// Report failures
return expected != actual
})
.map(function(integer) {
// Format reported failures
var number = Number(integer) + Number(fraction)
return {
given: number.toString(),
expected: (Number(integer.toFixed(0)) + Number(expectedFraction)).toString(),
actual: number.toFixed(fixLength)
}
})
}
использование
Следующий пример выполняется с целыми числами от 1 до 128, добавляя дробь 0,015 к каждому и возвращает массив «неожиданных» результатов. Каждый результат содержит given
expected
actual
Здесь мы используем массив и печатаем каждый элемент.
test({ fraction: .015, maxValue: 128 })
.forEach(function(item) {
console.log(item)
})
Выход
Для этого случая есть 6 неожиданных результатов.
Object { given: "1.015", expected: "1.02", actual: "1.01" }
Object { given: "4.015", expected: "4.02", actual: "4.01" }
Object { given: "5.015", expected: "5.02", actual: "5.01" }
Object { given: "6.015", expected: "6.02", actual: "6.01" }
Object { given: "7.015", expected: "7.02", actual: "7.01" }
Object { given: "128.015", expected: "128.02", actual: "128.01" }
Результаты
Я обнаружил, что ошибка состоит из трех частей:
- Последняя значащая цифра в дроби должна быть 5 (0,015 и 0,01500 дают одинаковый результат).
- Длина фиксации должна сокращать дробь только на одну цифру.
- Ошибка появляется непоследовательно, поскольку применяются различные целочисленные значения.
Непоследовательно?
Например, (value).toFixed(2)
- Исправление номеров, заканчивающихся на .005 ВСЕГДА не удается (!!)
- исправление чисел, заканчивающихся на 0,015 не выполняется в течение 1, затем с 4 по 7, затем 128
- исправление номеров, заканчивающихся на .025, завершается с ошибками 1, 2, 3, затем с 16 по 63
- исправление чисел, заканчивающихся на .035, завершается с ошибкой 1, затем с 32 по 128
- исправление чисел, заканчивающихся на .045, завершается с ошибкой от 1 до 15, затем 128
- исправление номеров, заканчивающихся на .055, не выполняется в течение 1, а затем с 4 по 63
- фиксировать числа, заканчивающиеся на .065, не удалось в течение 1, 2, 3, затем с 8 по 15, затем с 32 по 128
- Фиксировать числа, заканчивающиеся на .075, не удалось в течение 1, затем с 8 по 31, затем 128
- Исправление номеров, заканчивающихся на .085, не выполняется в течение 1–7, а затем 64–127 (!!)
- исправление чисел, заканчивающихся на .095, завершается с ошибкой 1, затем с 4 по 7, затем с 16 по 128
Те из вас, у кого больше математических знаний и чисел с плавающей запятой, чем я, вероятно, могут объяснить причину. Я оставляю это как упражнение для читателя.
Исправление toFixed()
Исправление значения более чем на один знак после запятой всегда округляется правильно; Например, (1.0151).toFixed(2)
И тест, и полифилл используют эти знания для проверки правильности.
Это означает, что есть простое исправление для всех реализаций toFixed()
Это может не быть «спецификацией», но это означает, что мы получим ожидаемые результаты без необходимости повторного анализа бинарных операций или операций с плавающей запятой более низкого уровня.
Polyfill
Пока все реализации не будут изменены, вы можете использовать следующий polyfill для перезаписи toFixed()
(1.005).toFixed(2) == "1.01" || (function(prototype) {
var toFixed = prototype.toFixed
prototype.toFixed = function(fractionDigits) {
var split = this.toString().split('.')
var number = +(!split[1] ? split[0] : split.join('.') + '1')
return toFixed.call(number, fractionDigits)
}
}(Number.prototype));
Затем снова запустите тест и убедитесь, что длина результатов равна нулю.
test({ fraction: .0015, maxValue: 516 }) // Array []
test({ fraction: .0015, maxValue: 516 }).length // 0
Или просто запустите начальную конверсию, которая началась с этого поста.
(1.015).toFixed(2) // returns "1.02" as expected
Спасибо за чтение!