Por que 0.1 + 0.2 != 0.3 (e o dia em que isso custa dinheiro)
Ponto flutuante, BigDecimal e arredondamento: o que todo sistema que mexe com dinheiro precisa acertar
Abra o jshell e digite:
jshell> 0.1 + 0.2
$1 ==> 0.30000000000000004
Não é bug do Java. O mesmo acontece em JavaScript, Python, C, Go e praticamente qualquer linguagem que use double. Na maior parte do tempo, ninguém se importa: se a posição de um elemento na tela está quatro quatrilionésimos fora do lugar, ninguém percebe. O problema começa quando esse número representa dinheiro, e aí um centavo a mais ou a menos vira divergência na conciliação, cliente reclamando e alguém do financeiro abrindo chamado.
De onde vem o erro
Um double segue o padrão IEEE 754: 64 bits divididos entre sinal, expoente e mantissa, e tudo em base 2. A questão é que nem todo número que é “redondo” em base 10 é redondo em base 2.
Pense em 1/3 em decimal: 0,3333… nunca termina. Com 0,1 em binário acontece a mesma coisa: vira 0,000110011001100110011… com o padrão se repetindo para sempre. Como a mantissa tem um tamanho fixo, o valor é cortado em algum ponto, e o double guarda o número representável mais próximo de 0,1, não 0,1 exato.
Dá para ver o valor que realmente está guardado criando um BigDecimal direto a partir do double:
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
Quando você soma dois valores que já têm um errinho cada, o resultado acumula os dois, e às vezes o número mais próximo do resultado não é o mesmo número que o Java usa para representar 0,3. Daí o 0.30000000000000004. Normalmente o erro fica escondido porque, na hora de imprimir, o Java mostra um decimal curto, só com os dígitos necessários para identificar aquele double, e 0,1 aparece como 0.1. Na soma, a máscara cai.
E o erro cresce com o número de operações:
double total = 0;
for (int i = 0; i < 10; i++) {
total += 0.1;
}
System.out.println(total); // 0.9999999999999999
System.out.println(total == 1.0); // false
Agora troque o loop de dez iterações por um job que soma milhões de transações para fechar o dia.
Onde isso custa dinheiro
Alguns cenários em que o double costuma morder:
- Somatórios grandes: o relatório de fechamento soma os pedidos do dia em
doublee o total não bate com o extrato do banco por alguns centavos. - Comparações: uma regra do tipo
if (valorPago == valorDevido)falha para um boleto pago certinho porque um dos lados passou por uma conta. - Arredondamento em cascata: cada etapa arredonda um pouco diferente (o front, o back, o banco de dados) e o valor da parcela mostrado na tela não é o mesmo cobrado no cartão.
- Juros e multas: multiplicações e divisões repetidas em cálculos de juros compostos espalham o erro por todas as parcelas.
O valor de cada erro é ridículo. O problema é que conciliação financeira não aceita “quase”: ou bate, ou alguém precisa explicar por que não bateu.
BigDecimal, do jeito certo
Em Java, a resposta padrão para dinheiro é BigDecimal, que guarda o número em base 10 com uma escala (quantas casas depois da vírgula). Mas ele tem algumas armadilhas próprias.
Não construa a partir de double. O exemplo lá de cima mostra o porquê: new BigDecimal(0.1) herda exatamente o erro que você queria evitar. Use a String ou o valueOf:
new BigDecimal("0.1"); // 0.1
BigDecimal.valueOf(0.1); // 0.1, usa a representação em String do double
new BigDecimal(0.1); // 0.1000000000000000055511151231257827021181583404541015625
O valueOf resolve o caso comum, mas se o valor já chegou como double de algum lugar, o erro pode ter acontecido antes. O ideal é que o dinheiro nunca passe por double em nenhum ponto do caminho, desde a leitura do JSON até a gravação no banco.
equals considera a escala. Para o BigDecimal, 2.0 e 2.00 não são iguais:
new BigDecimal("2.0").equals(new BigDecimal("2.00")); // false
new BigDecimal("2.0").compareTo(new BigDecimal("2.00")); // 0
Para comparar valores, use compareTo. Isso também pega quem usa BigDecimal como chave de HashMap ou dentro de um HashSet, que dependem de equals e hashCode.
Divisão exige dizer como arredondar. Se o resultado não tem representação decimal finita, o BigDecimal se recusa a adivinhar:
BigDecimal.ONE.divide(new BigDecimal("3"));
// ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
BigDecimal.ONE.divide(new BigDecimal("3"), 2, RoundingMode.HALF_EVEN);
// 0.33
Isso é uma qualidade, não um defeito: ele te obriga a tomar uma decisão de negócio que o double tomaria escondido.
Qual arredondamento?
O arredondamento que a gente aprende na escola é o HALF_UP: 2,5 vira 3. Ele tem um viés sutil, porque o caso do meio sempre vai para cima, e somado em milhões de operações isso empurra o total para cima.
O HALF_EVEN, conhecido como arredondamento bancário, manda o caso do meio para o vizinho par: 2,5 vira 2 e 3,5 vira 4. Em média, metade dos empates sobe e metade desce, e o viés some. É o modo de arredondamento padrão do próprio IEEE 754 e aparece com frequência em sistemas financeiros, mas a regra certa depende do contexto: às vezes um contrato, um regulador ou o sistema do parceiro define outro modo. O importante é que seja uma decisão explícita, escrita em um só lugar, e não o comportamento padrão de cada camada.
Dividir sem perder centavo
Um caso que aparece em todo sistema de pagamento: dividir R$ 100,00 em três parcelas. A divisão dá 33,333…, que arredonda para 33,33. Só que 33,33 × 3 = 99,99. Sumiu um centavo.
Arredondar melhor não resolve, porque não existe três valores iguais de centavos que somem 100,00. A saída é distribuir a sobra de forma explícita:
List<BigDecimal> dividir(BigDecimal total, int partes) {
BigDecimal parcela = total.divide(BigDecimal.valueOf(partes), 2, RoundingMode.DOWN);
BigDecimal resto = total.subtract(parcela.multiply(BigDecimal.valueOf(partes)));
List<BigDecimal> parcelas = new ArrayList<>();
for (int i = 0; i < partes; i++) {
parcelas.add(parcela);
}
parcelas.set(0, parcelas.get(0).add(resto));
return parcelas;
}
// dividir(new BigDecimal("100.00"), 3) -> [33.34, 33.33, 33.33]
Aqui a sobra vai para a primeira parcela, mas poderia ir para a última ou ser espalhada um centavo por vez. De novo, é uma regra de negócio. A propriedade que não pode ser quebrada é que a soma das partes seja exatamente igual ao total.
Centavos em long
Uma alternativa bem comum ao BigDecimal é guardar tudo em centavos, num long. R$ 19,90 vira 1990. Soma e comparação ficam exatas e rápidas, e não tem escala para atrapalhar o equals.
O cuidado é que “centavo” não é universal. Algumas moedas não têm subunidade e outras têm três casas decimais, então o código precisa saber a escala de cada moeda em vez de assumir duas casas. E qualquer operação que não seja soma ou subtração (juros, porcentagem, câmbio) vai precisar de uma regra de arredondamento do mesmo jeito.
Para quem quer um tipo pronto, existe a JSR 354 (Money and Currency API), cuja implementação de referência é a biblioteca Moneta, com um tipo Money que carrega valor e moeda juntos e evita somar real com dólar sem perceber.
As fronteiras do sistema
Mesmo com tudo certo no Java, o valor ainda passa por outros lugares:
- Banco de dados: use
NUMERIC/DECIMALcom precisão e escala definidas, nuncaFLOATouDOUBLE PRECISION. Mapear uma colunaNUMERICpara um campodoublena entidade joga fora todo o cuidado. - JSON: em JavaScript todo número é
double, então um front em JS que faz conta com o valor recebido reintroduz o problema. Algumas APIs mandam valores monetários como string ("19.90") ou em centavos inteiros justamente para não depender de como cada cliente interpreta números. - Jackson: se o DTO tem um campo
BigDecimal, o Jackson lê o número direto paraBigDecimalsem passar pordouble. Se o campo fordouble, o estrago acontece já na desserialização. - Planilhas e relatórios: exportar para CSV e somar no Excel é outro ponto onde o valor volta a ser ponto flutuante. Vale saber disso antes de discutir com o financeiro qual total está certo.
Então double é proibido?
Não. Para medidas físicas, estatística, gráficos, coordenadas, probabilidades de modelo e tudo que é aproximado por natureza, double é a escolha certa: é rápido, tem um alcance enorme e o erro é muito menor que a precisão dos dados de entrada. O ponto é que dinheiro não é uma medida aproximada. É uma contagem de unidades discretas, com regras de arredondamento definidas por contrato e por lei.
A regra que fica: se alguém vai conciliar aquele número com outro sistema, ele não pode passar por double em nenhum momento. E quando o resultado de uma conta precisar ser arredondado, alguém precisa ter decidido como, e essa decisão precisa estar no código, não no padrão da linguagem.