# Why doesn't round have an argument for digits?

I found a very old SO solution on how to round to a specific number of digits in Rust and the answer is a little wonky, with stability concerns listed in the comments once you try to round to a high number of digits.

It kinda feels like a bit of a gaping hole in the API as pointed out by this fairly up-voted comment.

So why not include a `round_to_digits()` function that does the chore illustrated in that solution? Is there a core philosophy to Rust this would violate? I get that you can string format, but isn't that a bit inefficient way to go about it, especially if you then have to parse back to a float?

What is an example use case for this?

`f64::round` has the benefit that the output is always exactly an integer (well, except for +inf, -inf and NaN).

But rounding to an exact multiple of 0.1 is impossible since 0.1 can't be represented exactly. So if you wanted to do anything on the assumption that it is an exact multiple of 0.1, you'd probably have to round again down the road.

1 Like

I was wondering about that question myself… till I looked on your links which gave me an answer.

As someone who deals with fixed point arithmetic and knows modern CPUs (with exception of x86, as usual) provide support for it I was sure you are asking about that. To enter uneven `3.4` into `round` with `digits = 3` and get back nice round `3.375`

I guess clash of my expectations and your expectations is the answer by itself.

I agree with tczajka here. To elaborate a bit...

`f32` and `f64` are binary floating-point numbers. That means that what they can represent is integer multiples of powers of two. However, if you want to round to a number of decimal digits, 10=2×5, and thus you can never get those exact negative powers of 5 out of powers of 2. And thus rounding will never work properly. (It might look like it sometimes, but that's only because of extra cosmetic rounding when you look at the value.)

So if you really want to round to decimal in a reliable way, you should use a decimal type, not a binary floating point type. (But why did you want to round it in the first place?)

3 Likes

I had no idea the caveat I was stepping on, thank you!

In most other languages you have `float` and `double` types and they all have various ways of rounding to a specific number of digits. I was just trying to find the equivalent way of doing so in Rust. While I understand `0.1` is an infinite series in binary, can you explain how `Decimal` solves this?

Then, I'd say the complete answer is floating point numbers are not the right tool for the rounding job, and then because of some magic `Decimal` type has the answer: round_dp_with_strategy (or just `round_dp` if you can stomach "banker's rounding").

https://float.exposed/0x3dcccccd is a great site for exploring things like this.

Decimal solves this by just not using a binary exponent. `0.1` with a binary exponent is forced to be 2⁻⁴ times an approximation of ⁸⁄₅. But with a decimal exponent it's trivial: just 1.0 * 10⁻¹. Similarly, you could store 1.35 as 135 * 10⁻². (Then you could store the `135` and the `-2` as binary numbers, to be easy on the computer, while still representing a decimal.)

There are various tricks that are sometimes used in the specifics for this -- see Binary-coded decimal - Wikipedia -- but at the core it's just representing it in a way that's a bit harder to compute with (computers like binary better, of course) but fits better for the decimalized world of humans where it's better to be a bit slower than to sometimes be off by 0.0001% sometimes -- financial anything being the usual example.

2 Likes

Of course, there are numbers that cannot be represented exactly in base 10 either. 1/3 is an obvious example. But I guess we don't care about having exactly a third of a euro nearly as much as we care about three tens.

Perhaps this is for the better, having to deal with fractional representations and risk overflowing when just adding or multiplying two numbers with different divisors sounds awful.

To give an "answer", when you need to round to the second decimal I assume it's for human display. And if that is the case you can just use the string formatting machinery.

``````format!("{:.2}", x)
``````

Keep the data as a floating point primitive for as long as possible and only format it on output.

2 Likes

And typically you don't even want a third of a euro.

The right way to split a €10 bill three ways is not 3× €3.33⅓, but 2× €3.33 and 1× €3.34, for example.

This is true, but only reinforces what I hinted at: It is an arbitrary cultural thing and we should be aware of its shortcomings and not blindly consider it "correct" without the context of where it is correct (and even then only by convention).

Base 10 won because that is what modern western society uses. And western society largely took over the world. Modern finance calculations would have looked different if we had stuck to base 60 like the Babylonians used (that only survives in time keeping to some extent).

There have been yet other bases used in other cultures as well. Any of the would have been just as good of a choice really, in fact most would have been better than base 10 thanks to having more divisors.

This operation is kind of tricky. Let's just say you want one decimal digit. You absolutely can ask the question "given `x` what is the closest integer to `10*x`?" However:

1. Rounding isn't continuous.
2. Even though 10 is exactly representable as a float, multiplication by 10 loses the low two bits of the product. They are going to get rounded and might cause a carry to propagate all the way across your number.

How much do you care about the bits you lost? How sad would you be if you were off by 1 for some tiny proportion of inputs? You can actually recover those bits with fma by computing `fma(10, x, -10*x)` and get the full 54-bit mantissa of the product. So in principle you can do the rounding you want yourself and solve the original problem. However if you need this level of precision you should rescale by 10 in the first place.

1 Like

For currencies, use integer microunits. It's got enough precision to satisfy basically any contractual rounding you will find in the real world. (So you end up representing microdollars, microeuro, microyen, etc.)

That doesn't mean it's correct. Pretty much every language uses binary IEEE-754 floating point nowadays; if they provide a way to round "to N decimal digits", they are only hiding the problem, not solving it. This is not specific to Rust.

Decimal rounding a binary floating point is essentially converting to decimal, rounding, then converting back to binary floating point, with full roundtrip accuracy to the bit (otherwise you get the 0.1000...07 or whatever issue).

It turns out this is an unexpectedly hard problem with ongoing research, for example Ryu was published in 2018!

But, as that link also references, this is already needed in most C standard libraries for eg printf, so honestly it's a bit annoying you can't directly get at the APIs that are already in there internally! But that does imply you can get a (fairly inefficient) round by formatting with a specific precision then parsing it back out.

1 Like

I don't really get the "cultural" and "base 10" argument? Irrational numbers are irrational regardless of the writing system used to describe them (same for infinitely repeating real numbers), you'd have the same issues in all bases.

Edit : guys ^^ I understood the first time. High school math is long long lost to me. Thank you all for the reminders.

Irrational numbers are not relevant; every number discussed here is is rational. But depending on your choice of base, some numbers have a finite sequence of fractional digits and some have an infinite repeating sequence. It's much easier to deal with the finite ones.

(Irrational numbers have an infinite non-repeating sequence.)

2 Likes

We are not tolking about irrational numbers but about numbers with finite numbers of digits. That's not such a big problem with integers because every integer written in decimal have equal number written in biinary.

This becomes important for fixed point numbers and floating point numbers: in this realm there are no one-to-one correspondence with numbers of fixed number of digits.

This is important for finances where lots of things are defined as precise numbers with decimal fractions. These are so important that COBOL with it's support for that notation and POWER/ z/Architecture still reign supreme in banks and other related organizations.

Not the same. 1/5 is 0.2 in base 10, but, if I'm not mistaken, 0.(0011) in base 2.

Thank you for this, but when you say hiding do you mean they are internally converting to a `Decimal` type also, rounding, then converting back to `float` or `double` (or equivalent) types?

For sure. There is a reason symbolic representations exist (in CASes for example). But even then, some problems have no known exact solutions and a numeric approximation will the best you can do.

You should ideally choose the numeric representation that is suitable for your needs with a full understanding of the various benefits and drawbacks.

But base 2 and base 10 is pretty bad due to having so few divisors. Base 12 or base 60 would have been better choices if you wanted to be able to exactly represent many fractions. Of course 2 and 10 are better in other aspects (easy for computers and human fingers respectively).

And obviously no one is claiming that you can exactly represent square root of 2 in any integer base. Or pi for that matter.

I think you perhaps misunderstood my two posts above though, I was trying to point out that base 10 didn't make sense either. Except that the financial world decided it was the "true" way of doing things for some unknown to me reason. And some people seem to think that is all that matters.

Every representation of numbers will have different trade offs and you need to always specify the context your statement makes sense in unless it is very clear from the context. On a general purpose forum that is even more true as people have different background and will assume different default contexts.