Representación IEEE 754 y aritmética de punto flotante

Cuando trabajas con tipos float o double en C, no estás manejando números reales, sino aproximaciones binarias de ellos. El estándar IEEE 754 [disponible desde C89] define cómo se codifican estos valores en la memoria mediante tres componentes: un bit de signo, un exponente con un sesgo (bias) para permitir valores negativos, y una mantisa (o significando) que representa la parte fraccionaria.

Esta arquitectura permite que una sola estructura de bits represente un rango dinámico inmenso, desde valores infinitesimales hasta números astronómicos. Sin embargo, este ahorro de espacio tiene un costo: la precisión es relativa. Cuanto más grande es el número, mayor es la distancia entre un valor y el siguiente representable. Por eso, al calcular 0.1 + 0.2, el resultado no es exactamente 0.3 en el motor de la CPU; los errores de redondeo se acumulan porque la mayoría de las potencias de 10 no tienen una representación exacta en base 2.

Debes usar el punto flotante para cualquier cálculo que requiera continuidad, pero bajo una regla de oro: nunca uses el operador de igualdad == para comparar resultados de operaciones aritméticas. Si lo haces, tu lógica fallará de forma intermitente y difícil de depurar debido a la acumulación de errores de precisión. Si ignoras esto, tus condiciones de control de flujo (if, while) se comportarán de manera errática cuando los bits de la mantisa no coincidan exactamente.

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdint.h>
#include <math.h>
#include <string.h>
#include <float.h>

// Función para inspeccionar la representación bit a bit de un float
void imprimir_bits_float(float f) {
    uint32_t bits;
    // Usamos memcpy para evitar violar las reglas de aliasing (strict aliasing)
    // Al tratar un float como un entero, evitamos comportamiento indefinido.
    memcpy(&bits, &f, sizeof(uint32_t));
    printf("Bits: 0x%08X (Signo: %u, Exp: %u, Mantisa: 0x%06X)\n", 
           bits, (bits >> 31) & 1, (bits >> 23) & 0xFF, bits & 0x7FFFFF);
}

int main(void) {
    // 1. El problema de la precisión: 0.1 + 0.2 != 0.3
    float a = 0.1f;
    float b = 0.2f;
    float suma = a + b;
    float objetivo = 0.3f;

    printf("--- Prueba de precisión ---\n");
    printf("0.1 + 0.2 = %f\n", suma);
    printf("¿Es 0.1 + 0.2 == 0.3? %s\n", (suma == objetivo) ? "SÍ" : "NO");

    // 2. La solución: Comparación con epsilon relativo
    // No comparamos la diferencia absoluta, sino cuánto representa ese error 
    // respecto a la magnitud de los números.
    float diferencia = fabsf(suma - objetivo);
    float epsilon_relativo = FLT_EPSILON * fabsf(objetivo);
    
    if (diferencia <= epsilon_relativo) {
        printf("Comparación con epsilon: ¡SÍ son iguales!\n");
    } else {
        printf("Comparación con epsilon: NO son iguales!\n");
    }

    // 3. Valores especiales: NaN e Infinito
    printf("\n--- Valores especiales ---\n");
    float inf = INFINITY; // Definido en <math.h> [C99]
    float nan_val = NAN;  // Definido en <math.h> [C99]

    printf("Infinito: %f | isinf: %s\n", inf, isinf(inf) ? "true" : "false");
    printf("NaN: %f | isnan: %s\n", nan_val, isnan(nan_val) ? "true" : "false");
    printf("NaN es digno de confianza? isfinite: %s\n", isfinite(nan_val) ? "true" : "false");

    // 4. Inspección de bits de un valor conocido
    // El número 1.0 en float: Signo 0, Exponente 127 (01111111), Mantisa 0
    printf("\n--- Inspección de bits (1.0f) ---\n");
    imprimir_bits_float(1.0f);

    return 0;
}

Análisis del código

En la función imprimir_bits_float, es crucial el uso de memcpy. Un error común de principiante es intentar castear un puntero de float* a uint32_t* para leer sus bits; esto viola las reglas de strict aliasing de la máquina abstracta de C y puede hacer que el optimizador del compilador elimine código que considere “inútil”. memcpy es la forma segura de realizar type punning sin introducir comportamiento indefinido.

En la prueba de precisión, observamos que suma == objetivo devuelve false. Esto sucede porque 0.1 en binario es una fracción periódica infinita, al igual que 0.2. El procesador debe truncar esos bits en algún momento, y ese truncamiento en a y b no produce exactamente el mismo bit final que el valor literal 0.3. Para solucionar esto, utilizamos FLT_EPSILON, que representa la diferencia entre 1.0 y el siguiente valor representable. Al multiplicar el epsilon por el valor de referencia (fabsf(objetivo)), escalamos el margen de error a la magnitud del número, permitiendo una comparación robusta.

Para los valores especiales, isinf() y isnan() de <math.h> [C99] son esenciales. Un NaN (Not a Number) es un estado donde la operación es matemáticamente indefinida (como 0.0 / 0.0). Es importante recordar que NaN != NaN según la lógica estándar de IEEE 754; la única forma de detectarlo de forma segura es mediante isnan().

El error frecuente

El error más peligroso es el uso de la igualdad directa para controlar la lógica de un programa:

// ERROR CRÍTICO
float x = calcular_presicion_delgada();
if (x == 0.0f) { 
    // Este bloque puede NUNCA ejecutarse, incluso si x es 0.00000000000000001
    ejecutar_logica_critica();
}

Debido a que los errores de redondeo pueden hacer que un valor que debería ser 0.0 sea en realidad un número extremadamente pequeño (pero distinto de cero), la condición fallará. Si necesitas verificar si un número es “casi cero”, utiliza fabs(x) < EPSILON_SOLIDO. Valgrind y AddressSanitizer no detectarán esto, ya que es un error de lógica matemática, no un error de gestión de memoria. Sin embargo, si usas una arquitectura con soporte para números subnormales (denormalizados), la precisión se degrada drásticamente cerca del cero, lo que hace que estas comparaciones sean aún más delicadas.

107

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio