Ejecuta 0.1 + 0.2 en una shell de Python y no obtienes 0.3. Obtienes 0.30000000000000004. Pruébalo en JavaScript, Java, Go, C o Rust y te sale la misma cola de dieciséis cifras. Cada uno de esos lenguajes hace exactamente lo que marca el estándar del hardware. Un número que necesita infinitos dígitos binarios se comprime en 64 bits, y algo tiene que ceder.

Por qué 0.1 no se puede guardar de forma exacta en binario

Los ordenadores guardan los números en coma flotante en base 2. En base 2, la fracción 1/10 tiene un desarrollo periódico, igual que 1/3 se repite sin fin en base 10 (0.3333...). Escrito entero, 0.1 en binario empieza así:

1
0.1₁₀ = 0.000110011001100110011...₂  (se repite sin fin)

Ningún número finito de bits corta esa secuencia devolviendo exactamente 0.1. Un “double” IEEE 754 de 64 bits (1 bit de signo, 11 bits de exponente, 52 bits de mantisa guardados, 53 bits de precisión contando el 1 implícito inicial) redondea ese desarrollo infinito al valor más cercano que puede representar, que siempre es una fracción de la forma J / 2^N. Para 0.1, ese valor es:

1
3602879701896397 / 2^55

Como decimal, es 0.1000000000000000055511151231257827021181583404541015625. Python te lo muestra. Cada fragmento de este artículo usa solo la biblioteca estándar, así que no necesitas montar un entorno virtual para seguirlo:

1
2
3
4
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> 0.1 == 0.1000000000000000055511151231257827021181583404541015625
True

0.2 trae su propio error de redondeo, un poco distinto. Suma los dos valores ya imperfectos y los errores no se cancelan: se combinan en un resultado un paso por encima del double más cercano a 0.3. De ahí 0.30000000000000004.

Qué números guarda la coma flotante de forma realmente exacta

No todos los decimales están condenados. Una fracción binaria termina cuando su denominador, reducido al mínimo, es una potencia de dos. 0.5 es 1/2. 0.25 es 1/4. 0.125 es 1/8. Esos son exactos:

1
2
3
4
>>> 0.5 + 0.25 == 0.75
True
>>> 0.1 + 0.2 == 0.3
False

Los decimales que la gente escribe de verdad, como precios y porcentajes, casi nunca tienen un denominador potencia de dos. Eso convierte el error de 0.1 + 0.2 en el resultado normal de la aritmética decimal sobre floats binarios, y se acumula. Suma 0.1 diez veces en cualquier lenguaje que use doubles y no llegas a 1.0:

1
2
3
4
5
>>> total = 0.0
>>> for _ in range(10):
...     total += 0.1
>>> total
0.9999999999999999

Diez sumas. Es todo lo que hace falta. Ten en cuenta que la notación Big O te dice lo rápido que crece ese bucle a medida que crece la entrada, nada sobre si el resultado es correcto: la corrección aquí es una comprobación aparte que tienes que hacer tú mismo.

Cómo comparar números en coma flotante sin arriesgarte

Como == sobre floats no es de fiar, compáralos con una tolerancia: comprueba que dos números estén lo bastante cerca, no que sean idénticos. Cada lenguaje mayoritario tiene su manera de hacerlo.

LenguajeComparación segura
Pythonmath.isclose(a, b) — tolerancia relativa 1e-9 por defecto
JavaScriptMath.abs(a - b) < Number.EPSILON * escala
JavaMath.abs(a - b) < 1e-9, o BigDecimal para el decimal exacto
C#Math.Abs(a - b) < 1e-9, o el tipo nativo decimal
Gomath.Abs(a-b) <= tolerancia (sin función auxiliar incorporada)

En Python:

1
2
3
4
5
>>> import math
>>> 0.1 + 0.1 + 0.1 == 0.3
False
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True

math.isclose escala la tolerancia según la magnitud de los números (rel_tol * max(abs(a), abs(b))), así que la misma llamada funciona con valores pequeños y grandes. Su abs_tol por defecto, eso sí, es 0.0. Si alguno de los dos valores puede ser legítimamente cero o muy cercano, pasa un abs_tol explícito, o la comparación fallará con valores que tú considerarías iguales.

El Number.EPSILON de JavaScript (2⁻⁵², unos 2.22e-16) es la distancia entre 1 y el siguiente double representable, así que por sí solo es un umbral válido solo para números cercanos a 1. Escálalo para cualquier otra magnitud:

1
2
3
4
5
function nearlyEqual(x, y, scale = 1) {
  return Math.abs(x - y) < Number.EPSILON * scale;
}
nearlyEqual(0.1 + 0.2, 0.3);              // true
nearlyEqual(1000.1 + 1000.2, 2000.3, 2000); // true, la escala coincide con la magnitud

Una aserción como result == 0.3 en un test es un bug esperando a que una entrada, una plataforma o un compilador distinto lo saque a la luz. Sea un test unitario o algo más arriba en la pirámide de pruebas, cambia la comparación exacta por una comprobación con tolerancia.

Cuándo la coma flotante es la herramienta equivocada: dinero y recuentos

Una tolerancia arregla la comparación. No arregla el almacenamiento. Si estás sumando precios o manteniendo un saldo acumulado, el error de redondeo es el problema en sí mismo, y la solución es dejar de usar float/double para ese valor.

La versión más simple: guarda el dinero como un entero de la unidad más pequeña. 19,99 $ es el entero 1999 céntimos, no el float 19.99. La suma y la resta entre enteros no tienen error de redondeo. Convierte a cadena decimal solo cuando muestres el valor.

Cuando necesites fracciones decimales reales con reglas de redondeo exactas (una contabilidad, un cálculo de impuestos, cualquier cosa que deba cuadrar hasta el céntimo), usa un tipo decimal:

1
2
3
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True

Java tiene BigDecimal para lo mismo; C# tiene un tipo decimal nativo de 128 bits con unas 28-29 cifras decimales significativas. Los tres guardan las cifras en base 10, así que las fracciones decimales que escribe la gente se guardan sin redondeo. Eso se paga en velocidad y verbosidad. Los tipos decimales son más lentos que los floats nativos y necesitan un modo de redondeo explícito para la división. Merece la pena para el saldo de un banco. Un mal cambio para un bucle numérico intensivo.

Cuándo la imprecisión de la coma flotante no importa

Las transformaciones gráficas, las simulaciones físicas, los pesos de modelos de machine learning y el procesamiento de audio corren sobre floats a propósito. Un error relativo en torno a 1e-16 queda muy por debajo de lo que esos campos pueden notar, y el hardware nativo de floats es muchísimo más rápido que la aritmética decimal.

Así que la pregunta sobre un número no es si tiene punto decimal. Pregúntate si estás contando cosas discretas y exactas, como dinero o el resultado esperado de un test, o midiendo una cantidad continua que ya era aproximada desde el principio, como la posición de un píxel o la lectura de un sensor. Los valores contados necesitan enteros o un tipo decimal. Los valores medidos son justo aquello para lo que se diseñó la coma flotante, y pasarlos a Decimal los hace más lentos sin hacerlos de verdad más correctos.

La próxima vez que veas un campo de saldo declarado como float o un test que afirma result == 0.3, decide de qué tipo de número se trata y corrige el tipo de almacenamiento o la comparación en esa revisión, antes de que falte un céntimo en producción.

Artículos relacionados