Python: Trabalhando com precisão em números decimais

É bem possível que você já tenha se deparado com pequenos erros de precisão com números float, isso aconteceu comigo

quando trabalhei em uma aplicação em Python para controle de gastos e ganhos da empresa onde trabalho.
A princípio, é simples, só guardo os valores em variáveis para depois passá-los para um banco de dados próprio. No mês de julho, tivemos 5 vendas de R$ 99.91
e compramos 3 equipamentos de R$ 110.10
:
ganhos_julho = 99.91 * 5
gastos_julho = 110.1 * 3
armazena_no_banco(ganhos_julho, gastos_julho)
A partir disso, os especialistas em finanças da empresa fazem análises para tentar melhorar nossos resultados.
Meu código é bem simples, mas tem uma lógica clara que deveria funcionar bem. Apesar disso, no final do mês a chefia acabou me dando uma bronca. Isso porque alguns cálculos não bateram com os resultados reais que tivemos. Fiquei confuso, e fui testar meu código para conferir se tudo está como deveria:
ganhos_julho = 99.91 * 5
print(ganhos_julho)
gastos_julho = 110.1 * 3
print(gastos_julho)
Olha os resultados que obtive:
499.54999999999995
330.29999999999995
Espera… o quê? Fazendo as contas manualmente, ou na calculadora, os resultados são outros: 499.55
e 330.3
. Por que o Python me entregou esses números compridos e, principalmente, imprecisos?
O problema do float
A lógica das nossas contas está correta, o problema, como vimos, está nos próprios resultados que o Python nos dá. O Python não sabe fazer conta direito, é isso?
Bem, a resposta pode ser sim… e não! Em primeiro lugar, essa questão não é exclusiva do Python, mas sim da computação e de como ela lida com números de ponto flutuante (nosso querido float). Além disso, não é exatamente um problema; vamos entender!
No mais baixo nível, computadores funcionam com a diferença de dois estados elétricos - baixa e alta voltagem, ligado e desligado, verdadeiro e falso. Daí temos o binário, o famoso 0 e 1, e é por conta desse sistema que os computadores conseguem ser tão rápidos com algumas coisas.
Entretanto, utilizando o formato binário para os números de ponto flutuante, os computadores não conseguem representar com precisão exata algumas frações (como 0.98
e 0.1
). Desse modo, esses números são automaticamente arredondados para o mais próximo que se encaixe na possibilidade do binário, o que resulta em um pequeno erro de precisão.
No geral, esse erro é muito pequeno para ser considerado relevante, mas há situações em que não podemos desconsiderá-lo, como agora!
No nosso caso, como estamos lidando com dinheiro, precisamos de uma precisão maior. Já vimos como arredondar e formatar valores monetários antes, mas mesmo essa forma pode resultar em alguns pequenos erros de arredondamento que queremos evitar. E agora?
Trabalhando com inteiros
Como estamos trabalhando com dinheiro, podemos rapidamente pensar numa alternativa que evitaria o problema dos números de ponto flutuante - trabalhar apenas com números inteiros. Mas como? Sabemos que 1 real equivale a 100 centavos, então podemos, simplesmente, trabalhar com essa subunidade e evitar números quebrados.
No nosso caso, tínhamos R$ 188.98
e R$ 13.10
que também podem ser representados, respectivamente, por 18898¢
e 1310¢
- dois números inteiros!
Por estarmos trabalhando com o Python, ainda evitamos (a princípio) o problema de overflow de número inteiro padrão, que é muito limitado em algumas linguagens (como em Java, que só chega a 2.147.483.647).
Como o banco de dados está guardando inteiros, agora, podemos simplesmente dividir por 100 no momento de imprimir os valores, o que resolveria os problemas. Olha:
ganhos_julho, gastos_julho = pega_valores_no_banco()
print(ganhos_julho / 100)
print(gastos_julho / 100)
E a resposta:
188.98
13.1
Legal! Com o código arrumado, fui compartilhar com as filiais internacionais de minha empresa. Entretanto, rapidamente recebi diversas reclamações mostrando erros nos cálculos. Mas por quê?
A primeira reclamação veio direto de nossa filial na Tunísia. O programa estava apresentando cálculos claramente errados para eles - o banco de dados armazenava 10000 milim e o valor impresso era de 100 dinares tunisianos, quando deveria ser 10. Isso é porque, na Tunísia (e em diversos outros países) a subunidade de moeda principal não vem do centésimo (1/100), mas do milésimo (1/1000) - 1 dinar tunisiano equivale a 1000 milim.
Desse jeito, temos um grande problema em potencial com a internacionalização do código. Na verdade, mesmo que trabalhemos com apenas um local, o perigo continua. No mundo financeiro, subunidades mudam com o tempo, devido à inflação e deflação.
Se armazenarmos números inteiros, teremos que migrar os valores armazenados toda vez que houver uma mudança, o que pode atrapalhar bastante a manutenção de todo o sistema.
O ideal ainda seria conseguir trabalhar com a unidade principal da moeda, com números quebrados, mas com exatidão. Será que tem como?
Trabalhando com precisão com o tipo Decimal
Por conta dessa necessidade recorrente de lidarmos com números não-inteiros exatos, a maioria das linguagens de programação nos disponibiliza tipos específicos para lidar com isso.
No caso do Java, por exemplo, temos o BigDecimal. No Python, temos todo o módulo decimal e, mais especificamente, o tipo Decimal. Importando-o, seu uso é direto:
from decimal import Decimal
ganhos_julho = Decimal('99.91') * 5
print(ganhos_julho)
gastos_julho = Decimal('110.1') * 3
print(gastos_julho)
Dessa vez, olha o resultado:
499.55
330.3
Exatamente como na calculadora! Os analistas da empresa não terão mais nenhum problema com os cálculos.
Usando a precisão exata quando precisamos
Começamos com um problema o tipo float, do Python, não conseguia nos devolver um resultado exato de um cálculo. Logo entendemos que o problema não estava no Python, em si, mas no float e em como o computador lida com ele.
Como estávamos lidando com dinheiro, a precisão nos cálculos era fundamental. Assim, precisávamos de alguma solução. Demos uma olhada em como podemos transformar tudo em números inteiros (por exemplo, transformando o valor monetário de Real para centavos), o que, algumas vezes, pode ser uma boa saída.
Tratar todos os números como inteiros, entretanto, tem suas consequências negativas, como um possível overflow e problemas de manutenção de código. Queríamos uma solução melhor e… conseguimos!
Aprendemos que a maioria das linguagens de programação tem algum tipo numérico exato para evitar esse problema. No caso do Python, esse tipo é o Decimal, com precisão arbitrária. Com ele, nossos cálculos ganharam a precisão necessária e acabamos com todo o problema inicial.
Uma intuição natural depois de se conhecer os tipos numéricos exatos nas linguagens de programação, como o Decimal, é querer usá-los para tudo. Apesar disso, é importante sempre analisarmos se vale a pena - tipos exatos demandam mais tempo de processamento.
Normalmente, um pequeno erro na 10ª casa decimal de um número é irrelevante, e a precisão exata desnecessária. Por isso, temos sempre que analisar o contexto de nosso próprio programa antes de aplicar uma decisão.
Já conhecia o tipo Decimal antes? E toda essa confusão com o float? Escreva um comentário com sua opinião sobre o post e, se se interessar mais por Python, não deixe de dar uma olhada em nossa formação Python para web!