Десятичные числа и точность

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

В большинстве случаев задачи сводятся к трём операциям:

  • форматирование числа;
  • разбор строки в число;
  • контроль точности отображения.

Форматирование десятичных чисел

Основной метод форматирования — Globalize.numberFormatter().

Базовый пример

const Globalize = require("globalize");

Globalize.locale("en");

const formatter = Globalize.numberFormatter();

console.log(formatter(12345.678));
// 12,345.678

Для английской локали:

  • запятая используется как разделитель тысяч;
  • точка используется как десятичный разделитель.

Для русской локали результат будет другим.

Globalize.locale("ru");

const formatter = Globalize.numberFormatter();

console.log(formatter(12345.678));
// 12 345,678

Особенности:

  • используется пробел для группировки;
  • запятая становится десятичным разделителем.

Количество знаков после запятой

Форматтер поддерживает настройку точности отображения через параметр maximumFractionDigits.

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 2
});

console.log(formatter(12.34567));
// 12.35

В данном случае число округляется до двух знаков после запятой.


Минимальное количество дробных знаков

Параметр minimumFractionDigits гарантирует фиксированную длину дробной части.

const formatter = Globalize.numberFormatter({
    minimumFractionDigits: 2
});

console.log(formatter(12));
// 12.00

Полезно для:

  • денежных значений;
  • таблиц;
  • финансовой отчётности;
  • выравнивания данных.

Одновременное использование minimumFractionDigits и maximumFractionDigits

Часто требуется ограничить диапазон точности.

const formatter = Globalize.numberFormatter({
    minimumFractionDigits: 2,
    maximumFractionDigits: 4
});

console.log(formatter(12));
// 12.00

console.log(formatter(12.3));
// 12.30

console.log(formatter(12.345678));
// 12.3457

Логика работы:

  • минимум — 2 символа;
  • максимум — 4 символа;
  • лишние цифры округляются.

Округление чисел

Globalize использует стандартные механизмы округления JavaScript.

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 2
});

console.log(formatter(1.005));

Результат может оказаться неожиданным из-за особенностей IEEE 754.


Проблема точности IEEE 754

JavaScript хранит числа в формате двойной точности IEEE 754. Из-за этого некоторые десятичные дроби невозможно представить точно.

Классический пример:

console.log(0.1 + 0.2);
// 0.30000000000000004

Проблема относится не к Globalize, а к самому механизму чисел JavaScript.


Почему возникают ошибки точности

Числа в памяти хранятся в двоичном виде.

Некоторые десятичные значения:

  • 0.1
  • 0.2
  • 1.005

не имеют точного двоичного представления.

В результате появляются микроскопические ошибки округления:

console.log(1.005 * 100);
// 100.49999999999999

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


Безопасное округление

Для повышения точности часто применяют промежуточное преобразование.

function round(value, precision) {
    const multiplier = Math.pow(10, precision);

    return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
}

console.log(round(1.005, 2));
// 1.01

После такого округления значение можно безопасно передавать в Globalize.


Форматирование уже округлённых значений

const formatter = Globalize.numberFormatter({
    minimumFractionDigits: 2,
    maximumFractionDigits: 2
});

const value = round(1.005, 2);

console.log(formatter(value));
// 1.01

Подход особенно важен в:

  • финансовых системах;
  • бухгалтерии;
  • расчётах налогов;
  • банковских приложениях.

Использование шаблонов форматирования

Globalize поддерживает форматные шаблоны.

const formatter = Globalize.numberFormatter({
    raw: "0.000"
});

console.log(formatter(12.3));
// 12.300

Шаблон задаёт:

  • обязательные цифры;
  • количество знаков;
  • правила вывода.

Форматирование очень маленьких дробей

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 10
});

console.log(formatter(0.000000123456));

Важно помнить:

  • отображение не исправляет ошибки хранения;
  • Globalize лишь форматирует уже существующее число.

Научная нотация

При работе с экстремально малыми или большими значениями JavaScript автоматически использует экспоненциальную форму.

console.log(0.00000000000001);
// 1e-14

Globalize способен корректно отображать такие значения при соответствующих настройках.

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 20
});

console.log(formatter(1e-14));

Разбор десятичных чисел

Для преобразования локализованной строки используется numberParser().

Globalize.locale("ru");

const parser = Globalize.numberParser();

console.log(parser("12 345,67"));
// 12345.67

Globalize автоматически:

  • распознаёт разделители;
  • удаляет символы группировки;
  • преобразует дробную часть.

Отличия parseFloat от Globalize.numberParser

parseFloat

parseFloat("12,5");
// 12

Стандартный парсер Jav * aScript:

  • не понимает локали;
  • работает только с точкой.

Globalize.numberParser

const parser = Globalize.numberParser();

console.log(parser("12,5"));
// 12.5

При русской локали значение будет обработано правильно.


Потеря точности при больших числах

JavaScript имеет ограничение:

Number.MAX_SAFE_INTEGER

Значение:

9007199254740991

После превышения безопасного диапазона появляются ошибки.

console.log(9007199254740991 + 1);
console.log(9007199254740991 + 2);

Оба результата могут совпасть.


BigInt и Globalize

Globalize ориентирован на тип Number.

const value = 12345678901234567890n;

Тип BigInt напрямую не поддерживается форматтерами чисел.

Обычно используют:

value.toString()

либо специализированные библиотеки.


Использование decimal-библиотек

Для критически важных вычислений применяют:

  • decimal.js
  • big.js
  • bignumber.js

Пример с decimal.js:

const Decimal = require("decimal.js");

const result = new Decimal(0.1)
    .plus(0.2);

console.log(result.toString());
// 0.3

После вычислений результат можно форматировать через Globalize.

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 2
});

console.log(
    formatter(Number(result))
);

Локализация дробных разделителей

Разные локали используют разные символы.

Локаль Дробный разделитель
en .
ru ,
fr ,
de ,

Пример:

Globalize.locale("de");

const formatter = Globalize.numberFormatter();

console.log(formatter(1234.56));
// 1.234,56

Группировка разрядов

Globalize автоматически применяет правила локали.

Globalize.locale("en");

console.log(
    Globalize.numberFormatter()(1000000)
);
// 1,000,000

Для Индии схема будет иной:

Globalize.locale("en-IN");

console.log(
    Globalize.numberFormatter()(1000000)
);
// 10,00,000

Отключение группировки

const formatter = Globalize.numberFormatter({
    useGrouping: false
});

console.log(formatter(1234567.89));
// 1234567.89

Подходит для:

  • технических идентификаторов;
  • сериализации;
  • экспортируемых данных.

Работа с процентами

Проценты также используют дробные значения.

const formatter = Globalize.numberFormatter({
    style: "percent",
    maximumFractionDigits: 2
});

console.log(formatter(0.1234));
// 12.34%

Точность процентов

Следует учитывать:

0.1 === 10%

Globalize умножает число на 100 при форматировании процентов.

console.log(formatter(0.005));
// 0.5%

Денежные значения и дробная точность

Валюты имеют разные правила дробной части.

const formatter = Globalize.currencyFormatter("USD");

console.log(formatter(12.5));
// $12.50

Японская иена:

const formatter = Globalize.currencyFormatter("JPY");

console.log(formatter(12.5));
// ¥13

У иены отсутствует дробная часть.


Управление точностью валют

const formatter = Globalize.currencyFormatter("USD", {
    maximumFractionDigits: 4
});

console.log(formatter(12.34567));
// $12.3457

Ошибки округления в финансовых системах

Опасный пример:

const total = 0.1 + 0.2 + 0.3;

Ошибки накапливаются при:

  • массовых расчётах;
  • суммировании;
  • конвертации валют;
  • начислении процентов.

Хранение денег в целых числах

Распространённая практика:

const cents = 1050;

Вместо:

10.50

Преимущества:

  • отсутствие дробных ошибок;
  • точные вычисления;
  • стабильное округление.

Форматирование:

const amount = cents / 100;

console.log(formatter(amount));

Проверка точности перед форматированием

Полезно контролировать конечное значение.

function normalizeMoney(value) {
    return Number(value.toFixed(2));
}

Форматирование без потери исходных данных

Иногда требуется:

  • хранить полную точность;
  • отображать сокращённую.
const original = 12.3456789;

const formatter = Globalize.numberFormatter({
    maximumFractionDigits: 2
});

console.log(formatter(original));
// 12.35

Исходное значение остаётся неизменным.


Производительность форматтеров

Создание форматтера — сравнительно дорогая операция.

Нежелательно:

for (const value of values) {
    const formatter = Globalize.numberFormatter();
}

Правильный подход:

const formatter = Globalize.numberFormatter();

for (const value of values) {
    console.log(formatter(value));
}

Кэширование форматтеров

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

const cache = {};

function getFormatter(locale) {
    if (!cache[locale]) {
        Globalize.locale(locale);

        cache[locale] =
            Globalize.numberFormatter({
                maximumFractionDigits: 2
            });
    }

    return cache[locale];
}

Проверка корректности входных данных

Перед форматированием желательно проверять значения.

function isValidNumber(value) {
    return typeof value === "number" &&
           Number.isFinite(value);
}

Обработка NaN и Infinity

console.log(
    Globalize.numberFormatter()(NaN)
);

console.log(
    Globalize.numberFormatter()(Infinity)
);

Следует заранее определять стратегию отображения:

  • "—"
  • "N/A"
  • "Ошибка"
  • специальный текст.

Работа с отрицательными дробями

const formatter = Globalize.numberFormatter({
    minimumFractionDigits: 2
});

console.log(formatter(-12.5));
// -12.50

Округление отрицательных чисел

Особенности IEEE 754 проявляются и здесь.

console.log(Math.round(-1.5));
// -1

Поведение может отличаться от бухгалтерских стандартов округления.


Банковское округление

В финансовой сфере иногда используют округление к ближайшему чётному числу.

Пример:

  • 1.5 → 2
  • 2.5 → 2

JavaScript такого механизма не предоставляет напрямую.

Для подобных задач обычно используют:

  • decimal.js;
  • специализированные финансовые библиотеки;
  • серверные вычисления.

Тестирование локализации чисел

Важно проверять:

  • разные локали;
  • отрицательные значения;
  • нули;
  • экстремально большие числа;
  • длинные дробные части;
  • значения возле границ округления.

Пример тестового набора:

[
    0,
    0.1,
    0.105,
    1.005,
    9999999.999,
    -12.3456
]

Практическая схема работы с точными числами

Наиболее надёжный подход:

  1. Выполнять вычисления через decimal-библиотеки.

  2. Хранить деньги в минимальных единицах.

  3. Контролировать округление отдельно.

  4. Использовать Globalize только для отображения.

  5. Разделять:

    • вычисление;
    • хранение;
    • форматирование.

Такой подход минимизирует:

  • ошибки округления;
  • потерю точности;
  • различия локалей;
  • проблемы финансовых вычислений.