C++ / FUNDAMENTAL TYPES AND VARIABLES
Floating-point types and their precision limits
Choose between float, double and long double, explain why 0.1 + 0.2 != 0.3, and compare computed floating-point values without relying on ==.
What you will learn
- Explain why 0.1 + 0.2 != 0.3 using binary fractions and round-to-nearest
- Query digits10, max_digits10 and epsilon from <limits> for float and double
- Compare computed doubles with an epsilon-scaled tolerance instead of ==
- Find where float and double stop representing consecutive integers (2^24, 2^53)
Understanding Floating-point types and their precision limits
C++ offers three floating-point types: float, double and long double. On every mainstream compiler float is IEEE-754 binary32 (4 bytes: one sign bit, 8 exponent bits, 24 significant bits counting the implicit leading 1) and double is binary64 (8 bytes, 11 exponent bits, 53 significant bits). The mental model is scientific notation in base two: a sign, a significand with a fixed bit budget, and a power-of-two exponent that slides the binary point. Because the bit budget is fixed and the exponent only rescales, precision is relative rather than absolute: you always get about 24 or 53 significant bits, whether the value is 0.001 or 1e20, so the gap between neighbouring representable values grows with magnitude.
That budget is spent on binary digits, and a finite binary fraction can only represent numbers whose denominator is a power of two. One tenth is 1/(2*5), so its binary expansion repeats forever (0.0001100110011...), and the compiler stores the nearest double instead, about 0.10000000000000000555. The literal is already off before you compute anything, and each arithmetic operation rounds its exact mathematical result to the nearest representable value again. Values like 0.5, 0.25 and 3.75 are exact; 0.1, 0.2 and 0.3 are not, which is the whole reason 0.1 + 0.2 lands exactly one step above the double closest to 0.3.
Two constants in <limits> tell you how many decimal digits you may trust. digits10 (6 for float, 15 for double) is how many decimal digits always survive a decimal to binary to decimal round trip, while max_digits10 (9 and 17) is how many digits you must print to identify a stored value uniquely, which is why setprecision(17) reveals what the default six-digit output hides. epsilon (about 2.22e-16 for double) is the distance from 1.0 to the next double, so it measures relative error: near 1e9 the spacing between doubles is already around 1e-7. The practical consequences are to compare with a tolerance scaled to the size of the operands instead of ==, and to keep quantities that must be exact, such as money, in integers.
<iostream>
<iomanip>
<limits>
int main() {
std::cout << "sizeof(float) = " << sizeof(float)
<< " digits10 = " << std::numeric_limits<float>::digits10
<< " max_digits10 = " << std::numeric_limits<float>::max_digits10 << '\n';
std::cout << "sizeof(double) = " << sizeof(double)
<< " digits10 = " << std::numeric_limits<double>::digits10
<< " max_digits10 = " << std::numeric_limits<double>::max_digits10 << '\n';
double sum = 0.1 + 0.2;
std::cout << std::setprecision(17);
std::cout << "0.1 + 0.2 = " << sum << '\n';
std::cout << "0.3 = " << 0.3 << '\n';
std::cout << std::boolalpha << "sum == 0.3 ? " << (sum == 0.3) << '\n';
float tenth = 0.1f;
std::cout << std::setprecision(9) << "0.1f = " << tenth << '\n';
}
A floating-point type stores a fixed number of significant binary digits, so most decimal values are kept as the nearest representable neighbour and the size of the gap between neighbours grows with the magnitude of the number.
Worked examples
Ten additions of 0.1 and a safe comparison
Accumulated rounding leaves the running total one step short of 1.0, and a tolerance derived from epsilon accepts it anyway.
<cmath>
<iomanip>
<iostream>
<limits>
int main() {
double total = 0.0;
for (int i = 0; i < 10; ++i) {
total += 0.1;
}
const double tol = 8 * std::numeric_limits<double>::epsilon();
std::cout << std::setprecision(17) << std::boolalpha;
std::cout << "total = " << total << '\n';
std::cout << "total - 1.0 = " << total - 1.0 << '\n';
std::cout << "total == 1.0 : " << (total == 1.0) << '\n';
std::cout << "within tolerance : " << (std::fabs(total - 1.0) <= tol) << '\n';
}
Example explained
Line 1total += 0.1 rounds to the nearest double after each of the ten additions, and the errors do not cancel, so the result stops one step below 1.0.
Line 2setprecision(17) is what makes the shortfall visible; at the default six significant digits the same line would read 1.
Line 3total - 1.0 is exactly -2^-53, the spacing between doubles just below 1.0, so it prints as -1.1102230246251565e-16.
Line 48 * epsilon is a deliberately loose budget for ten roundings near 1.0; that same absolute tolerance would be far too tight for operands near 1e9.
Where consecutive integers stop fitting
Shows the exact magnitude at which float and double can no longer tell n apart from n + 1.
<iomanip>
<iostream>
int main() {
float big = 16777216.0f; // 2^24
float next = big + 1.0f;
std::cout << std::setprecision(9) << std::boolalpha;
std::cout << "2^24 = " << big << '\n';
std::cout << "2^24 + 1 = " << next << '\n';
std::cout << "changed? " << (next != big) << '\n';
double huge = 9007199254740992.0; // 2^53
std::cout << std::setprecision(17);
std::cout << "2^53 = " << huge << '\n';
std::cout << "2^53 + 1 = " << huge + 1.0 << '\n';
}
Example explained
Line 1float has 24 significant bits, so 16777217 would need 25 bits and simply has no representation.
Line 2big + 1.0f falls exactly halfway between 16777216 and 16777218, and round-to-nearest-even selects the lower value, whose significand is even.
Line 3double has 53 significant bits and hits the identical wall at 2^53, which is why large integer identifiers lose their last digits once stored as double.
Line 4setprecision(9) and setprecision(17) are max_digits10 for float and double, so the printed text pins down exactly which value is stored.
Important notes
long double is whatever the platform provides: 64 bits (identical to double) on MSVC, 80-bit x87 padded to 16 bytes on x86-64 Linux, 128-bit on some ARM and POWER targets, so it buys no portable extra precision.
Flags such as -ffast-math let the compiler reassociate expressions and ignore these rounding rules, so the digits shown here hold only under default IEEE-754 semantics.
Common mistakes
Writing for (double x = 0.0; x != 1.0; x += 0.1): the running value goes 0.89999999999999991, then 0.99999999999999989, then past 1.0, so the exit condition never holds and the loop never terminates.
Trusting the default stream output: std::cout shows six significant digits, so 0.1 + 0.2 prints as 0.3 and looks exact, and the discrepancy only surfaces later in a comparison or in a file read by another tool.
Using float for an accumulator to save memory: once the total reaches 16777216, adding 1.0f rounds back to the same value, so the count silently freezes instead of overflowing or warning.
Try it yourself
Change, predict, then run
Add 0.1 to a double one thousand times and print total - 100.0 with setprecision(17), then repeat with a float accumulator and setprecision(9). Compare the two errors and note roughly how many times larger the float error is.
Open the C++ workspaceCheck your understanding
A program does std::cout << 0.1; and the output is 0.1. What does that tell you about the value stored in the double?
- Nothing about exactness: the default stream precision is six significant digits, so the nearest double to 0.1 rounds to 0.1 on output
- The value is stored exactly, since the printed text matches the literal in the source
- The compiler recognised a short decimal literal and stored it in a decimal fixed-point form to avoid error
- double keeps 15 exact decimal digits, so any literal written with fewer than 15 digits is stored exactly
Show answer
The stored value is about 0.10000000000000000555, and the default six significant digits of output round it straight back to 0.1, so the printed text proves nothing. Option 4 is tempting because digits10 really is 15, but that number means a 15-digit decimal survives a round trip through the type, not that the binary value equals the decimal one: 1/10 has an infinite binary expansion no matter how few digits you write.