Cansado de piadas bobas de JS? Escreva sua biblioteca

Há muitas coisas em JavaScript que imploram pela pergunta "O quê ???". Apesar do fato de a maioria deles ter uma explicação lógica, se você se aprofundar, eles ainda podem ser surpreendentes. Mas JavaScript definitivamente não merece piadas ultrajantes. Por exemplo, às vezes vemos piadas como esta:





Nesse caso, a crítica é absolutamente imerecida. Vamos descobrir o porquê.






JavaScript, como qualquer outra linguagem de programação popular , representa números usando um único padrão. Para ser mais preciso, este é o padrão IEEE 754 para números no formato binário de 64 bits. Vamos tentar testar a mesma piada em outras linguagens:



Que tal Ruby? Em que idioma 0,1 + 0,2 não é 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
      
      





Rubi! Que linguagem estúpida.



Ou Clojure? Em que idioma 0,1 + 0,2 não é 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! Que linguagem estúpida.



Ou que tal o poderoso Haskell? Em que idioma 0,1 + 0,2 não é 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! Hahaha. Que linguagem estúpida ...



Você entendeu. O problema aqui não é JavaScript. Este é um grande problema na representação binária de números de ponto flutuante. Mas, por enquanto, não quero entrar em detalhes sobre o IEEE 754. Porque, se quisermos números precisos arbitrários, o JavaScript os torna possíveis. Desde outubro de 2019, o BigInt faz parte oficialmente do padrão TC39 ECMAScript .



Por que se preocupar com isso?



Duramos muito com o IEEE 754. Isso não parece ser um problema na maioria das vezes. Verdadeiro. Isso quase sempre não é um problema. Mas às vezes ainda é um problema. E em momentos como este, é bom ter opções.



Por exemplo, no início deste ano, eu estava trabalhando em uma biblioteca de gráficos. Eu queria desenhar gráficos de velas em SVG. E o SVG tem um recurso bacana chamado transform . Você pode aplicá-lo a um grupo de itens e isso mudará o sistema de coordenadas para esses itens. Portanto, com um pouco de cuidado, você pode simplificar a geração da área do gráfico. Em vez de calcular as coordenadas do gráfico para cada vela, você especifica uma transformação. E então você define cada vela usando os valores de dados brutos. Muito legal. Pelo menos em teoria.



Mas em testes de propriedade eu estava tendo problemas. Se o gráfico fosse pequeno e os valores dos dados grandes, eu obteria erros de arredondamento. Isso geralmente é normal. Mas no gráfico, alguns pixels devem estar alinhados. Caso contrário, o desenho parece errado. Então comecei a aprender BigInt. O resultado foi uma biblioteca que chamei de Ratio. E vou mostrar como está escrito.



Classe de fração



O problema com os números de ponto flutuante é sua representação binária. Os computadores realizam todos os seus cálculos na forma binária. E para inteiros este binário está bom. O problema surge quando queremos representar números decimais. Por exemplo, em países de língua inglesa como a Austrália, escrevemos decimais como este:







3.1415926







A parte à esquerda dos pontos (...) é a parte inteira e à direita do ponto está a parte fracionária. Mas o problema é que alguns números têm partes fracionárias que não podem ser facilmente divididas em duas. Portanto, é difícil representá-los em binário. Mas o mesmo problema surge na base 10. Por exemplo, a fração 10/9. Você pode tentar escrever algo assim:







1.111111111111111111111111111111111111.11111111111111111111111111111111111







No entanto, esta é uma aproximação. Para representar 10/9 com precisão, as unidades devem ser infinitas. Portanto, devemos usar alguma outra notação para representar as repetições. Por exemplo, este:







1.1˙









Este ponto acima da unidade indica que as unidades estão continuando. Mas a maioria das linguagens de programação não tem esse ponto.



Observe que 10/9 tem uma precisão perfeita. E tudo o que é preciso para ser preciso são duas informações. Este é o numerador e denominador . Com um único valor BigInt, podemos representar inteiros arbitrariamente grandes. Mas se criarmos um par de inteiros, podemos representar números arbitrariamente grandes ou pequenos.



Em JavaScript, pode ser assim:



// 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;
  }
}
      
      





Então fizemos a parte mais complicada. "Inventou" uma forma de representar números com precisão quase infinita. (Ainda estamos limitados pela memória de nossos dispositivos.) Resta apenas aplicar a matemática. Então, vamos adicionar funcionalidade.



Igualdade



A primeira coisa que você deseja fazer é comparar as duas frações. Pelo que? Porque eu gosto de escrever testes primeiro . Se eu puder comparar duas frações para igualdade, escrever testes será muito mais fácil.



Em um caso simples, escrever um método de igualdade é muito 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
    );
  }
}
      
      





Isso é bom. Mas seria bom se nossa biblioteca pudesse dizer que 1/2 é 2/4. Para fazer isso, você precisa simplificar a fração. Ou seja, antes de verificar a igualdade, queremos reduzir os numeradores e denominadores de ambas as frações ao menor número possível. Então, como fazemos isso?



A abordagem ingênua é executar todos os números de 1 a min (n, d) (onde nn e dd são o numerador e o denominador, respectivamente). E foi isso que tentei no início. O código era parecido com este:



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);
}
      
      





E, como você esperaria, é incrivelmente lento. Meus testes demoraram uma eternidade. Portanto, precisamos de uma abordagem mais eficiente. Felizmente, um matemático grego o descobriu alguns milênios atrás. A solução é aplicar o algoritmo de Euclides. Essa é uma maneira de encontrar o máximo divisor comum de dois inteiros.



A versão recursiva do algoritmo de Euclides é bonita e elegante:



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





A memorização é aplicável, o que torna o algoritmo bastante atraente. Mas, infelizmente, não temos recursão de cauda no V8 ou SpiderMonkey ainda . (Pelo menos não no momento em que este artigo foi escrito.) Isso significa que se executarmos com números inteiros grandes o suficiente, obteremos um estouro de pilha. Os números inteiros grandes são como um ponto de partida.



Então, vamos usar a versão iterativa:



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





Não é tão elegante, mas dá conta do recado. E com este código, podemos escrever uma função para simplificar frações. Enquanto fazemos isso, faremos uma pequena mudança para que os denominadores sejam sempre positivos (ou seja, para números negativos, apenas o numerador muda o sinal).



// 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);
}

      
      





E agora podemos escrever nosso método de igualdade:



// 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
    );
  }

      
      





Agora você pode comparar duas frações de igualdade. Pode não parecer muito, mas significa que podemos escrever testes de unidade e garantir que nossa biblioteca funcione conforme o esperado.



Conversão para outros tipos



Não vou aborrecê-lo escrevendo todos os testes de unidade da minha biblioteca. Mas seria bom converter as frações para outros formatos. Por exemplo, podemos querer representá-los como uma string em mensagens de depuração. Ou talvez queiramos convertê-los em números. Portanto, vamos substituir os métodos .toString () e .toValue () para nossa classe.



O método .toString () é o mais fácil, então vamos começar com ele.



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





Simples o suficiente. Mas que tal voltar a ser um número? Uma maneira de fazer isso é simplesmente dividir o numerador pelo denominador:



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





Isso geralmente funciona. Mas podemos querer ajustar um pouco o código. O ponto principal de nossa biblioteca é que usamos números inteiros grandes para obter a precisão de que precisamos. E às vezes esses números inteiros serão muito grandes para serem convertidos de volta em Número. Mas queremos que o Number seja o mais próximo possível da verdade, sempre que possível. Então, fazemos alguma aritmética ao converter BigInt em Número:



// 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)
    );
  }
      
      





Ao extrair a parte inteira, reduzimos o tamanho dos valores BigInt antes de convertê-los em Número. Existem outras maneiras de fazer isso que apresentam menos problemas de alcance. Eles são geralmente mais difíceis e lentos. Se você estiver interessado, recomendo que você os examine mais a fundo. Mas neste artigo, uma abordagem simples cobre casos suficientes para ser útil.



Multiplicação e divisão



Vamos fazer algo com os números. Que tal multiplicação e divisão? É fácil para frações. Multiplique os numeradores pelos numeradores e os denominadores pelos denominadores.



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





A divisão é semelhante ao código acima. Vire a segunda fração e multiplique.



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





Adição e subtração



Agora temos multiplicação e divisão. Logicamente, a próxima coisa a escrever é adição e subtração. Isso é um pouco mais complicado do que multiplicação e divisão. Mas não muito.

Para adicionar duas frações, primeiro você precisa trazê-las para o mesmo denominador e, em seguida, adicionar os numeradores. No código, pode ser parecido com isto:



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





Tudo é multiplicado pelos denominadores. E usamos simplify () para manter a fração tão pequena quanto possível em termos de numerador e denominador.

A subtração é semelhante à adição. Estamos manipulando as duas frações para que os mesmos denominadores se alinhem como antes. Então não adicionamos, mas subtraímos.



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





Portanto, temos os operadores básicos. Você pode adicionar, subtrair, multiplicar e dividir. Mas ainda precisamos de alguns outros métodos. Em particular, os números têm uma propriedade importante: podemos compará-los uns com os outros.



Comparações



Já discutimos .equals (). Mas precisamos mais do que apenas igualdade. Também gostaríamos de ser capazes de definir as razões maiores-menos. Portanto, vamos criar um método .lte () que nos dirá se uma fração é menor ou igual a outra fração. Como acontece com .equals (), não é óbvio qual dos dois é menor. Para compará-los, precisamos converter ambos para o mesmo denominador e, em seguida, comparar os numeradores. Com um pouco de simplificação exagerada, pode ficar assim:



// 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;
  }

      
      





Assim que obtivermos .lte () e .equals (), podemos imprimir o restante das comparações. Você pode escolher qualquer operador de comparação. Mas se tivermos igual () e>, <, ≥ ou ≤, podemos inferir o resto usando a lógica booleana. Nesse caso, escolhemos lte () porque o padrão FantasyLand o usa . Outros operadores podem ser assim:



// 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);
  }

      
      





Arredondamento



Agora podemos comparar as frações. E também podemos multiplicar e dividir, somar e subtrair. Mas se quisermos nos divertir mais com nossa biblioteca, precisamos de mais ferramentas. Os objetos úteis do JavaScript Math contêm os métodos .floor () e .ceil ().

Vamos começar com .floor (). Floor pega um valor e o arredonda para baixo. Com números positivos, isso significa que apenas mantemos a parte inteira e descartamos o resto. Mas, para números negativos, arredondamos para cima de zero, portanto, os números negativos precisam receber um pouco mais de atenção.



// 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);
  }
      
      





Agora você pode usar o código acima para calcular os valores arredondados.



// 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);
  }
      
      





Agora temos a maior parte do que é necessário para muitas operações matemáticas. E com .toValue () podemos facilmente converter cálculos de volta em números decimais. Mas e se quisermos converter um número de ponto flutuante em uma fração?



Número para fração



Converter números em frações é mais difícil do que pode parecer à primeira vista. E existem muitas maneiras diferentes de fazer essa transformação. Minha implementação não é a mais precisa, mas é boa o suficiente. Para fazer funcionar, primeiro convertemos o número em uma string, que, como sabemos, assumirá um formato de sequência. Para fazer isso, o JavaScript fornece o método .toExponential (). O método retorna um número em notação exponencial. Aqui estão alguns exemplos para ajudá-lo a entender a ideia:



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'

      
      





O código funciona representando um número como um valor decimal normalizado e um multiplicador. O bit decimal normalizado é denominado mantissa e o fator é denominado expoente. Aqui, "normalizado" significa que o valor absoluto da mantissa é sempre menor que 10. E o expoente é sempre agora 10. Indicamos o início do fator com a letra 'e' (abreviação de 'expoente').



A vantagem dessa notação é que ela é consistente. Sempre há exatamente um dígito à esquerda da vírgula decimal. E .toExponential () nos permite especificar quantos dígitos significativos queremos. Em seguida, vem o 'e' e o expoente é sempre um inteiro. Como o valor é sequencial, podemos usar uma regex atrevida para analisá-lo.



O processo é mais ou menos assim. Conforme mencionado, .toExponential () usa um parâmetro para especificar o número de dígitos significativos. Precisamos de tantos números quanto possível. Portanto, definimos a precisão para 100 (isso é o que a maioria dos mecanismos JavaScript permite). Para este exemplo, entretanto, manteremos a precisão de 10. Agora imagine que temos o número 0,987654321e0. Queremos mover o ponto decimal 10 dígitos para a direita. Isso nos daria 9876543210. Em seguida, divida por 10 ^ 10 para obter 9876543210/100000000. Isso, por sua vez, simplifica para 987654321/100000000.



Mas devemos prestar atenção a este expositor. Se tivermos um número como 0,987654321e9, ainda moveremos o ponto decimal 10 dígitos para a direita. Mas nós dividimos por dez, à potência 10-9 = 1.







0.987654321×109=9876543210/101=











987654321/1







Para fazer dessa forma, definimos algumas funções 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("")}`);
}
      
      





Com a ajuda deles, podemos juntar as peças de toda a função 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));
  }

      
      





A maioria das funções básicas é abordada. Podemos ir de números a frações e vice-versa. Mas para minha aplicação específica, eu precisava de mais. Em particular, foi necessário encontrar exponenciação e logaritmos.



Exponenciação



A exponenciação é quando um número é multiplicado muitas vezes por si mesmo. Por exemplo, 2 ^ 3 = 2 × 2 × 2 = 8. Para casos simples em que o grau é um número inteiro, há um operador BigInt: ** integrado. Portanto, se estamos elevando uma fração da potência, essa é uma boa opção. É assim que uma fração é elevada a uma potência:





(xy)n=xnyn







Portanto, a primeira parte do nosso método de exponenciação pode ser parecida com isto:



// 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 bem. Bem ... principalmente bom. Agora as coisas ficam mais complicadas. Fora das restrições de garantia e matemática, temos que fazer alguns trade-offs. Podemos ter que sacrificar a precisão para obter uma resposta dentro de um prazo razoável.



A exponenciação gera facilmente grandes números. E quando os números aumentam, as coisas ficam mais lentas. Enquanto escrevia este artigo, também escrevi cálculos que não eram concluídos em alguns dias. Portanto, você precisa ter cuidado. Mas está tudo bem. Tudo vem para o BigInt.



Mas há outro problema. E se o denominador do grau não for um? Por exemplo, e se quiséssemos calcular 8 ^ (2/3)?



Felizmente, podemos dividir esse problema em dois problemas menores. Queremos reduzir uma fração à potência de outra. Por exemplo, podemos atribuir x / y a a / b. As leis de exponenciação afirmam que o seguinte é equivalente:





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







Já sabemos como converter um BigInt na potência de outro BigInt. Mas e os graus fracionários? Bem, há outro equivalente:





x1n=xn







Ou seja, reduzir xx à potência 1n1n é equivalente a encontrar a enésima raiz de xx. Isso significa que, se encontrarmos uma maneira de calcular a enésima raiz de BigInt, podemos calcular qualquer grau.



Com uma pesquisa na web bem pensada, encontrar um algoritmo para estimar a enésima raiz não deve demorar. O método mais comum é o método de Newton . Funciona a partir da avaliação, rr. Em seguida, um cálculo é feito para obter a melhor estimativa:





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







Continuamos repetindo esses cálculos até atingirmos a precisão desejada. Infelizmente, existem algumas raízes que não podem ser representadas como uma fração finita. Em outras palavras, precisamos de valores BigInt infinitamente longos para obter uma precisão perfeita. Na prática, isso significa que devemos escolher uma restrição de iteração arbitrária.



Voltaremos a este ponto. Por enquanto, vamos descobrir como calcular uma enésima raiz razoavelmente precisa. Como a estimativa de rr será uma fração, podemos escrevê-la como:





r=ab.







E isso nos permite reescrever os cálculos assim:





ab=(n1)an+xbnnban1







Agora tudo é em termos de computação inteira, adequado para uso com BigInt. Sinta-se à vontade para inserir abab na equação para r′r ′ acima e verificar minhas descobertas. Em JavaScript, é assim:



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);
      
      





Simplesmente repetimos este cálculo até atingirmos uma precisão adequada para nossa estimativa de raiz enésima. O problema é que precisamos encontrar valores adequados para nossas constantes. Ou seja, NUM_ITERATIONS e INITIAL_ESTIMATE.

Muitos algoritmos começam com INITIAL_ESTIMATE um. Esta é uma escolha inteligente. Freqüentemente, não temos uma boa maneira de adivinhar qual pode ser a enésima raiz. Mas vamos escrever "obstáculo". Vamos supor (por agora) que nosso numerador e denominador estão no intervalo Número. Podemos então usar Math.pow () para obter a pontuação inicial. Pode ser assim:



// 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))
);
      
      





Portanto, temos um valor para nossa avaliação inicial. E sobre NUM_ITERATION? Bem, na prática, o que fiz foi começar com uma suposição de 10. E então fiz testes de propriedade. Continuei aumentando o número até que os cálculos estivessem dentro de um prazo razoável. E a figura que finalmente funcionou ... 1. Uma iteração. Isso me entristece um pouco, mas somos um pouco mais precisos do que com cálculos de ponto flutuante. Na prática, você pode aumentar esse número se não estiver calculando muitas potências fracionárias.



Para simplificar, extrairemos o cálculo da enésima raiz em uma função separada. Juntando tudo, o código pode ter a seguinte aparência:



// 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);
  }

      
      





Imperfeito e lento. Mas a tarefa tornou-se amplamente realizável. A questão permanece como obter a estimativa se tivermos números inteiros maiores que Number.MAX_VALUE. No entanto, deixarei isso como um exercício para o leitor; este artigo já é muito longo.



Logaritmos



Devo admitir que os logaritmos me intrigaram por algumas semanas. Para o meu desenvolvimento, preciso calcular os logaritmos de base 10. Então, procurei algoritmos para calcular os logaritmos. E existem muitos deles. Mas não consegui encontrar um que funcionasse bem o suficiente para ser incluído na biblioteca de matemática.



Por que é tão difícil? Meu objetivo era calcular logaritmos para maior precisão do que números de ponto flutuante. Caso contrário, por que tudo isso? Função de logaritmo de ponto flutuante, Math.log10 (), rápido e integrado. Portanto, examinei algoritmos que fornecem maneiras de calcular logaritmos de forma iterativa. E eles funcionam. Mas eles demoram a ficar mais altos do que a precisão de ponto flutuante. Não apenas um pouco mais lento. Muito mais lento.



À medida que passamos pelas iterações, a fração que construímos se torna mais precisa. Mas essa precisão tem um preço. Os valores BigInt em frações estão ficando cada vez maiores. E à medida que ficam maiores, começam a demorar para se multiplicar. Em algum momento deixei o cálculo por três dias. Mas enquanto os cálculos estavam acontecendo, lembrei-me de algo.



Lembrei-me de que precisava de um método log10 () para poder calcular bons valores em escala para os gráficos. E para esses cálculos, sempre que chamei .log10 (), chamei imediatamente .floor (). Isso significa que eu quero apenas a parte inteira do logaritmo. Calcular o logaritmo com 100 casas decimais era apenas uma perda de tempo e energia.



Além disso, existe uma maneira fácil de calcular toda a parte do logaritmo de base 10. Tudo o que precisamos é contar os números. Uma tentativa ingênua pode ter a seguinte aparência:



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





Infelizmente, isso não funciona para valores menores que 1. Mas, mesmo assim, podemos usar algumas leis logarítmicas para trabalhar com esse valor.





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







Portanto:





log10(ba)=log10(ab)







Juntando tudo, obtemos um método floorLog10 () mais 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());
  }
      
      





Novamente. Por que sofrer?



No momento, a biblioteca possui todas as funções necessárias para minha aplicação, para trabalhar com gráficos. Mas ainda pode ser interessante, por que todo esse problema? Já existem várias bibliotecas de precisão arbitrária. Por que não usar um deles e acabar logo com isso?



Para ser honesto, eu usaria uma biblioteca existente na maioria dos casos. Principalmente se estiver com pressa. Não adianta fazer tudo isso se alguém já fez um trabalho superior.



A palavra-chave aqui é superior. E é aqui que meus motivos para querer escrever minha própria biblioteca entram em jogo. O método floorLog10 () acima é um exemplo perfeito. Ele fornece o cálculo exato de que preciso para o que desejo fazer. Ele faz isso de forma eficiente, em cerca de seis linhas de código.



Se eu fosse usar a biblioteca de outra pessoa, encontraria uma de duas coisas:



  1. Os desenvolvedores não implementaram log10 () ou qualquer outro método logarítmico.


ou



  1. Os desenvolvedores implementaram o método log10 () (ou equivalente).


No primeiro cenário, eu ainda teria que escrever floorLog10 (). Na segunda situação, provavelmente usaria seu método logarítmico. E meu código seria mais lento e complexo do que deveria ser.



Escrever minha própria biblioteca me permite adaptá-la ao meu aplicativo. Claro que outras pessoas podem achar o código útil, mas não sou responsável por suas necessidades. Para que meu aplicativo não precise carregar um código complexo que nunca usa.



Além de tudo isso, aprendi muito criando minha própria biblioteca. Agora eu entendo as limitações do BigInt muito melhor na prática do que antes. Eu sei que posso ajustar o desempenho do método enésimo raiz. Posso ajustá-lo dependendo de quanta computação estou fazendo e de quanta precisão preciso.



Às vezes, vale a pena escrever sua própria biblioteca de propósito geral. Mesmo se você não planeja abrir o código. Mesmo que ninguém mais o use. Você pode aprender muito e também trazer alegria.



Se você quiser saber mais sobre os problemas de ponto flutuante, consulte 0.30000000000000004.com . E se você quiser ver toda a biblioteca e fazer alguns cálculos, você pode conferir esta sandbox com o código .



imagem






Outras profissões e cursos


















All Articles