Coma flotante: IEEE 754, precisión y decimales de C23
Cómo representa C los números reales, por qué las comparaciones ingenuas fallan, cuándo float basta y cuándo no, y los tipos decimales y de anchura fija que C23 incorpora.
La coma flotante no es aritmética de números reales: es una aproximación finita con reglas propias, en la que la suma deja de ser asociativa y dos valores iguales sobre el papel pueden no serlo en memoria. Ignorarlo produce comparaciones que fallan una vez de cada mil y balances contables que no cuadran por un céntimo. C23 no cambia IEEE 754, pero sí te da tipos nuevos para no tener que usarlo donde no toca.
- Leer la representación IEEE 754 y sus valores especiales.
- Cuantificar la precisión con
float.hy comparar sin caer en la igualdad exacta. - Elegir entre
float,doubleylong doublecon criterio numérico, no por costumbre. - Conocer los tipos decimales y de anchura fija que C23 incorpora, y sus límites.
Cómo se representa un número real
Un flotante IEEE 754 se compone de signo, exponente y mantisa. En binary32 —el float habitual— son 1, 8 y 23 bits; en binary64 —el double— son 1, 11 y 52, con un bit implícito adicional en la mantisa de los valores normales. El valor es la mantisa escalada por una potencia de dos, y esa base binaria es la raíz de casi todos los problemas: 0,1 no tiene representación finita en base 2, igual que un tercio no la tiene en base 10.
#include <stdio.h>
int main(void) {
printf("%.20f\n", 0.1); // 0.10000000000000000555...
printf("%d\n", 0.1 + 0.2 == 0.3); // 0: son valores distintos
printf("%a\n", 0.1); // 0x1.999999999999ap-4, la verdad en hexadecimal
}
El especificador %a imprime el flotante en hexadecimal exacto, sin la conversión a decimal que oculta el error. Es la herramienta de diagnóstico que hay que usar cuando un número “no cuadra”.
Además de los valores normales existen los subnormales, que rellenan el hueco alrededor del cero a costa de perder precisión; los infinitos, que resultan de desbordar el exponente o de dividir por cero; y los NaN, resultado de operaciones sin respuesta como cero entre cero. El NaN tiene una propiedad que rompe intuiciones: no es igual a nada, ni siquiera a sí mismo.
#include <math.h>
double x = NAN;
x == x; // false, siempre
isnan(x); // la forma correcta de preguntarlo
isfinite(x); // descarta infinitos y NaN de una vez
Quedan dos rarezas que conviene tener presentes. Hay dos ceros, el positivo y el negativo: comparan iguales con ==, pero al dividir producen infinitos de signo contrario, y solo signbit los distingue. Y el modo de redondeo es estado global del proceso, manipulable desde fenv.h con fesetround: el modo por defecto —al más cercano, con desempate al par— es el que asumen todos los análisis de error habituales, así que cambiarlo altera silenciosamente resultados de código que no escribiste tú.
El estándar no obliga a implementar IEEE 754, solo a declararlo. C23 renombra la macro histórica __STDC_IEC_559__ como __STDC_IEC_60559_BFP__: si está definida, la aritmética binaria sigue la norma, con sus redondeos, sus infinitos y sus NaN. Cualquier plataforma de escritorio o servidor la define; algunos entornos empotrados sin unidad de coma flotante, no.
Precisión, error y comparaciones
float.h cuantifica lo que puedes esperar. FLT_EPSILON y DBL_EPSILON son la distancia del 1,0 al siguiente valor representable: unos 1,19e-7 y 2,22e-16. FLT_DIG y DBL_DIG dan los dígitos decimales significativos garantizados: 6 y 15.
#include <float.h>
#include <stdio.h>
int main(void) {
printf("float : %2d bits de mantisa, eps %g, %d digitos\n",
FLT_MANT_DIG, FLT_EPSILON, FLT_DIG);
printf("double: %2d bits de mantisa, eps %g, %d digitos\n",
DBL_MANT_DIG, DBL_EPSILON, DBL_DIG);
}
De ahí sale la regla operativa: nunca compares flotantes con igualdad exacta, salvo contra constantes que sepas exactamente representables como 0,5 o 2,0. Compara con una tolerancia relativa al tamaño de los operandos, porque un error absoluto aceptable en magnitudes de orden 1 es absurdo en magnitudes de orden mil millones.
#include <float.h>
#include <math.h>
#include <stdbool.h>
bool casi_igual(double a, double b, double tol_rel) {
double d = fabs(a - b);
double escala = fmax(fabs(a), fabs(b));
return d <= tol_rel * escala || d < DBL_EPSILON; // el segundo término cubre el cero
}
Hay dos fenómenos más que conviene nombrar. El primero es la cancelación catastrófica: al restar dos valores muy próximos, los dígitos significativos se anulan y el error relativo que arrastraban se amplifica, a veces hasta dominar el resultado. El segundo es la pérdida de asociatividad: sumar una lista de números en orden distinto da resultados distintos, porque cada suma parcial redondea. Sumar de menor a mayor magnitud, o usar la compensación de Kahan, reduce el error acumulado en órdenes de magnitud.
-ffast-math autoriza al compilador a reasociar operaciones, ignorar NaN e infinitos y sustituir divisiones por recíprocos. Puede acelerar mucho el código numérico y también invalidar en silencio toda comprobación de NaN que hayas escrito, además de romper la reproducibilidad entre compilaciones. Actívalo solo si sabes exactamente qué garantías estás vendiendo y a cambio de qué medición.
float, double y long double
double es el tipo por defecto del lenguaje: los literales sin sufijo son double y las funciones de math.h sin sufijo trabajan en double. Es también la elección correcta salvo que tengas una razón medida para otra cosa.
float
24 bits de mantisa, unos 7 dígitos decimales. Gana cuando el cuello de botella es el ancho de banda de memoria o quieres el doble de carriles SIMD: gráficos, audio, redes neuronales en inferencia. Exige sufijo en literales y funciones: 2.0f, sqrtf.
double
53 bits de mantisa, unos 16 dígitos. El punto dulce entre precisión y velocidad en hardware moderno, y lo que asume por defecto toda la biblioteca estándar. Ante la duda, este.
long double
Extremadamente dependiente del ABI: 80 bits extendidos en x86, igual que double en ARM de 64 bits, cuádruple en algunas plataformas. Precisión extra a cambio de portabilidad nula.
Un detalle que confunde a mucha gente es FLT_EVAL_METHOD: en el x87 histórico las operaciones sobre float se evaluaban internamente con más precisión, de modo que el mismo cálculo daba resultados distintos según si un valor intermedio pasaba o no por memoria. Con SSE2 el problema desapareció en la práctica, pero explica bugs legendarios de la época.
En cambio, fma de math.h es una herramienta que sí conviene conocer: calcula multiplicación y suma con un único redondeo final, mejorando la precisión de productos escalares y evaluaciones polinómicas sin coste extra en hardware que lo soporte.
flowchart TD A[Necesito representar una cantidad] --> B[Es dinero o una magnitud decimal exacta] B -->|si| C[Enteros de centimos o Decimal64 de C23] B -->|no| D[Necesito mas de siete digitos significativos] D -->|si| E[double: la opcion por defecto] D -->|no| F[El cuello de botella es memoria o ancho SIMD] F -->|si| G[float o Float16 si el hardware acompana] F -->|no| E style C fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b style G fill:#f9e2af,color:#11111b
Los tipos nuevos de C23
C23 incorpora al estándar dos familias que antes vivían en especificaciones técnicas aparte. Ambas son opcionales, y se detectan por macro.
Los decimales —_Decimal32, _Decimal64 y _Decimal128, señalados por __STDC_IEC_60559_DFP__— representan los números en base 10. Eso significa que 0,1 es exacto, que las sumas de importes monetarios cuadran y que el redondeo se comporta como espera un contable. Sus literales llevan sufijo df, dd o dl, y printf los imprime con los modificadores %H, %D y %DD.
_Decimal64 precio = 19.99dd;
_Decimal64 iva = 0.21dd;
_Decimal64 total = precio * (1.0dd + iva); // exacto en base 10
Conviene medir antes de entusiasmarse: GCC implementa los decimales en varias plataformas y Clang solo parcialmente, y en casi todo el hardware actual la aritmética decimal se emula por software, con un coste de uno a dos órdenes de magnitud sobre la binaria. Para un sistema contable esa diferencia es irrelevante frente a la corrección; para un núcleo numérico es descalificatoria.
Los tipos de anchura fija —_Float16, _Float32, _Float64 y _Float128, señalados por __STDC_IEC_60559_TYPES__— fijan el formato de intercambio de IEEE 754 con independencia de lo que haga float o double en esa plataforma. _Float16 es el que más tracción tiene hoy: es la media precisión que usan aceleradores y motores de inferencia, donde el ahorro de ancho de banda pesa más que los tres dígitos decimales que ofrece.
La lección profunda de la coma flotante no es la lista de trampas, sino el cambio de mentalidad que exige: todo resultado flotante es un intervalo, no un punto. En cuanto lo aceptas, las preguntas correctas dejan de ser “por qué esto no da exactamente cero” y pasan a ser cuánto error arrastra este cálculo, cómo se propaga a través de mis operaciones y qué tolerancia puede permitirse el dominio del problema. De ahí salen todas las decisiones prácticas: comparar con tolerancia relativa en lugar de igualdad, evitar restar magnitudes próximas, ordenar las sumas, usar fma donde importe, y —la más importante— no usar coma flotante binaria donde el dominio es decimal. El dinero no es un número real: es una cantidad discreta de céntimos con reglas de redondeo legalmente definidas. Represéntalo con enteros o con los _Decimal de C23, nunca con double. Ese único criterio, aplicado a tiempo, evita más incidentes en producción que cualquier optimización numérica que puedas escribir después.
- Imprime
0.1con%.20fy con%a, y explica qué te muestra cada uno. - Comprueba que
0.1 + 0.2 == 0.3es falso y arréglalo con una comparación por tolerancia relativa. - Suma un millón de veces
0.1fen unfloaty compara el resultado con hacerlo endouble. - Provoca cancelación catastrófica restando dos valores muy próximos y mide el error relativo.
- Comprueba si tu compilador define
__STDC_IEC_60559_DFP__y, si lo hace, repite un cálculo de importes con_Decimal64.