Los números fantasmas en Javascript

08 Abr 2011

Introducción

Vía Twitter me llegó un interesante problema relacionado con el tratamiento que Javascript hace de los números grandes.

En este caso, se trataba de un error que se producía cuando manejábamos determinados documentos de una colección NoSQL. Y es que, en este tipo de software, es fácil terminar trabajando con cifras muy largas correspondientes a los IDs generados de forma automática para cada documento y versión:

234567765432346
3456743256776534
123456543123876523
//...

El error era muy difícil de detectar, pero tras varias pruebas, quedaba claro que no se estaban realizando bien las operaciones aritméticas más básicas.

Tomemos el siguiente ejemplo:

var myID = 12345678912345678;
 
console.log(myID); // 12345678912345678

Todo correcto: obtenemos el resultado esperado. Sin embargo, a poco que juguemos con este número, empiezan a surgir los fantasmas

Problemas de aritmética básica

Probemos a incrementar una unidad:

var myID = 12345678912345678;
 
console.log( myID ); // 12345678912345678
console.log( ++myID ); // 12345678912345680 (?!)

Vaya; algo ha ido mal y, en lugar de una unidad, se ha incrementado dos. Probemos a ver qué ocurre si realizamos una suma ordinaria:

var myID = 12345678912345678;
 
console.log( myID ); // 12345678912345678
console.log( myID + 1 ); // 12345678912345680 (!!)

Raro, raro. El procesador matemático parece que se equivoca en la suma… ¿Y si continuamos incrementando?

var myID = 12345678912345678;
for( x = 0; x < 10; x++ ){
  console.log( myID++ );
}
 
// 12345678912345678
// 12345678912345680
// 12345678912345680
// 12345678912345680
// ...

Vale; tenemos un problema; parece que no podemos confiar en las funciones de cálculo con números altos: a partir de un determinado rango, los operadores de incremento (++) y decremento (- -) y las operaciones aritméticas dan un resultado erróneo.

La regla de los 17 dígitos

Para acotar el margen de error, podemos confiar en que aquellas operaciones aritméticas cuyos operandos o resultados no excedan de 17 dígitos, se realizan correctamente.

Una pista sobre ese comportamiento la encontramos preguntando al propio intérprete:

console.log( ( "" + Math.PI ).length ); // 17

Nota: En el código anterior hemos forzado la conversión del número PI a una cadena utilizando la coerción de tipos. Puede encontrarse más información y ejemplos sobre este proceso en el artículo «Coerción de Datos en Javascript«.

Es hora de recurrir a la especificación ECMAScript para documentarnos mejor sobre el comportamiento del objeto Number.

El tipo Number

Según la especificación ECMA262, el tipo Number tiene 18437736874454810627 (que es 264-253+3) valores correspondientes a la máxima precisión para números en coma flotante que establece el IEEE Standard for Binary Floating Point Arithmetic. Hay que tener en cuenta que estos posibles valores se reparten de forma equitativa entre números positivos y negativos, por lo que finalmente tendríamos 9218868437227405312 como máximo y -9218868437227405312 como mínimo. El 0 quedaría fuera de la horquilla al no disponerse de valores reales para +0 y -0.

NOTA: Cabe recordar que, en Javascript, todos los números se tratan internamente como flotantes (incluso aquellos devueltos por la función interna parseInt).

Sin embargo, hay que tener en cuenta que algunos operadores ECAMScript son capaces de manejar enteros dentro de un rango inferior, concretamente el comprendido entre -231 y 231-1. Estos operadores aceptan cualquier valor del tipo Number pero solo son capaces de operar con aquellos comprendidos entre los anteriores. Esto quiere decir que, pese a que Number es capaz de almacenar en teoría cualquier valor, en la práctica solo puede operar con un rango determinado de ellos.

NOTA: La documentación al respecto puede consultarse directamente en el apartado Number Type de la especificación.

Infinity

Además del conjunto de números reales, Javascript tiene una palabra reservada para aquellos valores más grandes cuyo tratamiento pasa a considerarse de un modo abstracto durante las operaciones aritméticas. Este valor es el denominado Infinity.

Infinity corresponde exactamente a aquellos números cuyo valor, tanto positivo como negativo, excede el limite superior o inferior de los números permitidos para el formato de tipo en coma flotante. Dicho valor podemos obtenerlo directamente del intérprete mediante un par de sencillos comandos:

console.log( Number.MAX_VALUE ) // 1.7976931348623157e+308
console.log( Number.MIN_VALUE ) // 5e-324

De hecho, Infinity realmente corresponde con dos valores distintos: Infinity positivo e Infinity Negativo.

NOTA: Para una explicación de cómo se obtienen estos valores, podemos revisar el artículo en Wikipedia sobre la precisíon de números en coma flotante establecida en el IEEE 754-1985.

Podemos comprobar la conversión incrementando por ejemplo la cifra anterior:

console.log( Infinity === 1.7976931348623157E+309 ) // true;

Sin embargo, no podemos realizar la operación inversa (conseguir un número real a partir del valor Infinity). La siguiente lista muestra los resultados obtenidos con diversas operaciones sobre este valor:

console.log( Infinity.length ); // undefined
console.log( Infinity - 99999999 ); // Infinity
console.log( Infinity - Infinity ); // NaN
console.log( Infinity + Infinity ); // Infinity
console.log( Infinity / Infinity ); // NaN
console.log( Infinity / 0 ); // Infinity
console.log( "" + Infinity ); // Infinity (String)

Precisión Máxima: librerías externas

Si pese a todo nuestro proyecto precisa de manipular números grandes y las limitaciones del lenguaje son un serio problema, podemos recurrir a una compleja librería que permite precisamente eso: ofrecer la máxima precisión de alto nivel para realizar operaciones matemáticas sin límites de dígitos.

Explicar el funcionamiento de esta librería excede el propósito de este artículo pero básicamente almacena los dígitos como un objeto String que luego subdivide en elementos de un array. Este proceso hace posible manejar virtualmente números de cualquier magnitud.

En la web de referencia, encontramos varios ejemplos de uso así como un desarrollo completo del API.

Conclusión

Como desarrolladores, tarde o temprano, nos encontraremos con que los números en Javascript son difíciles de manejar. Primero, fue una actualización de WordPress donde los IDs de los comentarios se componían de números de 18 dígitos. Últimamente, de nuevo los identificadores, esta vez del software NoSQL, llevan la capacidad del intérprete hasta su límite con números muy largos en grandes colecciones de datos.

Mientras que las limitaciones teóricas están perfectamente definidas en los estándares (IEEE), en la práctica encontramos con que algunos operadores aritméticos no funcionan correctamente con un rango de números menor al que admiten. Descubrir estos errores en tiempo de desarrollo es extremadamente complejo, ya que implicaría un diseño de tests poco frecuente.

Conocer que existen ciertas limitaciones en este campo, puede ser de gran utilidad cuando nos enfrentemos con desarrollos en los que cabe la posibilidad de terminar manejando este tipo de valores.

Más:

{10} Comentarios.

  1. Josep Sayol

    Buenas! Sólo un detalle:


    var myID = 12345678912345678;

    console.log( myID ); // 12345678912345678
    console.log( myID++ ); // 12345678912345678 (?!)

    Este resultado és correcto ya que se está obteniendo el valor de myID ANTES del incremento 😉
    La demostración debería ser con console.log( ++myID ); y, en esta ocasión sí, el resultado que se obtiene (12345678912345680) és erróneo.

    Por lo demás, interesantísimo el artículo. Un saludo!

    • Carlos Benítez

      Tienes todas la razón Josep; se me ha colado el incremento al revés 🙂
      Ya está actualizado.

      Gracias!

  2. Alejandro

    Que curioso.

  3. gnz/vnk

    No me queda del todo claro qué quieres decir con lo del 0, lo de +0 y -0.

    • Carlos Benítez

      Trato de explicar mejor el caso especial del cero:

      El estándar para números de precisión doble en formato de 64-bit (IEEE 754) establece el número máximo de valores que puede comprender el objeto Number: 18437736874454810627.

      Como vemos, es un número impar. A esta cifra hay que restar el valor NaN, que Javascript maneja como un número más en determinados contextos.

      Así, tendríamos: 18437736874454810627 – 1(NaN) = 18437736874454810626

      Ese número de valores se reparte entre números positivos y negativos: 18437736874454810626 / 2 = 9218868437227405313.

      A esa nueva cantidad hay que sustraer para cada conjunto de números, el correspondiente a los valores del cero. Como en el caso de Infinity, disponemos en Javascript de un cero positivo y un cero negativo, representados como +0 y -0 respectivamente. Sin embargo, dado que en la práctica no existe diferencia alguna entre ambos valores, se fusionan en un único valor: 0 (cero a secas) que, a su vez, corresponde con el valor lógico Booleano.

      Por lo tanto, después de sustraer dichos valores, nos queda el número real de valores posibles para un número:
      18437736874454810626 – 1 (correspondiente al 0 positivo o negativo) = 18437736874454810625

      Por eso hablamos de que, en el conjunto anterior, no contemplamos el valor cero al no poder hacer distinción entre sus valores positivo y negativo.

      Saludos!

  4. gnz/vnk

    Aha… ya veo a lo que te refieres.

    Sin embargo, Javascript sí que hace distinción entre +0 y -0. Y sí que existe diferencia entre lo que es +0 y -0. No es muy habitual, pero en ocasiones, y en matemáticas, es un concepto importante. Nos aproximamos a un valor, ¿por arriba o por abajo?

    Y si tenemos

    var menoscero = 1 / -Infinity;
    var mascero = 1 / Infinity;

    ¿no podemos diferenciarlos? Bueno, si hacemos mascero === menoscero, no, no podemos diferenciarlos. Peeero si hacemos (x===0 && (1/x)===-Infinity) para el primero obtendremos true, pero para el segundo tendremos false.

    • Carlos Benítez

      Si es cierto que en Javascript existe tanto el valor de cero positivo como de cero negativo; de hecho, como hemos visto antes, se les reserva un valor de coma flotante a cada uno de ellos.

      Esto es así porque en las Ciencias de Computación, concretamente en las operaciones aritméticas en coma flotante, se suelen reservar valores para aquellos no normalizados además de para el citado NaN. Dentro de estos ‘valores no normalizados’, encontramos el -0 entendido como una extensión del conjunto de los números reales junto a otros como el +∞ y el −∞ (+Infinity y +Infinity en Javascript).

      En el intérprete ECMAScript, esta dualidad de valores no se tienen en cuenta, pues únicamente pueden resultar útiles para indicar, como bien has comentado, la dirección de aproximación hacia un redondeo. En el punto 8.5 de la especificación, se recoge este comportamiento denominado modo de ‘rendondeo al más cercano’.

      Este dato, más allá de un significado para la elaboración de estadísticas, no tiene aplicación en un entorno de cálculo aritmético real. De hecho, dada la calidad del redondeo en Javascript, este valor es meramente testimonial y despreciable.

      Usado de forma activa, ambos valores se mapean automáticamente en el valor único 0.

      Sin embargo, tus ejemplos son interesantes para demostrar que ambos valores están ahí.

      Gracias por el aporte en este tema tan complejo 🙂

  5. Madman

    ¡Interesante y esclarecedor post!. Ahora me explico muchas cosas XD

    Mi pregunta es:
    si sabes que vas a trabajar con números grandes ¿se podría solucionar esta eventualidad empleando las funciones ‘toPrecision’ o ‘toFixed’? podrías establecer una precisión de dígitos concreta tanto para enteros como para decimales. Estas funciones devuelven string, pero se podrían pasar a número para seguir operando, ¿no? ¿o me estoy equivocando?

    un saludo!

    • Carlos Benítez

      Hola;
      si sabemos que vamos que vamos a operar con número grandes, como por en ejmplo en el caso de un NoSQL, la mejor opción es recurrir a librerías del tipo a la que he puesto el enlace. Intermante, utilizan una serie de métodos para pasar dichos números a cadenas para luego operar con ellos mediante un almacenamiento intermedio en arrays utilizando expresiones regulares y funciones como toPrecision Round.

      Con tu propuesta a secas, es cierto que los números pasarían a convertirse en cadenas, pero no podríamos operar con ellos sin ‘truncarlos’: una vez que pasemos de los 17 dígitos, ya Javascript se hace un lío 🙂

      Existen otros métodos para conseguir un comportamiento parecido como, por ejemplo, alterar la base de los enteros. Esta técnica es muy utilizada por los prgramadores de videojuegos para mapear entornos utilizando el menor número posible de dígitos (estos dígitos luego formarán matrices que, a su vez, se corresponderían con los vértices de un polígono para un entorno 3D). Sin embargo, esto ya es más complejo y es posible que lo trate en breve.

      Un saludo!!

  6. Madman

    Gracias por la respuesta y explicación 🙂 Daba por hecho que no funcionaría pero me queda cubierta la duda perfectamente.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *