Execute 0.1 + 0.2 em um shell Python e você não recebe 0.3. Recebe 0.30000000000000004. Tente em JavaScript, Java, Go, C ou Rust e aparece a mesma cauda de dezesseis dígitos. Cada uma dessas linguagens faz exatamente o que o padrão de hardware determina. Um número que precisaria de infinitos dígitos binários é comprimido em 64 bits, e algo precisa ser descartado.
Por que 0.1 não pode ser guardado de forma exata em binário
Computadores guardam números de ponto flutuante na base 2. Na base 2, a fração 1/10 tem uma expansão periódica, do mesmo jeito que 1/3 se repete para sempre na base 10 (0.3333...). Escrito por completo, 0.1 em binário começa assim:
| |
Nenhuma quantidade finita de bits consegue truncar essa sequência e ainda devolver exatamente 0.1. Um “double” IEEE 754 de 64 bits (1 bit de sinal, 11 bits de expoente, 52 bits de mantissa guardados, 53 bits de precisão contando o 1 implícito inicial) arredonda essa expansão infinita para o valor mais próximo que consegue representar, que é sempre uma fração da forma J / 2^N. Para 0.1, esse valor é:
| |
Como decimal, isso é 0.1000000000000000055511151231257827021181583404541015625. O Python mostra isso para você. Cada trecho de código deste artigo usa só a biblioteca padrão, então não é preciso montar um ambiente virtual para acompanhar:
| |
0.2 carrega seu próprio erro de arredondamento, um pouco diferente. Some os dois valores já imperfeitos e os erros não se cancelam: eles se combinam em um resultado um passo acima do double mais próximo de 0.3. Daí 0.30000000000000004.
Quais números o ponto flutuante guarda de forma realmente exata
Nem todo decimal está condenado. Uma fração binária termina quando seu denominador, reduzido ao mínimo, é uma potência de dois. 0.5 é 1/2. 0.25 é 1/4. 0.125 é 1/8. Esses são exatos:
| |
Os decimais que as pessoas realmente digitam, como preços e porcentagens, quase nunca têm um denominador que seja potência de dois. Isso torna o erro de 0.1 + 0.2 o resultado normal da aritmética decimal sobre floats binários, e ele se acumula. Some 0.1 dez vezes em qualquer linguagem que use doubles e você não chega a 1.0:
| |
Dez somas. É só isso que leva. Note que a notação Big O diz o quão rápido esse loop cresce com o tamanho da entrada, nada sobre se o resultado está certo: a correção aqui é uma verificação separada que você mesmo precisa fazer.
Como comparar números de ponto flutuante com segurança
Já que == em floats não é confiável, compare com uma tolerância: verifique se dois números estão perto o suficiente, não se são idênticos. Cada linguagem popular tem seu jeito de fazer isso.
| Linguagem | Comparação segura |
|---|---|
| Python | math.isclose(a, b) — tolerância relativa de 1e-9 por padrão |
| JavaScript | Math.abs(a - b) < Number.EPSILON * escala |
| Java | Math.abs(a - b) < 1e-9, ou BigDecimal para o decimal exato |
| C# | Math.Abs(a - b) < 1e-9, ou o tipo nativo decimal |
| Go | math.Abs(a-b) <= tolerância (sem helper nativo) |
Em Python:
| |
math.isclose ajusta a tolerância ao tamanho dos números (rel_tol * max(abs(a), abs(b))), então a mesma chamada funciona tanto em magnitudes pequenas quanto grandes. O abs_tol padrão dela, porém, é 0.0. Se algum dos dois valores puder legitimamente ser zero ou muito próximo disso, passe um abs_tol explícito, ou a comparação vai falhar em valores que você consideraria iguais.
O Number.EPSILON do JavaScript (2⁻⁵², cerca de 2.22e-16) é a distância entre 1 e o próximo double representável, então, isoladamente, só vale como limite para números perto de 1. Ajuste a escala para qualquer outra magnitude:
| |
Uma asserção como result == 0.3 em um teste é um bug esperando outra entrada, plataforma ou compilador para aparecer. Seja um teste unitário ou algo mais alto na pirâmide de testes, troque a comparação exata por uma verificação com tolerância.
Quando ponto flutuante é a ferramenta errada: dinheiro e contagens
Uma tolerância resolve a comparação. Não resolve o armazenamento. Se você está somando preços ou mantendo um saldo acumulado, o erro de arredondamento é o problema em si, e a solução é parar de usar float/double para esse valor.
A versão mais simples: guardar dinheiro como um inteiro da menor unidade. R$ 19,99 é o número inteiro 1999, em centavos, não o float 19.99. Soma e subtração entre inteiros não têm erro de arredondamento. Converta para string decimal só na hora de exibir o valor.
Quando você precisar de frações decimais reais com regras de arredondamento exatas (uma contabilidade, um cálculo de impostos, qualquer coisa que precise bater o centavo), use um tipo decimal:
| |
Java tem BigDecimal para o mesmo trabalho; C# tem um tipo decimal nativo de 128 bits com cerca de 28-29 dígitos decimais significativos. Os três guardam dígitos na base 10, então as frações decimais que as pessoas escrevem são guardadas sem arredondamento. Isso custa em velocidade e verbosidade. Tipos decimais são mais lentos que floats nativos e exigem um modo de arredondamento explícito na divisão. Vale a pena para o saldo de um banco. Uma troca ruim para um loop numérico intenso.
Quando a imprecisão do ponto flutuante não importa
Transformações gráficas, simulações físicas, pesos de modelos de machine learning e processamento de áudio rodam sobre floats de propósito geral. Um erro relativo em torno de 1e-16 fica bem abaixo de qualquer coisa que esses domínios possam notar, e o hardware nativo para floats é muito mais rápido que a aritmética decimal.
Então a pergunta sobre um número não é se ele tem ponto decimal. Pergunte se você está contando coisas discretas e exatas, como dinheiro ou o resultado esperado de um teste, ou medindo uma grandeza contínua que já era aproximada desde o início, como a posição de um pixel ou a leitura de um sensor. Valores contados precisam de inteiros ou de um tipo decimal. Valores medidos são exatamente para o que o ponto flutuante foi criado, e movê-los para Decimal só os torna mais lentos, sem os tornar de fato mais corretos.
Na próxima vez que você vir um campo de saldo declarado como float ou um teste que afirma result == 0.3, decida que tipo de número é esse e corrija o tipo de armazenamento ou a comparação ainda naquela revisão, antes que um centavo faltando apareça em produção.