Этот вопрос сложный.
Предположим, у нас есть функция, roundTo2DP(num)
которая принимает в качестве аргумента значение с плавающей точкой и возвращает значение, округленное до 2 десятичных знаков. Что должно оценивать каждое из этих выражений?
roundTo2DP(0.014999999999999999)
roundTo2DP(0.0150000000000000001)
roundTo2DP(0.015)
«Очевидный» ответ заключается в том, что первый пример должен округляться до 0,01 (потому что он ближе к 0,01, а не к 0,02), в то время как два других должны округляться до 0,02 (потому что 0,0150000000000000001 ближе к 0,02, чем к 0,01, и потому, что 0,015 находится точно на полпути между их и есть математическое соглашение, что такие числа округляются).
Уловка, о которой вы, возможно, догадались, заключается в том, что roundTo2DP
ее невозможно реализовать, чтобы дать эти очевидные ответы, поскольку все три переданных ей числа - это одно и то же число . Двоичные числа с плавающей точкой IEEE 754 (тип, используемый JavaScript) не может точно представлять большинство нецелых чисел, и поэтому все три числовых литерала выше округляются до ближайшего действительного числа с плавающей запятой. Это число, как это бывает, точно
0,01499999999999999944488848768742172978818416595458984375
что ближе к 0,01, чем к 0,02.
Вы можете видеть, что все три числа одинаковы на консоли браузера, в оболочке Node или в другом интерпретаторе JavaScript. Просто сравните их:
> 0.014999999999999999 === 0.0150000000000000001
true
Поэтому, когда я пишу m = 0.0150000000000000001
, точное значение,m
которое я получаю, ближе к тому, 0.01
чем оно есть 0.02
. И все же, если я преобразуюm
в строку ...
> var m = 0.0150000000000000001;
> console.log(String(m));
0.015
> var m = 0.014999999999999999;
> console.log(String(m));
0.015
... Я получаю 0,015, что должно округлить до 0,02, и что заметно не то число с 56 десятичными я ранее говорил, что все эти числа в точности равны. Так что же это за темная магия?
Ответ можно найти в спецификации ECMAScript, в разделе 7.1.12.1: ToString применяется к типу Number . Здесь изложены правила преобразования некоторого числа m в строку. Ключевой частью является точка 5, в которой генерируется целое число s , цифры которого будут использоваться в строковом представлении m :
пусть n , k и s будут целыми числами, так что k ≥ 1, 10 k -1 ≤ s <10 k , числовое значение для s × 10 n - k равно m , а k настолько мало, насколько это возможно. Обратите внимание, что k - это количество цифр в десятичном представлении s , что s не делится на 10, и что младшая значащая цифра s не обязательно определяется по этим критериям однозначно.
Ключевой частью здесь является требование, чтобы « k было как можно меньше». То, к чему относится это требование, - это требование о том, что для заданного числа m
значение String(m)
должно иметь наименьшее возможное количество цифр, но при этом удовлетворять этому требованию Number(String(m)) === m
. Поскольку мы это уже знаем 0.015 === 0.0150000000000000001
, теперь понятно, почему это String(0.0150000000000000001) === '0.015'
должно быть правдой.
Конечно, ни одно из этого обсуждения не дало прямого ответа на то, что roundTo2DP(m)
должно вернуться. Если m
точное значение равно 0,01499999999999999944488848768742172978818416595458984375, но его строковое представление равно 0,015, то каков правильный ответ - математически, практически, философски или как угодно - когда мы округляем его до двух десятичных знаков?
На этот вопрос нет однозначного правильного ответа. Это зависит от вашего варианта использования. Вы, вероятно, хотите уважать представление String и округлять вверх, когда:
- Представляемое значение по своей природе дискретно, например, количество валюты в валюте с 3 десятичными знаками, например, в динарах. В этом случае истинное значение числа , как 0.015 является 0,015, а представление 0,0149999999 ..., которое оно получает в двоичной переменной с плавающей запятой, является ошибкой округления. (Конечно, многие будут разумно утверждать, что вы должны использовать десятичную библиотеку для обработки таких значений и никогда не представлять их как двоичные числа с плавающей запятой.)
- Значение было введено пользователем. В этом случае, опять же, точное введенное десятичное число является более «истинным», чем ближайшее двоичное представление с плавающей запятой.
С другой стороны, вы, вероятно, хотите соблюдать двоичное значение с плавающей запятой и округлять в меньшую сторону, когда ваше значение по непрерывной шкале - например, если это считывание с датчика.
Эти два подхода требуют различного кода. Чтобы уважать строковое представление числа, мы можем (с небольшим количеством довольно тонкого кода) реализовать наше собственное округление, которое действует непосредственно на строковое представление, цифра за цифрой, используя тот же алгоритм, который вы использовали в школе, когда вы научили округлять числа. Ниже приведен пример, в котором соблюдается требование ОП о представлении числа в 2 десятичных знака «только при необходимости» путем удаления конечных нулей после десятичной точки; вам, конечно, может понадобиться настроить его в соответствии с вашими потребностями.
/**
* Converts num to a decimal string (if it isn't one already) and then rounds it
* to at most dp decimal places.
*
* For explanation of why you'd want to perform rounding operations on a String
* rather than a Number, see http://stackoverflow.com/a/38676273/1709587
*
* @param {(number|string)} num
* @param {number} dp
* @return {string}
*/
function roundStringNumberWithoutTrailingZeroes (num, dp) {
if (arguments.length != 2) throw new Error("2 arguments required");
num = String(num);
if (num.indexOf('e+') != -1) {
// Can't round numbers this large because their string representation
// contains an exponent, like 9.99e+37
throw new Error("num too large");
}
if (num.indexOf('.') == -1) {
// Nothing to do
return num;
}
var parts = num.split('.'),
beforePoint = parts[0],
afterPoint = parts[1],
shouldRoundUp = afterPoint[dp] >= 5,
finalNumber;
afterPoint = afterPoint.slice(0, dp);
if (!shouldRoundUp) {
finalNumber = beforePoint + '.' + afterPoint;
} else if (/^9+$/.test(afterPoint)) {
// If we need to round up a number like 1.9999, increment the integer
// before the decimal point and discard the fractional part.
finalNumber = Number(beforePoint)+1;
} else {
// Starting from the last digit, increment digits until we find one
// that is not 9, then stop
var i = dp-1;
while (true) {
if (afterPoint[i] == '9') {
afterPoint = afterPoint.substr(0, i) +
'0' +
afterPoint.substr(i+1);
i--;
} else {
afterPoint = afterPoint.substr(0, i) +
(Number(afterPoint[i]) + 1) +
afterPoint.substr(i+1);
break;
}
}
finalNumber = beforePoint + '.' + afterPoint;
}
// Remove trailing zeroes from fractional part before returning
return finalNumber.replace(/0+$/, '')
}
Пример использования:
> roundStringNumberWithoutTrailingZeroes(1.6, 2)
'1.6'
> roundStringNumberWithoutTrailingZeroes(10000, 2)
'10000'
> roundStringNumberWithoutTrailingZeroes(0.015, 2)
'0.02'
> roundStringNumberWithoutTrailingZeroes('0.015000', 2)
'0.02'
> roundStringNumberWithoutTrailingZeroes(1, 1)
'1'
> roundStringNumberWithoutTrailingZeroes('0.015', 2)
'0.02'
> roundStringNumberWithoutTrailingZeroes(0.01499999999999999944488848768742172978818416595458984375, 2)
'0.02'
> roundStringNumberWithoutTrailingZeroes('0.01499999999999999944488848768742172978818416595458984375', 2)
'0.01'
Вышеприведенная функция - это, вероятно, то, что вы хотите использовать, чтобы пользователи никогда не видели, что введенные ими числа неправильно округляются.
(В качестве альтернативы вы также можете попробовать библиотеку round10, которая предоставляет функцию с аналогичным поведением и имеет совершенно другую реализацию.)
Но что, если у вас есть второй тип числа - значение, взятое из непрерывной шкалы, где нет оснований думать, что приближенные десятичные представления с меньшим количеством десятичных знаков более точны, чем те, у которых больше? В этом случае мы не хотим уважать представление String, потому что это представление (как объяснено в спецификации) уже округлено; мы не хотим ошибаться, говоря: «0,014999999 ... 375 округляет до 0,015, что округляет до 0,02, поэтому 0,014999999 ... 375 округляет до 0,02».
Здесь мы можем просто использовать встроенный toFixed
метод. Обратите внимание, что при вызове Number()
String, возвращаемого функцией toFixed
, мы получаем число, чье представление String не имеет конечных нулей (благодаря тому, как JavaScript вычисляет строковое представление числа, обсуждаемое ранее в этом ответе).
/**
* Takes a float and rounds it to at most dp decimal places. For example
*
* roundFloatNumberWithoutTrailingZeroes(1.2345, 3)
*
* returns 1.234
*
* Note that since this treats the value passed to it as a floating point
* number, it will have counterintuitive results in some cases. For instance,
*
* roundFloatNumberWithoutTrailingZeroes(0.015, 2)
*
* gives 0.01 where 0.02 might be expected. For an explanation of why, see
* http://stackoverflow.com/a/38676273/1709587. You may want to consider using the
* roundStringNumberWithoutTrailingZeroes function there instead.
*
* @param {number} num
* @param {number} dp
* @return {number}
*/
function roundFloatNumberWithoutTrailingZeroes (num, dp) {
var numToFixedDp = Number(num).toFixed(dp);
return Number(numToFixedDp);
}