Exact Calculations in JavaScript
Whoever is careless with the truth in small matters cannot be trusted with important matters. ~ Albert Einstein
Exactitude, the quality of being very accurate and careful, is a mindset. It requires that nothing should be assumed and everything should be proven. This principle is especially important in programming, where precision is key.
Floating-Point Arithmetic and Exactitude
We all know that under the common math system, 1 + 1 = 2. Computers understand this as well. Every positive integer can be represented in any integer base greater than 1, including binary. This means some combination of the positive powers of 2 (1, 2, 4, 8, 16, 32, ...) can represent a digital integer. Integers can be added, subtracted, and multiplied with exact methods.
However, division introduces complications. For division, we need real values or floating-point numbers. Floating-point numbers use negative powers of 2 (1/2, 1/4, 1/8, 1/16, 1/32, ...), which leads to a problematic situation: we cannot guarantee exactness anymore.
Consider the number 0.1. Let's try to represent it in binary. A quick search shows that its exact binary representation is infinitely repeating. This means that numbers like 0.1 cannot be expressed exactly in binary, nor can most numbers be represented exactly in 32, 64, or any finite number of bits.
The Problem of Rounding in JavaScript
Despite these challenges, calculators and computers manage to give us seemingly exact results like 0.1 + 0.1 = 0.2. This is achieved through resolution and rounding. In JavaScript, the global Math
object has a round
function, which rounds a floating-point number to the nearest integer. However, this method is limited to a resolution of zero digits after the decimal.
Let's look at a simple rounding function in JavaScript that rounds to a specified resolution:
const round = (num, step) => Math.round(num / step) * step;
// Examples
console.log(round(1, 1)); // 1
console.log(round(0.5, 1)); // 1
console.log(round(0.49, 1)); // 0
console.log(round(-2, 1)); // -2
console.log(round(1, 0.5)); // 1
console.log(round(1.1, 0.5)); // 1
console.log(round(2.2, 0.5)); // 2
console.log(round(2.49, 0.5));// 2.5
While this works for many cases, it fails for others due to the inherent limitations of floating-point arithmetic. Consider the following:
console.log(round(0.6, 0.1)); // 0.6000000000000001
console.log(round(1, 0.1)); // 1
console.log(round(1.1, 0.1)); // 1.1
console.log(round(2.2, 0.1)); // 2.2
console.log(round(2.49, 0.1));// 2.5
The issue arises because 0.1 cannot be exactly represented in binary, leading to errors when performing arithmetic operations.
Improving the Rounding Function
To address these issues, we need a more robust method that considers resolution and mitigates floating-point errors. Here's an improved version:
const round = (n, k, resolution) => {
const precision = Math.pow(10, Math.trunc(resolution));
return Math.round(
Math.round(((n + Number.EPSILON) * precision) / k) * k
) / precision;
}
// Examples
console.log(round(1.005, 1, 2)); // 1.01
console.log(round(0.6, 1, 1)); // 0.6
Explanation:
- Move the decimal: Shift the decimal point to the right by multiplying by
10^resolution
. - Scale down: Divide the result by
k
. - First round: Round to the nearest integer.
- Scale up: Multiply by
k
. - Second round: Round to the nearest integer again.
- Move the decimal back: Shift the decimal point back to the left by dividing by
10^resolution
.
Number.EPSILON
The Role of Number.EPSILON
is the smallest difference between 1 and the next representable floating-point number. By adding Number.EPSILON
before the rounding operations, we compensate for small representation errors. This technique ensures that our rounding function provides accurate results within the limitations of floating-point arithmetic.
Practical Applications
With a robust rounding method, we can create helpers for various scenarios, such as currency calculations:
const cents = (value) => round(value, 1, 2); // Rounds to 2 decimal places
// For many forex applications, which often require 5 decimal places
const forex = (value) => round(value, 1, 5);
// To round to the nearest 5 cents, as required in some countries
const cents5 = (value) => round(value, 5, 2);
// To take a percentage of a total currency value
const percent = (value, percentage) => cents(value * percentage / 100);
// To sum currency values
const sum = (numbers) => numbers.reduce((a, b) => a + b, 0);
const centsSum = (numbers) => cents(sum(numbers));
Summary
Floating-point rounding is more challenging than it appears, particularly when exact results are required. By using the Number.EPSILON
approach, we can achieve reliable results for most practical applications, including financial computations. While exactitude in floating-point arithmetic is inherently limited, careful rounding ensures precision where it matters most.