Print exact value represented by float in [0,1)

Motivated by yet another floating point question on stackoverflow, I decided to print a float value $$\x\in[0,1)\$$, i.e., the fractional part of a float (I might try printing whole floats later). But not the rounded value like print(x) does but the exact represented value. And only using basic operations:

def print_float(x):

# Convert float x to integer fraction num/den.
num, den = 0, 1
while x:
x *= 2
num *= 2
den *= 2
if x >= 1:
x -= 1
num += 1

# Print num/den in decimal.
print('0.', end='')
while num:
num *= 10
print(num // den, end='')
num %= den
print()


Testing it against a simpler way using string formatting:

from random import random

for _ in range(3):
x = random()
print(f'{x}:')
print(('%.2000f' % x).rstrip('0'))
print_float(x)
print()


Output:

0.1659451324370237:
0.16594513243702369020837750213104300200939178466796875
0.16594513243702369020837750213104300200939178466796875

0.6127401513193578:
0.6127401513193577731186678647645749151706695556640625
0.6127401513193577731186678647645749151706695556640625

0.11658146732832175:
0.11658146732832175285210496440413407981395721435546875
0.11658146732832175285210496440413407981395721435546875


Separation of Concerns

Your function is doing too much. It is:

1. Converting the float to a fraction
2. Converting the fraction to a string of digits
3. Printing the string of digits

You should separate these.

Float to Fraction

First, let's move the float-to-fraction code into its own function.

from typing import Tuple

def float_to_fraction(value: float) -> Tuple[int, int]:
"""
Convert a floating point number in the range [0, 1) into a fraction.

>>> float_to_fraction(0.625)
(5, 8)

>>> float_to_fraction(0.1)
(3602879701896397, 36028797018963968)
"""

if not (0 <= value < 1):
raise ValueError("Value out of range (0 <= value < 1)")

numerator, denominator = 0, 1
while value:
value *= 2
numerator *= 2
denominator *= 2
if value >= 1:
value -= 1
numerator += 1
return numerator, denominator


• type hints, describing the input and output types for the function,
• a """docstring""" describing how to use the function,
• input range validation, since your expectation is for the range to be within a narrow range of float values.

I've also changed num to numerator, since it would be easy to misinterpret the abbreviation to mean number. Similarity, den became denominator and x became value.

Fraction to String

Again, converting a numerator/denominator fraction into a series of digits is logical unit of code, which could be reused elsewhere, so I've made it into a function:

def fraction_to_string(numerator: int, denominator: int) -> str:
"""
Convert a proper fraction into a corresponding series of digits.

>>> fraction_to_string(5, 8)
'0.625'
"""

if not (0 <= numerator < denominator):
raise ValueError("Improper or negative fraction given")

if denominator & (denominator - 1) != 0:
raise ValueError("Denominator must be a power of 2")

digits = '0.'
while numerator:
numerator *= 10
digits += str(numerator // denominator)
numerator %= denominator

return digits


Note: str(0.0) returns '0.0' but your code (and my duplication of it here) returns '0.'.

Your code originally guaranteed the denominator was a power of 2. With this new function, a caller could ask for fraction_to_digits(1, 3) which would be an infinitely long string of digits, so I've added a condition restricting the denominator to a power of 2.

Float to String

Now we can compose these two functions, as well as recreate the original function's functionality:

def float_to_string(value: float) -> str:
numerator, denominator = float_to_fraction(value)
return fraction_to_string(numerator, denominator)

def print_float(x):
print(float_to_string(x))

if __name__ == '__main__':
import doctest
doctest.testmod(verbose=True)

from random import random

for _ in range(3):
x = random()
print(f'{x}:')
print(('%.2000f' % x).rstrip('0'))
print_float(x)
print()


Additionally, I've added a "main guard" (always a good idea), and I've added a call to doctest.testmod() so the tests embedded in the """docstrings""" are executed.

Notes

The function float_to_fraction(value) may be replaced by the built-in function value.as_integer_ratio().

>>> value = 0.625
>>> value.as_integer_ratio()
(5, 8)

• Thanks. Nice improvements. Didn't know it's that easy to run doctests. fraction_to_string could handle denominators other than powers of 2, like 7/25 or 7/28 would also work. The exact rule might be that after simplifying the fraction, the denominator's only prime factors are 2 and 5. But yeah, for my purpose I don't mind simply requiring powers of 2. – Manuel Sep 24 '20 at 23:53