2
\$\begingroup\$

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
\$\endgroup\$
1
\$\begingroup\$

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

Here, I've added:

  • type hints, describing the input and output types for the function,
  • a """docstring""" describing how to use the function,
    • including an example formatted for use with the doctest module,
  • 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)
| improve this answer | |
\$\endgroup\$
  • 1
    \$\begingroup\$ 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. \$\endgroup\$ – Manuel Sep 24 at 23:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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