Skip to content
Topos Uranos

Summary

The numbers with decimals in a program are not the real numbers of mathematics: they are a finite subset of the rationals whose denominator is a power of two, and every value that does not belong to that subset is replaced by the nearest one that does. This lesson studies how a floating-point number is stored in 64 bits (sign, exponent and significand), why 0.10.1 has no exact representation and what value takes its place, how many digits are safe in float and in double, how rounding errors accumulate, why two reals are not compared with == and how they are compared with a justified tolerance, and what infinity and the not-a-number value are, which the format reserves for results that do not fit or do not exist.

Learning objectives

  1. Explain why 0.10.1 has no exact binary representation and what value is stored in its place.
  2. Predict the effect of rounding on repeated sums and on the sum of numbers of very different magnitudes.
  3. Compare real numbers with a suitable tolerance instead of ==, justifying the choice of tolerance.
  4. Choose between float and double according to the required precision, and recognize infinity and the not-a-number value.

Scientific notation in base 2

The lesson Integers and their representation showed that an integer of nn bits represents exactly 2n2^n consecutive values. Now, numbers with decimals pose a different difficulty: between any two reals there are infinitely many others, and no finite number of bits can tell them all apart. The solution adopted by every current computer is the same one science uses to write very large or very small magnitudes: scientific notation. In base 10, the mean distance from the Earth to the Sun is written 1.496×10111.496 \times 10^{11} meters; the number has a sign, a few significant digits (the significand, or mantissa, 1.4961.496) and a power of the base (the exponent, 1111), which says where the decimal point goes. If this is so, a fixed number of digits covers magnitudes of very different orders, at the price of keeping only the first digits of each.

The computer does the same in base 2. Every nonzero number is written in exactly one way as

x=(−1)s×1.m1m2m3…m52 (2)×2ex = (-1)^{s} \times 1.m_1 m_2 m_3 \ldots m_{52} \, {}_{(2)} \times 2^{e}

where ss is the sign (0 for positive, 1 for negative), 1.m1m2…1.m_1 m_2 \ldots is the significand written in binary, whose first digit is always 1, and ee is the exponent. That the first digit is always 1 has a practical consequence: there is no need to store it, and the bit saved goes to precision. The name floating point describes exactly this form: the point does not occupy a fixed place among the digits, but “floats” according to the exponent.

The C++ type double uses, in every current compiler, the internationally standardized binary double-precision format, of 64 bits: 1 bit for the sign, 11 bits for the exponent and 52 bits for the significand, without the leading digit. The exponent is stored biased: the 11-bit field holds e+1023e + 1023, so that it stores unsigned values from 1 to 2046 for exponents from −1022-1022 to 10231023 (the field values 0 and 2047 are reserved for zero, very small numbers, infinity and the not-a-number value, which are covered at the end of the lesson). The type float uses the single-precision format, of 32 bits: 1 for the sign, 8 for the exponent and 23 for the significand.

The bits of a double can be seen. The following program uses std::bit_cast, from the header <bit>, which reinterprets the 64 bits of the number as an unsigned 64-bit integer, without changing any of them; here it is used without further explanation, as an instrument for looking at memory. The specification {:064b} writes that integer in binary with 64 digits, as in the previous lesson, and {:016X} in hexadecimal with 16.

C++ program
#include <bit>#include <cstdint>#include <print>​int main(){    const double tenth{0.1};    // The 64 bits of the number, read as an unsigned integer.    const std::uint64_t bits{std::bit_cast<std::uint64_t>(tenth)};    std::println("{:064b}", bits);    std::println("{:016X}", bits);}
Output
00111111101110011001100110011001100110011001100110011001100110103FB999999999999A
ExampleThe 64 bits of 0.1

The figure groups the 64 bits that the program wrote and reads them field by field.

Proof

  1. 1+11+52=641 + 11 + 52 = 64

    The 64 bits, in hexadecimal 3FB999999999999A. They are read from left to right: 1 sign bit, 11 exponent bits and 52 significand bits.

  2. s=0s = 0

    The sign is 0: the number is positive.

  3. e=1019−1023=−4e = 1019 - 1023 = -4

    The exponent field, 0111111101101111111011 in binary, is 1019; subtracting the bias, 1023, gives the exponent −4-4.

  4. m≈1.6000000000000000888m \approx 1.6000000000000000888

    The significand, with the leading 1 that is not stored, is 1.1001100110011…10101.1001100110011\ldots1010 in binary: the group 10011001 repeats and the last group, 10101010, is rounded up. Its value is approximately 1.60000000000000008881.6000000000000000888.

  5. x=0.1000000000000000055511…≠0.1x = 0.1000000000000000055511\ldots \neq 0.1

    The stored number is +1.6000000000000000888×2−4+1.6000000000000000888 \times 2^{-4}, that is, 0.1000000000000000055511…0.1000000000000000055511\ldots: very close to 0.10.1, but not 0.10.1.