Назад в Центр знаний
Отпечатки

Intl.NumberFormat: режим округления и показ знака

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

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser позволяет воспроизвести авторизованный фикстур того же источника под объявленным профилем и сохранить ограниченную запись точной строки, которую страница вывела для числа, но он не может заставить форматтер округлять так, как предполагает читатель, не может вернуть минус, который убрала опция, и не может заставить две валюты показать одинаковое число знаков после запятой. Этот разбор запускает один зонд по одиннадцати фиксированным случаям и фиксирует, какой режим округления по умолчанию на границе .5, что каждый явный режим делает с теми же четырьмя значениями, как signDisplay и trailingZeroDisplay меняют выводимую строку, как roundingPriority решает между двумя пределами, почему знаки после запятой по умолчанию у валюты берутся из её кода и что сообщают resolvedOptions() и formatToParts() о том форматтере, который реально работал.

Кратко

  • Округление по умолчанию это half-expand, от нуля: зонд печатает default -> 3,4,-3,1,1 для 2.5, 3.5, -2.5, 0.5, 1.45 при нуле знаков после запятой, то есть 2.5 становится 3, а не 2.
  • halfEven это режим, который дал бы 2.5 -> 2, и зонд печатает halfEven-vs-expand -> 2,4 vs 3,4, то есть режимы различаются только там, где цифра перед границей чётная.
  • trunc, ceil и floor двигают отрицательное число в разные стороны: -2.5 становится -2 при trunc, -2 при ceil и -3 при floor.
  • signDisplay: 'never' убирает минус у отрицательного числа, поэтому зонд печатает sign:never -> 5,0,5, и потребитель, читающий эту строку, не может узнать, что она пришла из -5.
  • signDisplay: 'always' ставит + и на ноль, поэтому зонд печатает sign:always -> +5,+0,-5.
  • trailingZeroDisplay: 'stripIfInteger' сохраняет два знака у 3.10, но убирает те, что существовали только из-за минимума, поэтому 3.00 выводится как 3.
  • roundingPriority: 'morePrecision' расширяет вывод, чтобы удовлетворить оба предела, поэтому 12.345 печатает 12.35 при morePrecision, а auto и lessPrecision печатают 12.3.
  • Знаки после запятой по умолчанию у валюты берутся из её кода, поэтому те же опции печатают $5.00 и ¥5.
  • Установка maximumFractionDigits: 0 у форматтера валюты задаёт и minimumFractionDigits в 0, и resolvedOptions() сообщает min=0 max=0 mode=halfExpand.

Содержание

Первая панель показывает 2.5 и 3.5, округлённые до 3 и 4 режимом по умолчанию half-expand и до 2 и 4 режимом halfEven. Вторая показывает trunc, ceil и floor, ведущие -2.5 к -2, -2 и -3. Третья показывает sign never, превращающий -5 в 5, и sign always, превращающий 5 в +5. Четвёртая показывает morePrecision, выводящий 12.35 там, где auto выводит 12.3, и валютный умолчательный вариант из двух знаков для USD против нуля для JPY.

Какой режим округления по умолчанию

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

node=v26.9.0
default -> 3,4,-3,1,1
mode:halfExpand -> 3,4,-3,2
mode:halfEven -> 2,4,-2,2
mode:trunc -> 2,3,-2,1
mode:ceil -> 3,4,-2,2
mode:floor -> 2,3,-3,1
mode:expand -> 3,4,-3,2
mode:halfTrunc -> 2,3,-2,2
halfEven-vs-expand -> 2,4 vs 3,4
sign:auto -> 5,0,-5
sign:never -> 5,0,5
sign:always -> +5,+0,-5
sign:exceptZero -> +5,0,-5
sign:negative -> 5,0,-5
trailing:default -> 3.00 3.10
trailing:strip -> 3 3.10
priority -> 12.345:auto=12.3 more=12.35 less=12.3 | 1234.5:auto=1230 more=1234.5 less=1230 | 456.78:auto=457 more=456.78 less=457
currency -> $5.00 ¥5
currency:min0 -> $5
percent -> 13% 13%
compact -> 1.2M 1.2M
digits -> ar-EG=١٢٣٤ ar-EG-latn=1234
grouping -> de-DE=1.234.567,89 en-US=1,234,567.89
resolved -> min=0 max=0 mode=halfExpand
parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00

Первая строка это ответ на вопрос, который чаще всего понимают неверно. default -> 3,4,-3,1,1 значит, что 2.5 округляется до 3, а -2.5 до -3, то есть умолчание это half-expand, который округляет ничью от нуля. Это не правило «округление к чётному», которое многие предполагают из других языков или из курса финансов, и разница видна на каждом значении .5, а это именно тот вид числа, который часто содержит тестовый фикстур.

Второе значение показывает вторую половину правила. 1.45 при нуле знаков печатает 1, а не 2, потому что 1.45 не равно точно 1.45 в двоичной плавающей запятой; это значение чуть ниже середины, поэтому ничьей нет и результат ближайшее целое снизу. Страница, которая предполагает, что десятичный литерал точен, увидит, что .45 округляется вниз, а .5 вверх, и оба поведения из одного правила на разных входах.

Последнее значение в строке удивляет отчёт. 0.5 становится 1, то есть малое положительное значение округляется до целой единицы, хотя это наименьшее число с единственной ненулевой цифрой. Страница, которая использует ноль знаков для счёта, поэтому покажет 1 для любого значения из [0.5, 1.5), а страница, которая использует его для малой доли, покажет 1 для всего от половины вверх, что читается как ошибка в 100% на самых малых значениях.

Данные локали, которые управляют разделителями и формами цифр, это отдельная тема, и данные локали API Intl фиксирует, какие части тега локали читает форматтер. Здесь нет зависимости от локали. Зонд передаёт en-US, и режим это свойство форматтера, а не локали; de-DE или ja-JP округляют так же и различаются только разделителем и формами цифр, которые показывают поздние строки. Страница, которая проверяет округление только в одной локали, поэтому увидела всё правило, а страница, которая предполагает, что другая локаль округляет иначе, читает разницу группировки как разницу арифметики.

Умолчание видно и в resolvedOptions(), и это проверка, которую стоит записывать. Зонд печатает resolved -> min=0 max=0 mode=halfExpand для форматтера, который задал только maximumFractionDigits: 0, то есть разрешённый режим называет умолчание, которого вызывающий не писал. Запись, которая хранит входные опции, но не разрешённые, не может потом объяснить, почему 2.5 вывелось как 3, потому что причина живёт в поле, которое вызывающий не задал.

Что каждый режим делает на границе

Затем зонд задаёт каждый режим из спецификации и форматирует те же четыре значения, что превращает вопрос в таблицу. Каждая строка это режим и его четыре результата для 2.5, 3.5, -2.5, 1.6.

mode:halfExpand -> 3,4,-3,2 это умолчание, сделанное явным. mode:halfEven -> 2,4,-2,2 отправляет 2.5 к чётному соседу 2 и оставляет 3.5 у чётного соседа 4, то есть ничья всегда попадает на значение, чья последняя цифра чётная. Строка зонда делает разницу конкретной: halfEven-vs-expand -> 2,4 vs 3,4, то есть режимы совпадают на 3.5 и расходятся на 2.5.

mode:trunc -> 2,3,-2,1 отбрасывает каждую цифру за границей, насколько бы близко она ни была, поэтому 1.6 становится 1, а -2.5 становится -2. mode:ceil -> 3,4,-2,2 округляет к плюс бесконечности, поэтому 1.6 становится 2, а -2.5 становится -2, потому что -2 больше -2.5. mode:floor -> 2,3,-3,1 округляет к минус бесконечности, поэтому 1.6 становится 1, а -2.5 становится -3. mode:expand -> 3,4,-3,2 округляет от нуля на каждом значении, поэтому совпадает с ceil на положительных и с floor на отрицательных.

Три направленных режима это место, где ошибку обработки знака легче всего внести. Страница, которая хочет «округлить вверх» и берёт ceil, получает -2 для -2.5, что больше, но ближе к нулю, поэтому «округлить вверх» в смысле «от нуля» нуждается в expand. Страница, которая хочет осторожный выбор для величины, которую нельзя преувеличить, нуждается в floor на положительном и ceil на отрицательном, что две правила, а не одно, и имена режимов их не объединяют.

mode:halfTrunc -> 2,3,-2,2 округляет ничью к нулю, а остальное к ближайшему, поэтому 2.5 становится 2, 3.5 становится 4, а -2.5 становится -2. Это противоположное направление ничьей от halfExpand, и это режим для случая, когда страница сообщает итог, который нельзя округлить вверх на ничьей, например счёт, который нижележащая система сравнит с пределом.

Значение 1.6 это контроль в каждой строке. Это не ничья, поэтому каждый режим, округляющий к ближайшему, даёт 2, и только усекающие дают 1. Тест, который форматирует одно не-ничейное и одно ничейное значение, поэтому отделяет режим, меняющий только ничьи, от режима, меняющего каждое значение, а страница, которая тестирует только 1.6, не может отличить halfExpand от halfEven вовсе.

Следствие для записи в том, что режим принадлежит ей. Два прогона одного приложения могут напечатать 2 и 3 для одного 2.5, если форматтер построен раз с halfEven, а раз с умолчанием, и сохранённая строка не может сказать, какой режим её произвёл. Запись, которая хранит разрешённый roundingMode рядом с каждым отформатированным значением, делает разницу воспроизводимой, а не загадкой, которая появляется только на значениях, оказавшихся ничьими.

Как знак и конечный ноль меняют строку

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

sign:auto -> 5,0,-5 это умолчание: без знака на положительном и на нуле, минус на отрицательном. sign:always -> +5,+0,-5 ставит плюс и на положительном, и на нуле, поэтому читатель видит, что число отформатировано со знаком, а сравнение двух отчётов отличает неизменное положительное от неизменного отрицательного. sign:exceptZero -> +5,0,-5 ставит плюс на положительном, но оставляет ноль без знака, что и есть опция для случая, когда ноль это реальное значение, которое не должно выглядеть со знаком.

sign:never -> 5,0,5 опасна. Она убирает минус у -5 и печатает 5, поэтому потребитель, разбирающий строку обратно в число, получает положительное значение из отрицательного входа. Страница, которая выводит дельту с sign:never в предположении, что «знак показан в другом месте», даёт две одинаковые строки для +5 и -5, и последующий разбор не может восстановить, какая была. sign:negative -> 5,0,-5 это опция, которая показывает минус только когда значение отрицательно, что обычно и есть намерение за обращением к never, и она сохраняет знак на отрицательном.

Опция конечного нуля меняет знаки, которые существуют только из-за минимума. Зонд печатает trailing:default -> 3.00 3.10 для форматтера с минимумом и максимумом в два знака, и trailing:strip -> 3 3.10 после stripIfInteger. Значит целое значение теряет дробь из нулей, а 3.10 сохраняет конечный ноль, потому что 3.10 не целое и только целый случай урезается. Колонка цен с умолчанием показывает 3.00 и 4.00, а та же колонка с опцией обрезки показывает 3 рядом с соседним 3.10, то есть колонку смешанной ширины, которую таблица может выровнять плохо.

Обе опции взаимодействуют со знаком и дробной частью так, как список опций не объявляет. stripIfInteger на -3.00 даёт -3, то есть минус сохраняется; sign:always на -3.00 даёт -3.00, то есть знак и дробь независимы. Страница, которая составляет обе, должна решить, что она хочет для -0, и +0 зонда при sign:always показывает, что форматтер напечатает знак на нуле, а это значение, которое часто даёт вычитание равных чисел.

Ширина вывода поэтому не свойство только числа. Один и тот же 5 выводится как 5, +5, 5.00 или +5.00 в зависимости от трёх опций, и вёрстка, которая резервирует ширину колонки, измерив один вариант, обрежет или сдвинет другие. Страница, которая форматирует таблицу, должна задать опции один раз и использовать их для каждой ячейки, а не позволять умолчанию и явной опции встретиться в одной колонке.

Где сталкиваются два предела

Две опции могут ограничивать цифры одновременно, и какая побеждает, зависит от третьей опции, которую почти никакой код не задаёт. Зонд сочетает maximumSignificantDigits: 3 с maximumFractionDigits: 2 и форматирует три значения при каждом roundingPriority; запись фиксирует эти результаты в строке priority ->.

auto и lessPrecision совпадают на каждом значении: 12.345 становится 12.3, 1234.5 становится 1230, 456.78 становится 457. Обе соблюдают предел значащих цифр и считают предел дроби удовлетворённым результатом, поэтому правило трёх цифр побеждает, а правило двух знаков никогда не связывает. morePrecision это исключение: он печатает 12.35, 1234.5 и 456.78, что хранит больше цифр, чем позволил бы любой предел в одиночку, потому что округляет так, чтобы оба предела были удовлетворены, а не чтобы один доминировал.

Случай 1234.5 делает удивление видимым. При auto число становится 1230, три значащие цифры и без дроби, тогда как при morePrecision остаётся 1234.5, четыре значащие цифры и одна дробная. Страница, которая задаёт оба предела, ожидая «не более 3 значащих цифр и не более 2 знаков», получает 1230 при умолчании, а страница, которая задаёт morePrecision, ожидая «более строгий из двух», получает более широкий результат. Ни одно прочтение опций не соответствует поведению, поэтому разрешённый вывод принадлежит записи, а не намерению.

Форматтер, которому нужен один предел, должен задать один предел. Задав оба и полагаясь на умолчание, получаем поведение значащих цифр, поэтому страница, которой нужны только «два знака», должна задать только maximumFractionDigits, а страница, которой нужны только «три значащие цифры», должна задать только maximumSignificantDigits. Сочетание для случая, когда оба должны соблюдаться, и это morePrecision заявляет явно вместо того, чтобы оставлять умолчанию.

Строки валюты показывают второй предел, который вызывающий никогда не задаёт. currency -> $5.00 ¥5 печатает две дроби для доллара США и ни одной для японской иены, потому что знаки по умолчанию берутся из кода валюты, а не из локали и не из опций. currency:min0 -> $5 показывает, что minimumFractionDigits: 0 отменяет умолчание кода, то есть символ и знаки это два отдельных решения. Страница, которая печатает таблицу цен между валютами, поэтому получает разную ширину для каждой валюты, и единственный способ узнать какую это прочитать resolvedOptions() для каждого кода.

percent -> 13% 13% и compact -> 1.2M 1.2M показывают ту же форму удивления от другой опции. 0.125 в процентах это 12.5%, и умолчание округляет до 13%, поэтому доля, которую читатель ждал как половину процента, становится целым процентом. Компактная нотация округляет к значащим цифрам, а не к дробям, поэтому 1234567 становится 1.2M и при умолчании, и при явном maximumFractionDigits: 1, потому что нотация уже ограничивает цифры перед суффиксом.

Строки digits и grouping напоминают, что значение и строка это разные объекты, и те же данные локали решают, как выводится часовой пояс рядом с отформатированным числом. digits -> ar-EG=١٢٣٤ ar-EG-latn=1234 выводит один и тот же 1234 арабо-индийскими цифрами в одной локали и латинскими с расширением Unicode в другой, а grouping -> de-DE=1.234.567,89 en-US=1,234,567.89 показывает смену разделителя, которая идёт от одной локали. Страница, которая разбирает отформатированную строку обратно в число, должна знать обе, а страница, которая хранит только строку, уже потеряла число.

resolvedOptions() это поле, которое замыкает круг. Зонд печатает resolved -> min=0 max=0 mode=halfExpand после установки только максимума, то есть минимум последовал за максимумом до нуля, хотя вызывающий его не просил. Это правило чаще всего пропускают, когда задают максимум на валютном форматтере: валютный минимум по умолчанию в два знака заменяется, а не сохраняется, и разница между $5.00 и $5 это одна опция, которую вызывающий не писал.

formatToParts() это другая половина записи. Зонд печатает parts -> minusSign:-|currency:USD|literal: |integer:5|decimal:.|fraction:00, поэтому каждая часть вывода названа, и потребитель может пересобрать строку или переставить части, по именованным типам частей. Страница, которая выводит цену с валютой после числа в одной локали и перед в другой, может делать это по частям, а не по всей строке, что и есть надёжный способ для локали, где порядок другой.

Запись одного показания формата чисел

Показание формата чисел подтверждает узкое утверждение: эта среда, эта локаль, эти опции дали эту строку. Четыре поля держат его проверяемым:

  1. Разрешённые опции, потому что resolvedOptions() называет режим округления, минимум и максимум, которые реально работали, а намерение вызывающего не в строке.
  2. Режим округления и входные значения, потому что ничья и не-ничья могут различаться на целую единицу, и запись без входа не может сказать, что было.
  3. Опции показа знака и дроби, потому что sign:never стирает минус, а stripIfInteger стирает дробь из нулей, и ни одна не восстанавливается из вывода.
  4. Части, потому что порядок и части вывода локали различаются, и сохранённая строка не разбирается обратно без локали, которая её произвела.

Публичные источники

Практический вывод

Задайте режим округления вместо доверия умолчанию, потому что умолчание это half-expand и значение .5 округлится не так, как ждёт читатель «округления к чётному». Сохраняйте минус через sign:negative, а не sign:never, потому что never превращает отрицательное в положительную строку, которую разбор не восстановит. Выберите один предел цифр вместо двух, если оба не должны связывать, и читайте resolvedOptions() после построения валютного форматтера, потому что максимум понижает и минимум. О данных локали, которые управляют разделителями и формами цифр, читайте данные локали API Intl; о половине с часовыми поясами того же семейства форматтеров читайте часовые пояса и язык локали.

#Intl.NumberFormat#Округление#Локализация#Валюта#ECMAScript

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

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