Números flotantes: curiosidades y utilidades

¡Compártelo!
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

Evaluar si un número flotante (float o double) es igual a sí mismo (hacer x==x) puede parecer una perogrullada, pero resulta ser un inesperado y útil truco para los que necesitamos implementar aplicaciones de cálculo científico robustas.

El artículo de hoy nos llevará desde la teoría básica hasta algunos entresijos sucios de los números flotantes, así que espero que lo disfrutes sin importar la experiencia que tengas en programación.

1. La teoría de libro

Los ordenadores representan todos los números como ristras de ceros y unos, representaciones en código binario de números enteros positivos: 0,1,2,3,4,…

Desde los inicios de la era digital en el último tercio del siglo XX se hizo clara la necesidad de manejar otro tipo de números: ¡los que tienen decimales! 

Existe una solución obvia para inventarnos decimales en una máquina digital que sólo entiende de números enteros: asumir que todos los números tienen un número predeterminado de decimales. Así, si asumimos 2 decimales, 340 y 510 pueden ser 3,40 y 5,10 respectivamente. Esta técnica es la llamada de coma fija (fixed point). Su limitación obvia: el número 0,0001 no existe, y se truncaría a cero, por lo que seguiría siendo cero aunque lo multiplicásemos por 10000. Muy mal, eso es un error normalmente inaceptable.

La alternativa es el mecanismo de coma flotante (floating point), adoptado universalmente en cualquier procesador desde hace décadas. En este método, la ristra de ceros y unos que representa un número decimal se divide en tres secciones: un signo s (0 o 1 para positivo o negativo), un exponente e , un número fijo que se resta a este exponente para permitir números positivos y negativos (un bias) y una parte fraccionaria de la mantisa f, de forma que el número a representar queda (−1)s × 1,f × 2e-bias. La obvia mejora con respecto al punto fijo es el enorme rango dinámico que nos permite la exponencial.

A nivel «físico», de ceros y unos, cada fabricante de microprocesadores puede inventar su propio formato de número flotante, con un número arbitrario de bits para cada parte e y f. Para evitar el caos y asegurar la intercambiabilidad de datos entre distintas máquinas, hace casi 30 años que la IEEE normalizó una serie de formatos, los famosos IEEE 754. Dentro de esos formatos, el (probablemente) más usado en el mundo sea la versión de 64 bits, que tiene esta estructura:

Estructura del formato binario double (o binary64) (créditos)

Cualquier programador de C, C++, MATLAB o Java seguro que reconoce ese formato de número flotante de 64 bits bajo el nombre de double. Dentro del estándar IEEE también encontramos a su hermano pequeño de 32 bits, el tipo float, también muy utilizado, así como al ampliamente desconocido long double, que algunos compiladores se pasan directamente por el forro, supongo que siguiendo alguna una gran política de un jefe visionario. Sí, Microsoft hace algunas joyas pero otras veces me desespera…

En la siguiente tabla resumo los valores útiles que te ayudarán a decidir qué tipo necesitas dependiendo de la precisión requerida:

float
double
long double (teórico)
long double
(en la práctica)
Tamaño 32 bits / 4 bytes 64 bits / 8 bytes 128 bits / 16 bytes 80 bits
Dígitos decimales
 significativos
~7 ~16 ~34 ~19
Mínimo y máximo
 valor (normalizado)
[1.17549e-038,
3.40282e+038]
[2.22507e-308,
1.79769e+308]
[3.3621e-4932,
1.18973e+4932]

[3.3621e-4932,
1.18973e+4932]
Epsilon 1.19209e-007 2.22045e-016 1.0842e-19 1.0842e-19

Por supuesto, si necesitas estas constantes en algún programa ni se te ocurra aprenderlas de memoria o copiarlas: para eso tienes la template std::numeric_limits<>

2. Los números rarunos

Hasta aquí lo que seguro todo buen programador conoce por mucho empeño que haya puesto en su vida a no querer saber cómo funcionan los números flotantes. Pero ahora la cosa se complica, porque entramos a distinguir entre números «normales», «raros», y «especiales».

Se llama número normalizado a todo aquel cuya parte exponencial no sea ni todo ceros ni todo unos. Repito por conveniencia la fórmula de los números flotantes para darle una pensada a qué implica:

Número flotante = (−1)s × 1,f × 2e-bias

La parte fraccionaria del número (o «mantisa») va precedido por un primer dígito «1» que es implícito: se asume que siempre está ahí. Es como si en lugar de representar la mantisa con 52 bits tuvieramos 53 bits efectivos. Así, el máximo número positivo que podemos representar con un tipo double normalizado será:

Signo (1bit) Exponente (11bits) Fracción (1+52bits)
0 111 1111 1110 1,1111 1111 …  1111 1111

Fíjate que el último bit del exponente es cero, ya que si todos los bits fueran ceros o unos no sería un número normalizado. Es de aquí de donde viene el valor máximo de la tabla de arriba:

(−1)0 × 1,1111…1111 × 2111…110-bias=(2-2-52)×21023=1.79769e+308

usando el bias de 1023 que corresponde a un double (para un float sería 127).

Luego tenemos los números denormalizados (o según el último estándar, «subnormal numbers«). Estos son precisamente los que tienen todo el exponente a ceros o unos, y sólo en este caso, se asume que el primer dígito implícito es un 0 en lugar de un 1. Es decir, se pierde uno o varios bits de precisión al tener ceros por la izquierda, pero a cambio podemos representar números más pequeños.

En total, contando con ambos tipos de números, normalizados y denormalizados, podemos representar estos intervalos de números:


Denormalizados 
(números pequeños)
Normalizados Valor decimal
float de ± 2-149 a (1-2-23)×2-126 de ± 2-126 a (2-2-23)×2127 de ± ~10-44.85 a ~1038.53
double de ± 2-1074 a (1-2-52)×2-1022 de ± 2-1022 a (2-2-52)×21023 de ± ~10-323.3 a ~10308.3

Fíjate cómo hay continuidad entre el mayor número denormalizado y el mínimo normalizado, ya que el hueco entre ellos es simplemente del tamaño que permite la resolución discreta inherente a los números digitales.

Aún así, siguen existiendo tres segmentos de la línea de números reales, y un punto, para los que no tenemos representación. Son:

  1. Números negativos menores de -2-149 (negative underflow).
  2. Números positivos menores de 2-149 (positive underflow).
  3. El número cero. 

Efectivamente: no existe una forma única de representar el número cero. Como número normalizado no puede existir porque se asume que el número implícitamente empieza por un dígito 1. Lo que se hace en la práctica es que existen dos números cero:

  • Un cero positivo (0): como número denormalizado, con todos los bits del exponente y la mantisa a cero, y el bit de signo positivo.
  • Un cero negativo (-0): idem, pero con bit de signo negativo.

Exceptuando esos detalles, un programador normalmente ni se entera de que un resultado es normalizado o denormalizado. Y eso es bueno, ya que lo que queremos es hacer cuentas, sin importar qué bits hay por ahí abajo.

Pero a veces aparecen un tipo especial de números denormalizados, los valores especiales que sí que nos pueden afectar y mucho. Estos valores son:

  • Infinitos (Inf): Existen con signo positivo y negativo, y se representan con un exponente de todo unos y una mantisa de todo ceros. Este valor útil para darse cuenta de cuando una operación da un resultado más allá del límite máximo.
  • Not a Number (NaN): Se representan con un exponente de todo unos y un patrón concreto de bits en la mantisa. Existen dos tipos de NaN:
    • QNaN (Quiet NaN): Ocurren como resultado de operaciones matemáticas no bien definidas (por ejemplo, logaritmo de un número negativo). Se puede operar con estos números normalmente, pero el resultado de cualquier cálculo en que uno de los operandos sea un NaN resultará en un NaN, propagando la indeterminación
    • SNaN (Signaling NaN): Lanzará una excepción si se emplea en cualquier cálculo. Se pueden usar para detectar el uso de variables no inicializadas correctamente.

El estándar IEEE define claramente qué operaciones dan por resultado qué valores especiales, así como qué pasa al operar con algunos valores especiales:

Operación Resultado
n ÷ ±Inf 0
±Inf × ±Inf ±Inf
±valor_no_cero ÷ 0 ±Inf
Inf + Inf Inf
±0 ÷ ±0 NaN
Inf – Inf NaN
±Inf ÷ ±Inf NaN
±Inf × 0 NaN

¿Cómo detectar qué nos ha salido uno de estos valores especial?

La forma más curiosa que he encontrado la propone John D. Cook y sirve para detectar cuándo un número no es un NotANumber (NaN)… es decir, cuando un número es realmente un número:

bool esNumero(const double x)
{
return (x==x);
}

El truco, que funciona en Windows, Mac y GNU/Linux, está en que cualquier valor es siempre igual a sí mismo… excepto los NaN. ¿Curioso, verdad? Lo bueno de esta solución es que se ejecuta en hardware, por lo que es muy rápida.

Como alternativa más lenta, pero que permite averiguar de qué tipo exacto es un número (normalizado o no, etc), se pueden emplear las funciones _fpclass() en Visual Studio o fpclassify() en GCC.

Una de mis viñetas de XKCD favoritas de todos los tiempos: GOTO

3. El mejor consejo para números flotantes

La lección más importante es ésta:

¡No uses números flotantes si puedes evitarlos!

Si puedes ingeniártelas para operar con números enteros, ni te lo pienses: todo va un mínimo de diez veces más rápido con enteros. Es así de sencillo.

4. Trucos «sucios»

Pero si te ves obligado a usarlos y quieres exprimir al máximo la eficiencia, tendrás que primero intentar vectorizar tus operaciones usando SSE2,SSE3,CUDA, etc.

Aunque antes de llegar a esas técnicas, también existen webs repletas de trucos para los que estén dispuestos a pringarse los dedos con los ceros y los unos.

Dejo los enlaces abajo para los valientes, pero como muestra os copio un ejemplo de cómo averiguar si un número float es mayor o menor que cero usando la ALU de enteros, mucho más rápida que la unidad de números flotantes:

#define FasI(f)  (*1)int *) &(f)
#define FasUI(f) (*2)unsigned int *) &(f)

#define lt0(f) (FasUI(f) > 0x80000000U)
#define le0(f) (FasI(f) <= 0)
#define gt0(f) (FasI(f) > 0)
#define ge0(f) (FasUI(f) <= 0x80000000U)

Mucho más para leer (en inglés):


¡Compártelo!
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  

Referencias   [ + ]

1. int *) &(f
2. unsigned int *) &(f
Etiquetado con: ,