JavaScript: Большое целое Ну почему


    Не так давно JavaScript похвастался новым примитивным типом данных BigInt для работы с числами произвольной точности. Про мотивацию и варианты использования уже рассказан/переведен необходимый минимум информации. А мне бы хотелось обратить чуть больше внимания на превнесенную локальную «явность» в приведении типов и неожиданный TypeError. Будем ругать или поймем и простим (опять)?

    Неявное становится явным?


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

    1 + {}; // '1[object Object]'
    1 + [[0]]; // '10'
    1 + new Date; // '1Fri Feb 08 2019 00:32:57 GMT+0300 (Москва, стандартное время)'
    1 - new Date; // -1549616425060
    ...
    

    Мы неожиданно получаем TypeError, пытаясь сложить два, казалось бы, ЧИСЛА:

    1 + 1n; // TypeError: Cannot mix BigInt and other types, use explicit conversions
    

    И если предыдущий опыт неявностей не привел к нервному срыву при изучении языка, то тут появляется второй шанс сорваться и выбросить учебник по ECMA и уйти в какую-нибудь Java.

    Далее язык продолжает «троллировать» js-разработчиков:

    1n + '1'; // '11'
    

    Ах да, не забываем про унарный оператор +:

    +1n; // TypeError: Cannot convert a BigInt value to a number
    Number(1n); // 1
    

    Если коротко, то мы не можем смешивать в операциях BigInt и Number. Как следствие, не рекомендуется использовать «большие целые», если 2^53-1 (MAX_SAFE_INTEGER) нам достаточно в наших целях.

    Ключевое решение


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

    Когда мы складываем два значения разных числовых типов (большие целые и числа с плавающей точкой), математическое значение результата может оказаться вне их области возможных значений. Например, значение выражения (2n ** 53n + 1n) + 0.5 не может быть точно представлено ни одним из этих типов. Это уже не целое, а вещественное число, но его точность формат float64 уже не гарантирует:

    2n ** 53n + 1n; // 9007199254740993n
    Number(2n ** 53n + 1n) + 0.5; // 9007199254740992
    

    В большинстве динамических языков, где представлены типы и для целых чисел (integer), и для чисел с плавающей точкой (float), первые записываются как 1, а вторые — 1.0. Тем самым, при арифметических операциях по наличию десятичного разделителя в операнде можно сделать вывод о приемлемости точности float в вычислениях. Но JavaScript — не из их числа, и 1 — есть float! А это значит, что вычисление 2n ** 53n + 1 вернет float 2^53. Что, в свою очередь, ломает ключевую функциональность BigInt:

    2 ** 53 === 2 ** 53 + 1; // true
    

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

    И чтобы избежать этой проблемы, неявное приведение между Number и BigInt в операциях оказалось под запретом. Как итог, «большое целое» не получится безопасно прокинуть в какую-либо функцию JavaScript или Web API, где ожидается обычный number:

    Math.max(1n, 10n); // TypeError
    

    Необходимо явно выбирать один из двух типов применением Number() или BigInt().

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

    Конечно, это распространяется на неявные численные преобразования с другими примитивами:

    1 + true; // 2
    1n + true; // TypeError
    1 + null; // 1
    1n + null; // TypeError
    

    Но следующие (уже) конкатенации будут работать, так как ожидаемый результат — это строка:

    1n + [0]; // '10'
    1n + {}; // '1[object Object]'
    1n + (_ => 1); // '1_ => 1'
    

    Еще исключение — в виде операторов сравнения (как <, > и ==) между Number и BigInt. Здесь тоже нет потери точности, так как результат — это булево.

    Ладно, если вспомнить предыдущий новый тип данных Symbol, то TypeError уже не кажется таким радикальным дополнением?

    Symbol() + 1; // TypeError: Cannot convert a Symbol value to a number
    

    И да, но нет. Ведь концептуально symbol — совершенно не число, а целое — очень даже:

    1. Крайне мало вероятно, что symbol попадет в такую ситуацию. Тем не менее, подобное — очень подозрительно и TypeError здесь вполне уместен.
    2. Крайне вероятно и обычно, что «большое целое» в операциях окажется одним из операндов, когда на самом деле ничего страшного нет.

    Унарный оператор + же бросает исключение из-за проблемы совместимости с asm.js, где ожидается Number. Унарный плюс не может работать с BigInt аналогично Number, так как в этом случае предыдущий asm.js-код станет неоднозначным.

    Альтернативное предложение


    Несмотря на относительную простоту и «чистоту» внедрения BigInt, Axel Rauschmeyer подчеркивает недостаток нововведения. А именно, его лишь частичную обратную совместимость с существующим Number и вытекающее:
    Use Numbers for up to 53-bit ints. Use Integers if you need more bits
    В качестве альтернативы он предложил следующее.

    Пусть Number станет супертипом для новых Int и Double:

    • typeof 123.0 === 'number', а Number.isDouble(123.0) === true
    • typeof 123 === 'number', а Number.isInt(123) === true

    C новыми функциями для преобразований Number.asInt() и Number.asDouble(). И, конечно, с перегрузкой операторов и нужными приведениями:

    • Int × Double = Double (с приведением)
    • Double × Int = Double (с приведением)
    • Double × Double = Double
    • Int × Int = Int (все операторы, кроме деления)

    Интересно, что в упрощенной версии это предложение обходится (сначала) без добавления новых типов в язык. Вместо этого расширяется определение The Number Type: в дополнение ко всем возможным значениям 64-битных чисел двойной точности (IEEE 754-2008) number теперь включает и все целые числа. Следствием, «неточное число» 123.0 и «точное число» 123 — это отдельные числа единого типа Number.

    Выглядит очень знакомо и разумно. Однако, это серьезный апгрейд существующего number, который с бОльшей долей вероятности способен «сломать веб» и его инструменты:

    • Появляется различие между 1 и 1.0, которого не было до этого. Существующий код использует их взаимозаменяемо, что после апгрейда может привести к путанице (в отличие от языков, где это различие присутствовало изначально).
    • Возникает эффект, когда 1 === 1.0 (предполагается апгрейдом), и в то же время Number.isDouble(1) !== Number.isDouble(1.0): опять таки, такое себе.
    • «Особенность» равенства 2^53 и 2^53+1 пропадает, что сломает полагающийся на это код.
    • Та же проблема совместимости с asm.js и прочее.

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

    Когда сидишь на двух стульях


    Собственно, комментарий комитета начинается словами:
    Find a balance between maintaining user intuition and preserving precision

    С одной стороны, хотелось добавить наконец что-то «точное» в язык. А с другой стороны, сохранить его уже привычное для многих разработчиков поведение.

    Просто так это «точное» добавить не получится, потому что нельзя ломать: математику, эргономику языка, asm.js, возможность дальнейшего расширения системы типов, производительность и, в конце концов, сам веб! И ломать это все нельзя одновременно, что и приводит к подобному.

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

    Комментарии 24

      0
      В каких случах по Вашему мнению лучше всего подходит BigInt?
        0
        Собственно, про это уже писали:
        — для больших числовых идентификаторов
        — для точных меток времени
        — для реализации BigDecimal
          +2
          При работе с вебсервисами, которые не знают про странные ограничения js.
            0
            При работе с RSA, например.
            0
            Спасибо за статью, ну тут бы сначала нужно решить проблему с вещественным типом: хранения и отображения данных 0.2 + 0.1 = 0.30000000000000004, 0.2 + 0.1 === 0.3 (false), а то как-то неудобно работать )
              +7
              А как её можно решить в принципе? Эти особенности у вычислений с плавающей точкой были с рождения.
                0
                Считать как в Экселе вручную а ля «в столбик». Правда, погрешности всё равно вылезут на чем-то сложнее сложения.
                  0
                  Ну я конечно не спец по компиляторам и хранению данных. Но специально проверил, Visual FoxPro (когда то писал на нем просто много лет) и PostgreSQL работает адекватно, вот пример на PostgreSQL
                  SELECT 0.1 + 0.2, 0.1 + 0.2 = 0.3
                  -------------------------------------
                  0.3 true
                    0
                    Ну так там 0.1 — это numeric, а не real. То есть используется фиксированная точка, а не плавающая.
                      0

                      psql просто округлил число при выводе.

                        +3

                        А false округлил до true.

                      0
                      Считать в конструктивных вещественных.

                      Но там свои небольшие проблемы в виде неразрешимости сравнения для совпадающих чисел, например.
                      0
                      BigDecimal на базе BigInt :)
                        +1
                        Это проблема вообще не решаема, так как 0.3 нельзя представить как конечное число в двоичном виде. Например, представьте что вы храните числа в ячейке из 5 десятичных разрядов. Как вы в нем запишите 1/3 = 0,(3)? 0.3333? 0.3334? спойлер, никак) Вот точно так же в конечном двочином значении невозможно записать некоторые конечные десятичные числа
                          –1
                          Вообще-то решаема, но очень сложно, созданием новой арифметики вычислений. Хранить такие числа например как (очень грубо говоря) строки типа «1/3».
                          Вот, кажется, даже реализация есть на PHP:
                          github.com/brick/math
                            +3
                            нерешаема при сохранении скорости вычислений как у double, если быть точнее
                              +1
                              вот предложение Dr. Axel Rauschmayer по этому поводу
                                –1
                                Какой прок в быстром вычислении того, что 0.3 * 3 + 0.1 !== 1?
                            +1

                            Статья отличная, язык ***** [очень сложный, противоречивый, с большим количеством неверных решений, которые приходится поддерживать].

                              +1
                              Отдельно хотелось бы добавить еще ложечку дегтя: bigint вызывает ошибку сериализации при JSON.stringify:

                              let data = { x: 1n };
                              JSON.stringify(data); // TypeError: Do not know how to serialize a BigInt
                              

                              В этом тоже есть некоторая здравость… даа. Придется использовать внешние либы.
                                +6
                                В этом тоже есть некоторая здравость

                                Как я понимаю, json никак не ограничивает точность сериализуемых значений, можно написать хоть {"a": 10000000[ещёстонулей]0000001}, то есть сериализовать BigInt проблемы нет вообще. То есть текст ошибки очевидно врёт.


                                Проблема в том, что не ясно, как десериализовать конкретное значение, в Int или BigInt, а так как два этих типа абсолютно не взаимозаменяемы, то результат десериализации будет не равен результату сериализации, поэтому сериализацию BigInt решили запретить.

                                  +2
                                  Спасибо! Согласен, что проблема именно в десериализации. Просто защитились от возможной неоднозначности еще одним TypeError.
                                    +1

                                    Симметричность JSON.parse/stringify уже и так нарушается, если присутствует объект Date, например. Так что непонятно, почему для BigInt это стало внезапно важно

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

                              Самое читаемое