Por que Float Falha para Dinheiro: NUMERIC, Centavos-como-Inteiros, e BigDecimal
Objective
0.1 + 0.2 não é igual a 0.3 em quase nenhum computador, e o motivo não é um bug de arredondamento na adição: é que float/double representam números em base 2, e a maioria das frações decimais (0.1, 0.2, 0.3, 0.7) não têm expansão binária finita, do mesmo jeito que 1/3 não tem expansão decimal finita. O valor de fato armazenado é a aproximação representável mais próxima, então qualquer coluna ou variável tipada como float8/double precision já está mentindo por algumas unidades na décima sétima casa decimal antes de qualquer cálculo rodar. Para dinheiro isso deixa de ser uma curiosidade: um total armazenado de 108.80 pode ser silenciosamente 108.80000000000001 internamente, e WHERE total = 108.80 pode não encontrar a linha que está procurando. O conserto não é "arredondar mais forte": arredondar a cada passo de uma agregação grande introduz a mesma decisão enviesada milhares de vezes; é escolher uma representação que seja exata desde o início: NUMERIC/DECIMAL no banco de dados, unidades menores inteiras (centavos), ou BigDecimal em código de aplicação, em vez de um float binário fingindo ser decimal.
Use Cases
- Armazenar ou comparar totais monetários em uma coluna de banco de dados, onde
WHERE total = 108.80precisa de fato corresponder à linha que exibe como108.80. - Somar milhares de itens de linha (faturas, totais de pedido, entradas de livro-razão) onde quando o arredondamento acontece (uma vez no final versus uma vez por linha) muda o total final por dinheiro real.
- Escolher um tipo de schema para uma coluna de preço/valor:
NUMERIC(10,2), uma coluna de centavos inteira, ou (a escolha errada)FLOAT/DOUBLE PRECISION. - Integrar com uma API de pagamento ou faturamento, a maioria das quais fala unidades menores inteiras (centavos, pence) na comunicação especificamente para desviar desse problema.
- Explicar uma discrepância de "um centavo a menos/mais" entre um total que seu código computou e um total que um banco, fatura, ou planilha reporta de volta.
Deep Dive
Por que 0.1 não tem representação binária exata
Um float/double é uma soma de potências de dois. Alguns decimais por acaso são exatos em binário porque são construídos a partir de potências negativas de dois:
text0.5 = 1/2 = 0.1₂ 0.25 = 1/4 = 0.01₂ 0.125 = 1/8 = 0.001₂
Mas 0.1 é 1/10, e 10 não é uma potência de dois: então, assim como 1/3 em decimal produz um 0.3333... interminável, 1/10 em binário produz uma fração repetindo interminável:
text0.1 (decimal) ≈ 0.0001100110011001100110011... (binary, repeating)
Um double tem 52 bits de mantissa, então esse padrão repetido é cortado e arredondado para o valor representável mais próximo. O número de fato armazenado para o literal 0.1 é:
text0.1000000000000000055511151231257827021181583404541015625
não 0.1. Ele só exibe como 0.1 porque a rotina de impressão arredonda de volta para o decimal mais curto que lê igual. Essa é uma propriedade do próprio ponto flutuante binário IEEE 754: vale identicamente se o 0.1 é um float do Python, um float8 do PostgreSQL, um double do Java, ou um number do JavaScript, porque os quatro usam o mesmo formato binary64.
Veja acontecendo: somando duas aproximações, não dois decimais
0.1 + 0.2 não soma os decimais 0.1 e 0.2: soma o que quer que o binary64 de fato armazenou para cada um deles, e essa soma é o que é impresso:
A aritmética em si não cometeu nenhum erro de arredondamento; as duas entradas já estavam erradas antes de o + rodar, e somar dois números levemente errados não consegue produzir um exatamente certo.
"Arredondar no final" ganha de "arredondar a cada passo", mas não é uma regra universal
Um conserto tentador é arredondar depois de todo cálculo:
plaintext(0.1 + 0.2).toFixed(2) // "0.30"
toFixed arredonda o valor já impreciso na memória: não recupera o decimal original, então ainda pode dar errado em valores exatamente numa fronteira de arredondamento. Pior é arredondar em todo passo intermediário de uma agregação grande. Dadas 10.000 linhas cada uma valendo 10.004:
textvalue → round to 2 places → sum -- 10,000 independent rounding decisions sum all values → round once -- 1 rounding decision
A primeira forma toma a mesma decisão direcional 10.000 vezes, o que pode acumular em uma deriva real e sistemática; a segunda a toma uma vez. Isso não é uma regra universal: regras fiscais e contábeis às vezes exigem arredondamento em passos intermediários específicos, mas arredondar ou truncar arbitrariamente a cada passo, sem nenhuma regra conduzindo isso, acumula viés sem motivo. Truncamento torna isso pior do que arredondamento: truncar 10.009 para duas casas dá 10.00, enquanto arredondar dá 10.01: repetido milhares de vezes, truncamento enviesa o total para baixo de um jeito que arredondamento não faz.
Strings preservam dígitos mas não são um modelo numérico
Armazenar "108.80" como texto preserva os caracteres exatos, mas no momento em que o código precisa fazer aritmética nisso, precisa ser parseado de volta em um número:
plaintextNumber("108.80") // 108.8 — a float again, same problem as before
Uma string está bem para transporte ou exibição; não é um substituto para um tipo numérico que suporta adição, comparação, e regras de arredondamento.
Unidades menores inteiras: dinheiro como um inteiro puro
Em vez de armazenar 10.99, armazene 1099 (centavos) e faça toda operação como aritmética inteira:
text1099 (10.99) + 550 ( 5.50) ------ 1649 (16.49)
Não há 0.1 + 0.2 aqui: só inteiros, que é por que APIs de pagamento (Stripe, a maioria dos trilhos bancários) transacionam em unidades menores na comunicação. Duas coisas que isso não resolve de graça: nem toda moeda usa duas casas decimais (JPY tem zero, algumas moedas usam três), e divisão não distribui limpamente: 1099 / 3 não é um número inteiro de centavos, então o código ainda precisa decidir como alocar o resto (por exemplo, dar o centavo extra para a primeira ou última parcela).
NUMERIC/DECIMAL no PostgreSQL, e BigDecimal no Java
NUMERIC(precision, scale) armazena um valor decimal exato, não uma aproximação binária:
plaintextcreate table invoice ( id bigserial primary key, total numeric(10, 2) not null ); insert into invoice (total) values (108.80); select total = 108.80 from invoice; -- true, exactly
FLOAT/DOUBLE PRECISION na mesma tabela carregaria a mesma imprecisão binary64 descrita acima diretamente para comparações SQL.
O double do Java tem o problema idêntico, e BigDecimal é o conserto equivalente, mas só quando construído corretamente. new BigDecimal(double) converte a partir do valor binário armazenado real do double, reproduzindo a imprecisão em vez de curá-la:
javanew BigDecimal(0.1);
// 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1"); // exact — parses the decimal text directly
BigDecimal.valueOf(0.1); // exact — routes through Double.toString() firstA regra prática: construa BigDecimal a partir de uma String ou uma unidade menor inteira, nunca a partir de um literal double que já perdeu precisão antes de o BigDecimal sequer vê-lo.
A correção que importa: a matemática está certa, a representação é aproximada
"19.90 × 100 não dá exatamente 1990" é fácil de declarar descuidadamente. Matematicamente, 19.90 × 100 = 1990.00, exatamente. O problema aparece só uma vez que 19.90 é representado como um double:
text19.90 (double) ≈ 19.899999999999998578914528479799628257751464843750 19.90 * 100 ≈ 1989.9999999999998
A multiplicação é aritmética correta sobre uma entrada já aproximada. A representação é o que é aproximado, não a matemática.
Trade-offs
float/doublesão rápidos e compactos, mas nunca exatos para dinheiro: a maioria das frações decimais não tem forma binária finita, então comparações de igualdade e totais correntes derivam silenciosamente.plaintext0.1 + 0.2 === 0.3 // false- Strings são exatas de se olhar, mas não são um modelo numérico: toda operação aritmética exige parsear de volta em um número, o que reintroduz o problema de float que se pretendia evitar.
- Unidades menores inteiras são exatas e rápidas, mas empurram disciplina de unidade e arredondamento para todo pedaço de código que toca o valor: uma moeda com um número diferente de casas decimais, ou uma divisão que não divide igualmente, ambas exigem uma decisão explícita que o próprio tipo inteiro não vai tomar por você.
NUMERIC/DECIMAL(eBigDecimalem código de aplicação) dão aritmética decimal exata de ponta a ponta, ao custo de velocidade e decisões de arredondamento explícitas:BigDecimal.dividelançaArithmeticExceptionem uma expansão decimal não terminante a menos que umRoundingMode(ou escala) seja fornecido, que é o tipo forçando você a tomar a mesma decisão de arredondamento que umfloatteria silenciosamente estragado por você.javanew BigDecimal("10").divide(new BigDecimal("3")); // ArithmeticException: Non-terminating decimal expansion