Armadilhas Numéricas – Reais- Parte II

Este é o quarto 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 no artigo anterior como é feita a representação de números reais em forma binária, conforme a norma IEEE754 que é usada nas plataformas modernas. Veremos neste artigo alguns problemas que nascem do uso desta representação e que os programadores normalmente desconhecem levando a problemas, que podem, inclusive, ser catastróficos.

Problemas com a IEEE754

Múltipla Representação

Primeiro é importante entender que o mesmo valor matemático pode ter mais do que uma representação binária, ou seja, dois conjuntos distintos de bits podem representar o mesmo valor. Ou seja, a representação não é unívoca. Usando a representação em notação científica de base dez podemos entender a razão. O mesmo número 0.1 pode ser representado como :

a) 0.1 = 1.00 x 10-1

b) 0.1 = 0.01 x 101

Manipulando o valor da mantissa e do expoente podemos criar várias representações diferentes para o mesmo valor. Quando estamos falando de constantes a representação será aquela com mais dígitos significativos e portanto uma versão mais “canônica” do valor. Contudo, após realizar cálculos, a representação final irá depender dos cálculos feitos.

Quando == não é igual

Esta propriedade afeta como comparamos dois valores para saber se são iguais ou diferentes. Em C# ou Java utilizar o operador == ou o método Equals, não produz o mesmo resultado para os tipos Double e Float mesmo quando o valor matemático em causa é o mesmo. O operador == compara os bits em ambas as variáveis. Ou seja, ele compara a representação. Se a representação não é a mesma, o resultado da igualdade é falso; os valores matematicamente iguais, não são considerados iguais desta forma. Daqui se entende que é importante nunca usar o operador == entre tipos de virgula flutuante como double ou float. Por outro lado, Equals também compara a representação, mas não da mesma forma. Pela norma IEEE754 um double pode representar valores especiais que não são realmente números como, por exemplo,  infinito. Em particular ele pode representar um NaN (Not A Number – “Não é um número”).Na realidade NaN não é um apenas um único valor, é um conjunto de valores especiais que são designados como NaN. Cada NaN contém a informação do “erro” que lhe deu origem. Isto porque nas linguagens e sistemas mais antigos o conceito de exceção não existia. O NaN permite que o cálculo termine e o programador possa avaliar se deu certo ou não. Contudo, nenhum NaN é igual a si mesmo , nem a outro NaN , nem a outro valor qualquer! Portanto, se tivermos uma variável com o resultado de um cálculo cujo valor deu NaN e fizermos valor == NaN a resposta é sempre falsa ( mesmo quando a representação é a mesma). Isto viola o conceito comum do operador ==.  Além de == sempre retornar falso, o mais estranho é que  a comparação com Equals retorna true, em violação à norma (mas necessário para poder usar doubles como chaves em dicionários, ou seja, cumprir o contrato Equals-HashCode) o que deixa tudo confuso..  Portanto :

// C#
double a = double.NaN;
double b = double.NaN;
double c = 10.9; // qualquer valor double
Assert.IsFalse(double.IsNaN(a));
Assert.IsFalse(double.IsNaN(b);
Assert.IsFalse(a == b);
Assert.IsFalse(a == c);
Assert.IsFalse(b == c);
Assert.IsFalse(a.Equals(c));
Assert.IsTrue(a.Equals(b);

Este problema é bem desconhecido da comunidade pois, embora possível, é raro que aconteça e mais raro ainda que o programador descubra que seu programa está errado.

Para contornar este problema e como boa prática de programação utilize apenas o método CompareTo (da interface IComparable (C#) / Comparable (java)) para ter a certeza que os valores, e não as representações, estão sendo comparados.

O resultado da conta depende do algoritmo da conta

O resultado final de um cálculo depende de como o calculo é feito. Por exemplo, podemos calcular 10 * 0.1 como a repetição da soma de 0.1 , dez vezes; ou, simplesmente usar a operação de multiplicar da IEEE 754. O curioso é que estas duas formas não dão mesmo resultado. 10 * 0.1 = 1 e a soma dez vezes dá 0.(9) (com quinze dígitos 9, porque estamos usando um double) . A razão para isto é, como vimos antes, que nem todos os valores podem ser representados como uma série de fatores binários, especialmente as potencias negativas de dez, como 0.1.

//C#
double ten = 10.0;
double one = 1.0;
double oneTenth = 0.1;
double result = 0.0;
for (int i = 0; i < 10; i++){
    result += oneTenth;
}
Assert.IsFalse(result.CompareTo(one) == 0);
Assert.IsTrue((ten * oneTenth).CompareTo(one) == 0);

Granularidade da Representação

Outro problema tem que ver com a granularidade da representação. Double deveria representar todos os reais, contudo há números reais que simplesmente ele não consegue representar, isto significa que o resultado de um cálculo pode (e, normalmente é) o resultado correto, mas representado de uma forma limitada, e às vezes, aproximada. Isto também significa que com Double é possível representar números muito grandes ou muito pequenos, mas não as duas coisas ao mesmo tempo. Por exemplo, uma variável que representasse os nanosegundos desde o big bang poderia representar valores muito pequenos e muitos grandes ao longo do seu uso no sistema. Ao tentar fazer contas com estes valores teríamos problemas de precisão que iriam transformar o resultado em lixo numérico.

Apenas representa a forma decimal do número

Porque na base de double está um conjunto finito de bits isso  impede a representação exata de irracionais e até mesmo de alguns racionais como 1/3 ou 1/10.

Não use double

A mensagem é que, se você vai usar double (ou float) para alguma coisa, tenha a certeza que essa é a modelagem correta e que você compreende como os cálculos funcionam (lembre que o resultado depende do algoritmo). Em mais casos, do que em menos, você não compreende de fato. As propriedades matemáticas de double não são aquelas que nos ensinaram na escola e isto pode estragar muitos algoritmos e assumpções. Coisas simples como alterar uma sequência de soma por uma multiplicação ou vice-versa , podem não dar o mesmo resultado.

Double é perigoso, e mais tarde ou mais cedo você vai descobrir que seu sistema está errado. Nem todos os testes unitários do mundo podem garantir que seu programa está certo (seria o equivalente a testar todas as fórmulas com todos os números. É simplesmente impossível).

Os padrões Double e Float criados pela norma IEEEE 754  aderem um propósito com base em regras que são claras, mas cujas implicações podem não ser. O uso destes padrões deve ser evitado sempre que possível a menos que você os entenda e saiba o que está fazendo ou desastres podem acontecer. Compare o que sabe com um texto de quem entende e veja se está disposto a correr o risco.

Resumo

Vimos neste artigo vários problemas da representação binária de reais , em particular da representação IEEE754, e como eles podem afetar seu código, seus algoritmos e seus resultados. Não é realmente nada fácil se lembrar como usar um double ou um float  corretamente e mesmo que lembrássemos ele não oferece uma representação exata para os reais. Veremos no próximo, e último artigo desta série, como utilizar orientação a objetos para ajudar a representar números reais com mais exatidão e com mesmo problemas em diversos cenários.





Desenvolvido por hacklab/ com WordPress