Esegui 0.1 + 0.2 in una shell Python e non ottieni 0.3. Ottieni 0.30000000000000004. Prova in JavaScript, Java, Go, C o Rust e trovi la stessa coda di sedici cifre. Ognuno di questi linguaggi fa esattamente quello che impone lo standard hardware. Un numero che richiederebbe infinite cifre binarie viene compresso in 64 bit, e qualcosa deve cedere.
Perché 0.1 non si può memorizzare esattamente in binario
I computer memorizzano i numeri in virgola mobile in base 2. In base 2, la frazione 1/10 ha uno sviluppo periodico, proprio come 1/3 si ripete all’infinito in base 10 (0.3333...). Scritto per intero, 0.1 in binario inizia così:
| |
Nessun numero finito di bit interrompe quella sequenza restituendo esattamente 0.1. Un “double” IEEE 754 a 64 bit ha 1 bit di segno, 11 bit di esponente e 52 bit di mantissa memorizzati, cioè 53 bit di precisione contando l'1 implicito iniziale. Arrotonda lo sviluppo infinito al valore più vicino che può rappresentare: sempre una frazione della forma J / 2^N. Per 0.1, quel valore è:
| |
Come decimale, è 0.1000000000000000055511151231257827021181583404541015625. Python te lo mostra. Ogni snippet di questo articolo usa solo la libreria standard, quindi non serve configurare un ambiente virtuale per seguirlo:
| |
Anche 0.2 porta il suo errore di arrotondamento, leggermente diverso. Somma due valori già imperfetti e gli errori non si annullano: si combinano in un risultato che cade un passo sopra il double più vicino a 0.3. Da qui 0.30000000000000004.
Quali numeri la virgola mobile memorizza davvero in modo esatto
Non tutti i decimali sono condannati. Una frazione binaria termina quando il suo denominatore, ridotto ai minimi termini, è una potenza di due. 0.5 è 1/2. 0.25 è 1/4. 0.125 è 1/8. Questi sono esatti:
| |
I decimali che le persone scrivono davvero, come prezzi e percentuali, non hanno quasi mai un denominatore potenza di due. Questo rende l’errore di 0.1 + 0.2 l’esito normale dell’aritmetica decimale su float binari, e si accumula. Somma 0.1 dieci volte in un linguaggio qualsiasi che usa i double e non arrivi a 1.0:
| |
Dieci addizioni. Basta questo. Nota che la notazione Big O dice quanto velocemente cresce quel ciclo al crescere dell’input, non se il risultato è corretto: la correttezza qui è un controllo separato, che devi fare tu.
Come confrontare i numeri in virgola mobile in sicurezza
Dato che == sui float non è affidabile, confrontali con una tolleranza: verifica che due numeri siano abbastanza vicini, non identici. Ogni linguaggio diffuso ha il suo idioma per farlo.
| Linguaggio | Confronto sicuro |
|---|---|
| Python | math.isclose(a, b) — tolleranza relativa 1e-9 di default |
| JavaScript | Math.abs(a - b) < Number.EPSILON * scala |
| Java | Math.abs(a - b) < 1e-9, oppure BigDecimal per il decimale esatto |
| C# | Math.Abs(a - b) < 1e-9, oppure il tipo nativo decimal |
| Go | math.Abs(a-b) <= tolleranza (nessun helper integrato) |
In Python:
| |
math.isclose scala la tolleranza in base alla grandezza dei numeri (rel_tol * max(abs(a), abs(b))), quindi la stessa chiamata funziona sia su valori piccoli che grandi. Il suo abs_tol di default, però, è 0.0. Se uno dei due valori può essere legittimamente zero o molto vicino a zero, passa un abs_tol esplicito, altrimenti il confronto fallirà su valori che considereresti uguali.
Number.EPSILON di JavaScript (2⁻⁵², circa 2.22e-16) è la distanza tra 1 e il successivo double rappresentabile, quindi di per sé è una soglia valida solo per numeri vicini a 1. Scalala per qualsiasi altra grandezza:
| |
Un’asserzione come result == 0.3 in un test è un bug in attesa di un input, una piattaforma o un compilatore diverso che lo faccia emergere. Che sia un test unitario o che stia più in alto nella piramide dei test, sostituisci il confronto esatto con un controllo di tolleranza.
Quando la virgola mobile è lo strumento sbagliato: soldi e conteggi
Una tolleranza risolve il confronto, non la memorizzazione. Se stai sommando prezzi o mantieni un saldo progressivo, l’errore di arrotondamento è il problema in sé, e la soluzione è smettere di usare float/double per quel valore.
La versione più semplice: memorizza i soldi come numero intero dell’unità più piccola. $19,99 è l’intero 1999 centesimi, non il float 19.99. Addizione e sottrazione tra interi non hanno errore di arrotondamento. Converti in stringa decimale solo quando mostri il valore.
Quando ti servono vere frazioni decimali con regole di arrotondamento esatte (una contabilità, un calcolo fiscale, qualsiasi cosa che deve riconciliarsi al centesimo), usa un tipo decimale:
| |
Java ha BigDecimal per lo stesso scopo; C# ha un tipo decimal nativo a 128 bit con circa 28-29 cifre decimali significative. Tutti e tre memorizzano le cifre in base 10, quindi le frazioni decimali che le persone scrivono vengono memorizzate senza arrotondamento. Lo paghi in velocità e verbosità. I tipi decimali sono più lenti dei float nativi e richiedono una modalità di arrotondamento esplicita per la divisione. Ne vale la pena per il saldo di una banca. Un pessimo scambio per un ciclo numerico intensivo.
Quando l’imprecisione della virgola mobile non conta
Trasformazioni grafiche, simulazioni fisiche, pesi di modelli di machine learning ed elaborazione audio girano sui float di proposito. Un errore relativo intorno a 1e-16 è ben al di sotto di quanto quei domini possano notare, e l’hardware nativo per i float è molto più rapido dell’aritmetica decimale.
Il punto decimale non è la domanda giusta. Quello che conta è se stai contando qualcosa di discreto ed esatto, come i soldi o il risultato atteso di un test, oppure se stai misurando una quantità continua che era già approssimata in partenza, come la posizione di un pixel o la lettura di un sensore. I valori contati hanno bisogno di interi o di un tipo decimale. I valori misurati sono ciò per cui la virgola mobile è stata costruita, e spostarli su Decimal li rende più lenti senza renderli davvero più corretti.
La prossima volta che vedi un campo saldo dichiarato float o un test che asserisce result == 0.3, decidi di che tipo di numero si tratta e correggi il tipo di memorizzazione o il confronto in quella revisione, prima che un centesimo mancante si presenti in produzione.