¿Estás cansado de los chistes tontos de JS? Escribe tu biblioteca

Hay muchas cosas en JavaScript que atraen la pregunta "¿Qué ???". A pesar de que la mayoría tienen una explicación lógica, si se profundiza, aún pueden resultar sorprendentes. Pero JavaScript definitivamente no merece bromas extravagantes. Por ejemplo, a veces vemos bromas como esta:





En este caso, la crítica es absolutamente inmerecida. Averigüemos por qué.






JavaScript, como cualquier otro lenguaje de programación popular , representa números usando un solo estándar. Para ser precisos, este es el estándar IEEE 754 para números en formato binario de 64 bits. Probemos este mismo chiste en otros idiomas:



¿Qué tal Ruby? ¿En qué idioma 0,1 + 0,2 no es igual a 0,3?



$ irb
irb(main):001:0> 0.1 + 0.2 == 0.3
=> false
irb(main):002:0> 0.1 + 0.2
=> 0.30000000000000004
      
      





¡Rubí! Qué lenguaje tan estúpido.



¿O Clojure? ¿En qué idioma 0,1 + 0,2 no es igual a 0,3?



$ clj
Clojure 1.10.1
user=> (== (+ 0.1 0.2) 0.3)
false
user=> (+ 0.1 0.2)
0.30000000000000004

      
      





Clojure! Qué lenguaje tan estúpido.



¿O qué tal el poderoso Haskell? ¿En qué idioma 0,1 + 0,2 no es igual a 0,3?



$ ghci
GHCi, version 8.10.1: https://www.haskell.org/ghc/  :? for help
Prelude> 0.1 + 0.2 == 0.3
False
Prelude> 0.1 + 0.2
0.30000000000000004

      
      





Haskell! Jajaja. Qué lenguaje tan estúpido ...



Entiendes la idea. El problema aquí no es JavaScript. Este es un gran problema en la representación binaria de números de coma flotante. Pero por ahora no quiero entrar en los detalles de IEEE 754. Porque si queremos números precisos arbitrarios, JavaScript los hace posibles. Desde octubre de 2019, BigInt forma parte oficialmente del estándar TC39 ECMAScript .



¿Por qué molestarse con esto?



Duramos con IEEE 754 durante años. Esto no parece ser un problema la mayor parte del tiempo. Cierto. Esto casi siempre no es un problema. Pero a veces sigue siendo un problema. Y en momentos como estos, es bueno tener opciones.



Por ejemplo, a principios de este año estaba trabajando en una biblioteca de gráficos. Quería dibujar gráficos de velas en SVG. Y SVG tiene una característica interesante llamada transformar . Puede aplicarlo a un grupo de elementos y cambiará el sistema de coordenadas de esos elementos. Entonces, con un poco de cuidado, puede simplificar la generación del área del gráfico. En lugar de calcular las coordenadas del gráfico para cada vela, especifica una transformación. Y luego defina cada vela usando los valores de datos brutos. Muy aseado. Al menos en teoría.



Pero en las pruebas de propiedad estaba teniendo problemas. Si el gráfico fuera pequeño y los valores de los datos fueran grandes, obtendría errores de redondeo. Y esto suele ser normal. Pero en el gráfico, algunos píxeles deben alinearse. De lo contrario, la imagen se verá mal. Entonces comencé a aprender BigInt. El resultado fue una biblioteca que llamé Ratio. Y te mostraré cómo está escrito.



Clase de fracción



El problema con los números de coma flotante es su representación binaria. Las computadoras realizan todos sus cálculos en forma binaria. Y para enteros, este binario está bien. El problema surge cuando queremos representar números decimales. Por ejemplo, en países de habla inglesa como Australia, escribimos decimales como este:







3.1415926







La parte a la izquierda de los puntos (...) es la parte completa y a la derecha del punto está la parte fraccionaria. Pero el problema es que algunos números tienen partes fraccionarias que no se pueden dividir fácilmente en dos. Entonces es difícil representarlos en binario. Pero el mismo problema surge en base 10. Por ejemplo, la fracción 10/9. Puedes intentar escribir algo como esto:







1.111111111111111111111111111111111111.11111111111111111111111111111111111







Sin embargo, esta es una aproximación. Para representar 10/9 con precisión, las unidades deben ser infinitas. Por lo tanto, debemos usar alguna otra notación para representar repeticiones. Por ejemplo esto:







1.1˙









Este punto encima de la unidad indica que las unidades continúan. Pero la mayoría de los lenguajes de programación no tienen este punto.



Tenga en cuenta que 10/9 tiene una precisión perfecta. Y todo lo que se necesita para ser exacto son dos piezas de información. Este es el numerador y denominador . Con un solo valor de BigInt, podemos representar números enteros arbitrariamente grandes. Pero si creamos un par de números enteros, podemos representar números arbitrariamente grandes o pequeños.



En JavaScript, podría verse así:



// file: ratio.js
export default class Ratio {
  // We expect n and d to be BigInt values.
  constructor(n, d) {
    this.numerator = n;
    this.denominator = d;
  }
}
      
      





Así que hicimos la parte más complicada. "Inventó" una forma de representar números con una precisión casi infinita. (Todavía estamos limitados por la memoria de nuestros dispositivos). Todo lo que queda es aplicar las matemáticas. Así que agreguemos funcionalidad.



Igualdad



Lo primero que quieres hacer es comparar las dos fracciones. ¿Para qué? Porque me gusta escribir exámenes primero . Si puedo comparar dos fracciones para determinar la igualdad, entonces escribir pruebas es mucho más fácil.



En un caso simple, escribir un método de igualdad es bastante fácil:



// file: ratio.js
export default class Ratio {
  constructor(n, d) {
    this.numerator = n;
    this.denominator = d;
  }

  equals(other) {
    return (
      this.numerator === other.numerator &&
      this.denominator === other.denominator
    );
  }
}
      
      





Eso es bueno. Pero sería bueno si nuestra biblioteca pudiera decir que 1/2 es 2/4. Para hacer esto, necesitas simplificar la fracción. Es decir, antes de verificar la igualdad, queremos reducir los numeradores y denominadores de ambas fracciones a números tan pequeños como sea posible. ¿Entonces como hacemos esto?



Un enfoque ingenuo es ejecutar todos los números desde 1 hasta min (n, d) (donde nn y dd son el numerador y el denominador, respectivamente). Y esto es lo que intenté al principio. El código se parecía a esto:



function simplify(numerator, denominator) {
    const maxfac = Math.min(numerator, denominator);
    for (let i=2; i<=maxfac; i++) {
      if ((numerator % i === 0) && (denominator % i === 0)) {
        return simplify(numerator / i, denominator / i);
      }
    }
    return Ratio(numerator, denominator);
}
      
      





Y, como era de esperar, es increíblemente lento. Mis pruebas tardaron una eternidad. Por tanto, necesitamos un enfoque más eficaz. Afortunadamente, un matemático griego lo encontró hace un par de milenios. La solución es aplicar el algoritmo de Euclides. Esta es una forma de encontrar el máximo común divisor de dos números enteros.



La versión recursiva del algoritmo de Euclid es hermosa y elegante:



function gcd(a, b) {
    return (b === 0) ? a : gcd(b, a % b);
}
      
      





La memorización es aplicable, lo que hace que el algoritmo sea bastante atractivo. Pero, por desgracia, todavía no tenemos recursividad de cola en V8 o SpiderMonkey . (Al menos no en el momento de escribir este artículo). Esto significa que si lo ejecutamos con enteros lo suficientemente grandes, obtendremos un desbordamiento de pila. Los números enteros grandes son como un punto de partida.



Entonces usemos la versión iterativa en su lugar:



// file: ratio.js
function gcd(a, b) {
    let t;
    while (b !== 0) {
        t = b;
        b = a % b;
        a = t;
    }
    return a;
}
      
      





No es tan elegante, pero cumple su función. Y con este código, podemos escribir una función para simplificar fracciones. Mientras hacemos esto, haremos un pequeño cambio para que los denominadores sean siempre positivos (es decir, para números negativos, solo el numerador cambia el signo).



// file: ratio.js

function sign(x) {
  return x === BigInt(0) ? BigInt(0)
       : x > BigInt(0)   ? BigInt(1) 
       /* otherwise */   : BigInt(-1);
}

function abs(x) {
  return x < BigInt(0) ? x * BigInt(-1) : x;
}

function simplify(numerator, denominator) {
  const sgn = sign(numerator) * sign(denominator);
  const n = abs(numerator);
  const d = abs(denominator);
  const f = gcd(n, d);
  return new Ratio((sgn * n) / f, d / f);
}

      
      





Y ahora podemos escribir nuestro método de igualdad:



// file: ratio.js -- inside the class declaration
  equals(other) {
    const a = simplify(this);
    const b = simplify(other);
    return (
      a.numerator === b.numerator &&
      a.denominator === b.denominator
    );
  }

      
      





Ahora puedes comparar dos fracciones para determinar la igualdad. Puede que no parezca mucho, pero significa que podemos escribir pruebas unitarias y asegurarnos de que nuestra biblioteca funcione como se espera.



Conversión a otros tipos



No te aburriré escribiendo todas las pruebas unitarias en mi biblioteca. Pero sería bueno convertir las fracciones a otros formatos. Por ejemplo, podríamos querer representarlos como una cadena en mensajes de depuración. O tal vez queremos convertirlos en números. Así que anulemos los métodos .toString () y .toValue () para nuestra clase.



El método .toString () es el más fácil, así que comencemos con eso.



// file: ratio.js -- inside the class declaration
  toString() {
    return `${this.numerator}/${this.denominator}`;
  }
      
      





Suficientemente simple. Pero, ¿qué pasa con la conversión de nuevo a un número? Una forma de hacer esto es simplemente dividir el numerador por el denominador:



// file: ratio.js -- inside the class declaration
  toValue() {
    return  Number(this.numerator) / Number(this.denominator);
  }
      
      





Esto a menudo funciona. Pero es posible que queramos modificar un poco el código. El objetivo de nuestra biblioteca es que usamos números enteros grandes para obtener la precisión que necesitamos. Y, a veces, estos enteros serán demasiado grandes para volver a convertirlos en Número. Pero queremos acercar a Number lo más posible a la verdad, siempre que sea posible. Así que hacemos algo de aritmética mientras convertimos BigInt en Number:



// file: ratio.js -- inside the class declaration
  toValue() {
    const intPart = this.numerator / this.denominator;
    return (
      Number(this.numerator - intPart * this.denominator) /
        Number(this.denominator) + Number(intPart)
    );
  }
      
      





Al extraer la parte entera, reducimos el tamaño de los valores de BigInt antes de convertirlos en Number. Hay otras formas de hacer esto que tienen menos problemas de alcance. Generalmente son más duros y lentos. Si está interesado, le recomiendo que los investigue más a fondo. Pero en este artículo, un enfoque simple cubre suficientes casos para ser útil.



Multiplicación y división



Hagamos algo con los números. ¿Qué tal la multiplicación y la división? Es fácil para las fracciones. Multiplica los numeradores por los numeradores y los denominadores por los denominadores.



// file: ratio.js -- inside the class declaration
  times(x) {
    return simplify(
      x.numerator * this.numerator,
      x.denominator * this.denominator
    );
  }
      
      





La división es similar al código anterior. Da la vuelta a la segunda fracción y luego multiplica.



// file: ratio.js -- inside the class declaration
  divideBy(x) {
    return simplify(
      this.numerator * x.denominator,
      this.denominator * x.numerator
    );
  }
      
      





Adición y sustracción



Ahora tenemos multiplicación y división. Lógicamente, lo siguiente que hay que escribir es la suma y la resta. Esto es un poco más complicado que la multiplicación y la división. Pero no demasiado.

Para sumar dos fracciones, primero debes llevarlas al mismo denominador y luego sumar los numeradores. En código, podría verse algo como esto:



// file: ratio.js -- inside the class declaration
  add(x) {
    return simplify(
      this.numerator * x.denominator + x.numerator * this.denominator,
      this.denominator * x.denominator
    );
  }
      
      





Todo se multiplica por los denominadores. Y usamos simplify () para mantener la fracción lo más pequeña posible en términos de números de numerador y denominador.

La resta es similar a la suma. Estamos manipulando las dos fracciones para que los mismos denominadores se alineen como antes. Entonces no sumamos, sino que restamos.



// file: ratio.js -- inside the class declaration
  subtract(x) {
    return simplify(
      this.numerator * x.denominator - x.numerator * this.denominator,
      this.denominator * x.denominator
    );
  }
      
      





Entonces, tenemos los operadores básicos. Puede sumar, restar, multiplicar y dividir. Pero todavía necesitamos algunos otros métodos. En particular, los números tienen una propiedad importante: podemos compararlos entre sí.



Comparaciones



Ya hemos hablado de .equals (). Pero necesitamos algo más que igualdad. También nos gustaría poder definir las proporciones mayor-menor. Por lo tanto, crearemos un método .lte () que nos dirá si una fracción es menor o igual que otra fracción. Al igual que con .equals (), no es obvio cuál de los dos es más pequeño. Para compararlos, necesitamos convertir ambos al mismo denominador, luego comparar los numeradores. Con un poco de simplificación excesiva, podría verse así:



// file: ratio.js -- inside the class declaration
  lte(other) {
    const { numerator: thisN, denominator: thisD } = simplify(
      this.numerator,
      this.denominator
    );
    const { numerator: otherN, denominator: otherD } = simplify(
      other.numerator,
      other.denominator
    );
    return thisN * otherD <= otherN * thisD;
  }

      
      





Una vez que obtengamos .lte () y .equals (), podemos imprimir el resto de las comparaciones. Puede elegir cualquier operador de comparación. Pero si tenemos iguales () y>, <, ≥ o ≤, entonces podemos inferir el resto usando lógica booleana. En este caso, elegimos lte () porque el estándar FantasyLand lo usa . Otros operadores pueden tener este aspecto:



// file: ratio.js -- inside the class declaration
  lt(other) {
    return this.lte(other) && !this.equals(other);
  }

  gt(other) {
    return !this.lte(other);
  }

  gte(other) {
    return this.gt(other) || this.equals(other);
  }

      
      





Redondeo



Ahora podemos comparar fracciones. Y también podemos multiplicar y dividir, sumar y restar. Pero si vamos a divertirnos más con nuestra biblioteca, necesitamos más herramientas. Los objetos de conveniencia de JavaScript Math contienen los métodos .floor () y .ceil ().

Comencemos con .floor (). Floor toma un valor y lo redondea hacia abajo. Con números positivos, esto significa que nos quedamos con la parte completa y descartamos el resto. Pero para los números negativos redondeamos desde cero, por lo que es necesario prestar un poco más de atención a los números negativos.



// file: ratio.js -- inside the class declaration
  floor() {
    const one = new Ratio(BigInt(1), BigInt(0));
    const trunc = simplify(this.numerator / this.denominator, BigInt(1));
    if (this.gte(one) || trunc.equals(this)) {
      return trunc;
    }
    return trunc.minus(one);
  }
      
      





Ahora puede usar el código anterior para calcular los valores redondeados.



// file: ratio.js -- inside the class declaration
  ceil() {
    const one = new Ratio(BigInt(1), BigInt(0));
    return this.equals(this.floor()) ? this : this.floor().add(one);
  }
      
      





Ahora tenemos la mayor parte de lo que se necesita para muchas operaciones matemáticas. Y con .toValue () podemos convertir fácilmente los cálculos a números decimales. Pero, ¿y si queremos convertir un número de punto flotante en una fracción?



Número a fracción



Convertir números a fracciones es más difícil de lo que parece a primera vista. Y hay muchas formas diferentes de hacer esta transformación. Mi implementación no es la más precisa, pero es lo suficientemente buena. Para que funcione, primero convertimos el número a una cadena que, como sabemos, adoptará un formato de secuencia. Para hacer esto, JavaScript nos proporciona el método .toExponential (). El método devuelve un número en notación exponencial. A continuación, se muestran algunos ejemplos para ayudarlo a comprender la idea:



let x = 12.345;
console.log(x.toExponential(5));
// ⦘ '1.23450e+1''

x = 0.000000000042;
console.log(x.toExponential(3));
// ⦘ '4.200e-11'

x = 123456789;
console.log(x.toExponential(4));
// ⦘ '1.2346e+8'

      
      





El código funciona representando un número como un valor decimal normalizado y un multiplicador. El bit decimal normalizado se llama mantisa y el factor se llama exponente. Aquí "normalizado" significa que el valor absoluto de la mantisa es siempre menor que 10. Y el exponente siempre es ahora 10. Indicamos el inicio del factor con la letra 'e' (abreviatura de 'exponente').



La ventaja de esta notación es que es consistente. Siempre hay exactamente un dígito a la izquierda del punto decimal. Y .toExponential () nos permite especificar cuántos dígitos significativos queremos. Luego viene la 'e' y el exponente es siempre un número entero. Dado que el valor es secuencial, podemos usar una expresión regular descarada para analizarlo.



El proceso es algo como esto. Como se mencionó, .toExponential () toma un parámetro para especificar el número de dígitos significativos. Necesitamos tantos números como sea posible. Por lo tanto, establecemos la precisión en 100 (que es lo que permiten la mayoría de los motores JavaScript). Para este ejemplo, sin embargo, nos quedaremos con una precisión de 10. Ahora imagina que tenemos el número 0.987654321e0. Queremos mover el punto decimal 10 dígitos a la derecha. Eso nos daría 9876543210. Luego, divida por 10 ^ 10 para obtener 9876543210/100000000. Esto, a su vez, se simplifica a 987654321/100000000.



Pero debemos prestar atención a este expositor. Si tenemos un número como 0.987654321e9, entonces todavía desplazaremos el punto decimal 10 dígitos hacia la derecha. Pero dividimos por diez, elevado a 10-9 = 1.







0.987654321×109=9876543210/101=











987654321/1







Para mantenerlo así, hemos definido un par de funciones auxiliares:



// Transform a ‘+’ or ‘-‘ character to +1 or -1
function pm(c) {
  return parseFloat(c + "1");
}

// Create a new bigint of 10^n. This turns out to be a bit
// faster than multiplying.
function exp10(n) {
  return BigInt(`1${[...new Array(n)].map(() => 0).join("")}`);
}
      
      





Con su ayuda, podemos reconstruir toda la función fromNumber ().



// file: ratio.js -- inside the class declaration
  static fromNumber(x) {
    const expParse = /(-?\d)\.(\d+)e([-+])(\d+)/;
    const [, n, decimals, sgn, pow] =
      x.toExponential(PRECISION).match(expParse) || [];
    const exp = PRECISION - pm(sgn) * +pow;
    return exp < 0
      ? simplify(BigInt(`${n}${decimals}`) * exp10(-1 * exp), BigInt(1))
      : simplify(BigInt(`${n}${decimals}`), exp10(exp));
  }

      
      





La mayoría de las funciones básicas están cubiertas. Podemos pasar de números a fracciones y viceversa. Pero para mi aplicación particular, necesitaba más. En particular, fue necesario encontrar exponenciaciones y logaritmos.



Exponenciación



La exponenciación es cuando un número se multiplica muchas veces por sí mismo. Por ejemplo, 2 ^ 3 = 2 × 2 × 2 = 8. Para casos simples donde el grado es un número entero, hay un operador BigInt: ** incorporado. Entonces, si subimos una fracción a la potencia, esa es una buena opción. Así es como una fracción se eleva a una potencia:





(xy)n=xnyn







Por lo tanto, la primera parte de nuestro método de exponenciación podría verse así:



// file: ratio.js -- inside the class declaration
  pow(exponent) {
    if (exponent.denominator === BigInt(1)) {
        return simplify(
            this.numerator ** exponent.numerator,
            this.denominator ** exponent.numerator
        );
    }
  }
      
      





Funciona genial. Bueno ... mayormente bueno. Ahora las cosas se complican más. Fuera de las limitaciones de las garantías y las matemáticas, tenemos que hacer algunas concesiones. Es posible que tengamos que sacrificar la precisión para obtener una respuesta en un plazo razonable.



La exponenciación genera fácilmente grandes números. Y cuando los números aumentan, las cosas se ralentizan. Mientras escribía este artículo, también escribí cálculos que no se completaron durante un período de días. Entonces debes tener cuidado. Pero eso está bien. Todo viene por BigInt.



Pero hay otro problema. ¿Y si el denominador del título no es uno? Por ejemplo, ¿qué pasaría si quisiéramos calcular 8 ^ (2/3)?



Afortunadamente, podemos dividir este problema en dos problemas más pequeños. Queremos reducir una fracción a la potencia de otra. Por ejemplo, podemos atribuir x / y a a / b. Las leyes de exponenciación establecen que lo siguiente es equivalente:





(xy)ab=((xy)1b)a=(x1by1b)a







Ya sabemos cómo convertir un BigInt en el poder de otro BigInt. Pero, ¿qué pasa con los grados fraccionarios? Bueno, hay otro equivalente:





x1n=xn







Es decir, reducir xx a la potencia 1n1n equivale a encontrar la raíz n-ésima de xx. Esto significa que si encontramos una manera de calcular la raíz n-ésima de BigInt, entonces podemos calcular cualquier grado.



Con una búsqueda web bien pensada, encontrar un algoritmo para estimar la raíz enésima no debería llevar mucho tiempo. El método más común es el método de Newton . Funciona desde la evaluación, rr. Luego se realiza un cálculo para obtener la mejor estimación:





rx1nr=1n((n1)r+(xrn1))







Seguimos repitiendo estos cálculos hasta que alcanzamos la precisión deseada. Desafortunadamente, hay algunas raíces que no se pueden representar como una fracción finita. En otras palabras, necesitamos valores BigInt infinitamente largos para obtener una precisión perfecta. En la práctica, esto significa que debemos elegir una restricción de iteración arbitraria.



Volveremos a este punto. Por ahora, descubramos cómo calcular una raíz n-ésima razonablemente precisa. Dado que la estimación rr será una fracción, podemos escribirla como:





r=ab.







Y esto nos permite reescribir los cálculos así:





ab=(n1)an+xbnnban1







Ahora todo está en términos de cálculo de enteros, adecuado para su uso con BigInt. Siéntase libre de insertar abab en la ecuación para r′r ′ anterior y verifique mis hallazgos. En JavaScript, se ve así:



const estimate = [...new Array(NUM_ITERATIONS)].reduce(r => {
  return simplify(
    (n - BigInt(1)) * r.numerator ** n + x * r.denominator ** n,
    n * r.denominator * r.numerator ** (n - BigInt(1))
  );
}, INITIAL_ESTIMATE);
      
      





Simplemente repetimos este cálculo hasta que alcancemos una precisión adecuada para nuestra enésima raíz estimada. El problema es que necesitamos encontrar valores adecuados para nuestras constantes. Es decir, NUM_ITERATIONS e INITIAL_ESTIMATE.

Muchos algoritmos comienzan con INITIAL_ESTIMATE uno. Ésta es una elección inteligente. A menudo, no tenemos una buena forma de adivinar cuál podría ser la raíz enésima. Pero escribamos "inconveniente". Supongamos (por ahora) que nuestro numerador y denominador están en el rango Número. Luego podemos usar Math.pow () para obtener la puntuación inicial. Podría verse así:



// Get an initial estimate using floating point math
// Recall that x is a bigint value and n is the desired root.
const initialEstimate = Ratio.fromNumber(
    Math.pow(Number(x), 1 / Number(n))
);
      
      





Entonces tenemos un valor para nuestra evaluación inicial. ¿Y NUM_ITERATION? Bueno, en la práctica, lo que hice fue comenzar con una suposición de 10. Y luego hice pruebas de propiedad. Seguí aumentando el número hasta que los cálculos estuvieron dentro de un período de tiempo razonable. Y la cifra que finalmente funcionó ... 1. Una iteración. Esto me entristece un poco, pero somos un poco más precisos que con los cálculos de coma flotante. En la práctica, puede aumentar este número si no está calculando muchas potencias fraccionarias.



Para simplificar, extraeremos el cálculo de la raíz n en una función separada. Poniéndolo todo junto, el código podría verse así:



// file: ratio.js -- inside the class declaration
  static nthRoot(x, n) {
    // Handle special cases
    if (x === BigInt(1)) return new Ratio(BigInt(1), BigInt(1));
    if (x === BigInt(0)) return new Ratio(BigInt(0), BigInt(1));
    if (x < 0) return new Ratio(BigInt(1), BigInt(0)); // Infinity

    // Get an initial estimate using floating point math
    const initialEstimate = Ratio.fromNumber(
      Math.pow(Number(x), 1 / Number(n))
    );

    const NUM_ITERATIONS = 1;
    return [...new Array(NUM_ITERATIONS)].reduce((r) => {
      return simplify(
        n -
          BigInt(1) * (r.numerator ** n) +
          x * (r.denominator ** n),
        n * r.denominator * r.numerator ** (n - BigInt(1))
      );
    }, initialEstimate);
  }

  pow(n) {
    const { numerator: nNumerator, denominator: nDenominator } = n.simplify();
    const { numerator, denominator } = this.simplify();
    if (nNumerator < 0) return this.invert().pow(n.abs());
    if (nNumerator === BigInt(0)) return Ratio.one;
    if (nDenominator === BigInt(1)) {
      return new Ratio(numerator ** nNumerator, denominator ** nNumerator);
    }
    if (numerator < 0 && nDenominator !== BigInt(1)) {
      return Ratio.infinity;
    }

    const { numerator: newN, denominator: newD } = Ratio.nthRoot(
      numerator,
      nDenominator
    ).divideBy(Ratio.nthRoot(denominator, nDenominator));
    return new Ratio(newN ** nNumerator, newD ** nNumerator);
  }

      
      





Imperfecto y lento. Pero la tarea se ha vuelto en gran medida factible. La pregunta sigue siendo cómo obtener la estimación si tenemos números enteros mayores que Number.MAX_VALUE. Sin embargo, dejaré esto como un ejercicio para el lector; este artículo ya es demasiado largo.



Logaritmos



Debo admitir que los logaritmos me dejaron perplejo durante algunas semanas. Para mi desarrollo, necesito calcular los logaritmos en base 10. Así que busqué algoritmos para calcular los logaritmos. Y hay muchos de ellos. Pero no pude encontrar uno que funcionara lo suficientemente bien como para ser incluido en la biblioteca de matemáticas.



Porqué es tan dificil? Mi objetivo era calcular logaritmos para obtener una mayor precisión que los números de coma flotante. De lo contrario, ¿por qué todo esto? Función de logaritmo de coma flotante, Math.log10 (), rápida e integrada. Entonces, miré algoritmos que brindan formas de calcular logaritmos iterativamente. Y funcionan. Pero tardan en llegar a una precisión superior a la del punto flotante. No solo un poco más lento. Mucho más lento.



A medida que avanzamos en las iteraciones, la fracción que construimos se vuelve más precisa. Pero esta precisión tiene un precio. Los valores de BigInt en fracciones son cada vez más grandes. Y a medida que crecen, comienzan a tardar en multiplicarse. En algún momento, dejé el cálculo durante tres días. Pero mientras se realizaban los cálculos, recordé algo.



Recordé que necesitaba un método log10 () para poder calcular buenos valores escalados para los gráficos. Y para estos cálculos, cada vez que llamaba a .log10 (), llamaba inmediatamente a .floor (). Esto significa que solo necesito la parte entera del logaritmo. Calcular el logaritmo con 100 decimales fue solo una pérdida de tiempo y energía.



Además, existe una manera fácil de calcular la parte completa del logaritmo en base 10. Todo lo que necesitamos es contar los números. Un intento ingenuo podría verse así:



// file: ratio.js -- inside the class declaration
  floorLog10() {
    return simplify(BigInt((this.numerator / this.denominator).toString().length - 1), BigInt(1));
  }
      
      





Desafortunadamente, esto no funciona para valores menores que 1. Pero aun así, podemos usar algunas leyes logarítmicas para trabajar con ese valor.





log10(ab)=log10(a)log10(b)log10(1x)=log10(1)log10(x)=log10(x)







Por lo tanto:





log10(ba)=log10(ab)







Poniéndolo todo junto, obtenemos un método floorLog10 () más robusto:



// file: ratio.js -- inside the class declaration

  invert() {
    return simplify(this.denominator, this.numerator);
  }

  floorLog10() {
    if (this.equals(simplify(BigInt(0), BigInt(1)))) {
      return new Ratio(BigInt(-1), BigInt(0));
    }
    return this.numerator >= this.denominator
      ? simplify((this.numerator / this.denominator).toString().length - 1, 1)
      : simplify(BigInt(-1), BigInt(1)).subtract(this.invert().floorLog10());
  }
      
      





Otra vez. ¿Por qué sufrir?



Por el momento, la biblioteca tiene todas las funciones necesarias para mi aplicación, para trabajar con gráficos. Pero aún puede ser interesante, ¿por qué todo este problema? Ya existen varias bibliotecas de precisión arbitrarias. ¿Por qué no usar uno de ellos y terminar con él?



Para ser honesto, usaría una biblioteca existente en la mayoría de los casos. Especialmente si tengo prisa. No tiene sentido hacer todo esto si alguien ya ha hecho un trabajo superior.



La palabra clave aquí es superior. Y aquí es donde entran en juego mis motivos para querer escribir mi propia biblioteca. El método floorLog10 () anterior es un ejemplo perfecto. Proporciona el cálculo exacto que necesito para lo que quiero hacer. Lo hace de manera eficiente, en unas seis líneas de código.



Si tuviera que usar la biblioteca de otra persona, me encontraría con una de dos cosas:



  1. Los desarrolladores no implementaron log10 () ni ningún otro método logarítmico.


o



  1. Los desarrolladores han implementado el método log10 () (o equivalente).


En el primer escenario, aún tendría que escribir floorLog10 (). En la segunda situación, probablemente usaría su método logarítmico. Y mi código sería más lento y complejo de lo que debería ser.



Escribir mi propia biblioteca me permite adaptarla a mi aplicación. Por supuesto, otras personas pueden encontrar útil el código, pero yo no soy responsable de sus necesidades. Para que mi aplicación no tenga que llevar un código complejo que nunca usa.



Además de todo esto, he aprendido mucho creando mi propia biblioteca. Ahora entiendo las limitaciones de BigInt en la práctica mucho mejor que antes. Sé que puedo ajustar el rendimiento del enésimo método raíz. Puedo modificarlo dependiendo de la cantidad de cálculos que esté haciendo y de la precisión que necesite.



A veces vale la pena escribir su propia biblioteca de propósito general. Incluso si no planea abrir el código. Incluso si nadie más lo usa. Puedes aprender mucho y también puede traer alegría.



Si desea obtener más información sobre problemas de coma flotante, consulte 0.30000000000000004.com . Y si desea ver toda la biblioteca y hacer algunos cálculos, puede consultar este sandbox con el código .



imagen






Otras profesiones y cursos


















All Articles