Un buen día me topé con un test que verificaba si sabías de programación. Me decidí a hacerlo. ¿Por qué no? Al fin y al cabo, ¿qué podía salir mal?
El resultado del test fue el esperado... excepto por la primera pregunta. ¿Qué tipo de dato se utiliza para representar valores económicos? Imaginemos una aplicación bancaria. ¿Qué tipo de dato se utilizaría para representar el saldo de una cuenta? Si has pensado en un double (el tipo de dato de número real con mejor precisión que un float), como parece intuitivo, te remito a mi resultado del test: "Te pillé. Por precisión, se utilizan enteros."
Es totalmente cierto. La razón tiene que ver con los tipos de números reales (de coma flotante), y su falta de precisión. La coma flotante tiene varias representaciones para los mismos números, y de hecho, utilizar la igualdad para comparar números reales es arriesgado. ¿Por qué?
La razón tiene que ver con el estándar IEEE 754. Este estándar se publicó en 1985 (aunque se ha desarrollado y revisado a partir de esa fecha). Los números reales son un problema al fin y al cabo. Los números posibles entre 0.1 y 0.2 y entre 0.01 y 0.02 son los mismos: infinitos. En una representación digital, el infinito es una imposibilidad. Incluso los números enteros son infinitos, aunque al menos se pueden acotar a unos límites manejables o prácticos.
Ejemplos:
console.log(0.1 + 0.1 + 0.1)
// 0.30000000000000004
print(0.1 + 0.1 + 0.1)
# 0.30000000000000004
System.Console.WriteLine(0.1 + 0.1 + 0.1);
// 0.30000000000000004
Mmm... interesante. Escribamos un ejemplo. Un calculador de consumo de un vehículo.
public class FuelConsumption {
public FuelConsumption()
{
this._trips = new Stack<double>();
}
// The initial gas in the car.
public required double Litres { get; init; }
/// <summary>Adds a new trip to the account.</summary>
/// <param name="km">The distance, in kilometers.</param>
public void Add(double km)
=> this._trips.Push( km );
/// <summary>Gets the total distance run.</summary>
/// <returns>The distance, in kilometers.</returns>
public double Total()
=> this._trips.Sum();
/// <summary>A report of all trips, one per line.</summary>
/// <returns>A string with all the trips.</returns>
public string List()
{
var toret = new StringBuilder();
foreach(double km in this._trips.Reverse()) {
toret.Append( $"{km.ToString( "0.000" )} km.\n" );
}
return toret.ToString();
}
/// <summary>Calculate the average fuel consumption.</summary>
/// <returns>Fuel consumption in litres per 100 km.</returns>
public double AverageFuel()
=> this.Litres / ( this.Total() / 100 );
public override string ToString()
{
return this.List()
+ $"\n\nTotal: {this.Total().ToString( "0.000" )} km."
+ "\nFuel Consumption: "
+ $"{this.AverageFuel().ToString( "0.00" )} litres / 100 km.";
}
private Stack<double> _trips;
}
Veamos un ejemplo de uso de esta clase:
var fc = new FuelConsumption{ Litres = 30 };
fc.Add( 100.1 );
fc.Add( 100.1 );
fc.Add( 100.1 );
Console.WriteLine( fc );
La salida es la siguiente:
100,100 km.
100,100 km.
100,100 km.
Total: 300,300 km.
Fuel Consumption: 9,99 litres / 100 km.
Aparentemente, todo va bien, pero... hay que tener en cuenta que estamos formateando la salida de estos números reales. Limitando el número de dígitos para los decimales, forzamos un redondeo que es una "trampa" muy típica en el uso de números reales.
¿Qué pasa si creamos una prueba de unidad con nUnit para este código?
public class TestFuelConsumption {
[SetUp]
public void Setup()
{
_fc = new FuelConsumption{ Litres = 30 };
}
[Test]
public void TestTotal()
{
_fc.Add( 100.1 );
_fc.Add( 100.1 );
_fc.Add( 100.1 );
Assert.That( _fc.Total(), Is.EqualTo( 300.0 ) );
}
private FuelConsumption? _fc;
}
El resultado no deja lugar a dudas:
$ dotnet test
FuelConsumption/Tests/Tests.cs(27): error TESTERROR:
TestTotal (29ms): Error message: Assert.That(fc.Total(), Is.EqualTo( 300.0 ))
Expected: 300.30000000000001d
But was: 300.29999999999995d
Tests summary: total: 1; with errors: 1; correct: 0; omitted: 0; duration: 0,9 s
Este es típico problema de punto flotante que comprobamos en los ejemplos anteriores en JavaScript, Python o el mismo C#: la precisión en coma flotante, una vez que se producen varios cálculos acumulados, se degrada. La suma de 0.2 y 0.1 no produce 0.3, sino un valor cercano a 0.3.
El problema en una aplicación bancaria es obvio: con suficientes cálculos realizados, podemos llegar a perder o ganar un céntimo. Si continuamos, podemos llegar a perder euros enteros. Y esto no es aceptable para un banco, por supuesto. Bueno, probablemente para nosotros tampoco.
¿Cómo podemos llegar a solucionar esto? ¿Cómo podemos transformar el uso de números en punto flotante en números enteros? Podemos simplemente cambiar ligeramente de punto de vista. ¿Cuál es la precisión deseada?
En nuestra aplicación de cálculo medio de combustible, la precisión dada por metros sería más que suficiente. Entonces, podemos guardar los kilometrajes en metros, como números enteros (por ejemplo 98250 m.), en lugar de kilómetros (98.50 km.). Lo mejor de todo es que no tenemos que cambiar la interfaz de nuestra clase FuelConsumption: al fin y al cabo, los errores de precisión se provocan con cálculos acumulados continuados, no con uno solo. Es decir, podemos transformar metros a kilómetros (y viceversa), sin problema.
Vamos a utilizar dos funciones estáticas que transformen metros en kilómetros, y viceversa. Además, cambiamos ya el tipo de nuestro Stack para que almacene enteros.
class FuelConsumption {
public FuelConsumption()
{
this._trips = new Stack<int>();
}
// More things...
private static double KmFromMeters(int m)
{
return (double) m / 1000;
}
private static int MetersFromKm(double km)
{
return (int) ( km * 1000 );
}
private Stack<int> _trips;
}
Ahora solo tenemos que hacer dos cambios: al almacenar los recorridos, debemos convertir los kilómetros a metros. Cuando devolvemos el resultado de los cálculos, debemos convertir metros a kilómetros.
Lo primero es muy sencillo:
class FuelConsumption {
// More things...
public void Add(double km)
=> this._trips.Push( MetersFromKm( km ) );
}
El resto de la clase debe convertirse para devolver kilómetros. Nótese que las conversiones a kilómetros deben hacerse al final, dejando todos los cálculos con enteros. Por ejemplo, FuelConsumption.Total(): double, que suma todos los kilómetros recorridos debe hacer la suma con valores enteros y solo al final convertir a kilómetros.
class FuelConsumption {
// More things...
public double Total()
=> KmFromMeters( this._trips.Sum() );
public string List()
{
var toret = new StringBuilder();
foreach(int km in this._trips.Reverse()) {
toret.Append( $"{KmFromMeters( km ).ToString( "0.000" )} km.\n" );
}
return toret.ToString();
}
}
Estamos listos. ¿Pasará ahora nuestro código los tests? Véamoslo.
$ dotnet test
Tests summary: total: 1; with errors: 0; correct: 1; omitted: 0; duration: 0,9 s
¡Funciona!
Lección aprendida: cuando necesitemos precisión, siempre, siempre números enteros.
Top comments (1)
Lo primero que pensé fue en el tipo de datos decimal en C#. Aunque después de leer tu post, recordé que esa es la misma solución que utiliza Stripe para las monedas. Dependiendo la moneda, las cantidades están multiplicadas por un factor (100 para el caso de USD)