Устали от глупых шуток о JS? Напишите свою библиотеку

Original author: James Sinclair
  • Translation
В JavaScript есть немало моментов, вызывающих вопрос «Чего???». Несмотря на то что у большинства из них есть логическое объяснение, если вы вникнете, они всё равно могут удивлять. Но JavaScript точно не заслуживает возмутительных шуток. Например, иногда мы видим такие шутки:


В этом случае критика абсолютно не заслужена. Давайте разбираться почему.



JavaScript, как и какой-либо другой популярный язык программирования, представляет числа, использующие единый стандарт. Если быть точным, это стандарт IEEE 754 для чисел в 64-битном двоичном формате. Давайте попробуем проверить эту же шутку на других языках:

Как насчёт Ruby? На каком языке 0.1 + 0.2 не равно 0.3?

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

Ruby! Какой глупый язык.

Или Clojure? На каком языке 0.1 + 0.2 не равно 0.3?

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

Clojure! Какой глупый язык.

Или как насчёт могучего Haskell? На каком языке 0.1 + 0.2 не равно 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! Хахаха. Какой глупый язык…

Вы поняли мысль. Проблема здесь не в JavaScript. Это большая проблема представления чисел с плавающей точкой в двоичном виде. Но я не хочу пока вдаваться в подробности IEEE 754. Потому что, если нам нужны произвольные точные числа, JavaScript делает их возможными. С октября 2019 года BigInt официально входит в стандарт TC39 ECMAScript.

Зачем беспокоиться об этом?


Мы продержались с IEEE 754 целую вечность. Большую часть времени это не кажется проблемой. Правда. Почти всегда это не проблема. Но иногда это всё-таки проблема. И в такие моменты хорошо иметь варианты.

Например, в начале этого года я работал над библиотекой диаграмм. Хотел нарисовать свечные графики на SVG. А в SVG есть такая аккуратная функция, называемая transform. Вы можете применить её к группе элементов, и она изменит систему координат для этих элементов. Так что с небольшой осторожностью вы можете упростить генерацию области диаграммы. Вместо того чтобы вычислять координаты графика для каждой свечи, вы указываете одно преобразование. А затем определяете каждую свечу, используя значения сырых данных. Очень аккуратно. По крайней мере в теории.

Но в тестах свойств у меня были проблемы. Если бы график был маленьким, а значения данных были большими, я бы получил ошибки округления. И зачастую это нормально. Но на графике некоторые пиксели должны выстраиваться в линию. Иначе рисунок выглядит неправильно. Так что я начал изучать BigInt. Итогом стала библиотека, которую я назвал Ratio. И я покажу, как она пишется.

Класс дроби


Проблема с числами с плавающей точкой — их двоичное представление. Компьютеры выполняют все свои вычисления в двоичном виде. И для целых чисел эта двоичность подходит. Проблема приходит, когда мы хотим представить десятичные числа. Например, в англоязычных странах, таких как Австралия, мы пишем десятичные числа следующим образом:

$3.1415926$


Часть слева от точек (… ) — это целая часть, а справа от точки — дробная часть. Но проблема в том, что некоторые числа имеют дробные части, которые нелегко разделить на две. Так что их трудно представить в двоичном виде. Но та же проблема возникает при основании 10. Например дробь 10/9. Можно попробовать написать что-нибудь вроде этого:

$1.111111111111111111111111111111111111.11111111111111111111111111111111111$


Однако это приближение. Чтобы представить 10/9 точно, единицы должны быть бесконечными. Поэтому мы должны использовать какую-то другую нотацию для представления повторяющихся. Например такую:

$1.\dot{1}$



Эта точка над единицей указывает на то, что единицы продолжаются. Но в большинстве языков программирования этой точки нет.

Заметьте, что 10/9 имеет идеальную точность. И всё, что нужно для точности, — это два кусочка информации. Это числитель и знаменатель. С помощью одного значения BigInt мы можем представлять произвольно большие целые числа. Но если мы создадим пару из целых чисел, то сможем представлять произвольно большие или маленькие числа.

В JavaScript это может выглядеть так:

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

Итак, мы проделали самую хитрую часть. «Изобрели» способ представления чисел с почти бесконечной точностью. (Мы все еще ограничены объемом памяти наших устройств.) Осталось только применить математику. Так что давайте добавим функциональности.

Равенство


Первое, что хочется сделать, — это сравнить две дроби. Зачем? Потому, что мне нравится сначала писать тесты. Если я могу сравнить две дроби на равенство, то писать тесты намного проще.

В простом случае написать метод равенства довольно легко:

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

Вот и хорошо. Но было бы неплохо, если бы наша библиотека могла сообщить, что 1/2 равна 2/4. Для этого нужно упростить дробь. То есть, прежде чем проверять равенство, мы хотим уменьшить числители и знаменатели обеих дробей до как можно более маленьких чисел. Итак, как мы сделаем это?

Наивный подход заключается в прогоне всех чисел от 1 до min(n,d) (где nn и dd — числитель и знаменатель соответственно). И это то, что я попробовал вначале. Код выглядел как-то так:

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

И, как и следовало ожидать, он невероятно медленный. Мои тесты заняли вечность. Так что нам нужен более эффективный подход. К счастью, греческий математик нашел его пару тысячелетий назад. Решение — применение алгоритма Евклида. Это способ найти наибольший общий делитель двух целых чисел.

Рекурсивная версия алгоритма Евклида красива и элегантна:

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

Применима мемоизация, что делает алгоритм довольно привлекательным. Но, увы, у нас еще нет хвостовой рекурсии в V8 или SpiderMonkey. (По крайней мере не на момент написания статьи.) Это означает, что если мы запустим его с достаточно большими целыми числами, то получим переполнение стека. А большие целые числа — это что-то вроде точки отсчёта.

Так что вместо этого воспользуемся итерационной версией:

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

Не так элегантно, но делает свою работу. И с этим кодом мы можем написать функцию для упрощения дробей. Пока мы это делаем, мы внесём и небольшое изменение, чтобы знаменатели всегда были положительными (т. е. для отрицательных чисел знак меняет только числитель).

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

И теперь мы можем написать наш метод равенства:

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

Теперь можно сравнить две дроби на равенство. Может показаться, что это не так уж и много, но это значит, что мы можем написать юнит-тесты и убедиться, что наша библиотека работает, как ожидается.

Преобразование в другие типы


Не буду утомлять, выписывая все юнит-тесты моей библиотеки. Но было бы неплохо конвертировать дроби в другие форматы. Например, мы можем захотеть представить их как строку в отладочных сообщениях. Или, возможно, мы захотим преобразовать их в числа. Итак, давайте переопределим методы .toString() и .toValue() для нашего класса.

Метод .toString() проще всего, так что давайте начнём с него.

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

Достаточно просто. Но как насчёт преобразования обратно в число? Один из способов сделать это — просто разделить числитель на знаменатель:

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

Зачастую это работает. Но, возможно, мы захотим немного подправить код. Весь смысл нашей библиотеки заключается в том, что мы используем большие целые числа для получения необходимой точности. И иногда эти целые числа будут слишком большими, чтобы их можно было преобразовать обратно к типу Number. Но мы хотим получить Number как можно ближе к истине, где это возможно. Так что мы выполняем немного арифметических действий, пока конвертируем BigInt в 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)
    );
  }

Извлекая целую часть, мы уменьшаем размер значений BigInt, прежде чем преобразовать их в Number. Есть и другие способы сделать это, у которых меньше проблем с диапазоном. В основном они сложнее и медленнее. Если вам интересно, я рекомендую вам заглянуть в них глубже. Но в этой статье простой подход охватывает достаточно случаев, чтобы быть полезным.

Умножение и деление


Сделаем что-нибудь с числами. Как насчёт умножения и деления? Это несложно для дробей. Умножаем числители на числители, а знаменатели на знаменатели.

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

Деление похоже на код выше. Переворачиваем вторую дробь, затем умножаем.

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

Сложение и вычитание


Теперь у нас есть умножение и деление. Логически следующая вещь, которую нужно написать, — это сложение и вычитание. Это немного сложнее, чем умножение и деление. Но не слишком.
Чтобы сложить две дроби, сначала нужно привести их к одному знаменателю, затем сложить числители. В коде это может выглядеть примерно так:

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

Всё умножается на знаменатели. И мы используем simplify(), чтобы сохранялась дробь как можно меньше в смысле чисел числителя и знаменателя.
Вычитание похоже на сложение. Мы манипулируем двумя дробями так, чтобы одинаковые знаменатели выстраивались в ряд, как раньше. Затем не складываем, а вычитаем.

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

Итак, у нас есть основные операторы. Можно складывать, вычитать, умножать и делить. Но нам всё ещё нужно несколько других методов. В частности, цифры имеют важное свойство: мы можем сравнивать их друг с другом.

Сравнения


Мы уже обсуждали .equals(). Но нам нужно нечто большее, чем просто равенство. Мы также хотели бы иметь возможность определить отношения дробей «больше, меньше». Поэтому создадим метод .lte(), который расскажет нам, является ли одна дробь меньшей или равной другой дроби. Как и в случае .equals(), не очевидно, какая из двух дробей меньше. Чтобы сравнить их, нам нужно преобразовать обе к одному знаменателю, затем сравнить числители. С небольшим упрощением это может выглядеть так:

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

Как только мы получим .lte() и .equals(), то сможем вывести остальные сравнения. Можно выбрать любой оператор сравнения. Но если у нас есть equals() и >, <, ≥ или ≤, то мы сможем выводить остальные с помощью булевой логики. В данном случае мы выбрали lte(), потому что его использует стандарт FantasyLand. Вот как могут выглядеть другие операторы:

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

Округление


Теперь мы можем сравнить дроби. А ещё можем умножать и делить, складывать и вычитать. Но если мы собираемся делать больше интересного с нашей библиотекой, нам нужно больше инструментов. Удобные объекты JavaScript Math содержат методы .floor() и .ceil().
Начнём с .floor(). Floor принимает значение и округляет его вниз. При положительных числах это означает, что мы просто сохраняем целую часть и отбрасываем оставшуюся часть. Но для отрицательных чисел мы округляем вверх от нуля, так что отрицательным числам нужно уделить немного больше внимания.

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

Теперь можно использовать код выше, чтобы рассчитать округленные вверх значения.

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

Сейчас у нас есть большая часть необходимого для многих математических операций. А с помощью .toValue() мы можем легко преобразовать вычисления обратно в десятичные числа. Но что, если мы хотим преобразовать число с плавающей точкой в дробь?

Число в дробь


Преобразование чисел в дроби сложнее, чем может показаться на первый взгляд. И есть много разных способов проделать это преобразование. Мой способ реализации не самый точный, но он достаточно хорош. Чтобы он сработал, сначала конвертируем число в строку, которая, как мы знаем, приобретёт формат последовательности. Для этого JavaScript предоставляет нам метод .toExponential(). Метод возвращает число в экспоненциальной нотации. Вот несколько примеров для понимания идеи:

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'

Код работает, представляя число в виде нормализованного десятичного значения и множителя. Нормализованный десятичный бит называется мантиссой, а множитель — экспонентой. Здесь «нормализованный» означает, что абсолютное значение мантиссы всегда меньше 10. А экспонента всегда теперь 10. Мы указываем начало множителя с буквой 'e' (сокращение от 'exponent').

Преимущество этой нотации в том, что она последовательна. Всегда есть ровно одна цифра слева от десятичной точки. А .toExponential() позволяет указать, сколько значащих цифр мы хотим. Затем идет 'e' и экспонента — всегда целое число. Поскольку значение последовательно, мы можем использовать наглое регулярное выражение, чтобы разобрать его.

Процесс идёт примерно так. Как уже упоминалось, .toExponential() принимает параметр для указания количества значащих цифр. Нам нужен максимум цифр. Итак, мы установили точность на 100 (столько позволит большинство JavaScript-движков). В этом примере, однако, мы будем придерживаться точности 10. Теперь представьте, что у нас есть число 0.987654321e0. Мы хотим перенести десятичную точку на 10 цифр вправо. Это дало бы нам 9876543210. Затем делим на 10^10, и получаем 9876543210/100000000. Это, в свою очередь, упрощает до 987654321/100000000.

Но мы должны обратить внимание на эту экспоненту. Если у нас есть число вроде 0.987654321e9, то мы всё равно сдвинем десятичную точку на 10 цифр вправо. Но мы делим на десять, к степени 10-9=1.

$0.987654321×10^9 = 9876543210/ 10^1=$


$987654321/1$


Чтобы всё было именно так, мы определили пару вспомогательных функций:

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

С их помощью мы можем собрать всю функцию 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));
  }

Охвачено большинство основных функций. Мы можем перейти от чисел к дробям и обратно. Но для моего конкретного приложения мне нужно было большее. В частности нужно было найти возведение в степень и логарифмы.

Возведение в степень


Возведение в степень — это когда число многократно умножается само на себя. Например, 2^3=2×2×2=8. Для простых случаев, когда степень — целое число, есть встроенный оператор BigInt: **. Так что, если мы возводим в степень дробь, это хороший вариант. Вот так дробь возводится в степень:

$\left(\frac{x}{y}\right)^{n} = \frac{x^n}{y^n}$


Следовательно, первый отрезок нашего метода возведения в степень может выглядеть примерно так:

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

Прекрасно работает. Ну… в основном хорошо. Теперь всё становится сложнее. Из-за пределов ограничений обеспечения и математики мы должны пойти на некоторые компромиссы. Возможно, нам придётся пожертвовать точностью ради получения ответа в разумные сроки.

Возведение в степень легко порождает большие числа. И когда числа становятся большими, всё замедляется. Пока я писал эту статью, я также написал вычисления, которые, не завершаясь, выполнялись в течение дней. Так что нужно соблюдать осторожность. Но ничего страшного. Все поставляется для BigInt.

Но есть и другая проблема. Что делать, если знаменатель степени — не единица? Например, что, если бы мы хотели рассчитать 8^(2/3)?

К счастью, мы можем разделить эту проблему на две проблемы поменьше. Мы хотим привести одну дробь к степени другой. Например, мы можем отнести x/y к a/b. Законы возведения в степень гласят, что следующее эквивалентно:

$\left(\frac{x}{y}\right)^\frac{a}{b} = \left(\left(\frac{x}{y}\right)^\frac{1}{b}\right)^a = \left(\frac{x^\frac{1}{b}}{y^\frac{1}{b}}\right)^a$


Мы уже знаем, как привести одно число BigInt к степени другого числа BigInt. Но как насчёт дробной степени? Ну, есть ещё один эквивалент:

$x^\frac{1}{n} = \sqrt[n]{x}$


То есть приведение xx к степени 1n1n эквивалентно нахождению n-го корня из xx. Это означает, что если мы найдем способ вычислить n-й корень BigInt, то мы сможем вычислить любую степень.

При хорошо продуманном поиске в вебе, нахождение алгоритма оценки n-го корня не займёт много времени. Наиболее распространённый метод — метод Ньютона. Он работает начиная с оценки, rr. Затем делается такой расчёт, чтобы получить лучшую оценку:

$\begin{align} r &\approx x^{\frac{1}{n}} \\ r^{\prime} &= \frac{1}{n}\left((n-1)r + \left(\frac{x}{r^{n-1}}\right)\right) \end{align}$


Мы продолжаем повторять эти расчёты до тех пор, пока не достигнем желаемой точности. К сожалению, есть некоторые корни, которые не могут быть представлены в виде конечной дроби. Другими словами, чтобы получить идеальную точность, нам понадобятся бесконечно длинные значения BigInt. На практике это означает, что мы должны выбрать произвольное ограничение итераций.

Мы вернемся к этому моменту. А пока давайте разберёмся, как вычислить достаточно точный корень n-й степени. Так как оценка rr будет дробью, мы можем записать её как:

$r = \frac{a}{b}.$


И это позволяет нам переписать расчёты так:

$\frac{a^{\prime}}{b^{\prime}} = \frac{(n - 1)a^{n} + x b^{n}}{n b a^{n - 1}}$


Теперь всё в терминах целочисленных вычислений, подходящих для использования с BigInt. Не стесняйтесь вставлять abab в уравнение для r′r′ выше и проверьте мои выводы. В JavaScript это выглядит вот так:

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

Мы просто повторяем это вычисление до тех пор, пока не достигнем подходящей точности для нашей оценки корня n-й степени. Проблема в том, что нам нужно придумать подходящие значения для наших констант. То есть NUM_ITERATIONS и INITIAL_ESTIMATE.
Многие алгоритмы начинаются с INITIAL_ESTIMATE в единицу. Это разумный выбор. Зачастую у нас нет хорошего способа предположить, каким может быть корень n-й степени. Но напишем «обманку». Предположим (пока), что наш числитель и знаменатель находятся в диапазоне Number. Затем мы можем использовать Math.pow() для получения начальной оценки. Это может выглядеть так:

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

Итак, у нас есть значение для нашей первоначальной оценки. А как же NUM_ITERATION? Ну, на практике, то, что я делал, начиналось с предположения в 10. А потом я проводил тесты свойств. Я продолжал наращивать число до тех пор, пока вычисления укладывались в разумные сроки. И цифра, которая, наконец, сработала… 1. Одна итерация. Это меня немного огорчает, но мы немного более точны, чем при вычислениях с плавающей точкой. На практике вы можете увеличивать это число, если не вычисляете много дробных степеней.

Для простоты мы извлечём вычисление n-го корня в отдельную функцию. Если сложить всё вместе, код может выглядеть так:

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

Неидеально и медленно. Но задача стала в основном выполнимой. Остаётся вопрос, как получить оценку, если у нас целые числа больше Number.MAX_VALUE. Однако я оставлю это как упражнение для читателя; эта статья и так уже слишком длинная.

Логарифмы


Должен признать, логарифмы озадачили меня на несколько недель. Для моей разработки мне нужно вычислить логарифмы по основанию 10. Поэтому я пошёл искать алгоритмы для вычисления логарифмов. И их много. Но я не смог найти такой, который работал бы достаточно хорошо, чтобы включить его в математическую библиотеку.

Почему это так сложно? Моей целью было вычислить логарифмы ради точности большей, чем точность чисел с плавающей точкой. Иначе зачем всё это? Функция вычисления логарифма с типом числа с плавающей точкой, Math.log10(), быстрая и встроенная. Итак, я посмотрел на алгоритмы, которые дают способы итеративного вычисления логарифмов. И они работают. Но они медленны в получении точности выше точности числа с плавающей точкой. Не просто немного медленнее. Намного медленнее.

По мере того как мы проходим через итерации, дробь, которую мы строим, становится всё точнее. Но за эту точность приходится платить. Значения BigInt в дроби становятся всё больше и больше. И по мере того как они становятся больше, их умножение начинает занимать много времени. В какой-то момент я оставил вычисление на три дня. Но пока шли расчёты, я кое-что вспомнил.

Я вспомнил, что мне нужен метод log10() для того, чтобы можно было вычислять красивые масштабированные значения для графиков. И для этих вычислений каждый раз, когда я вызывал .log10(), я сразу же вызывал .floor(). Это означает, что мне нужна только целочисленная часть логарифма. Расчёт логарифма до 100 знаков после запятой был просто пустой тратой времени и мощностей.

Более того, есть простой способ вычислить целую часть логарифма по основанию 10. Всё, что нам нужно, — это посчитать цифры. Наивная попытка может выглядеть так:

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

К сожалению, это не работает для значений меньше 1. Но даже в этом случае мы можем использовать некоторые логарифмические законы для работы с таким значением.

$\begin{align} \log_{10}\left(\frac{a}{b}\right) &= \log_{10}(a) - \log_{10}(b) \\ \log_{10}\left(\frac{1}{x}\right) &= \log_{10}(1) - \log_{10}(x) \\ &= -\log_{10}(x) \end{align}$


Поэтому:

$\log_{10}\left(\frac{b}{a}\right) = -\log_{10}\left(\frac{a}{b}\right)$


Собрав всё воедино, мы получаем более надежный метод floorLog10():

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

Опять. Зачем мучиться?


На данный момент библиотека имеет все необходимые функции для моего приложения, для работы с графиками. Но всё равно может быть интересно, зачем все эти неприятности? Уже есть несколько библиотек произвольной точности. Почему бы просто не использовать одну из них и не покончить с этим?

Честно говоря, я бы использовал существующую библиотеку в большинстве случаев. Особенно, если я тороплюсь. Нет смысла делать всё это, если кто-то уже сделал превосходящую работу.

Ключевое слово здесь — «превосходящую». И именно здесь в игру вступают мои мотивы желания написать свою собственную библиотеку. Метод floorLog10() выше — идеальный пример. Он обеспечивает точный расчёт, который мне нужен для того, что хочу сделать я. Он делает это эффективно, примерно в шести строках кода.

Если бы я воспользовался чужой библиотекой, я бы столкнулся с одним из двух:

  1. Разработчики не реализовали log10() или любые другие логарифмические методы.

или

  1. Разработчики реализовали метод log10() (или его эквивалент).

В первом сценарии мне всё равно пришлось бы написать floorLog10(). Во второй ситуации я бы, вероятно, использовал их логарифмический метод. И мой код был бы медленнее и сложнее, чем должен быть.

Написание собственной библиотеки позволяет мне адаптировать её к приложению. Конечно, другие люди могут найти код полезным, но я не в ответе за их потребности. Чтобы моему приложению не приходилось таскать за собой сложный код, который оно никогда не использует.

Кроме всего этого, я многому научился, создавая свою собственную библиотеку. Теперь я на практике понимаю ограничения BigInt намного лучше, чем раньше. Я знаю, что могу настроить производительность метода корня n-й степени. Я могу настроить его в зависимости от того, сколько вычислений выполняю и какая точность мне нужна.

Иногда стоит написать свою собственную библиотеку общего назначения. Даже если ты не планируешь открывать код. Даже если никто больше ею не воспользуется. Ты можешь многому научиться, и, кроме того, это может принести радость.

Если вы хотите узнать больше о проблемах с числами с плавающей точкой, обратитесь к 0.30000000000000004.com. А если вы хотите посмотреть библиотеку целиком и сделать некоторые вычисления, то можете посмотреть эту песочницу с кодом.

image



SkillFactory
Школа Computer Science. Скидка 10% по коду HABR

Comments 109

    +22
    Автор, извините, но насколько корректно называть это проблемой чисел с плавающей точкой? Является ли отсутствие рациональных дробей проблемой целочисленных типов? Вряд ли, согласитесь? Вот точно также и с плавающей точкой. Это особенность этих чисел, а не проблема. Проблема — неумение их использовать или незнание того, как они устроены внутри. Чисел с плавающей точкой нам полностью хватает для вычисления тех точностей, которые нужны нам в прикладном смысле. Да, там куча самых разных приемов, но тем не менее, двойной точности точно хватает за глаза. В очень редких случаях бывают нужны большие точности. Но и в тех точностях будет та же проблема, что и у вас.
    Касаемо же самой простой задачи сравнения, мне кажется любой знакомый с плавающей точкой должен знать о небольшом эпсилон, который нужно использовать при проверке на равенство.
    Напоследок, да, понятно, что этими числами не попользуешься в финансах. Но они и не были созданы для этого. У них достаточно узкий круг задач, с которыми они справляются на ура, если разработчики могут справиться с пониманием плавающей точки.
      0

      Когда-то давно я даже из интереса перебрал кучу языков изучая такое поведение. Единственный язык, где на тот момент 0.1+0.2==0.3 — MySQL. Причём в SQLite такое не работало. Pg не проверял. Так же на тот момент ещё не был популярен Rust, поэтому он тоже не попал в обзор

      +19
      Заметьте, что 10/9 имеет идеальную точность. И всё, что нужно для точности, — это два кусочка информации. Это числитель и знаменатель. С помощью одного значения BigInt мы можем представлять произвольно большие целые числа. Но если мы создадим пару из целых чисел, то сможем представлять произвольно большие или маленькие числа.

      Не произвольные числа, а только рациональные.
      Число π или корень из 2 все равно не удастся представить с таком виде, даже имея бесконечный объем памяти
        –4
        При особом желании иррациональные числа можно попробовать добавить. Для этого нужно и к числителю и к знаменателю добавить массив множителей. Класть в него Symbol('PI'), Symbol('e'), Symbol('sqrt(2)'). Ещё в библиотеке завести хэш этих иррациональных чисел чтоб не пересоздавать символы. Переписать все методы. Получится неплохой математический пакет. Но, естественно, это будет сильно дороже.
          +21

          Да-да, и добавить туда же функции типа арксинуса, функцию Бесселя… И результат представлять в виде выражения: arcsin(arcsin(1/3)) в виде такой дроби, как вы предложили, вроде не представляется.
          Правда, операции с такими числами будут дико ресурсоёмкими, а порой их сравнение произвести не удастся (равенство двух выражений – это, по сути, теорема, доказательство которой может оказаться весьма сложным). Зато абсолютно точно.


          Ах да, это уже сделали. Называется "пакет символьной математики". Derive, Mathematica, Mapple. Имеют свою узкую нишу для применения, с "плохими" floating-point числами не сравнить.

            +5
            Правда, операции с такими числами будут дико ресурсоёмкими, а порой их сравнение произвести не удастся (равенство двух выражений – это, по сути, теорема, доказательство которой может оказаться весьма сложным).

            Сравнение двух чисел в таком случае вообще будет неразрешимым по Тьюрингу.

            +2

            А ничего, что иррациональных чисел будет побольше чем рациональных (множество которых счётно, т.е., эквивалентно множесту натуральных чисел)? Их нельзя так представить.

              –10
              Их не больше. Вокруг каждого иррационального числа вьётся бесконечное количество его рациональных представлений. Бесконечное в прямом смысле.
                +4
                Ну и что? Больше не в прямом смысле, а в смысле мощности множеств (а это вполне адекватный критерий для сравнения бесконечных множеств): мощность рациональных чисел счетно (их всех можно пронумеровать натуральными числеми), а иррациональных — напротив, не счетно. Это автоматически означает, что организовать их точное представление в конечной памяти нельзя. Можно представлять формальные «ссылки» на определённые иррациональные числа (типа ваших множителей), но по определению, чисел, которые вы не сможете представить точно, останется гораздо больше, чем тех, которые можно будет представить (т.к. представить вы сможете счетное множество в лучшем случае).
                  +1

                  Справедливости ради, и нужны не все вещественные числа, а только те, которые могут получиться в ходе вычислений – а значит, те, которые мы можем представить (например, в виде алгоритма, вычисляющего это число, заканчивающегося за конечное время). Т.е. конечное множество чисел (ну или счётное, если начинать вычисления с натуральных чисел).
                  Но представление так себе получается, не очень практичное :-)

                    0
                    В таком случае, формульное представление (типа того, что выдаёт Wolfram|Alpha при упрощении выражения) будет как раз тем самым универсальным представлением. Но такое представление не пригодно для расчетов на простых машинах (с использованием стандартных библиотек мейнстримовых языков), потребуется как раз что-то похожее на вышеозначенную систему.

                    Плюс, это полностью бесполезно, если будет открыта какая-то новая важная трансцендентная константа: придётся сначала изучить все её свойства, добавить в программу знания об этой константе.
                      0

                      Я примерно то же самое чуть выше написал. Символьная математика – хорошо и круто, но для практических применений в большинстве случаев нужна не она, а что попроще, пусть и с потерей точности.

                    0

                    Справедливости ради, для многих иррациональных алгебраических чисел и даже для некоторых трансцендентных имеются способы их представления даже в конечной памяти с заданной (конечной) точностью. Начиная от рядов Тейлора и заканчивая более экзотичными цепными дробями например.

                      +2
                      мощность рациональных чисел счетно (их всех можно пронумеровать натуральными числеми), а иррациональных — напротив, не счетно. Это автоматически означает, что организовать их точное представление в конечной памяти нельзя.

                      Вы так говорите, как будто в конечной памяти можно организовать точное представление элементов счётного множества, да хотя бы натуральных чисел неограниченной величины.
                    +1

                    Вычислимых иррациональных столько же, сколько рациональных.

                    0
                    Их же несчётное множество, таким описанием не получится все добавить
                    0

                    На первом курсе университета преподаватель линейной алгебры очень советовал почитать книжку "Конкретная математика". Могу ошибаться, но там вроде бы чем-то похожим занимаются

                    –38
                    В этом случае критика абсолютно не заслужена.

                    Язык, в котором операции типа 0.1 + 0.2 приводят на протяжении вот уже 15 лет к куче статей в интернете надо не критиковать, этот язык надо хоронить.

                    А так за статью плюс.
                      +12

                      Почему только 15 лет? IEEE 754 приняли ещё в 1985м. Это особенность формата чисел с плавающей точкой, который поддерживается железом. Об этом было прекрасно известно ещё до появления Javascript в том же C например. Не бывает бесконечной точности при фиксированном размере. Предложение хоронить язык на таком основании очень смелое. Какие языки тогда останутся, Python?

                        +2

                        Не останется, в python есть модули для работы с дробями, но float там аппаратный, 0.1 + 0.2 != 0.3

                        +1
                        Удивительно. Тогда минус Python, например.
                        –41
                        Я сейчас напишу немного не по теме, и скорее всего меня заминусуют. Но я вообще не силен в js, пару дней назад стал изучать react для создания mini apps в вк. И несколько раз чуть не заплакал. Наверняка, это из-за того, что я просто толком не разобрался в языке или фреймворке, но блин, что это за дичь???
                        image
                        Вариант сверху — нерабочий. Чтобы я смог в метод передать аргумент — надо вызвать анонимную функцию, которая вызовет этот метод. Почему? Зачем? Это проблема реакта или самого js?
                        И это только одна из приколюх с которой я столкнулся.
                          +19
                          Потому что в первом случае вы вызываете метод и присваиваете onClick результат вызова, а во втором случае передаёте объект-лямбду с методом внутри как коллбек. Но такие вопросы и правда не по теме, лучше идите на Stackoverflow
                            0
                            Спасибо за ответ!
                            –26

                            Проблема реакта, потому что такие проблемы должен решать фреймворк. Во vue и angular такого нет.

                              –7

                              Потому что this указывает на объект в котором он вызывается, соответственно на элемент <button/> если его не обернуть в функцию. А если обернуть то на объект функции OnClick().
                              Логично ли это — да.
                              Очевидно — нет.
                              Но благодаря динамической природе this вам не нужно прокидывать "контекст".

                                +1

                                Ну this-то тут причём?

                                  0

                                  Если this не причем тогда вы можете написать <div onClick={this.doSome}>?

                                    +3

                                    Могу, только забинжу сначала :-)


                                    Но ошибка-то в коде на картинке в другом, там написано <div onClick={this.doSome()}>

                                +4

                                Ответ на ваш вопрос заключается примерно в этом:


                                typeof (2 + 2); // number
                                typeof (() => 2 + 2); // function

                                рекомендую посмотреть на то, во что компилируется ваш JSX код, чтобы исчезла "магия" и всё стало очень простым и понятным

                                  0
                                  Спасибо большое!
                                • UFO just landed and posted this here
                                    +21

                                    Поставил минус в карму за быдлятину в примере кода — я не хочу видеть это дерьмо на хабре.

                                      +1

                                      Да, признаю ошибку. Извините.

                                      +1
                                      Правильно пишет MomoDev, функция — это тоже объект, а вы не передаете функцию, а вызываете её, после вызова присваивается её результат (в данном случае скорее всего undefined, функция ничего не возвращает). Вы хотите не просто передать в поле колбэка функцию, вы хотите привязать к этой функции аргумент ('second'). Для этого можно обернуть функцию в другую (прямо в поле или где угодно в замыкании, чтобы не создавать новую функцию с каждой перерисовкой), либо создать такуя функцию с помощью bind или apply
                                      this.changePanel.bind(this, 'second')


                                      Если бы вы не использовали jsx хотя бы на время изучения, вся последовательность «рендера» компонента была бы куда понятнее.
                                        0
                                        Cпасибо большое! Да, почитал об основах языза и ES6 и всё стало понятно. Извиняюсь за тупой вопрос и «быдлячничество»
                                        –1
                                        this приобретает разное значение в разных случаях, почитай об этом habr.com/ru/company/ruvds/blog/419371
                                            –1
                                            человек спросил именно про анонимную функцию где this = window, а не про синтаксис…
                                              0

                                              Чего? Это у вас changePanel-то анонимная функция? Или у вас в стрелочных функциях this каким-то образом равен window стал?

                                                –1
                                                не знаю при чем тут changePanel

                                                почти не использовал this, и думал что в анонимной функции он не будет ссылаться на объект (элемент)
                                                  +1

                                                  С 2015го года прошло уже 5 лет, пора бы и узнать как в языке стрелочные функции работают…

                                                    –1
                                                    все помнить невозможно, особенно когда это всего лишь расслабляющее хобби
                                        +20
                                        Ruby! Какой глупый язык.
                                        Clojure! Какой глупый язык.
                                        Haskell! Какой глупый язык.

                                        Я не видел ни одного «wtf js?!» с демонстрациями вида «0.1 + 0.2 != 0.3», потому что это довольно общеизвестная проблема среди большинства языков. С этим сталкиваются практически все в любой арифметике (за исключением отдельных математических движков вроде numpy/wolfram-mathematica, которые профессионально умеют вплоть до натуральных дробей), поэтому или принудительно округляют числа до какого-то знака перед прямым сравнением, или сравнивают их исключительно на «больше-меньше».

                                        Некоторые ребята называют JS глупым языком из-за странных автоматических приведений типов, вроде
                                        0 == [] //> true
                                        //но
                                        [] == ![] //> true
                                        // и
                                        10 + "20" //> "1020"
                                        10 - "20" //> -10
                                        10 + [1, 2] //> "101,2"
                                        10 - [1, 2] //> NaN
                                        

                                        По неочевидному поведению this, и ещё по нескольким причинам, как правило, приводящим к выстрелам в ногу на пустом месте, пока не выучишь конкретное поведение конкретной фигнюшки во всех возможных комбинациях, зато потом будешь ими хакать и взрывать мозги окружающим.

                                        А по плавающей запятой претензий что-то не видно. Если кто-то называет язык глупым по подобной причине, его можно смело, вообще без каких либо вопросов, гнать учить машинные представления чисел, до просветления. Или(!) даже заставить его выучить хотя бы один, любой, популярный язык: подобные претензии явно указывают, что оратор вообще не понимает о чём говорит и принципиально ничем не владеет. Вот ещё, на громких неучей срываться, и писать для них статьи, метать перед ними бисер. Они же даже не попытаются научиться, да и у них глобально другая цель: обозвать что-то «глупым», возвышая себя над целым комьюнити «тупиц, которые пишут на своём глупом языке».
                                          0

                                          Мне всё-таки кажется, что фраза "какой глупый язык" не более чем просто ирония. Не думаю, что автор действительно считает обозначенные языки глупыми.

                                            +3

                                            Учитывая, что, по крайней мере, в хаскеле эти рациональные числа есть из коробки, а литералы вроде 0.1 — перегруженные:


                                            λ> :t 0.1
                                            0.1 :: Fractional p => p

                                            то можно было бы просто использовать аннотацию типов у какого-нибудь одного литерала:


                                            λ> 0.1 + 0.2 == (0.3 :: Rational)
                                            True

                                            ведь глупость только в том, что по дефолту выбирается Double (про что, кстати, есть ворнинг):


                                            λ> :set -Wall
                                            λ> 0.1 + 0.2 == 0.3              
                                            
                                            <interactive>:6:1: warning: [-Wtype-defaults]
                                                • Defaulting the following constraints to type ‘Double’
                                                    (Eq a0) arising from a use of ‘==’ at <interactive>:6:1-16
                                                    (Num a0) arising from a use of ‘+’ at <interactive>:6:1-9
                                                    (Fractional a0)
                                                      arising from the literal ‘0.1’ at <interactive>:6:1-3
                                                • In the expression: 0.1 + 0.2 == 0.3
                                                  In an equation for ‘it’: it = 0.1 + 0.2 == 0.3
                                            False
                                            0
                                            Пошел пересматривать www.destroyallsoftware.com/talks/wat
                                              +3
                                              10 + "20" //> "1020"
                                              10 - "20" //> -10
                                              10 + [1, 2] //> "101,2"
                                              10 - [1, 2] //> NaN

                                              Складывать разные типы данных не изучив принцип их конвертации это конечно сильно, но увы, зачем изучать язык — давайте сразу писать, а потом если что — компилятор с багами язык кривой.
                                                +4

                                                Подобные нюансы обычно стреляют в ногу не потому, что кто-то специально решил складывать разные типы, а потому что где-то в длиной цепочке вызовов возникает ошибка. Ну например внешний сервис вернул вместо числа булевое значение или массив. Да, конечно же тип ответа нужно на каждом шагу проверять, но ошибки случаются.

                                                  0
                                                  В других языках тоже пойдет всё не так как задумано, где-то сразу с ошибкой свалится, где-то не предполагаемое преобразование пойдет дальше.
                                                    +1

                                                    А у меня у JS претензий и нет :) Но всё-таки свалиться с ошибкой лучше, чем продолжит выполнять код с непредсказуемыми последствиями. Хотя опять таки, это свойство всех языков с неявным приведением типов.

                                                  0
                                                  В языках с худо-бедно строгой типизацией, будут ошибки вроде «невозможно сложить число с массивом», когда извне приходит не то что ожидалось, и из-за этого может вылезать гора ошибок. И вот из-за этого некоторые ребята называют жаваскрипт дурацким, потому что при беглом прогоне как бы должна была выскочить ошибка, а её не было, всё как бы нормально, потом ошибка идёт дальше неотловленной.
                                                  Это только одно из мнений, их множество.
                                                    –1
                                                    Непонятно, зачем в язык вообще закладывалась подобная функциональность. Как и 2 типа сравнений (стандартное, всеми используемое === vs. нестрогое == непонятно для чего). Вот какие вообще юзкейсы для подобных хаков?
                                                    В Python'е, например, строгая типизация, никаких лишних преобразований типов, и в результате он проще для изучения.
                                                    В старых Бейсиках тоже были функции STR$, VAL и др., и никаких автоматических конвертаций.
                                                    А в ЖабоСкрипте — сначала создаем проблемы на ровном месте, а потом их героически преодолеваем. Язык и так непрост для новичков (лямбда-выражения, особое прототипное ООП, правила использования this), так еще зачем-то дополнительно решили усложнить.
                                                      +1
                                                      В Python'е, например, строгая типизация, никаких лишних преобразований типов, и в результате он проще для изучения.

                                                      И такие же два оператора сравнения: == и is.

                                                      В старых Бейсиках тоже были функции STR$, VAL и др., и никаких автоматических конвертаций.

                                                      Сейчас перепроверил: в VB4 (1995) уже были автоматические конвертации, и "1"+2 равнялось 3. Они сохранились в VBA и по сей день.
                                                        +2

                                                        Нет, is и == это аналог == и .equals() из java, а не === и == из js.
                                                        В питоне и яве есть сравнение ссылок, а есть семантическое сравнение описываемое для каждого типа отдельно. А в js есть сравнение с приведением типов, а есть с проверкой равности типа.

                                                    +3
                                                    lang = if [] == "" then "JavaScript" else "Haskell"
                                                    main = putStrLn $ "I love " ++ lang
                                                    

                                                    Простите.
                                                      +1

                                                      А что смущает? Тип [] и "" одинаковый (с точностью до того, что первое выражение полиморфное) — строка ведь просто алиас для списка символов.

                                                        0
                                                        Ну так и для любого JS WTF есть какое-то объяснение. Я это к тому, что язык должен соответствовать своей спеке, а не ожиданиям каких-то людей.
                                                        Если [] == "" соответствует спеке Хаскеля, то меня ничего не смущает.
                                                          +1

                                                          Язык должен соответствовать спеке, конечно же, но вот спека должна соответствовать ожиданиям. Вернее, спека должна соответствовать принципу наименьшего удивления, чтобы WTF-моментов в языке было поменьше.


                                                          При этом для описания поведения [] == "" в хаскеле достаточно помнить, что строка — это в прямом смысле алиас для списка символов (ну вот прям type String = [Char]). Для описания поведения сравнения в JS нужно помнить больше вещей, как я понимаю.

                                                            0
                                                            Принцип наименьшего удивления – это маркетинговый ход Ruby, который привел к тому, что у них есть три метода для взятия длины строки (length, size и count). Чтобы уж точно никого не удивить.
                                                            В реальности этот принцип не работает, потому что у всех свои собственные ожидания от языка. Например, я ожидаю, что пустая строка кастанется к false, но Ruby считает по-другому.
                                                            Поэтому мораль простая: ты или знаешь спеку или не знаешь.

                                                            Для описания поведения сравнения в JS нужно помнить больше вещей, как я понимаю.

                                                            Достаточно запомнить правило «никогда не используй ==». А если забудешь, то статический анализ напомнит.
                                                              0
                                                              Поэтому мораль простая: ты или знаешь спеку или не знаешь.

                                                              Окей, в терминах спеки разница в том, какого она объёма и как часто туда надо смотреть. На JS я код как-то не писал, зато много и долго писал на плюсах. Там чтобы убедиться, что ты какой-то код имеешь право написать, спеку (большую, больше тыщи страниц) надо листать регулярно, и натыкаться на совершенно неочевидные места даже для совершенно тривиального кода. В хаскеле мне спеку открывать не приходилось вообще, наверное, никогда — ядро языка просто работает так, как ожидается.


                                                              Впрочем, если говорить о WTF-моментах, в хаскеле есть такой тайпкласс, как Foldable, описывающий сущности, по которым можно сворачиваться, типа списков, деревьев и так далее, и у него есть метод length (потому что длина — это, в конце концов, тоже свёртка). Из соображений удобства пары тоже реализуют Foldable, при этом учитывая только второй элемент (по вполне разумным причинам). Короче, в результате


                                                              λ> length (3, 4)
                                                              1

                                                              Это куда больший WTF-момент, чем [] == "", имхо.

                                                                0
                                                                Принцип наименьшего удивления – это маркетинговый ход Ruby, который привел к тому, что у них есть три метода для взятия длины строки (length, size и count). Чтобы уж точно никого не удивить.

                                                                Count совсем не для этого. Ну а length и size, по сути, псевдонимы. Они по факту вызывают одну и ту же функцию.
                                                        0
                                                        Некоторые ребята называют JS глупым языком из-за странных автоматических приведений типов, вроде


                                                        Ребята, у которых в реальном коде складываются числа с массивами, я извиняюсь, — говнокодеры. Если код нормально писать, никогда в эти тонкости приведения не придеться вникать.
                                                        +4
                                                        С помощью одного значения BigInt мы можем представлять произвольно большие целые числа. Но если мы создадим пару из целых чисел, то сможем представлять произвольно большие или маленькие числа.

                                                        Можем, но практического смысла в этом нет, т.к. арифметические операции с рациональными дробями могут очень быстро раздувать числитель и знаменатель до достижения переполнения. Например, если в цикле складывать числа с разными знаменятелями, метод simplify перестанет справляться со своей задачей. Решая эту проблему, мы введем погрешность, заменив simplify на нормализацию и… изобретем float

                                                          0
                                                          До достижения переполнения чего? (Напомню, что тип BigInt в JS не ограничен в размере)
                                                            +1

                                                            Ок, резиновый int не переполнися пока есть свободная RAM.
                                                            Но тогда появляется другая проблема — сложность арифметических операций будет пропорциональна размерам num, den. Сложность алгоритмов становится неопределенной.

                                                              +2
                                                              V8 мне не разрешил сделать BigInt больше гигабайта.
                                                            +1
                                                            0.1 + 0.2 не равно 0.3


                                                            Ну вот ни разу не встречал, чтобы именно это ставили в претензию именно Javascript'у. Откуда вы это взяли? Да и вообще, чтобы его называли глупым. Скорее он обманчив. Сначала многим кажется, что он простой. Некоторые вообще его называют самым простым языком из всех, да еще рекомендуют в качестве первого (рукалицо). Но когда начинаешь копать глубже, вдруг обнаруживается, что он вообще нифига не прост.
                                                            Что удручает, что очень многие люди на нем пишут, но практически не знают его. А когда человек не знает язык, он постоянно допускает ошибки и многие моменты он вообще не понимает, почему тут что-то работает так, а не иначе. Ну и людям, конечно, свойственно назвать язык глупым нежели взять и изучить его на нормальном уровне.
                                                              0

                                                              Ну, он очень простой по устройству. Гениально простой, ненамного сложнее Lisp.
                                                              А для использования… Ну, я не думаю, что он проектировался для использования в тех масштабах, которые мы имеем сейчас – только и всего. Если бы по прежнему речь шла о 10 строчках для создания необычного функционала на страничке – никто б не жаловался, это можно и на brainfuck написать.

                                                                +2
                                                                И чем же он простой? Многие люди с трудом разбираются с тем, как на самом деле работает this, что же это такое за прототипная модель, что такое промисы, деструктурирующее присваивание или как на самом деле работает event loop и т.д. Да и часто не разбираются вообще, стараются пребывать в неведении, нежели сесть и разобраться ))) Javascript — это, по-моему, единственный в своем роде язык, когда большинство пишущих на нем людей не представляет, как он на самом деле работает, а в подавляющем большинстве случаев просто занимаются копипастом.
                                                                А если уж так посмотреть, то любой динамически типизированный язык не рассчитан на более чем условные 10 строчек кода. И тут Javascript не лучше и не хуже, чем любой другой динамически типизированный язык.
                                                                  0
                                                                  И чем же он простой?
                                                                  Видимо имелся в виду вариант из середины 90-х, когда JS действительно был простым и понятным.
                                                                  И тут Javascript не лучше и не хуже
                                                                  Вот здесь интересный момент: есть три библиотеки, написанных на C, Python и JS. У них есть внешние зависимости. На С их 3, на Python 9, на JS подтягивается аж 705 внешних пакетов. Понятно, что это типично для JS экосистемы, но все же как-то это смущает, что может понадобиться искать ошибку в таком количестве модулей.
                                                                    +1
                                                                    Видимо имелся в виду вариант из середины 90-х, когда JS действительно был простым и понятным.

                                                                    Отчасти да. Только понятным он не был. Он был прост внутри – но примерно все понимали его неправильно. В силу сходства с Java ожидали и схожего поведения, для 10 строк кода это обычно срабатывало, а потом уже начинало ломаться.
                                                                    В общем, жаль, что Айку не дали просто воткнуть в Netscape Scheme, дали задание на Java-подобный язык.


                                                                    на JS подтягивается аж 705 внешних пакетов.

                                                                    Ну, это психологический выверт, привнесённый, как я понимаю, node.js, а не самим языком. См. историю с left-pad.

                                                                    0

                                                                    Я сказал по устройству, а не по использованию :-).


                                                                    По устройству прототипная модель проще, чем классы с VMT, this в JS сделан максимально примитивно (this задаётся при вызове. Для сравнения посмотрите реализации делегатов на C++. До стандартизации std::function было довольно уныло).
                                                                    Промисы и деструктурирующее присваивание появились позже, но даже они устроены довольно просто. Это не значит, что при их использовании не выстрелишь себе в ногу (наоборот) – это значит, что сделать их реализацию на коленке можно за разумное время.

                                                                  0

                                                                  JS был моим первым языком, ничего плохого не случилось. Прототипы, промисы, this, деструктурирование, event loop и так далее хорошо понимаю, а в те времена, когда я его изучал, это и вовсе был es4, верстка под ie6 и так далее.

                                                                    0
                                                                    Я пишу о тех, кто не хочет во всем этом разбираться. И поверьте, таких людей хватает. Но хорошо, что такие не все.
                                                                      +2
                                                                      А что нам за беда от проблем людей, которые не хотят разбираться? Это их личные трудности. Они на любом другом языке будут точно так же «думать, что программируют».
                                                                        –2
                                                                        В любой другой язык они с большой вероятностью не полезут, особенно в компилируемый. Так как эти языки «прощают» некомпетентность в куда меньшей степени. Веб, он такой, туда идут все, кому не лень. Даже те, кто никогда бы не пошел в реальное программирование.
                                                                          0
                                                                          А проблема-то в чём? Мы же с Вами от этого не начнём писать код хуже?
                                                                      +1

                                                                      es4 — это Action Script, вы наверное es3 имели в виду

                                                                        0
                                                                        Упс, да.
                                                                      0

                                                                      https://habr.com/ru/company/mailru/blog/335292/#tochnost-vychisleniya-01--02
                                                                      Вот, например, на хабре. Вообще в гугле много результатов по этому запросу. В большинстве из них есть пометка, что претензия абсурдная.

                                                                      0
                                                                      Проблема здесь не в JavaScript
                                                                      Конечно проблема не в этом. Проблема в том, что многие "современные программисты" далеки от железа и не знают что там делается под капотом. Да им этого знать и не надо — функцию позвал, она сделала что надо, а что там как и почему — их это не интересует.
                                                                        –1

                                                                        Вы говорили про железо, но это и к программированию относится, сейчас разработчики обычно не знают как работают инструменты под капотом.


                                                                        Если им показать как сделать SPA без фреймворков и библиотек, у них еще и волосы дыбом встанут, и собес не пройдешь J


                                                                        По моему опыту, что я вижу, сейчас почти весь рынок забит "React-разработчиками", которые дальше библиотеки и компонентов плюс гайдов как работать с сагой и прочим, не работают.


                                                                        Поэтому я сейчас рынок специалистов воспринимаю как рынок каменщиков, где люди умеют ложить один конкретный кирпич, и всё. Дальше дай им сложную задачу, и всё рассыпется от отсутствия знаний и опыта, потому что дальше Реакта они не смотрели.

                                                                        +5

                                                                        Честно говоря, завис на выражении
                                                                        const one = new Ratio(BigInt(1), BigInt(0));


                                                                        Я до этого думал, что второй параметр — это знаменатель, но ...

                                                                          0
                                                                          на языке PL/1 0.1+0.2 точно равно 0.3
                                                                            0

                                                                            Умножьте число на 100 или 1000, и будет вам счастье.


                                                                            0.1 + 0.2 = 0.300...4


                                                                            или если умножить на 100


                                                                            (0.1 x 100) + (0.2 x 100) = 30
                                                                            30 / 100 = 0.3


                                                                            Или этот вариант может давать сбой?

                                                                              +1
                                                                              В чём проблема проверить?
                                                                              Эти выражения не равны
                                                                                +3

                                                                                Только 30 на 100 делить не надо :-)


                                                                                И да, после исправления ошибки получается true; но это случайность.

                                                                                  0

                                                                                  Кстати, интересный эффект. Если сравнивать с 0.3 то true, но с делением (30 / 100) выходит false, при том что 30 на 100 делится без погрешности (0.3).


                                                                                  Есть идеи почему работает именно так?
                                                                                  Насколько я понимаю, здесь нет проблемы с дробями, т.е. должно работать.

                                                                                    0

                                                                                    Потому что тут сравнивается 30 == 30/100. Ну конечно же это false. Как у вас true для 0.3 получилось — без понятия.

                                                                                      0

                                                                                      Тьфу, голова уже не работает под вечер J
                                                                                      Спасибо

                                                                              –1
                                                                              Автор, вы не точны в самом начале.
                                                                              Число 0.1 НЕ представимо точно в двоичном виде. Поэтому компьютер использует его приближение, равное «0.100000001490116119384765625» (для 32-битного float). То же самое и с числом 0.2 — оно будет равно «0.20000000298023223876953125». А сложение компьютер всегда делает с абсолютной точностью, вот вы и получаете «неточный» результат. Просто надо помнить про то, что далеко не все числа представимы точно и НИКОГДА не использовать прямое сравнение в случае чисел с плавающей точкой. И все будет хорошо.

                                                                              p.s. даже пи представимо примерно в компьютере, для 32-х бит это будет 3.1415927410125732421875, для 64-х бит это будет 3.141592653589793115997963468544185161590576171875 (я выделил точные знаки), как видите при любой точности для пи у вас будет только примерно треть точных знаков.
                                                                                0

                                                                                Про сложение с плавающей точкой сильно круто сказано. Степень основания может сильно отличаться и мантиссу меньшего числа можно будет усечь или даже проигнорировать без потери точности результата. Это числа с фиксированной точкой при сложении как целые, и тоже могут переполняться.

                                                                                  0
                                                                                  Да, наверное я неточно выразился. Финальный результат сложения зависит от представления чисел, поэтому для плавающей точки могут быть варианты, когда прибавление маленького числа даже не изменит результат. Например для float (32-bit): 16777216.0 + 1.0 = 16777216.0.
                                                                                +1
                                                                                Дело в том, что в языках, имеющих дело с финансово-экономическими расчетами (вроде PL/1), т.е. там, где требуются точные расчеты, числа делятся не на целые и действительные, а на точные и приближенные.
                                                                                Если в языке нет точных чисел никакие масштабные коэффициенты или предложение хранить деньги в виде целого числа копеек не помогут.
                                                                                В том же PL/1 точка является признаком только дробной части, а вот показатель степени «E» — уже признаком приближенного числа.
                                                                                Поэтому в PL/1 0.1Е0+0.2Е0 не равно точно 0.3Е0 как и в других языках (как и в примере).
                                                                                А вот 0.1+0.2 точно равны 0.3 поскольку это точные числа, представленные в двоично-десятичном виде.
                                                                                  –6
                                                                                  Кто вообще может шутить про js? Про инвалидов шутить не красиво!
                                                                                    0
                                                                                    если бы график был маленьким, а значения данных были большими, я бы получил ошибки округления. И зачастую это нормально. Но на графике некоторые пиксели должны выстраиваться в линию. Иначе рисунок выглядит неправильно.


                                                                                    Проблема выглядит слегка надуманной. Не совсем понимаю, каким образом в свечном графике можно заметить несоответствие пикселей из-за округления.
                                                                                    А так, рациональные числа это забавно, но узок круг применения. Стоит попытаться применить их к каким-то кругам/тригонометрии/площадям — так всё и сломается.
                                                                                      0
                                                                                      Но JavaScript точно не заслуживает возмутительных шуток.


                                                                                      То есть вы нашли (а может сами придумали) одну-единственную действительно глупую шутку, и на основании этого сделали вывод что JavaScript не заслуживает возмутительных шуток? Нет, заслуживает, ещё как. Просто пример выбран неудачный.
                                                                                      А хотите удачный? Нет 64-битных целых. Да что там, вообще нет целых, но реально напрягает именно отсутствие 64-битных. То есть чтобы разобрать например json (JavaScript Object Notation, на секундочку) с 64-битными целыми, придётся сначала регулярками (или как-то ещё) превратить эти числа в строки, потом разобрать json, а потом уже как-то обрабатывать эти строки, вероятнее всего преобразовав в bigInt. Множество операций для решения проблемы, которая в здравых языках вообще не существует. И это — только один из примеров неадекватности языка.
                                                                                        0
                                                                                        Отличная статья. Проблеме (или особенности, если угодно) чисел с плавающей точкой посвящен целый сайт: 0.30000000000000004.com. Что касается JS, то равенство 0.1 + 0.2 и 0.3 можно проверить следующим образом: 0.1+0.2-0.3 < Number.EPSILON. Обычно, как отмечено в одном из комментариев выше, рекомендуется приводить дробные числа к целым, осуществлять над ними необходимые операции и выполнять обратное преобразование
                                                                                          0

                                                                                          Тогда уж 0.1 + 0.2 - 0.3 < Number.EPSILON * 0.3

                                                                                          0
                                                                                          В большинстве задач эта точность неважна

                                                                                          Для вывода пользователю каждый новичок возьмет на вооружение простое округление

                                                                                          Math.round((0.1 + 0.2)*10)/10
                                                                                          
                                                                                          или
                                                                                          
                                                                                          +(0.1 + 0.2).toFixed(1)
                                                                                            0
                                                                                            Я когда-то давно сделал функцию

                                                                                                //value - число
                                                                                                //сс    - количество знаков после точки
                                                                                                function okr(value, cc) {
                                                                                                    if (cc > 0) {
                                                                                                        var vv = Math.pow(10, cc);
                                                                                                        return Math.round(value * vv) / vv;
                                                                                                    } else {
                                                                                                        return Math.round(value);
                                                                                                    }
                                                                                                }
                                                                                            


                                                                                            и вообще проблем нет)
                                                                                            а вот .toFixed(2) возвращает строку…
                                                                                              0
                                                                                              let okr = (value, cc) => +value.toFixed(cc)
                                                                                                0
                                                                                                Я наверное не нормальный, но вот эти неявные преобразования мне не нравятся)
                                                                                                  0
                                                                                                  + еще кажется toFixed не было, когда эта функция писалась)
                                                                                                    0
                                                                                                    Учитывая, что это единственное полезное применение унарного плюса — можете считать его оператором явного преобразования в число.
                                                                                              0
                                                                                              О, Господи, человек делает сравнение натуральных дробей через циклы, и потом чему-то пытается учить и делает вокруг этого библиотеку! Вмеcто того, чтобы сравнивать их в одно целочисленное действие:
                                                                                              const equals = (one, two) =>
                                                                                                  one.numerator * two.denominator === two.numerator * one.denominator;
                                                                                              И так же со сравнениями.
                                                                                                +1
                                                                                                Здесь собрано гораздо больше глупых шуток.
                                                                                                  –3
                                                                                                  Спасибо, что пытаетесь развенчать миф об ущербности js даже из-за банально «0.1+0.2»

                                                                                                  Only users with full accounts can post comments. Log in, please.