Rational Numbers in C Programming
Floating Point Limitations
The Trouble with Tenths
Computers are built on a foundation of absolute certainty. A bit is either a 0 or a 1. There's no in-between. So it's strange that they have such a hard time with a number as simple as 0.1.
Internally, computers don't think in base 10 like we do. They think in base 2, or binary. Whole numbers are easy to translate. The number 5 in our system is $101_2$ in binary. Simple.
But what about fractions? In base 10, we represent fractions as sums of powers of 10. For example, 0.75 is $7/10 + 5/100$. In binary, fractions are sums of powers of 2: $1/2$, $1/4$, $1/8$, and so on. A number like 0.75 is straightforward. It's $1/2 + 1/4$, which in binary is written as $0.11_2$. It has a clean, finite representation.
Here's the problem: a simple fraction like 1/10 (or 0.1) cannot be represented as a finite sum of powers of 2. It's the binary equivalent of trying to write 1/3 as a finite decimal. You get an infinitely repeating sequence: $0.33333...$. In binary, 0.1 becomes $0.0001100110011...$, repeating forever.
Unfortunately, most decimal fractions cannot be represented exactly as binary fractions.
Since computers have finite memory, they can't store an infinite sequence. They have to cut it off somewhere. The standard for doing this is called for floating-point arithmetic. It defines how many bits are used for the number (the precision) and how to round the part that gets cut off. But no matter how you round it, the stored value is an approximation, not the real thing.
This tiny difference between the true value of 0.1 and its stored binary approximation is called representation error. And it's the source of countless bugs and unexpected behaviors in software.
When Small Errors Compound
A tiny error on its own might seem harmless. Who cares if a number is off by a trillionth of a percent? You care when you start doing math with it.
Each calculation can introduce a new rounding error or compound existing ones. In a simple loop that adds 0.1 ten thousand times, you don't end up with exactly 1,000. The result will be something like 1000.00000000014. These small inaccuracies accumulate, a phenomenon known as rounding error accumulation leading to significant, noticeable deviations from the correct answer.
This is especially dangerous in fields like finance and science. If you're calculating compound interest over millions of accounts, these tiny errors add up to real money being lost or created out of thin air. In scientific simulations, like modeling a climate system over decades, the accumulated error can render the entire simulation useless.
This also breaks a core principle programmers rely on: determinism. An operation should yield the same result every time. But with floating-point math, 0.1 + 0.2 might not exactly equal 0.3, which can cause comparisons to fail unexpectedly.
The problem isn't that the computer is bad at math. The problem is that the floating-point format itself is a compromise between precision and range, and some numbers just don't fit.
Building a Better Number
So if the native float and double types are fundamentally imprecise for many common decimal values, what's the alternative? If you need perfect accuracy, you have to abandon the built-in types and represent numbers differently.
This is the motivation behind creating a custom rational number type in a language like C. Instead of storing an approximation of 1/3 as 0.333333..., you can create a structure that stores two integers: a numerator (1) and a denominator (3).
When you need to perform calculations, you implement functions that follow the rules of fraction arithmetic:
- Addition:
- Multiplication:
By keeping the numbers as a pair of integers, you never introduce representation errors. The number 1/10 is stored perfectly as numerator = 1, denominator = 10. Every calculation is exact. The trade-off, of course, is performance. Performing arithmetic on two integers and then simplifying the resulting fraction is much slower than using the processor's dedicated floating-point hardware.
This approach highlights a key engineering principle: choosing the right tool for the job. Native floating-point types are fast and efficient for graphics, scientific simulations, and applications where a high degree of precision isn't critical. But for financial ledgers, high-stakes calculations, or any system where correctness is absolute, the cost of building and using a custom rational type is a necessary price to pay for accuracy.
Why do computers struggle to store a seemingly simple decimal number like 0.1 with perfect accuracy?
What is the term for the phenomenon where tiny inaccuracies from floating-point arithmetic grow into significant errors after many calculations?
