Saltar al contenido
Topos Uranos

Resumen

Los números con decimales de un programa no son los números reales de la matemática: son un subconjunto finito de racionales cuyo denominador es una potencia de dos, y todo valor que no pertenece a ese subconjunto se reemplaza por el más cercano que sí pertenece. Esta clase estudia cómo se guarda un número de coma flotante en 64 bits (signo, exponente y mantisa), por qué 0.10.1 no tiene representación exacta y qué valor ocupa su lugar, cuántas cifras son seguras en float y en double, cómo se acumulan los errores de redondeo, por qué dos reales no se comparan con == y cómo se comparan con una tolerancia justificada, y qué son el infinito y el valor no numérico que el formato reserva para los resultados que no caben o no existen.

Objetivos de aprendizaje

  1. Explicar por qué 0.10.1 no tiene representación binaria exacta y qué valor se guarda en su lugar.
  2. Predecir el efecto del redondeo en sumas repetidas y en la suma de números de magnitudes muy distintas.
  3. Comparar números reales con una tolerancia adecuada en lugar de ==, justificando la elección de la tolerancia.
  4. Elegir entre float y double según la precisión requerida, y reconocer el infinito y el valor no numérico.

Notación científica en base 2

La clase Los enteros y su representación mostró que un entero de nn bits representa exactamente 2n2^n valores consecutivos. Ahora bien, los números con decimales plantean una dificultad distinta: entre dos reales cualesquiera hay infinitos otros, y ningún número finito de bits puede distinguirlos a todos. La solución que adoptan todos los computadores actuales es la misma que usa la ciencia para escribir magnitudes muy grandes o muy pequeñas: la notación científica. En base 10, la distancia media de la Tierra al Sol se escribe 1.496×10111.496 \times 10^{11} metros; el número tiene un signo, unas pocas cifras significativas (la mantisa, 1.4961.496) y una potencia de la base (el exponente, 1111), que dice dónde va la coma. Si esto es así, con un número fijo de cifras se cubren magnitudes de órdenes muy distintos, al precio de guardar solo las primeras cifras de cada una.

El computador hace lo mismo en base 2. Todo número distinto de cero se escribe de una única manera como

x=(−1)s×1.m1m2m3…m52 (2)×2ex = (-1)^{s} \times 1.m_1 m_2 m_3 \ldots m_{52} \, {}_{(2)} \times 2^{e}

donde ss es el signo (0 para positivo, 1 para negativo), 1.m1m2…1.m_1 m_2 \ldots es la mantisa escrita en binario, cuya primera cifra es siempre 1, y ee es el exponente. Que la primera cifra sea siempre 1 tiene una consecuencia práctica: no hace falta guardarla, y el bit que se ahorra se gana para la precisión. El nombre coma flotante describe precisamente esta forma: la coma no ocupa un lugar fijo entre las cifras, sino que «flota» según el exponente.

El tipo double de C++ usa en todos los compiladores vigentes el formato binario de doble precisión normalizado internacionalmente, de 64 bits: 1 bit para el signo, 11 bits para el exponente y 52 bits para la mantisa, sin la cifra inicial. El exponente se guarda desplazado: el campo de 11 bits contiene e+1023e + 1023, de modo que guarda valores sin signo de 1 a 2046 para exponentes de −1022-1022 a 10231023 (los valores 0 y 2047 del campo se reservan para el cero, los números muy pequeños, el infinito y el valor no numérico, que se verán al final de la clase). El tipo float usa el formato de precisión simple, de 32 bits: 1 de signo, 8 de exponente y 23 de mantisa.

Los bits de un double pueden verse. El programa siguiente usa std::bit_cast, de la cabecera <bit>, que reinterpreta los 64 bits del número como un entero sin signo de 64 bits, sin cambiar ninguno; aquí se usa sin explicarlo más, como un instrumento para mirar la memoria. El formato {:064b} escribe ese entero en binario con 64 cifras, como en la clase anterior, y {:016X} en hexadecimal con 16.

Programa en C++
#include <bit>#include <cstdint>#include <print>​int main(){    const double tenth{0.1};    // Los 64 bits del número, leídos como un entero sin signo.    const std::uint64_t bits{std::bit_cast<std::uint64_t>(tenth)};    std::println("{:064b}", bits);    std::println("{:016X}", bits);}
Salida
00111111101110011001100110011001100110011001100110011001100110103FB999999999999A
EjemploLos 64 bits de 0.1

La figura agrupa los 64 bits que escribió el programa y los lee campo por campo.

Demostración

  1. 1+11+52=641 + 11 + 52 = 64

    Los 64 bits, en hexadecimal 3FB999999999999A. Se leen de izquierda a derecha: 1 bit de signo, 11 de exponente y 52 de mantisa.

  2. s=0s = 0

    El signo es 0: el número es positivo.

  3. e=1019−1023=−4e = 1019 - 1023 = -4

    El campo del exponente, 0111111101101111111011 en binario, vale 1019; se le resta el desplazamiento, 1023, y el exponente es −4-4.

  4. m≈1.6000000000000000888m \approx 1.6000000000000000888

    La mantisa, con el 1 inicial que no se guarda, es 1.1001100110011…10101.1001100110011\ldots1010 en binario: el grupo 10011001 se repite y el último grupo, 10101010, está redondeado hacia arriba. Su valor es aproximadamente 1.60000000000000008881.6000000000000000888.

  5. x=0.1000000000000000055511…≠0.1x = 0.1000000000000000055511\ldots \neq 0.1

    El número guardado es +1.6000000000000000888×2−4+1.6000000000000000888 \times 2^{-4}, es decir, 0.1000000000000000055511…0.1000000000000000055511\ldots: muy cerca de 0.10.1, pero no 0.10.1.