Por Qué Nunca Usar float y double para Representar Montos de Dinero
Cuando aprendemos a programar una de las primeras lecciones es que se usan diferentes tipos de datos para representar números enteros y números no enteros. Una variable entera no puede tener el valor 0,5. En Pascal se usan los tipos Integer y Real y en C están por ejemplo los tipos int, long, float y double.
Supongamos que tenemos que hacer un sistema de permita administrar sumas de dinero; más específicamente, deudas y pagos. Puede ser para un banco, una tarjeta de crédito, un almacén o cualquier aplicación comercial. Para dicho sistema tenemos que hacer una función en C muy sencilla que reciba la deuda actual y un arreglo de pagos que realizó el deudor y que devuelva la deuda actualizada una vez descontados los pagos. Este podría ser el prototipo:
double actualizarDeuda(double deuda, double pagos[], int cantPagos);
Como la deuda puede ser con centavos, no podemos usar int para representarla entonces usamos double.
Lo que hace la función es restar de la deuda cada elemento del arreglo pagos y devolver el monto actualizado.
double actualizarDeuda(double deuda, double pagos[], int cantPagos){ double deudaActualizada = deuda; int i; for(i = 0; i<cantPagos;i++){ deudaActualizada -= pagos[i]; } return deudaActualizada; }
Vamos a constatar que la función hace lo esperado con una prueba.
Supongamos que un deudor tenía una deuda de $308,21 y realizó 4 pagos por $8,01; $0,20; $299,99 y $0,01. Si bien no es sencillo pagar en efectivo montos como $8,01, es un caso que puede darse perfectamente en transacciones electrónicas.
double deuda = 308.21d; double pagos[4] = {8.01d, 0.20d, 299.99d, 0.01d};
En teoría la deuda está saldada así que la función debería devolvernos que la deuda es $0,00.
double miDeuda = actualizarDeuda(deuda, pagos, 4); if(miDeuda == 0.0d){ printf("Libre deuda.\n"); } else { printf("Al Veraz.\n"); }
Sin embargo la prueba imprime Al Veraz. es decir que nuestro sistema sigue registrando una deuda a pesar de que está saldada.
La nueva deuda no es $0,00, pero casi. El valor es $-0,00000000000000909516. Es decir, nuestra función nos calculó que el deudor pagó 9 milbillonésimas de peso más de lo que debía.
Esto nos hace pensar que hay un error de redondeo así que decidimos usar la función round.
miDeuda = round(actualizarDeuda(deuda, pagos, 4)); if(miDeuda == 0.0d){ printf("Redondeando, logro el libre deuda.\n"); } else { printf("Redondeando, igual voy al Veraz.\n"); }
Ahora sí obtenemos el resultado Redondeando, logro el libre deuda lo que es en principio correcto.
A pesar de que en muchos sistemas el problema de la precisión está “solucionado” de esa manera, no se está atacando la causa del problema sino una de sus consecuencias. Como se observa a continuación, redondear el resultado puede llevarnos a dar por saldada una deuda que todavía no está totalmente paga.
pagos[2]-=0.4d; miDeuda = round(actualizarDeuda(deuda, pagos, 4)); if(miDeuda == 0.0d){ printf("Con redondeo, debiendo $0,40 igual logro el libre deuda.\n"); } else { printf("A pesar de redondear, como debo voy al Veraz.\n"); }
Si descontamos $0,40 de uno de los pagos, está claro que no deberíamos extender el libre deuda. Sin embargo, obtenemos el cartel Con redondeo, debiendo $0,40 igual logro el libre deuda lo que es incorrecto y le hace perder dinero quien nos encargó hacer el sistema. Es una situación complicada para nosotros ya que desde el punto de vista del acreedor, es una atrocidad.
Este problema lo tenemos en cualquier lenguaje de programación y también lo tenemos en muchas aplicaciones muy usadas para hacer cálculos con montos de dinero.
La única solución que ofrecen al problema en el caso de esa planilla de cálculo tan famosa es “redondear” (http://support.microsoft.com/kb/214118/es).
La causa del problema está en la representación binaria de los números de coma flotante. Como no todos los números son representables se tiene que buscar el número más cercano al especificado y eso implica un error en la representación. Por ejemplo el número 0,11 no es representable como double (porque en binario es periódico) y es aproximado con 0,11000000000000000056.
Los números de coma flotante están definidos por el estándar IEEE-754 que data de 1985. A partir de 2008, el estándar incorporó además de los números de binarios de coma flotante que ya existían (que usan base 2 como los float y double) nuevos números decimales de coma flotante (que usan base 10).
Esa es la solución al problema de la precisión que estamos teniendo. Vamos a reescribir la función de esta forma:
_Decimal64 actualizarDeuda(_Decimal64 deuda, _Decimal64 pagos[],int cantPagos){ _Decimal64 deudaActualizada = deuda; int i; for(i = 0; i<cantPagos;i++){ deudaActualizada -= pagos[i]; } return deudaActualizada; }
Usamos el tipo _Decimal64 en lugar de double. El resto es igual.
Y ahora sin aplicar ningún redondeo, las cuentas simplemente dan el resultado esperado.
_Decimal64 deuda = 308.21dd; /* el sufijo dd es el que corresponde al tipo _Decimal64 */ _Decimal64 pagos[4] = {8.01dd, 0.20dd, 299.99dd, 0.01dd}; _Decimal64 miDeuda = actualizarDeuda(deuda, pagos, 4); if(miDeuda == 0.0dd){ printf("Con _Decimal64, obtengo en libre deuda normalmente.\n"); } else { printf("Con _Decimal64, al Veraz.\n"); }
El soporte de decimales de coma flotante está disponible a partir de GCC 4.2, pero no está disponible para todas las plataformas. En particular en Windows con Cygwin obtenemos un error que dice “error: decimal floating point not supported for this target”.
Otros compiladores sí lo soportan en Windows como por ejemplo el de Intel. http://software.intel.com/en-us/articles/using-decimal-floating-point-with-intel-c-compiler/.