Sí, se pueden hacer ciclos con números flotantes, pero no siempre es seguro controlar su finalización mediante comparaciones directas de igualdad o mediante incrementos que esperen alcanzar exactamente un valor decimal.
La aritmética de coma flotante es siempre más compleja de lo que parece. Cada vez que creemos haber comprendido todas sus sutilezas y consecuencias, puede aparecer otro factor que no habíamos considerado.
Qué son los números de coma flotante
Una coma flotante es una forma de representar y realizar operaciones aritméticas sobre números reales en computación.
Es un tipo de datos numéricos que le permite manejar valores con partes fraccionarias y una amplia gama de magnitudes.
Los números de coma flotante son esenciales en la computación porque nos permiten trabajar con valores del mundo real que no son números enteros.
Muchos cálculos científicos, de ingeniería y financieros requieren una representación precisa de números decimales con diferentes niveles de precisión.
Los números de coma flotante se representan típicamente usando un formato estandarizado conocido como el estándar de coma flotante Institute of Electrical and Electronics Engineers (IEEE).
Este estándar especifica cómo los números se codifican en formato binario, que consiste en un bit de signo, un exponente y un significando, también llamado mantisa.
Sí, hay diferentes tamaños de números de coma flotante.
Los tamaños más comunes son precisión simple, de 32 bits, y precisión doble, de 64 bits.
La precisión simple permite un mayor rango de números, mientras que la precisión doble proporciona una mayor precisión y un mayor rango.
La aritmética de coma flotante funciona realizando operaciones en las representaciones codificadas de números de coma flotante.
Las operaciones aritméticas, suma, resta, multiplicación y división, consideran el exponente y el significado, aplicando reglas definidas por el estándar Institute of Electrical and Electronics Engineers (IEEE) para garantizar resultados precisos.
Por qué un ciclo con flotantes puede fallar
La aritmética de coma flotante no es exacta.
Valores simples como 0.1 no pueden representarse precisamente usando números de coma flotante binarios, y la precisión limitada de estos números significa que ligeros cambios en el orden de las operaciones o en la precisión de los valores intermedios pueden cambiar el resultado.
Los números de coma flotante no pueden representar con precisión todos los valores decimales debido a la precisión finita de su representación binaria.
Algunos números decimales, como fracciones simples como 1/3 o decimales recurrentes como 0.1, no pueden representarse con precisión en binario.
Estos números pueden dar lugar a errores de redondeo o aproximaciones cuando se almacenan y manipulan como valores de punto flotante.
Por esta razón, un ciclo como el siguiente puede no comportarse como se espera:
float x = 0.0;
while (x != 1.0) {
x += 0.1;
}
El problema es que el valor obtenido después de varias sumas puede estar cerca de 1.0, pero no ser exactamente igual a 1.0.
Si la condición de salida depende de que un flotante alcance exactamente un límite mediante sumas sucesivas, el ciclo puede ejecutarse más veces de las previstas o no terminar.
La situación es especialmente peligrosa cuando el incremento es pequeño frente al valor actual: llega un momento en que sumar el incremento no cambia el valor representable, aunque el programa continúe ejecutando la operación.
Un ejemplo de acumulación de error
Este código intenta calcular «uno» de tres formas diferentes: mediante sumas repetidas y dos variantes ligeras de multiplicación.
Antes de continuar, es necesario aclarar la diferencia entre 0.1, float(0.1) y double(0.1).
En C/C++, los números 0.1 y double(0.1) son la misma cosa, pero cuando se dice «0.1» en el texto se hace referencia al número exacto en base 10, mientras que float(0.1) y double(0.1) son versiones redondeadas de 0.1.
Además, float(0.1) y double(0.1) no tienen el mismo valor, porque float(0.1) tiene menos dígitos binarios y, por lo tanto, más error.
sum = 1.000000119209290: este cálculo comienza con un valor redondeado y después lo suma diez veces, con un posible redondeo en cada suma, por lo que existe mucho espacio para que aparezca el error.
El resultado final no es 1.0 ni es 10 * float(0.1).
mul = 1.000000000000000: este cálculo comienza con un valor redondeado y después lo multiplica por diez, por lo que hay menos oportunidades de que aparezca un error adicional.
La conversión de 0.1 a float(0.1) redondea hacia arriba, pero la multiplicación por diez, en este caso, redondea hacia abajo, y algunas veces dos redondeos producen el resultado correcto.
Por tanto, obtenemos la respuesta correcta por razones equivocadas.
mul2 = 1.000000014901161: este cálculo comienza con un valor redondeado y después realiza una multiplicación en doble precisión, evitando así un error de redondeo posterior.
La primera respuesta es incorrecta, aunque solo está a un flotante de distancia.
Este ejemplo muestra por qué un ciclo basado en sumas de números flotantes no debe asumir que la suma acumulada coincidirá exactamente con el valor final esperado.
La igualdad directa entre flotantes
Sí, puedes realizar comparaciones con números de coma flotante.
Sin embargo, debido a los errores de redondeo, las comparaciones directas de igualdad entre números de coma flotante pueden ser problemáticas.
Eso significa que comparar dos flotantes para comprobar si son iguales normalmente no es lo que se desea.
Incluso cuando dos cálculos deberían producir matemáticamente el mismo resultado, el orden de las operaciones, la precisión de los valores intermedios y los redondeos pueden producir representaciones diferentes.
Una comparación directa como a == b solo es apropiada cuando se conoce con seguridad que ambos valores se han producido de forma idéntica o cuando se están comprobando valores especiales y condiciones perfectamente controladas.
Comparación mediante una tolerancia absoluta
La forma más sencilla de comparar valores aproximados es comprobar si la diferencia absoluta entre ellos es menor que una tolerancia:
fabs(a - b) < epsilon
Esta estrategia funciona bien cuando se comparan números cercanos a cero o cuando la escala de los valores está claramente limitada.
Sin embargo, una tolerancia absoluta fija no funciona igual de bien para todos los tamaños de números.
Para números entre 1.0 y 2.0, FLT_EPSILON representa la diferencia entre dos flotantes adyacentes.
Para números mayores que 2.0, la separación entre flotantes crece.
Si se comparan flotantes usando FLT_EPSILON, se está realizando una comprobación de igualdad más costosa y menos evidente.
Es decir, si dos flotantes mayores que 2.0 no son iguales, su diferencia está garantizada como mayor que FLT_EPSILON.
Para números superiores a 16777216, la epsilon adecuada para flotantes es realmente mayor que uno, y una comparación usando FLT_EPSILON deja de ser apropiada.
Comparación mediante una tolerancia relativa
La idea de una comparación con epsilon relativo consiste en hallar la diferencia entre los dos números y comprobar qué tamaño tiene en relación con sus magnitudes.
Para obtener resultados coherentes, siempre se debe comparar la diferencia con el mayor de los dos números.
Para comparar f1 y f2 se calcula:
diff = fabs(f1-f2)
Después se compara esta diferencia con la magnitud mayor de los dos valores.
Este método no es malo.
Funciona, en su mayor parte.
Al realizar una comparación relativa de flotantes, suele funcionar bastante bien establecer maxRelDiff en FLT_EPSILON o en un pequeño múltiplo de FLT_EPSILON.
Un valor menor que ese corre el riesgo de ser equivalente a no utilizar epsilon.
Se puede hacer mayor si se espera un error superior, pero no conviene exagerar.
La comparación relativa es adecuada cuando se compara con un número distinto de cero y los valores tienen magnitudes semejantes.
El problema de comparar con cero
La idea de las epsilons relativas se rompe cerca de cero.
La razón es bastante sencilla.
Si se espera obtener cero, probablemente se está obteniendo mediante la resta de dos números.
Para alcanzar exactamente cero, los números que se restan deben ser idénticos.
Consideremos el código de ejemplo del principio.
Si se suma float(0.1) diez veces, se obtiene un número obviamente cercano a 1.0, y cualquiera de las dos comparaciones relativas indicará que son aproximadamente iguales.
Sin embargo, si se resta 1.0 del resultado, se obtiene FLT_EPSILON cuando se esperaba cero.
Si se realiza una comparación relativa entre cero y FLT_EPSILON, o prácticamente cualquier número, la comparación fallará.
Si se calcula f1 - f2 y después se compara el resultado con cero, se sabe que se está tratando con una cancelación catastrófica y que solo se obtendrá cero si f1 y f2 son iguales.
Por ello, al comparar con cero, una epsilon relativa o una comparación basada en ULP normalmente carece de sentido.
Se necesita una epsilon absoluta cuyo valor puede ser algún múltiplo pequeño de FLT_EPSILON y de las entradas del cálculo.
Si los dos números que se comparan son extremadamente cercanos, se deben tratar como iguales independientemente de sus valores relativos.
Esta técnica es necesaria siempre que se espere obtener cero como consecuencia de una resta.
El valor de la epsilon absoluta debe basarse en la magnitud de los números restados; debería ser algo parecido a maxInput * FLT_EPSILON.
Desafortunadamente, esto significa que depende del algoritmo y de las entradas.
Hacer primero una comprobación de epsilon absoluta en coma flotante y tratar después todos los números con signos diferentes como no iguales es una opción más sencilla y segura.
La solución general: combinar tolerancias
No existe una solución universal.
La respuesta más general consiste en utilizar una combinación de epsilons absolutas y relativas.
Si se comparan dos números arbitrarios que pueden ser cero o distintos de cero, se necesita utilizar todas las herramientas disponibles.
Si se compara con cero, normalmente se necesita una epsilon absoluta.
Su valor puede ser algún múltiplo pequeño de FLT_EPSILON y de las entradas del cálculo.
Si se compara con un número distinto de cero, probablemente se necesita una epsilon relativa o una comparación basada en ULP.
Para la epsilon relativa suele ser apropiado utilizar algún múltiplo pequeño de FLT_EPSILON, o un número pequeño de ULP.
Por encima de todo, es necesario comprender qué se está calculando, la estabilidad de los algoritmos y qué se debe hacer si el error es mayor de lo esperado.
Qué significa ULP
Los flotantes adyacentes tienen representaciones enteras adyacentes.
Esto significa que, si se restan las representaciones enteras de dos números, la diferencia indica qué distancia hay entre ellos en el espacio de los flotantes.
Si se obtiene uno, los dos flotantes están tan cerca como es posible sin ser iguales.
Si se obtiene dos, siguen estando muy cerca, con un solo flotante entre ellos.
La diferencia entre las representaciones enteras indica cuántas Units in the Last Place, o ULP, separan a los números.
Una diferencia de un ULP es la menor diferencia posible entre dos números.
Un ULP entre dos flotantes es mucho mayor que un ULP entre dos dobles, aunque la nomenclatura sigue siendo breve y práctica.
La comprobación de flotantes adyacentes mediante ULP es bastante similar a utilizar AlmostEqualRelative con epsilon igual a FLT_EPSILON.
Para números ligeramente superiores a una potencia de dos, los resultados suelen ser iguales.
Para números ligeramente inferiores a una potencia de dos, la técnica de FLT_EPSILON es dos veces más permisiva.
Las comparaciones basadas en ULP también tienen características de rendimiento diferentes.
Es más probable que sean eficientes en arquitecturas como SSE, que favorecen reinterpretar flotantes como enteros.
Sin embargo, pueden provocar pausas importantes en otras arquitecturas debido al coste de mover valores flotantes a registros enteros.
Signos, ceros y valores especiales
La comprobación de signos diferentes es necesaria por varias razones.
Restar la representación de magnitud con signo de los flotantes utilizando aritmética en complemento a dos no resulta especialmente significativo, y la resta produciría un resultado de 33 bits con desbordamiento.
Incluso un número ridículamente pequeño, como 1e-30, tiene una representación entera «grande» de 228,737,632.
Después de resolver los casos especiales, simplemente se restan las representaciones enteras, se obtiene el valor absoluto y ya se conoce la diferencia entre los números.
Para muchos usos se pueden ignorar los NaN, aunque se deberían habilitar las excepciones de operación ilegal para descubrir cuándo se generan, y también las infinitudes, activando las excepciones de desbordamiento.
Eso deja a los números desnormalizados y a los ceros como los problemas más importantes.
Sí, hay valores especiales en la representación de coma flotante.
Dos valores especiales que se encuentran habitualmente son el infinito positivo y el negativo, representados respectivamente como «inf» y «-inf».
Estos valores aparecen cuando los cálculos producen números que exceden el rango de valores representables.
También existen dos representaciones distintas del cero: el cero positivo y el cero negativo.
La especificación los define como iguales, por lo que normalmente no producen problemas graves, pero su representación binaria es diferente al realizar comparaciones.
En algunos casos, los dos ceros no producen resultados iguales.
Si se tiene cuidado con el tipo de conversión utilizado para la igualdad, este problema se puede evitar, aunque es una trampa sutil que conviene recordar.
Los ciclos con incrementos flotantes
Un ciclo con un número fijo de iteraciones suele ser más seguro que un ciclo que espera que un flotante alcance un límite exacto.
La razón es que el número de iteraciones no depende de que una secuencia de operaciones de coma flotante produzca exactamente un valor concreto.
Cuando el límite del ciclo representa una condición aproximada, se puede utilizar una comparación con tolerancia.
Sin embargo, la tolerancia debe elegirse según la magnitud de los valores, el incremento, el algoritmo y el error esperado.
Si el ciclo necesita detenerse al acercarse a cero, se debe utilizar una tolerancia absoluta o una combinación de tolerancias.
Si el límite es un número distinto de cero y los valores tienen magnitudes comparables, puede utilizarse una tolerancia relativa o una comparación basada en ULP.
En situaciones críticas, también conviene incluir un límite máximo de iteraciones, porque una comparación defectuosa puede convertir un pequeño error numérico en un ciclo que no termina.
La cancelación catastrófica
La cancelación catastrófica ocurre cuando se restan dos números muy próximos y el resultado esperado es pequeño.
La resta elimina los dígitos significativos que ambos números tienen en común y deja una cantidad cuyo error relativo puede ser muy grande.
La trigonometría enseña que cierto resultado debería ser cero.
Pero ese no será necesariamente el resultado obtenido.
Si se realiza una comparación mediante ULP o una epsilon relativa con el valor «correcto» de cero, el resultado puede parecer muy malo y estar lejos de cero.
El cálculo de sin() puede ser muy cercano a la perfección.
El problema no está necesariamente en sin() o fsin, sino en otra parte.
No se está pidiendo a la función seno que calcule sin(pi), sino sin(double(pi)) o sin(float(pi)).
El cálculo diferencial enseña que, dado que la derivada de sin(pi) es negativa uno, para valores de theta suficientemente pequeños se cumple que sin(pi-theta) == theta.
Por tanto, si la función seno es suficientemente precisa, se espera que sin(double(pi)) sea aproximadamente igual a pi-double(pi).
En otras palabras, sin(double(pi)) calcula realmente el error de double(pi).
Por ello, float(pi) + sin(float(pi)) es un valor de pi más preciso que float(pi).
sin(double(pi)) es preciso hasta dieciséis o diecisiete cifras significativas como medida del error de double(pi), y sin(float(pi)) es preciso hasta seis o siete cifras significativas.
sin(float(pi)) calcula realmente pi-float(pi), lo que constituye un ejemplo clásico de cancelación catastrófica.
Los errores se acumulan en las operaciones
En la aritmética de coma flotante, los números se redondean y el orden de las operaciones puede crear diferencias sutiles.
Además, estos errores solo aparecen en una pequeña fracción de los casos, por lo que es fácil que pasen inadvertidos.
Sin embargo, un único error puede propagarse y hacer que un sistema financiero complejo no cuadre completamente.
Las operaciones aritméticas estándar, como la suma, la resta, la multiplicación y la división, pueden crear problemas acumulativos según el orden de las operaciones y según el resultado exacto que se necesite.
El resultado probablemente estará muy cerca de la respuesta, lo que suele ser suficiente para aplicaciones científicas o gráficas, pero los sistemas financieros exigen resultados exactos.
Redondeo y ciclos numéricos
Para minimizar los errores de redondeo, es importante comprender las limitaciones de la aritmética de punto flotante y emplear técnicas como el redondeo, el truncamiento o el uso de precisión extendida cuando sea necesario.
La estandarización de un método de redondeo no es suficiente; también hay que tener cuidado con los esquemas concretos utilizados.
Las reglas de redondeo IEEE-754 definen cinco tipos diferentes de redondeo que deben tenerse explícitamente en cuenta, y muchas implementaciones exigen considerar uno adicional:
- Redondeo hacia cero, o truncamiento.
- Redondeo hacia el infinito positivo, o ceiling.
- Redondeo hacia el infinito negativo, o floor.
- Redondeo al más cercano, con empates hacia el par, conocido como redondeo bancario o gaussiano.
- Redondeo al más cercano, con empates alejándose de cero.
- Redondeo al más cercano, con empates hacia cero.
El último método no forma parte de los cinco métodos de IEEE, pero muchos lenguajes como Ruby lo implementan como una opción adicional.
También existe un comportamiento que es igual para los números positivos, pero diferente para los negativos.
No tener en cuenta estas diferencias puede producir consecuencias desastrosas.
Los errores de redondeo no son anomalías ni fenómenos nuevos: han provocado pérdidas de valor bursátil, cambios en resultados electorales y ataques de misiles incorrectos.
Los errores de redondeo tampoco son exclusivos de los flotantes; en algunos casos, los formatos basados en enteros también pueden necesitar lógica de redondeo cuando se trabaja con diferentes precisiones.
El redondeo depende del lenguaje y del entorno
Los modos de redondeo predeterminados varían según las implementaciones.
Puede resultar tentador confiar en el comportamiento de redondeo predeterminado de la plataforma en lugar de especificarlo explícitamente.
La mayoría de las implementaciones utilizan por defecto el redondeo bancario, pero esto deja de funcionar cuando aparecen casos poco frecuentes en los que los valores predeterminados difieren.
- Ruby: mitad alejándose de cero.
- Python: mitad hacia el par.
- JavaScript: mitad hacia el infinito positivo.
Actualmente es habitual construir aplicaciones web con un frontend en JavaScript y un backend en otro lenguaje, por lo que seguir únicamente el valor predeterminado del lenguaje puede no ser práctico.
Incluso si se utiliza el mismo lenguaje en todo el sistema, el comportamiento puede cambiar entre versiones.
Python cambió el comportamiento predeterminado entre las versiones 2 y 3.
- Python 2: mitad alejándose de cero.
- Python 3: mitad hacia el par.
Ruby también introdujo un cambio en el comportamiento del redondeo durante la versión preliminar de Ruby 2.4.0, que provocó problemas importantes y fue revertido inmediatamente.
Incluso dentro de la misma versión del mismo lenguaje pueden existir diferencias entre funciones.
El método round de Ruby utiliza mitad alejándose de cero, mientras que sprintf de Ruby utiliza mitad hacia el par, porque se basa en las cadenas formateadas de C.
Para resolver estas diferencias, el único método fiable consiste en especificar explícitamente el modo de redondeo en todas las llamadas de función y en todos los entornos.
La representación binaria puede contradecir la intuición decimal
Incluso utilizar el mismo lenguaje, el mismo modo y un comportamiento de redondeo coherente no siempre es suficiente.
El ordenador puede estar calculando correctamente, pero la representación binaria de los flotantes contradice nuestra intuición porque intentamos razonar en decimal.
Por ejemplo, en aritmética decimal, la suma de 0.1 y 0.2 es 0.3.
Sin embargo, debido a los errores de redondeo en la aritmética de coma flotante, el resultado podría ser un número como 0.3004.
En la práctica, el resultado representado puede ser muy cercano a 0.3 sin coincidir exactamente con la representación almacenada del literal 0.3.
Por qué los flotantes no son ideales para dinero
Los números de coma flotante permiten representar valores aproximados con un espacio reducido y un rango enorme.
Una de las principales ventajas de los números de coma flotante es que pueden representar números enormemente grandes a pesar de ocupar muy poco espacio, limitando el número de dígitos significativos permitidos para los exponentes.
Por ejemplo, un flotante de 32 bits puede alcanzar magnitudes cercanas a 3.4 × 10³⁸, mientras que un entero de 32 bits solo alcanza aproximadamente 2.2 × 10⁹.
La diferencia fundamental es que los flotantes consiguen este rango representando valores aproximados, mientras que los tipos enteros representan los números enteros con precisión.
La aproximación es ideal para la computación científica y las aplicaciones de ingeniería, pero complica los cálculos financieros.
Incluso cuando no existe un problema de cantidad limitada de dígitos significativos, la mayoría de los valores monetarios sencillos no pueden almacenarse exactamente mediante flotantes IEEE-754.
Esto sucede porque los números decimales del mundo financiero están en base 10, mientras que los ordenadores trabajan en binario, en base 2.
Para almacenar 2.78 como número de coma flotante, se descompone en potencias de dos hasta obtener la representación más cercana: 2.7799999713897705078125.
El valor está muy cerca, pero claramente no es exactamente igual a 2.78.
Este ejemplo no es excepcional; casi todos los números habituales de las finanzas no pueden representarse con precisión, salvo que sean potencias de dos como los siguientes:
- $0.5 = 2⁻¹
- $16 = 2⁴
- $24.25 = 16 + 8 + 0.25 = 2⁴ + 2³ + 2⁻²
El uso de flotantes IEEE-754 para representar dinero exige, por tanto, soluciones recurrentes como redondear o comprobar si un valor se encuentra dentro de una tolerancia de error.
Es fácil observar cómo esto aumenta la complejidad del código.
Para sistemas monetarios suele ser preferible utilizar tipos de precisión fija, como los enteros.
Usar enteros para cantidades monetarias
El uso de enteros permite representar con precisión todos los importes posibles dentro de un rango de -92,233,720,368,547,758.08 a 92,233,720,368,547,758.07, es decir, más de 92 cuatrillones de dólares.
En los sistemas de movimiento de dinero, la precisión es fundamental.
Una forma habitual consiste en almacenar el importe en las unidades de la fracción monetaria:
12.34 dólares se almacena como el entero 1234.
De esta manera no es necesario preocuparse por el redondeo, salvo cuando se trabaja con cantidades fraccionarias de la subunidad monetaria, como una parte de un centavo.
Junto con el importe también se almacena la moneda para determinar el número de posiciones decimales mediante el estándar ISO 4217.
Los enteros ofrecen varias ventajas adicionales:
- Rendimiento: prácticamente todas las operaciones estándar pueden realizarse en un solo ciclo en las CPU recientes, que pueden ejecutar miles de millones de ciclos por segundo.
- Optimización de hardware: existen muchas optimizaciones de bajo nivel para int64, y estas operaciones se paralelizan muy bien.
- Almacenamiento: en los ordenadores modernos, int64 tiene el tamaño de una sola palabra binaria.
- Compatibilidad: es un tipo primitivo compatible con prácticamente todos los lenguajes de programación y sistemas informáticos.
- Legibilidad: las operaciones aritméticas estándar funcionan directamente y no necesitan importaciones especiales.
Si los enteros de 64 bits no son suficientemente grandes para un caso de uso, la mayoría de los lenguajes de programación modernos también ofrecen soporte para enteros de 128 bits.
Esto debería cubrir casi todos los casos, pero existen otras alternativas que también utilizan una representación fija y precisa.
Alternativas a los números flotantes
Enteros de precisión arbitraria
Si se necesita un rango aún mayor que el permitido por int64, existen tipos enteros capaces de escalar hasta cualquier tamaño.
Normalmente funcionan asignando más bloques de 64 bits según sea necesario para almacenar el número requerido.
- Ruby: Integer, con conversión automática.
- Python: int, con conversión automática.
- Go: big.Int, como tipo de dato explícito.
- JavaScript: BigInt, como tipo de dato explícito.
- Java: BigInteger, como tipo de dato explícito.
- C#: BigInteger, como tipo de dato explícito.
En bases de datos como PostgreSQL existe numeric, donde se puede establecer una precisión fija arbitraria.
BigDecimal
BigDecimal es un tipo de dato de nivel superior que combina la exactitud de los enteros con una precisión variable similar a la de los flotantes, apoyándose en un tamaño variable como BigNumber.
Estos tipos tienen un rango mucho menor que los flotantes, pero compensan esta limitación con exactitud en el almacenamiento.
BigDecimal normalmente se implementa como un tipo híbrido que almacena un entero de tamaño arbitrario junto con otro número que representa la precisión.
- Ruby: BigDecimal.
- Python: decimal.
- Go: big.Float.
- JavaScript: no tiene soporte nativo, pero existen paquetes como js-big-decimal.
- Java: BigDecimal.
- C#: no tiene soporte nativo, pero existen paquetes como ExtendedNumerics.BigDecimal.
El soporte de las bases de datos es variado, pero las bases de datos populares como PostgreSQL admiten enteros de precisión arbitraria mediante numeric.
JavaScript y los enteros
Si se utiliza JavaScript, hay que tener presente que el tipo number no es un entero, sino un número de coma flotante IEEE-754.
Como el lenguaje solo admite flotantes en su tipo numérico principal, los enteros se pueden obtener mediante BigInt o mediante bibliotecas de terceros.
Otra alternativa consiste en utilizar enteros en un lenguaje de backend diferente y dejar que JavaScript se ocupe únicamente de la presentación, que es el enfoque utilizado en algunos sistemas.
Rendimiento de la aritmética de coma flotante
La aritmética de coma flotante en la serie AVR Mega es bastante rápida en GCC si se enlaza la biblioteca libm.a.
Una multiplicación de coma flotante IEEE tarda aproximadamente 125 ciclos y una suma IEEE tarda entre 75 y 275 ciclos.
La variabilidad de los tiempos de suma se debe a la necesidad de realizar desplazamientos extensos de normalización cuando los dos valores de entrada tienen magnitudes muy diferentes o cuando una resta de dos valores casi iguales produce un resultado pequeño.
En el otro extremo, una multiplicación de punto fijo en formato 8:8, con 16 bits y 8 bits de fracción, tarda aproximadamente 16 ciclos y la suma 8:8 tarda dos ciclos.
Se implementaron dos sistemas: uno con mantisa de 16 bits y otro con mantisa de 8 bits.
En conjunto, la versión de 16 bits resulta más útil.
El formato flotante con una mantisa de 16 bits, un exponente de 7 bits y un bit de signo se almacena en el espacio de un entero largo de 32 bits.
Este formato proporciona una aceleración de entre 2.5 y 3 veces en la multiplicación frente a IEEE y una aceleración de entre 1.3 y 4.0 veces en la suma.
La velocidad de multiplicación es de aproximadamente 35 ciclos.
La velocidad de suma es de entre 35 y 106 ciclos.
El formato flotante con una mantisa de 8 bits, un exponente de 7 bits y un bit de signo se almacena en el espacio de un entero sin signo de 16 bits.
La velocidad de multiplicación es de aproximadamente 30 ciclos.
La velocidad de suma es de entre 30 y 80 ciclos.
En estos formatos, el valor de la mantisa se considera una fracción binaria con valores entre 0.5 y 1.0.
El bit de orden superior del significando siempre es uno, salvo cuando el valor real es cero, porque no se permiten números desnormalizados.
Si la suma de los exponentes de entrada es menor que 0x3f, el exponente sufrirá underflow y el producto será cero.
Para sumar, primero se determina cuál entrada es mayor y cuál es menor en valor absoluto, comparando primero los exponentes y después las mantisas si es necesario.
Se determina la diferencia entre los exponentes y se desplaza hacia la derecha la mantisa de la entrada menor según dicha diferencia.
Si los signos de las entradas son iguales, se suman la mantisa mayor y la mantisa desplazada menor.
El resultado debe satisfacer 0.5<sum<2.0.
Si el resultado es mayor que uno, se desplaza la suma de las mantisas un bit hacia la derecha y se incrementa el exponente mayor.
Si los signos de las entradas son diferentes, se resta la mantisa desplazada menor de la mayor para que el resultado sea siempre positivo.
Después se desplaza la mantisa hacia la izquierda hasta que el bit superior quede activado, reduciendo al mismo tiempo el exponente mayor.
Cuándo sí conviene usar flotantes en ciclos
Los flotantes son apropiados cuando se trabaja con mediciones, simulaciones, gráficos, cálculos científicos o de ingeniería en los que un pequeño error relativo es aceptable.
La representación de coma flotante puede afectar a la precisión de las funciones matemáticas debido a los errores de redondeo y a la precisión limitada de los números.
Las operaciones matemáticas comunes, como las funciones trigonométricas, los logaritmos y la exponenciación, pueden introducir pequeños errores en el resultado, especialmente en cálculos extremos o complejos.
En esos casos, un ciclo con flotantes puede utilizar una condición basada en una tolerancia adecuada al problema.
Si los flotantes todavía tienen sentido para el caso de uso, es necesario comprender bien todas sus posibles sutilezas.
También conviene estudiar la estabilidad de los algoritmos, los números de condición y la aritmética de punto flotante IEEE.

Reglas prácticas para ciclos con números flotantes
- No dependas de que una suma repetida alcance exactamente el límite decimal.
- Evita comparar directamente con == cuando el resultado procede de operaciones aproximadas.
- Utiliza una tolerancia absoluta cerca de cero.
- Utiliza una tolerancia relativa cuando compares números distintos de cero con magnitudes semejantes.
- Utiliza una combinación de tolerancias cuando los valores puedan ser cero o no cero.
- Considera una comparación basada en ULP cuando necesites conocer la distancia entre representaciones flotantes.
- Elige la tolerancia según el algoritmo, las entradas y el error esperado.
- Especifica explícitamente el modo de redondeo en todos los entornos.
- Incluye un número máximo de iteraciones cuando exista riesgo de que la condición no llegue a cumplirse.
- Para dinero, almacena las cantidades como enteros en la unidad fraccionaria de la moneda.
Exactitud, precisión y errores: los pilares de una buena medición (Podcast)
La aritmética de coma flotante puede ser extraordinariamente precisa, pero es necesario comprender qué se está calculando realmente.