sábado, 21 de enero de 2012

Errores de precisión, redondeo y representación con float y double

Java
Los tipos de datos float y double aunque muy usados y útiles tienen defectos, como que no son capaces de representar el resultado de ciertas operaciones ariméticas entre números reales con total precisión, en algunos casos solo son capaces de representar aproximaciones a ellos y que las operaciones las realizan en base dos. Al igual que nosotros no podemos repsentar 1/3 con números en base diez con total exactitud (solo una aproximación 0,333333333... con treses hasta el infinito) a las computadoras les pasa otro tanto de lo mismo, aunque estas guardan los números en base dos. Por tanto usarlos para hacer ciertas operaciones aritméticas, y siendo acumuladas varias de ellas, puede dar lugar a errores de redondeo y precisión en el resultado final. Por estos motivos usar estos tipos de datos no son los más adecuados para trabajar en una aplicación que calcule precios. Veámoslo con un ejemplo.

Supongamos que tenemos una cantidad de dinero tal que 100,05 a la que aplicamos un 10% de descuento y posteriormente un 5% en concepto de impuestos. Las líneas de código que calculan esto son:

import java.text.NumberFormat;

double amount = 100.05;
double discount = amount * 0.10;
double total = amount - discount;
double tax = total * 0.05;
double taxedTotal = tax + total;

NumberFormat money = NumberFormat.getCurrencyInstance();
System.out.println("Subtotal: "+ money.format(amount));
System.out.println("Discount: " + money.format(discount));
System.out.println("Total: " + money.format(total));
System.out.println("Tax: " + money.format(tax));
System.out.println("Tax+Total: " + money.format(taxedTotal));

Y el resultado es:

Subtotal: 100,05 €
Discount: 10,00 €
Total: 90,04 €
Tax: 4,50 €
Tax+Total: 94,55 €

Si nos fijamos en la suma de total más impuestos hay una diferencia de 0.01 y si presentamos un desglose de precios como este a un usuario puede que este piense que hay algún error en el cálculo, le genere desconfianza y no haga la compra en el peor de los casos. Viendo los valores sin los redondeos que hace NumberFormat tenemos:

Subtotal: 100.05
Discount: 10.005
Total: 90.045
Tax: 4.50225
Tax+Total: 94.54725

Los redondeos que hace NumberFormat es HALF_EVEN por defecto, de modo que cuando un decimal está equidistante a las dos partes se redondea a la parte par por lo que con una precisión de dos decimales:

Discount: 10.005 se redondea a 10.00
Total: 90.045 se redondea a 90.04
Tax: 4.50225  se redondea a 4.50
Tax+Total: 94.54725 se redondea a 94.55

En este caso se trata de un problema de redondeo pero ahora supongamos que tenemos una cantidad de 0,70 céntimos a la que no aplicamos un descuento pero si el procentaje de impuestos del 5%. Tendríamos:

import java.text.NumberFormat;
double amount = 0.70;
double tax = amount * 0.05;
double taxedTotal = tax + amount;

NumberFormat money = NumberFormat.getCurrencyInstance();
System.out.println("Subtotal: "+ money.format(amount));
System.out.println("Tax: " + money.format(tax));
System.out.println("Tax+Total: " + money.format(taxedTotal));

Subtotal: 0,70 €
Tax: 0,03 €
Tax+Total: 0,74 €

Nos encontramos otra vez con la diferencia de 0.01. Vemos los valores sin redondear por NumberFormat:

Subtotal: 0.7
Tax: 0.034999999999999996
Tax+Total: 0.735

Aquí se ve que el resultado de ciertas operaciones aritméticas entre datos double (o float) son almacenadas por una computadora con errores de precisión, 0.70 * 0.05 (debería ser 0.035).

Para evitar estos errores debemos utilizar la clase BigDecimal que pemite almacenar números con una precisión en la práctica infinita en base 10, realizar los cálculos como los humanos esperan, en base diez, y hacer los redondeos de precisión. Aplicando una precisión de dos decimales a los números y usando BigDecimal tenemos:

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;

RoundingMode RM = RoundingMode.HALF_EVEN;
BigDecimal amount = new BigDecimal("100.05"); 
BigDecimal discountPercent = new BigDecimal("0.10");
BigDecimal discount = amount.multiply(discountPercent).setScale(2, RM); 
BigDecimal total = amount.subtract(discount).setScale(2, RM);
BigDecimal taxPercent = new BigDecimal("0.05");
BigDecimal tax = total.multiply(taxPercent).setScale(2, RM);
BigDecimal taxedTotal = total.add(tax).setScale(2, RM);
NumberFormat money = NumberFormat.getCurrencyInstance(); 
System.out.println("Subtotal : " + money.format(amount));
System.out.println("Discount : " + money.format(discount));
System.out.println("Total : " + money.format(total)); 
System.out.println("Tax : " + money.format(tax)); 
System.out.println("Tax+Total: " + money.format(taxedTotal));

Ahora los precios si están correctos:

Subtotal : 100,05 €
Discount : 10,00 €
Total : 90,05 €
Tax : 4,50 €
Tax+Total: 94,55 €

Para el otro caso en el que teníamos un error de precisión:

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;

RoundingMode RM = RoundingMode.HALF_EVEN;
BigDecimal amount = new BigDecimal("0.70");
BigDecimal taxPercent = new BigDecimal("0.05");
BigDecimal tax = amount.multiply(taxPercent).setScale(2, RM);
BigDecimal taxedTotal = tax.add(amount).setScale(2, RM); 

NumberFormat money = NumberFormat.getCurrencyInstance(); 
System.out.println("Subtotal: "+ money.format(amount)); 
System.out.println("Tax: " + money.format(tax)); 
System.out.println("Tax+Total: " + money.format(taxedTotal));

Subtotal: 0,70 €
Tax: 0,04 €
Tax+Total: 0,74 €

Referencia:
http://www.javamexico.org/blogs/luxspes/por_que_usar_bigdecimal_y_no_double_para_calculos_aritmeticos_financieros
http://speleotrove.com/decimal/decifaq1.html#tzeros
http://www.mkyong.com/java/how-do-calculate-monetary-values-in-java-double-vs-bigdecimal/
http://blogs.oracle.com/CoreJavaTechTips/entry/the_need_for_bigdecimal
http://stackoverflow.com/questions/7539/please-explain-the-use-of-java-math-mathcontext/7561#7561
http://en.wikipedia.org/wiki/Floating_point