DEV Community

Cosas que no sabías que no sabías: los decimales y tú.

Ya escribí sobre esto anteriormente, en concreto, decía en ¿Dónde está mi dinero?

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.

Efectivamente, un soporte digital tiene problemas para representar todo aquello que esté relacionado con el infinito. Igual que un círculo, que tiene infinitos lados. ¿Te has dado cuenta de cómo en los videojuegos, nada llega a ser verdaderamente redondo?

Es fascinante. ¿Podemos llegar a solventar, de verdad, este problema?

Es una pregunta interesante. Ya sabemos que la respuesta es no, aunque hay varias formas de paliarlo. Al fin y al cabo, si un sistema, aunque utilice un soporte digital, tiene la suficiente precisión, es posible que sea indistinguible para el ser humano.

Efectivamente, C# tiene soporte para números con decimales que mantienen un buen soporte de precisión. Se trata del tipo decimal. Utilizando este tipo, se emplean muchos más bits para representar la mantisa, y solo unos pocos para representar el exponente.

Evidentemente esto ayuda, pero no es una solución definitiva. Por ejemplo, en el siguiente programa en Python:

x = 0.0

for i in range(100):   
    if (i != 0
    and i % 10 == 0
    and x % 10 != 0):
        print(f"ERROR {i} != {x}")
        break

    x += 0.1
Enter fullscreen mode Exit fullscreen mode

...avanzamos hasta que i es divisible por 10 (es decir, cada 10 elementos), y comprobamos si el resto de la división de x con 10 es 0. Si esto no se cumple, hemos encontrado una imprecisión. ¿Será un número muy elevado? ¡Solo estamos empleando incrementos de 0.1!

Pues el resultado es sorprendente:

$ python precision.py
ERROR 10 != 0.9999999999999999
Enter fullscreen mode Exit fullscreen mode

Ya arrastramos un error significativo después de... ¡tan solo 10 incrementos de 0.1 en 0.1!

Esto me hizo pensar... ¿era todo distinto en los tiempos del Speccy? Pues resulta que... no.

10 let x = 0
20 for i = 0 to 100
30     print x: pause 0
40     let x = x + 0.1
50 next i
Enter fullscreen mode Exit fullscreen mode

La salida es... ¿la esperada?

0
0.1
0.2
0.3
0.4
0.5
0.6
0.7
0.8
0.9
1
1.1
1.2
1.3
1.4
...
Enter fullscreen mode Exit fullscreen mode

Sumando de 0.1 en 0.1

Al encontrarme con esto, me puse a buscar las razones que pudieran explicar este comportamiento. Resulta que Sinclair Basic almacena las variables numéricas en 5 bytes, sin importar demasiado si son enteras o decimales. Si son enteras, los bits dedicados al exponente estarán vacíos, aspecto del que se aprovechan varias rutinas matemáticas en ROM. En cualquier caso, los números en Sinclair Basic no se "desbordan", sino que pasan a representarse como números en coma flotante.

10 let x = 1
20 for i = 0 to 100
30     print x: pause 0
40     let x = x * 100
50 next i
Enter fullscreen mode Exit fullscreen mode

Por ejemplo, ante un programa como el anterior, la salida pasa a coma flotante a partir de un cierto valor, hasta que admite que no puede representar valores tan grandes.

Números muy grandes en el Speccy

Esto es sencillo de conseguir en el Speccy, porque los números decimales y los números enteros comparten representación: 5 bytes con 0 como byte para el exponente, y un valor en el exponente en caso contrario. Nótese que 10 e+38 no deja de ser un número muy grande... ¡un 1 seguido de 38 ceros!

Pero volvamos al PC moderno... ¿podemos conseguir representar estos valores monetarios de forma exacta? Claro que podemos. Ya vimos que la trampa siempre proviene de varias operaciones matemáticas acumuladas. En el artículo enlazado, se guardaba el valor monetario como número entero multiplicado por cien y solo cuando se pedía se dividía entre cien.

También podemos guardar valor entero y céntimos en una estructura, como se puede ver a continuación.



public struct Money {
    public required int Value { get; init; }
    public required byte Cents { get; init; }

    public override string ToString()
    {
        return $"{this.Value},{this.Cents:00}";
    }

    public static explicit operator double(Money m)
    {
        return (double) ( (decimal) m );
    }

    public static explicit operator decimal(Money m)
    {
        return ( (decimal) m.Value ) + ( (decimal) m.Cents / 100 );
    }

    public static explicit operator int(Money m)
    {
        int toret = m.Value;

        if ( m.Cents >= 50 ) {
            toret += 1;
        }

        return toret;
    }

    public static Money From(int x)
    {
        return new Money{Value=x, Cents=0};
    }

    public static Money From(decimal x)
    {
        return new Money{
                    Value=(int) x,
                    Cents=(byte) ( ((int) ( x * 100) ) % 100 )};
    }

    public static Money From(double x)
    {
        return new Money{
                    Value=(int) x,
                    Cents=(byte) ( ((int) ( x * 100) ) % 100 )};
    }    
}
Enter fullscreen mode Exit fullscreen mode

Los lenguajes de programación intentan hacer el manejo de los datos numéricos tan intuitivo, que a veces nos olvidamos cómo se está representando realmente "por debajo".

Top comments (0)