Vai al contenuto
Topos Uranos

Riepilogo

I numeri con decimali di un programma non sono i numeri reali della matematica: sono un sottoinsieme finito di razionali il cui denominatore è una potenza di due, e ogni valore che non appartiene a quel sottoinsieme viene sostituito dal più vicino che vi appartiene. Questa lezione studia come si memorizza un numero in virgola mobile in 64 bit (segno, esponente e mantissa), perché 0.10.1 non ha una rappresentazione esatta e quale valore ne prende il posto, quante cifre sono sicure in float e in double, come si accumulano gli errori di arrotondamento, perché due reali non si confrontano con == e come si confrontano con una tolleranza giustificata, e che cosa sono l’infinito e il valore non numerico che il formato riserva ai risultati che non ci stanno o non esistono.

Obiettivi di apprendimento

  1. Spiegare perché 0.10.1 non ha una rappresentazione binaria esatta e quale valore viene memorizzato al suo posto.
  2. Prevedere l’effetto dell’arrotondamento in somme ripetute e nella somma di numeri di grandezze molto diverse.
  3. Confrontare numeri reali con una tolleranza adeguata invece di ==, giustificando la scelta della tolleranza.
  4. Scegliere tra float e double secondo la precisione richiesta, e riconoscere l’infinito e il valore non numerico.

Notazione scientifica in base 2

La lezione Gli interi e la loro rappresentazione ha mostrato che un intero di nn bit rappresenta esattamente 2n2^n valori consecutivi. Ora, i numeri con decimali pongono una difficoltà diversa: tra due reali qualsiasi ce ne sono infiniti altri, e nessun numero finito di bit può distinguerli tutti. La soluzione che adottano tutti i computer attuali è la stessa che la scienza usa per scrivere grandezze molto grandi o molto piccole: la notazione scientifica. In base 10, la distanza media della Terra dal Sole si scrive 1.496×10111.496 \times 10^{11} metri; il numero ha un segno, poche cifre significative (la mantissa, 1.4961.496) e una potenza della base (l’esponente, 1111), che dice dove va la virgola. Se è così, con un numero fisso di cifre si coprono grandezze di ordini molto diversi, al prezzo di conservare solo le prime cifre di ciascuna.

Il computer fa lo stesso in base 2. Ogni numero diverso da zero si scrive in un unico modo come

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}

dove ss è il segno (0 per positivo, 1 per negativo), 1.m1m2…1.m_1 m_2 \ldots è la mantissa scritta in binario, la cui prima cifra è sempre 1, ed ee è l’esponente. Che la prima cifra sia sempre 1 ha una conseguenza pratica: non occorre memorizzarla, e il bit risparmiato va a vantaggio della precisione. Il nome virgola mobile descrive proprio questa forma: la virgola non occupa un posto fisso tra le cifre, ma «si muove» secondo l’esponente.

Il tipo double di C++ usa, in tutti i compilatori attuali, il formato binario a doppia precisione normalizzato a livello internazionale, di 64 bit: 1 bit per il segno, 11 bit per l’esponente e 52 bit per la mantissa, senza la cifra iniziale. L’esponente si memorizza traslato: il campo di 11 bit contiene e+1023e + 1023, così che memorizza valori senza segno da 1 a 2046 per esponenti da −1022-1022 a 10231023 (i valori 0 e 2047 del campo sono riservati allo zero, ai numeri molto piccoli, all’infinito e al valore non numerico, che si vedranno alla fine della lezione). Il tipo float usa il formato a precisione singola, di 32 bit: 1 di segno, 8 di esponente e 23 di mantissa.

I bit di un double si possono vedere. Il programma seguente usa std::bit_cast, dell’header <bit>, che reinterpreta i 64 bit del numero come un intero senza segno di 64 bit, senza cambiarne nessuno; qui lo si usa senza ulteriori spiegazioni, come uno strumento per guardare la memoria. La specifica {:064b} scrive quell’intero in binario con 64 cifre, come nella lezione precedente, e {:016X} in esadecimale con 16.

Programma in C++
#include <bit>#include <cstdint>#include <print>​int main(){    const double tenth{0.1};    // I 64 bit del numero, letti come un intero senza segno.    const std::uint64_t bits{std::bit_cast<std::uint64_t>(tenth)};    std::println("{:064b}", bits);    std::println("{:016X}", bits);}
Output
00111111101110011001100110011001100110011001100110011001100110103FB999999999999A
EsempioI 64 bit di 0.1

La figura raggruppa i 64 bit scritti dal programma e li legge campo per campo.

Dimostrazione

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

    I 64 bit, in esadecimale 3FB999999999999A. Si leggono da sinistra a destra: 1 bit di segno, 11 di esponente e 52 di mantissa.

  2. s=0s = 0

    Il segno è 0: il numero è positivo.

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

    Il campo dell’esponente, 0111111101101111111011 in binario, vale 1019; gli si sottrae la traslazione, 1023, e l’esponente è −4-4.

  4. m≈1.6000000000000000888m \approx 1.6000000000000000888

    La mantissa, con l’1 iniziale che non si memorizza, è 1.1001100110011…10101.1001100110011\ldots1010 in binario: il gruppo 10011001 si ripete e l’ultimo gruppo, 10101010, è arrotondato per eccesso. Il suo valore è circa 1.60000000000000008881.6000000000000000888.

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

    Il numero memorizzato è +1.6000000000000000888×2−4+1.6000000000000000888 \times 2^{-4}, cioè 0.1000000000000000055511…0.1000000000000000055511\ldots: molto vicino a 0.10.1, ma non 0.10.1.