Armadilhas Numéricas – Reais- Parte III

Este é o quinto e último artigo de uma série de artigos sobre a representação de números nas plataformas de programação modernas como C# e  Java, os problemas práticos derivados dessas representação, erros comuns dos programadores e formas OO de resolver e lidar com as limitações.

Vimos nos artigo anteriores como é feita a representação de números inteiros e reais em forma binária nas plataformas modernas. Vimos também as limitações destas representações binárias e para o caso dos inteiros como contorná-las. Veremos neste artigo como contornar os problemas associados à representação binária de números reais ditadas pela norma IEEE754

Alternativas à representação binária de números reais

Se double e float são assim tão perigosos e difíceis de usar, então quais são as alternativas ?

Se você está em uma linguagem não OO, nenhuma que você possa usar. No mundo OO temos algumas opções interessantes.

O principal mérito da Orientação a Objetos é que você pode modelar o mundo real, neste caso a matemática real, sem se preocupar com truques de bits e representações binárias. É claro que não usar representações binárias vai tornar as coisas um pouco mais lentas, e o trade-off aqui é o inverso daquele que IEEE754 fez. Queremos agora exatidão sacrificando a performance. Acontece que nas máquinas de hoje, este sacrifíco não é assim tão grande e a diferença não é assim tão importante entre usar objetos e usar tipos primários que operam sobre conjuntos de bits. Especialmente se estivermos dentro de uma máquina virtual que pode realizar uma serie de otimizações.

Encapsulamento

A primeira tentativa orientada a objetos  seria encapsular o uso de double e float em um outro objeto que , por exemplo, implementa == e Equals de forma consistente usando IComparable. Isto não resolve os outros problemas da norma, mas ajuda a não cometer erros de comparação.  Se nenhum das opções a seguir for possivel no seu sistema, considere pelo menos encapsular o uso de double / float em um outro objeto.

Decimal

Encapsulamento é uma solução interessante sempre que existem regras ou algoritmos que precisamos realizar frequentemente em cima de uma abstração. Com o encapsulamento do double / float em um objeto conseguimos controlar o algoritmo de comparação, mas realmente seria interessante controlar mais do que isso.

Como vimos, o problema principal de double e float é usarem a representação binária, então claramente a opção é construir um objeto que possa representar em fatores decimais um número, em vez de binários. Isto é facilmente resolvido com um array de inteiros, onde o valor do array é entre 0 e 9 e a posição no array indica a potência de 10 a que o fator se relacionada. Porque os arrays só têm índices positivos (os índices são números inteiros naturais em |N0), e as potências podem ser negativas. Neste caso é necessário que o objeto simule índices negativos usando apenas índices positivos. Para isto ele usa um número inteiro separado, chamado escala. O índice do array representa então 10escala onde a escala pode ser um número negativo. Tendo esta base é possível calcular o valor representado. Note que isto nada mais é que a notação científica base 10 que vimos anteriormente, mas com a mantissa sendo colocada em um array de inteiros, em vez de em sequência de bits.

Depois que escolhemos o array de inteiros para representar o número, podemos realizar as operações aritméticas em cima desse array, da forma como estamos habituados e nos foi ensinado na escola primária. Isto é interessante e funciona. Em Java a class java.math.BigDecimal implementa este conceito. Em .NET a estrutura (struct) System.Decimal, segue a mesma idéia. Contudo,  há um peso de performance nesta forma de representação ao realizar as operações aritméticas. Várias pessoas desenvolveram algoritmos não triviais para aumentar a performance das operações, especialmente da multiplicação. O algoritmo de Karatsuba é normalmente o escolhido. Esta é a vantagem de usar objetos, os algoritmos complexos podem ser encapsulados de forma simples. Em sistema modernos os custos de performance são irrisórios face a double, o que nos permite preferir esta opção sempre que disponível.

Este tipo é melhor que double porque não usa a representação binária. Ele usa a representação decimal que todos conhecemos. Contudo, o Decimal ainda tem o problema da representatividade finita que o impede de representar 1/3 por exemplo (mas 1/10 ele representa corretamente) .  Dependendo da implementação, este tipo também pode ter problemas com representações não unívocas pois, no fim de contas, é um tipo de notação cientifica.

Este padrão foi introduzido para resolver o problema de sistemas financeiros que se utilizam de centavos que como vimos, são potências negativas de 10 irrepresentáveis com exatidão no sistema binário usado para o double. Isto gerava problemas de contabilidade em que as partidas dobradas não somavam o mesmo valor com erros de centavos. Os erros de centavos são os mais difíceis para encontrar as causas, e ainda mais quando é o software que conspira contra você. Sistemas modernos de contabilidade e finanças em geral, orientados a objetos, não usam double para representar dinheiro (pelo menos não deveriam. Infelizmente já vi muitos que usam). Aqueles que usam, estão fadados ao insucesso, porque mais tarde ou mais cedo os números não irão bater. Sempre que seu tipo de dinheiro não use double , nem float. Use BigDecimal, ou melhor ainda, use Money.

O padrão Money

O problema com a representação de dinheiro parte da necessidade de representar centavos, que por definição, são potências negativas de 10 e, portanto, não representáveis com double.  A solução de Decimal ajuda, mas não resolve. Primeiro porque o dinheiro não é apenas um número, ele é acompanhado por uma unidade (a moeda) e por outro lado, as operações sobre dinheiro seguem as regras dos números naturais e não as dos reais, como pode parecer à primeira vista. Se quisermos dividir 100 reais por 3 pessoas, quanto damos a cada uma ?  A unidade  atómica é o centavo, não possível distribuir menos deu m centavo, então o valor seria 33,33 reais. Mas o que fazer com o centavo que sobra ?. A quem o entregamos? Isto é importante em sistema onde o comprador pode parcelar a compra, por exemplo. Nestes casos a primeira ou a ultima parcela ganham um centavo a mais de forma que o comprador paga 33.34 + 33.33 + 33.33 que é exatamente 100.  Qualquer coisa diferente disto estará onerando umas das partes obrigando-a a perder um centavo.Pode parece pouco, mas um centavo em 1000 clientes , 10000 clientes ou mais, pode representar muito dinheiro no fim de um período.

Trabalhar com dinheiro está relacionado a trabalhar com centavos que é uma quantidade inteira e atômica (não existem, no mundo real, frações de centavos). A solução para operações monetárias têm que levar isto em consideração.  Contudo, em cenários como câmbio e calculo de juros podem aparecer valores monetários que não casam com este conceito. Bom, nem precisamos desses casos complicados. O preço da gasolina é em muitos locais colocado com 3 casas decimais ( que embora sendo ilegal, é uma prática aceite porque a tabela é na realidade a cada 10 litros, e portanto o valor apresentado é um cálculo, e não o valor verdadeiro).

A solução é, portanto, representar de uma forma diferente usando uma classe especifica para isso, normalmente chamada Money. Este é o Money Design Pattern. O padrão Money é uma especialização do padrão Quantity que por sua vez é uma especialização do padrão Value Object, que como vimos no princípio está na origem das representações numéricas.

A representação que você usar dentro de Money depende da sua aplicação. Se a sua aplicação é multimoeda ou não, quanta precisão você precisa em cálculos, etc. Normalmente se opta por usar um long em centésimos da moeda que o objeto está representando ( no caso da gasolina pode usar milésimos em vez de centésimos). Em um sistema de uma única moeda, está implícito qual é a moeda. Em um sistema multimoeda, a moeda é um campo do objeto Money (é a unidade do valor). Contudo, esta simplificação pode ser desavisada em um cenário onde é necessário trabalhar com taxas. As taxas são normalmente valores decimais com 3 ou 5 casas. O calculo de juros, por exemplo, implica em que o valor final do calculo terá mais milésimos de centavo.  Aqui a opção é realizar as contas com muito cuidado tendo muita atenção aos arredondamentos para centavos (ou usar o padrão Ratio que veremos a seguir). O objeto Money irá então, incorporar as regras especiais que se aplicam em cálculos relacionados a dinheiro. Isto é tão importante que, por exemplo, a nova API java JSR 354 para o java 9 vem padronizar isto no mundo java. Enquanto a Microsoft não faz o mesmo para o mundo .NET o jeito é você implementar seus próprios objetos Money com muito cuidado e atenção e nunca jamais usar Decimal ou double.

O padrão Ratio

Conseguimos resolver o problema de representar dinheiro com Decimal (e de melhor forma com Money). Contudo não resolvemos o problema para cálculos genéricos, como cálculo estatístico ou de engenharia, por exemplo.

O problema aqui é que Double e Decimal não permitem representar finitamente frações como 1/3. Ou seja, se em um ponto do sistema você faz k = 1 / 3 (divisão real) e em outro ponto do sistema você faz k* 3, o resultado não é 1.

Isto é um problema da matemática cuja solução é o uso de frações. Este é o padrão Ratio. Também conhecido como Rational ou Fraction.  A vantagem deste padrão é que permite realizar operações matematicamente corretas entre quaisquer números racionais. Com frações é verdade que (1 / 3 ) * 3 = 1, o padrão ratio cuida para que isto seja sempre verdade. Isto é possível por este padrão usar apenas inteiros que são representáveis exatamente, portanto o resultado é sempre representável exatamente. Não importa o algoritmo usado, o valor será realmente sempre o valor que se pretende.

O uso do padrão Ratio possibilita a criação de um objeto que realmente simula o elemento matemático do conjunto do Racionais. Ratio é a representação de um número racional com todas as suas propriedades formando realmente um magma em todas as operações aritméticas básicas, coisas que nem decimal consegue fazer. Portanto, se quer ter paz de espírito nos seus programas utilize Ratio em todos os lugares onde um número real é necessário.

Resolvendo a representação de racionais, ainda temos o problema de cálculos com números irracionais como π, ou raiz de dois que não podem ser representados como frações. Cálculos com irracionais acontecem normalmente em cálculos de geometria e limites. Sistemas empresariais usam pouco essa necessidade, portanto o problema não é assim tão grande. Para cálculos geométricos em UI, por exemplo, não é necessária tanta precisão porque a própria API de desenho em tela normalmente trabalha apenas com float. Portanto, trabalhar com uma aproximação dos números irracionais é normalmente suficiente.  Mesmo para UI, a exatidão dos cálculos com float e double dependem do algoritmo e se possível, é bom realizar os cálculos intermédios com Ratio, e só converter para float ou double no final.

Se você realmente precisa realizar cálculos exatos que incluem irracionais então a solução é utilizar-se do padrão Composite. Desta forma você pode usar as formas compostas r +i e r*i onde r é um número racional e i é um numero irracional. É parecido com o padrão usado para representar números complexos, por exemplo. Este tipo de calculo só é necessário em engenharias, mas normalmente em engenharia várias considerações de aproximação são utilizadas onde o uso de Ratio ou Decimal é suficiente. Bom, muitos usam double também. Mas, como disse antes, usar double é só para quem sabe o que está fazendo. Assume-se que engenheiros sabem, afinal IEEE significa Instituto de Engenheiros Elétricos e Electrónicos (Institute of Electrical and Electronics Engineers)  …

Evite Armadilhas Númericas

Os desastres mortais (e não é no sentido figurativo, pessoas morrem mesmo) relacionados ao uso de double devem-se normalmente à representação inexata que leva a arredondamentos ou tentativas de converter valores de double para integer ou vice-versa. Por exemplo, é verdade que todos os inteiros cabem num double, mas o double permite representar mais inteiros do que aqueles que cabem num integer.

Mesmo de posse dos conhecimentos perfeitos sobre a norma IEEE754 é bom também ler a documentação da sua API, por que podem haver surpresas desagradáveis. A própria API de arredondamento a plataforma.Net System.Math.Round() estabelece que os resultados podem não ser os esperados matemáticamente dependendo do uso e dos valores em causa. O que torna a API irrelevante para cálculos sérios (inclusive para apresentação de dados em tela).

Hoje vivemos num mundo de máquinas virtuais e linguagens orientadas à objetos. Ficar preso ao padrão IEEE754 é uma escolha, e não uma imposição como no tempo do Fortran. Faça a escolha certa, não use esta norma a menos que realmente saiba muito bem o que está fazendo, e não assuma que sabe só porque seus sistemas nunca deram problema. Simplesmente não passou tempo suficiente para os defeitos serem visíveis.

Use os padrões Ratio e Money sempre que possível. Se não for possível, pelo menos use Decimal. Evite a todo o custo uso de double e se possível evite misturar inteiros com reais. Refactorar toda a sua aplicação para usar estes padrões pode ser proibitivo, então pense neles com carinho logo no inicio, antes de começar a programar seus algoritmos e definir seu modelo de dados. Depois pode ser tarde demais, porque você já caiu nas armadilhas numéricas.

Resumo

Há bastante a dizer sobre este assunto, então tentei focar nos problemas e soluções mais comuns com que me fui deparando ao longo dos anos. Mesmo assim, foi assunto de uma série de artigos. Espero que tenham gostado e nunca jamais usem double ou float em sistema corporativos seja em Java ou em .NET .





Desenvolvido por hacklab/ com WordPress