3
\$\begingroup\$

Whatever language you choose, you may encounter a rounding problem. In fact, this is due to the limit of the required number of bits needed to get the right number after a calculation.

Simple example:

enter image description here

In javascript (like any other language), you may want to use a function to round the result after even a simple sum (with decimals) :

enter image description here

enter image description here

Unfortunately it's not always working as we expect.

enter image description here

That's why I created my own function which is based on a string version. I read every char and make the round by myself so I'm not limited by the number of bits.

round(number, decimalsLength) {

// If the number does contains a scientific notation, we use a more traditionnal calculation method
if(number.toString().includes("e"))
    number = parseFloat(number.toFixed(4));

// Split the integer part and the decimal part
var number = (typeof number === "number") ? number : (number != null) ? parseFloat(number) : 0; // Return NaN for [null; {}]
var decimalsLength = decimalsLength || 0;
var array = (number+"").split(".");
var left = array[0];
var right = "1"+(array[1] || 0); // We add 1 to keep the left 0

// Round only if we have more decimal than needed
if(right.length-1 >= decimalsLength) {

    // Round up
    if(parseInt(right.substr(decimalsLength+1, 1)) >= 5)
        right = parseInt(right.substr(0, decimalsLength+1))+1;

    // If we moved to the upper integer
    if(right.toString().substr(0, 1) == 2) { // If the added 1 has became a 2
        var noDecimal = parseInt(right.toString().substr(1, decimalsLength)) == 0; // Return an integer if we no longer have any decimal
        left = (parseInt(left) + (number > 0 ? 1 : -1)).toString();
        return parseFloat(left + (noDecimal ?"":".") + (noDecimal?"":right.toString().substr(1, decimalsLength)));
    }
}

// Else, we don't need to round
var noDecimal = parseInt(right.toString().substr(1, decimalsLength)) == 0;
return parseFloat(left + (noDecimal?"":".") + (noDecimal?"":right.toString().substr(1, decimalsLength)));

}

I wanted to share this with you in case it can help, but also to have a feedback and improve the function if you find it useful.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Doing things like this is more a sign that you need to implement actual decimal arithmetic, hacking floats this way is not reliable. \$\endgroup\$
    – user555045
    Aug 21, 2023 at 20:33
  • \$\begingroup\$ Why wouldn't it be reliable? It solves the previous examples \$\endgroup\$ Aug 21, 2023 at 20:42
  • 1
    \$\begingroup\$ OK let me put that a bit differently: even if it is reliable (which I don't know for sure whether it is or isn't), I would not be able to reason about whether it is or isn't, so I cannot discover whether it is reliable. Can you? By contrast, actual decimal arithmetic is not hard to reason about. Additionally, note that this function is inherently incapable of returning 1.04 for example, you can get a number that is printed as 1.04 but it will be 1.04000000000000003552713678800500929355621337890625 (depending on the context in which that value is used, that may or may not matter) \$\endgroup\$
    – user555045
    Aug 21, 2023 at 21:09
  • \$\begingroup\$ If you call round(1.04000000000000003552713678800500929355621337890625, 2) you will get a type number which value 1.04. That's because by manipulating the char we get a string of "1.04" after truncating the right decimals. Then the parseFloat convert it to a number which is not only a printed value. I use this function after every sum or substract where decimals are implicated. I generated hundred of thousands accounting entries with this function, I had too much problems with the traditionnal methods. I generated a million of tests and compared with Math.round(number*100)/100 \$\endgroup\$ Aug 21, 2023 at 22:03
  • 3
    \$\begingroup\$ My point was the value cannot be 1.04, it's impossible, there is no 64-bit float with that exact value. The best you can hope for is 1.04000000000000003552713678800500929355621337890625, which is the closest representable value to 1.04. However, printing that number gives 1.04, so it seems you get 1.04, but you don't. \$\endgroup\$
    – user555045
    Aug 21, 2023 at 22:11

2 Answers 2

3
\$\begingroup\$

document the return value

round(number, decimalsLength) {

The name suggests something similar to this documented function:

Math.round()

The Math.round() static method returns the value of a number rounded to the nearest integer.

That's a crystal clear guarantee about the return value.

Your function offers no assurances at all about what's returned.


document the input value

var number = (typeof number === "number") ? number : (number != null) ? parseFloat(number) : 0; // Return NaN for [null; {}]

Your round() is a total function. Silently accepting any and all types for what ostensibly should be a number, and mapping a subset of them to 0, sounds disastrous for the caller.

Much better to insist on one-or-more input types, perhaps FP and string, and throw a fatal error if caller messed up by passing in some random object.


be direct

var right = "1"+(array[1] || 0); // We add 1 to keep the left 0

Hacks such as this do not lead to maintainable code.

Say what you mean and mean what you say. Starting with "234.567" and then assigning right = "1567" tells the reader that right does not mean "the digits to the right of the decimal point". We're left with the identifier not having a clear meaning. Break this out as a well-named helper if there's a transform you need.


solve a general problem

if(right.length-1 >= decimalsLength) {
    ...
    if(parseInt(right.substr(decimalsLength+1, 1)) >= 5)
        right = ... [an integer expression, definitely not a string] ...

    if(right.toString().substr(0, 1) == 2) { 
        ...
        return ...
    }
}
...
return ... [expression which involves 'right'] ...

Wow, that's a lot of special cases.

First, extract helper, which would give you an opportunity to name and to document that you're transforming from this to that. Plus you could separately unit test the helpers.

Second, consider using FP arithmetic to solve this problem. Your chief complaint seems to be that FP quantities describe intervals on the real number line, and some are "a bit off", a bit negative. You give the example of 10.395, which encodes an interval that contains 10.395, but which starts at 10.394999...99905, leading to unfortunate truncated string results. For restricted input ranges, which you'd need to document, you might be able to get away with always adding epsilon = 1e-12 to the input number, and then rounding, obviating the need for many ifs.

Obtaining 100% code coverage via unit tests would pose some slight challenge, given the current code.


scaled integer

No app-level motivating use case was offered in the original submission.

It is possible that an app which cares about rounding details of a very large set of FPs which includes 10.395 may want a different approach. Let's focus on the "two decimal places" case.

Consider advising app developers to work with integer number of cents, rather than FP decimal number of dollars. Format with two decimal places upon display. For FP operations on a restricted range of integers, conservatively the natural numbers less than a trillion, FP calculations are always exact. Fifty-three bits of significand is a lot of bits to represent exact integer quantities.


As a consumer of this code, I would not want to call into it, as it is unclear what value it might return.

As a QA tester, I would not want to be tasked with obtaining 100% code coverage. (The OP, alas, did not include any automated testing.)

As a maintenance engineer on the team responsible for this library routine, I would not be able to confidently add features or fix bugs, as the current contract is unclear. Recommend revising this code before attempting to merge it down to main.

\$\endgroup\$
2
  • \$\begingroup\$ Thanks for your valuable answer. As you may noticed, it looks more like a personal function, it need improvements. The 2 returns give a parseFloat and may return NaN for a string input, and fatal error for null/undefined input (Math.round seems to return NaN in place of fatal error, I may make an update) I don't know for the epsilon, we may want to add or remove it depending on the calculation. round(4) before a round(2) may be interesting too Where should I update my new version? I should directly update the question? I'm not sure there's a better solution than working with string \$\endgroup\$ Aug 21, 2023 at 21:55
  • 1
    \$\begingroup\$ Good. Feel free to post new code in an answer, or perhaps request a new review. Just "do not change the code in the question after receiving an answer." // I wholeheartedly agree with changing to a "string in / string out" API. Consider .3 - .2 --> .0999...998. The trouble with the current API is it is unclear what number means. It has a precise mathematical meaning, which you don't like and want to adjust via rounding. Scaled integer or string would make more sense. \$\endgroup\$
    – J_H
    Aug 21, 2023 at 23:16
1
\$\begingroup\$

Questionable goal

Code suffers from/abuses double rounding.

Javascript numbers use binary64 encoding. That provides about 1019 different values.
10.395 is not one of them as it is not a dyadic rational, an integer times a power of 2.

In the vicinity of 10.395 are encodable values:
10.394999999999999573674358543939888477325439453125
10.3950000000000013500311979441903531551361083984375

As a string "10.395" converted to a number, it is convert to the closer, yet lesser one.

With OP's code, both of these return a value near 10.40.

So if number is a string like "10.395", 10.40 is a good result. Yet if number is a number prints like 10.395, it really is 10.394999... and 10.39 is a good result.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.