Как Number в JavaScript и float в Python хранят числа
Многие статически типизированные языки предлагают несколько числовых типов. В Java byte, int и long имеют 8, 32 и 64 бита, а float и double — 32 и 64 бита. В C размеры зависят от реализации: обычный char может быть знаковым или беззнаковым, а long не всегда 64-битный. Здесь мы рассматриваем IEEE-754 binary64, используемый JavaScript Number и Python float на обычных платформах.
JavaScript и Python этой схеме не следуют. В JavaScript долгое время был всего один общий тип Number, который обслуживал любые числовые значения — и целые, и дробные, — а позже добавился BigInt как отдельный тип целых произвольной точности.
Аналоги в Python — int для целых произвольной точности и float для дробных значений.
Возможно, вы уже встречали такое странное поведение: наберите 0.1 + 0.2 в консоли JavaScript, и в ответ придёт 0.30000000000000004, а не 0.3. По этой теме существует бесчисленное множество тем на StackOverflow, issue на GitHub и статей в блогах, и почти все они винят JavaScript. Но тот же самый ответ приходит в Python, Java, C и любом другом языке, который использует IEEE-754 binary64 в качестве типа с плавающей точкой, — 64-битный формат двойной точности, лежащий в основе и Number в JavaScript, и float в Python.
Та же самая внутренняя механика проявляется и в другом поведении, которое выглядит как баги языка, но ими не является: два больших — но не настолько уж больших — целых оказываются равны, или значение не равно самому себе:
> 9007199254740992 === 9007199254740993
true
> NaN === NaN
falseТе же выражения в Python — те же ответы, вплоть до бита:
>>> 9007199254740992.0 == 9007199254740993.0
True
>>> float('nan') == float('nan')
FalseВ этой статье мы разберём, как на самом деле работают эти 64 бита согласно IEEE-754, проведём одни и те же эксперименты в обоих языках, чтобы убедиться, что они дают одинаковые битовые последовательности, и точно определим, какое поведение исходит от float, а какое — от правил самого языка.
Какие числовые типы предоставляет каждый язык
Прежде чем разбирать биты, полезно уточнить, какие типы предоставляет каждый язык. У обоих есть отдельные типы для целых и дробных чисел — с разными названиями и разными значениями по умолчанию:
| Язык | Вид | Тип | Подробности |
|---|---|---|---|
| Python | Целое (без дробной части, точное) | int | Произвольная точность. CPython по мере необходимости наращивает хранилище, так что 2**1000 просто работает, и результат точен. |
| Число с плавающей точкой (64-битный double IEEE-754) | float | Само значение binary64 занимает 8 байт; объект float в Python требует дополнительного места для метаданных. | |
| JavaScript | Целое (без дробной части, точное) | BigInt | Целое произвольной точности, добавленное позже как отдельный тип. Записывается с суффиксом n (например, 5n). Ближайший аналог питоновского int. |
| Число с плавающей точкой (64-битный double IEEE-754) | Number | Тот же формат, что и float в Python. JavaScript использует его и для целых, и для дробных; отдельного целочисленного типа по умолчанию нет. |
Целочисленные литералы Python, например 5, создают int. Десятичная точка или обозначение порядка делает литерал вещественным: и 5.2, и 5e0 создают float.
>>> type(5)
<class 'int'>
>>> type(5.2)
<class 'float'>
>>> type(5 + 0.0) # int + float продвигается до float
<class 'float'>JavaScript по умолчанию поступает наоборот — каждый числовой литерал является Number (типом с плавающей точкой), если только вы не помечаете его n, чтобы получить BigInt. typeof сообщает 'number' и для целых, и для дробных:
> typeof 5
'number'
> typeof 1.5
'number'
> typeof 5n
'bigint'А получив BigInt, вы уже не можете смешивать его с Number в арифметике — движок отказывается вместо молчаливого приведения типов:
> 5n + 1
TypeError: Cannot mix BigInt and other types, use explicit conversions
> 5n + BigInt(1)
6n
> Number(5n) + 1 // обратное направление тоже работает, но за счёт точности
6В Python целочисленные литералы по умолчанию имеют тип int, а в JavaScript числовые литералы без суффикса — Number. Далее рассматриваем числа с плавающей точкой: Python float, JavaScript Number и их представление binary64.
Представление чисел в научной нотации
Прежде чем говорить о плавающей точке и стандарте IEEE754, нужно сначала разобраться, что значит представить число в научной нотации. Вы наверняка видели значения вроде (число Авогадро) или (форма e-нотации, часто используемая в коде) — обычно они обозначают величины, слишком большие или слишком маленькие, чтобы выписывать их цифра за цифрой, и именно эту задачу решает научная нотация.
В общем виде число в научной нотации можно представить так:
Значащая часть — числовой множитель перед основанием в степени; точность — количество доступных значащих цифр или битов. Значащую часть часто называют «мантиссой»; ниже это слово также неформально обозначает хранимое дробное поле. Нули внутри значащей части могут быть значимыми: 1.01 отличается от 1.1. Основание равно 10 для десятичной системы и 2 для двоичной; порядок задаёт масштаб.
Любое число можно представить в научной нотации. Например, число 7 в десятичной и двоичной системах можно представить так:
Порядок 0 просто показывает нам, что для получения исходного числа никаких дополнительных операций делать не нужно. Посмотрим на другой пример — число 0.00000022. Значащие цифры здесь — 22, так что уберём нули:
Мы умножаем на , поэтому значение не меняется. Умножение на сдвигает десятичную запятую на восемь позиций вправо и даёт . Оставшийся множитель компенсирует этот сдвиг.
Расчёт выше показывает, почему порядок основания уменьшается, если точка сдвигается вправо. Итак, выполнив умножение, мы привели исходное число к виду, где остались только значащие цифры:
Мы умножили на , поэтому компенсируем это множителем . Для 22300000 деление на даёт значащую часть 223:
На этот раз точка сдвинулась влево, и потому порядок увеличился. Как видите, научная нотация — это способ легко работать с очень большими или очень маленькими числами. В зависимости от порядка мантисса может представлять целое число или число с дробной частью. При обратном преобразовании к исходному числу отрицательный порядок требует сдвига точки влево. Положительный порядок требует сдвига вправо и обычно обозначает большие целые числа.
Важно также понимать, что такое нормализованная форма числа. Число нормализовано, когда оно записано в научной нотации с одной ненулевой десятичной цифрой перед точкой. Так что если взять наши исходные числа и представить их в нормализованной форме, они получат такой вид:
Представление чисел в нормализованной форме позволяет легко сравнивать их по порядку величины.
Как вы, возможно, догадались, у двоичных чисел перед точкой всегда будет 1.
Поскольку в двоичной системе всего две цифры — 0 и 1 — а нормализация требует, чтобы ведущая цифра была ненулевой, цифра перед точкой в нормализованном двоичном виде всегда 1.
Научную нотацию можно рассматривать как представление числа с плавающей точкой. Термин «плавающая точка» отражает тот факт, что точка числа может «плавать» — её можно поставить в любое место относительно значащих цифр числа. И, как мы узнали, исходное положение указывается порядком.
Плавающая точка согласно IEEE754
Стандарт IEEE для арифметики с плавающей точкой (IEEE 754) определяет много всего, связанного с такой арифметикой, но для целей нашего исследования нас интересует только то, как числа хранятся, округляются и складываются. Я написал очень подробную статью с объяснением, как округлять двоичные числа. Округление — частая операция, и оно происходит, когда выбранный формат не даёт достаточно бит для хранения числа. Это важная тема, так что хорошо разберитесь в её механике. А теперь посмотрим, как хранятся числа. Все примеры далее будут в основном для чисел в двоичной системе.
Как числа хранятся
Стандарт определяет два формата, которые используются чаще всего, — одинарной и двойной точности. Они различаются числом занимаемых бит и, следовательно, диапазоном чисел, которые каждый формат может хранить. Подход к переводу числа из научной нотации в форму IEEE754 одинаков для всех форматов, различается только число бит, выделенных под мантиссу (значащие цифры) и порядок.
Плавающая точка IEEE754 выделяет биты для хранения знака числа, его мантиссы (значащих цифр) и порядка. Вот как они распределяются в формате двойной точности (64 бита на число), который используют и Number в JavaScript, и float в Python:
63 62 52 51 0 ← позиция бита
0 00000000000 0000000000000000000000000000000000000000000000000000
│ │ │
sign(1) exponent(11) mantissa(52) (significand)Знаку отводится 1 бит, порядку — 11 бит, и 52 бита выделено под мантиссу (significand). Вот таблица с числом бит, выделяемых в каждом формате:
| Название | Всего бит | Порядок | Хранимые биты дроби |
|---|---|---|---|
| Одинарная точность | 32 | 8 | 23 |
| Двойная точность | 64 | 11 | 52 |
Порядок хранится в формате смещённого двоичного кода. Я написал подробную статью с объяснением этого формата и его отличий от дополнительного кода. Пожалуйста, потратьте немного времени на эту тему, потому что я буду опираться на неё при переводе чисел в формат с плавающей точкой.
Примеры хранения целых чисел
Прежде чем разбирать конкретные случаи бит за битом, вот наглядная сквозная картина для целого числа 7 (111 в двоичном виде) — исходные биты, нормализованная научная форма и итоговая 64-битная упаковка IEEE-754:
111 → 1.11 × 2^2 → 0 10000000001 11 0...0
^^^ ^ ^^ ^ ^ ^^^^^^^^^^^ ^^ ^^^^^
│ │ │ │ │ │ │ └── 50 нулей-заполнителей (мантисса всего 52 бита)
│ │ │ │ │ │ └── хранимая мантисса: «11» (биты после ведущей 1)
│ │ │ │ │ └── хранимый порядок: 2 + 1023 (смещение) = 1025 = 10000000001
│ │ │ │ └── бит знака: 0 (положительное)
│ │ │ └── порядок: на сколько сдвинуть точку обратно
│ │ └── мантисса (биты после неявной ведущей 1)
│ └── неявная ведущая 1 (не хранится)
└── исходные биты целого числаТеперь пойдём шаг за шагом и посмотрим, как по той же схеме хранятся целые 1 и 3.
Число 1 во всех системах счисления представляется как 1, так что преобразование не требуется. В научной форме его можно записать так:
Здесь у нас мантисса 1 и порядок 0. Исходя из этого можно предположить, что число представлено в плавающей точке так:
63 62 52 51 0 ← позиция бита
0 00000000000 0000000000000000000000000000000000000000000000000001
│ │ │
sign exponent mantissa (significand)Посмотрим на биты. Эта функция JavaScript записывает число через DataView в порядке big-endian, затем форматирует байты по порядку. Явное указание порядка байтов делает её независимой от архитектуры компьютера:
function to64bitFloat(number) {
const buffer = new ArrayBuffer(8);
new DataView(buffer).setFloat64(0, number, false);
return Array.from(new Uint8Array(buffer),
byte => byte.toString(2).padStart(8, '0')).join('');
}Эквивалент на Python — это одна строка с модулем struct: упаковать число как 8 байт с прямым порядком (>d), затем распаковать эти же байты как 64-битное беззнаковое целое (>Q) и отформатировать целое как 64-символьную двоичную строку:
import struct
def to64bit_float(x):
return f"{struct.unpack('>Q', struct.pack('>d', x))[0]:064b}"Обе выдают одну и ту же 64-символьную двоичную строку для одного и того же входа — to64bitFloat(1) в JavaScript и to64bit_float(1.0) в Python дают идентичные биты. Дальше мы будем использовать их взаимозаменяемо.
Итак, с помощью любой из них можно увидеть, что число 1 хранится так:
63 62 52 51 0 ← позиция бита
0 01111111111 0000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)Это совершенно не совпадает с предположениями выше. В мантиссе нет ни одной значащей цифры, а в порядке появились единицы. Теперь разберёмся, почему так.
Ключевая мысль в том, что IEEE-754 хранит не само число: сначала оно преобразуется в нормализованную научную форму (одна ненулевая цифра перед точкой, остальные после), и уже эти части хранятся.
А как мы установили ранее, ведущая цифра перед точкой в нормализованном двоичном виде всегда 1 — так что формат просто не тратится на её хранение.
Это приём скрытого бита: неявная 1 приписывается обратно аппаратурой всякий раз при чтении значения, что даёт нам дополнительный бит точности практически бесплатно.
Конкретно для числа 1 нормализованная форма — это 1.0 × 2^0: после точки стоят только нули, а цифра перед ней (неявная 1) не хранится.
Так что мантиссе нечего записывать — вот почему она полностью из нулей.
Теперь посмотрим, откуда в порядке взялись единицы. Я упоминал выше, что порядок хранится в смещённом двоичном коде. Если вычислить смещение:
то видно, что это ровно то, что у нас в представлении. То есть в смещённом двоичном коде хранимое там значение на самом деле равно 0. Если непонятно, как смещение даёт нам 0, прочитайте мою статью о смещённом двоичном коде.
Воспользуемся тем, что мы узнали выше, и попробуем представить в форме с плавающей точкой число 3. В двоичном виде оно записывается как 11. Если вы не помните почему, посмотрите мою очень подробную статью об алгоритмах преобразования десятичных чисел в двоичные. А после нормализации число 3 принимает такой вид (числа в двоичной системе):
После точки у нас всего одна цифра 1, которая и будет храниться в мантиссе. Как объяснялось выше, первая цифра перед точкой не хранится. Кроме того, нормализация дала нам порядок 1.
Вычислим, как он представляется в смещённом двоичном коде, и тогда у нас будет вся необходимая информация:
Одно, что нужно помнить о мантиссе: цифры хранятся ровно в том порядке, в каком они стоят в научной форме — слева направо от точки. С учётом этого сложим всё в представление с плавающей точкой:
63 62 52 51 0 ← позиция бита
0 10000000000 1000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)Если запустить любую из функций выше — to64bitFloat(3) в JavaScript или to64bit_float(3.0) в Python — вы увидите, что мы получили верное представление.
Замечание о порядке бит
Возможно, вы заметили, что биты мантиссы выровнены по левому краю: для 3 мы получили 1000…000, а для 7 (который мы разбирали выше) получили бы 1100…000. Это может показаться неправильным, ведь те же числа, сохранённые как обычные 8-битные целые, выглядят как 00000011 и 00000111 — с единицами справа. Эти два формата не зеркальны; они просто привязывают биты к разным точкам отсчёта.
- Хранение целых: позиции бит представляют степени , возрастающие справа налево. Биты целых чисел скапливаются справа, потому что именно там живут наименьшие величины ().
- Мантисса числа с плавающей точкой: биты стоят после точки в нормализованной форме и представляют степени , убывающие от точки. Самый левый хранимый бит — это , следующий — , и так далее. Поэтому мантисса для хранит
11слева направо, помещая первую1в , а вторую — в : выравнивание по левому краю, потому что именно там живут наибольшие дробные величины.
Десятичная аналогия показывает то же самое: целое 123 выровнено по правому краю (3 — это разряд единиц). Дробь 0.123 выровнена по левому краю (1 — это разряд десятых). Оба варианта по-прежнему считают, что слева стоит самое значимое, — просто отсчёт ведётся от разных точек (правый край против точки).
Почему 0.1+0.2 не равно 0.3
Теперь, когда мы знаем, как хранятся числа, посмотрим, что происходит в этом часто цитируемом примере. Короткое объяснение сводится к тому, какие дроби вообще могут быть точно представлены в двоичном виде.
После сокращения дробь имеет конечное двоичное разложение тогда и только тогда, когда знаменатель — степень двойки. Поэтому 0.1 и 0.2 в двоичной арифметике приходится приближать. В binary64 с выбором чётного при равном расстоянии их сумма отличается от хранимого 0.3. Само бесконечное разложение не доказывает неравенство при любой точности; важны конкретные округления.
Кому-то из разработчиков такого объяснения достаточно, но лучший способ увидеть, что происходит под капотом, — самому выполнить все вычисления, которые делает компьютер. Именно это я сейчас и сделаю.
Представление 0.1 и 0.2 в формате с плавающей точкой
Посмотрим на битовую картину 0.1 в форме с плавающей точкой. Первое, что нужно сделать, — перевести 0.1 в двоичный вид. Это делается алгоритмом умножения на 2. Его механику я объясняю в статье об алгоритмах преобразования десятичных чисел в двоичные. Если перевести 0.1 в двоичный вид, получится бесконечная дробь:
0.1 · 2 = 0.2 0.0...
0.2 · 2 = 0.4 0.00...
0.4 · 2 = 0.8 0.000...
0.8 · 2 = 1.6 0.0001...
0.6 · 2 = 1.2 0.00011...
0.2 · 2 = 0.4 0.000110...Следующий шаг — представить это число в нормализованной научной нотации:
Поскольку мантисса может содержать только 52 бита, нам нужно округлить бесконечное двоичное разложение числа до 52 бит после точки.
По правилам округления, заданным стандартом IEEE-754 и разобранным в моей статье об округлении двоичных чисел, нам нужно округлить число вверх до:
Остаётся вычислить представление порядка в смещённом двоичном коде:
И в представлении с плавающей точкой число 0.1 имеет такую битовую картину:
63 62 52 51 0 ← позиция бита
0 01111111011 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Предлагаю вам самостоятельно вычислить представление 0.2 с плавающей точкой. У вас должны получиться такие представления в научной нотации и в двоичном виде:
63 62 52 51 0 ← позиция бита
0 01111111100 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Вычисление результата 0.1 + 0.2
Если собрать числа назад из их представления с плавающей точкой в научную форму, вот что мы получим:
Чтобы складывать числа, у них должны быть равные порядки. Правило гласит, что нужно подогнать число с меньшим порядком к числу с большим. Итак, приведём порядок -4 первого числа к порядку -3, как у второго:
Теперь можно складывать:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
─────────────────────────────────────────────────────────
10.0110011001100110011001100110011001100110011001100111Результат вычисления хранится в формате с плавающей точкой, так что нам нужно нормализовать его, при необходимости округлить и вычислить порядок в смещённом двоичном коде.
Нормализованное число попадает ровно посередине между вариантами округления, поэтому мы применяем правило для равных расстояний и округляем к чётному. Это даёт такое результирующее число в нормализованной научной форме:
А при переводе в формат с плавающей точкой для хранения оно имеет такую битовую картину:
63 62 52 51 0 ← позиция бита
0 01111111101 0011001100110011001100110011001100110011001100110100
│ │ │
sign exponent mantissa (significand)Это ровно та битовая картина, которая сохраняется, когда вы выполняете выражение 0.1+0.2.
Чтобы её получить, компьютеру приходится округлять трижды — по одному разу на каждое число и третий раз для их суммы. Когда же просто сохраняется 0.3, компьютер выполняет округление лишь один раз. Эти операции округления и приводят к разным битовым картинам, сохраняемым для 0.1+0.2 и для отдельного 0.3.
Когда язык сравнивает 0.1+0.2 с 0.3 — через === в JavaScript, == в Python — сравниваются именно эти битовые картины, и поскольку они различаются, возвращается false. Если бы существовали такие форматы, в которых даже с округлением битовые картины оказались бы равны, сравнение вернуло бы true — независимо от того, что 0.1 и 0.2 не представимы в двоичном виде конечным числом бит.
Попробуйте проверить биты числа 0.3 с помощью показанных выше функций — to64bitFloat(0.3) в JavaScript или to64bit_float(0.3) в Python. Картина будет отличаться от той, что мы вычислили выше для результата 0.1+0.2.
Чтобы восстановить фактическое десятичное значение, которое представляют эти биты, возьмите двоичную научную форму (неявная 1, за которой идут 52 бита мантиссы, умноженные на 2 в степени истинного порядка), сдвиньте точку в соответствии с порядком, и переведите получившуюся двоичную дробь в десятичную. Проделав эту арифметику для 0.1 + 0.2, получим 0.3000000000000000444089209850062616169452667236328125. Проделав её для 0.3, получим 0.299999999999999988897769753748434595763683319091796875. Они близки, но не равны — именно поэтому сравнение возвращает false.
Различия между языками при одном формате с плавающей точкой
Сам float общий, но языки совпадают не в каждой операции, которая использует float. Эти расхождения — следствия правил, принятых на уровне языка, а не различия в нижележащей арифметике. Несколько таких, о которых стоит знать:
Деление на ноль. JavaScript возвращает Infinity.
> 1 / 0
Infinity
> -1 / 0
-InfinityPython выбрасывает ZeroDivisionError.
>>> 1 / 0
Traceback (most recent call last):
...
ZeroDivisionError: division by zeroЧтобы получить в Python Infinity, нужно попросить об этом явно: math.inf или float('inf'). У формата с плавающей точкой есть вполне приличная кодировка бесконечности (мы увидим её ниже) — Python просто решает не выдавать её из буквального 1/0.
Остаток от деления отрицательных чисел. В JavaScript % следует знаку делимого.
> -7 % 3
-1В Python % следует знаку делителя.
>>> -7 % 3
2Округление половин. Math.round в JavaScript округляет половину вверх (в сторону ).
> Math.round(2.5)
3
> Math.round(3.5)
4Встроенный round в Python использует «банковское округление» — к чётному, — что соответствует поведению IEEE-754 по умолчанию и правилу, которое использует внутри себя сам FPU.
>>> round(2.5)
2
>>> round(3.5)
4Вывод десятичных чисел по умолчанию. JavaScript показывает ровно столько цифр, чтобы значение можно было прочитать назад в тот же float. repr() в Python делает то же самое. Оба скрывают хвост …44089… у 0.1 + 0.2, пока вы не запросите больше точности через .toFixed(20) или спецификатор формата f"{x:.20f}".
Практическое правило: когда два языка расходятся насчёт числа, сначала проверьте правила языка. Сам 64-битный формат у них одинаков.
Граница — в обоих языках
Хранение целых чисел в float натыкается на жёсткий предел. Мантисса содержит 52 бита плюс неявную ведущую 1 — итого 53 бита точности. Так что любое целое до может быть сохранено точно. Дальше бит мантиссы не хватает, чтобы закодировать каждое целое, и формату приходится некоторые пропускать.
JavaScript выставляет эту границу как константу:
> Number.MAX_SAFE_INTEGER
9007199254740991 // 2^53 - 1
> 2 ** 53
9007199254740992
> 2 ** 53 + 1
9007199254740992 // а не 9007199254740993!
> 9007199254740992 === 9007199254740993
trueВыражение 2 ** 53 + 1 не даёт 9007199254740993. Формат не может представить это значение, поэтому округляет до ближайшего представимого — а это 9007199254740992.
Стоит быть точным насчёт того, что на самом деле означает MAX_SAFE_INTEGER: это не самое большое целое, которое может хранить JavaScript. JavaScript умеет хранить куда большие значения — Number.MAX_VALUE дотягивается вплоть до 1.7976931348623157e+308, самого большого конечного double. MAX_SAFE_INTEGER на самом деле обозначает наибольшее целое N, для которого и N, и N + 1 представимы точно. Дальше начинают появляться дыры: MAX_SAFE_INTEGER + 3 (то есть 9007199254740994) в порядке, а вот MAX_SAFE_INTEGER + 2 (то есть 9007199254740993) — первое целое, которое формат представить не может: наберите его в консоли, и вам вернётся 9007199254740992, молча округлённое вниз на 1. По мере дальнейшего роста порядка дыры расширяются, так что к тому времени, когда вы окажетесь рядом с MAX_VALUE, соседние представимые значения могут отстоять друг от друга на огромные расстояния.
Если вам действительно нужна точная целочисленная арифметика за пределами MAX_SAFE_INTEGER в JavaScript, то именно для этого и существует BigInt — у него произвольная точность, так что граница исчезает совсем:
> 9007199254740993n
9007199254740993n // BigInt, остаётся точным — суффикс n важен
> 9007199254740993n === 9007199254740992n
false // разные значения, в отличие от Number
> 2n ** 53n + 1n
9007199254740993n // точное целочисленное сложение
> 2n ** 1000n // размер ограничен ресурсами и реализацией
10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376nТа же граница точного хранения целых во float существует и в Python, но она невидима, пока вы не перейдёте от int к float.
Точно как BigInt в JavaScript, int в Python имеет произвольную точность, так что целочисленная арифметика точна:
>>> 2**53 + 1
9007199254740993 # точно, потому что обе стороны int
>>> 2**53 + 1 == 2**53
FalseНо в тот момент, когда вы приводите к float, вступает в силу тот же 64-битный формат и та же граница:
>>> 2.0**53 + 1.0
9007199254740992.0
>>> 2.0**53 + 1 == 2.0**53
True
>>> float(9007199254740993) == float(9007199254740992)
TrueТе же биты, та же граница. JavaScript выставляет её как константу верхнего уровня, потому что каждый обычный числовой литерал в JavaScript — это float, а BigInt добавили позже как аварийный выход, требующий явного суффикса n, — вот язык и помечает границу именем, чтобы вас предупредить. Python переворачивает значения по умолчанию: голый целочисленный литерал уже является int произвольной точности, так что граница невидима, пока вы явно не приведёте к float. Поэтому MAX_SAFE_INTEGER и не встречается в стандартной библиотеке Python — в коде, работающем только с int, она никогда не важна.
Почему именно ?
Посмотрите на битовую картину числа 9007199254740991 (это ). Это целое, двоичное представление которого — пятьдесят три 1 подряд, нормализуемое как
что полностью заполняет мантиссу единицами. Чтобы сохранить следующее целое — — мы прибавляем 1, что прокатывает перенос через все 52 бита мантиссы (каждая 1 переворачивается в 0), и остаётся
Повторная нормализация сдвигает точку на одну позицию влево и увеличивает порядок на единицу, давая нулевую мантиссу:
Для следующего целого, , нам понадобился бы установленный бит мантиссы на позиции 53 — но мантисса всего 52 бита шириной. Места нет. Формат молча округляет до ближайшего представимого значения (либо , либо в зависимости от правила для равных расстояний; для +1 попадает на ).
Механическая причина, по которой это происходит: при порядке 52 мантисса ровно вмещает каждое целое до . Чтобы сохранить что-то большее, приходится увеличить порядок — но подъём его до 53 означает, что точка сдвигается на 53 позиции вправо, тогда как мантисса по-прежнему даёт нам только 52 хранимых бита, так что 53-я битовая позиция всегда неявно 0. При порядке 54 формат приписывает два нуля; при 55 — три; и так далее.
Чтобы увидеть это наглядно, посмотрите на битовые позиции первых нескольких целых после . Каждое шириной 54 бита (с бита 53 по бит 0), но у формата есть места только для 53 из них — неявная ведущая 1 плюс 52 хранимых бита мантиссы.
Младшему биту (самому правому, биту 0 — назовём его LSB) места не досталось, так что формат физически не может поставить туда 1. Отсюда следуют два вывода:
- В этом диапазоне представимы только чётные целые.
- Соседи отличаются на
2. Прибавление1даёт значение ровно посередине: результат может округлиться обратно или к следующему чётному — в зависимости от последнего сохранённого бита. Например,(2 ** 53 + 2) + 1округляется вверх до2 ** 53 + 4.
2^53 = 9007199254740992 = 1 0000…0000 0 ← LSB=0 (чёт.) ✓ представимо
2^53 + 1 = 9007199254740993 = 1 0000…0000 1 ← LSB=1 (нечёт.) ✗ для LSB нет места
2^53 + 2 = 9007199254740994 = 1 0000…0001 0 ← LSB=0 (чёт.) ✓ представимо
2^53 + 3 = 9007199254740995 = 1 0000…0001 1 ← LSB=1 (нечёт.) ✗ для LSB нет места
↑ └─── 52 ───┘ ↑
│ mantissa │
│ bits │
│ └── для LSB нет места в мантиссе
│ → всегда 0 → целое всегда чётное
└── неявная ведущая 1 (не хранится)Следствие поразительное: каждое целое больше MAX_SAFE_INTEGER заканчивается хотя бы одним неявным нулём, так что ни одно нечётное целое выше MAX_SAFE_INTEGER вообще не может быть представлено — только чётные.
Поднимайтесь по порядку дальше, и представимыми останутся только числа, кратные 4, затем кратные 8, затем 16 — разрыв удваивается на каждом шаге. Это можно наблюдать прямо в любом REPL: Здесь ^ обозначает возведение в степень в математической записи. В JavaScript используйте **, а в Python — основание типа float, например 2.0 ** 53, чтобы увидеть округление.
2^53 → 9007199254740992
2^53 + 1 == 2^53 → true (разрыв здесь 2, +1 схлопывается)
2^53 + 2 > 2^53 → true (+2 — следующее представимое)
2^54 + 2 == 2^54 → true (разрыв 4, даже +2 схлопывается)
2^54 + 4 > 2^54 → true (+4 — следующее)
2^55 + 4 == 2^55 → true (разрыв теперь 8)
2^55 + 8 > 2^55 → true (+8 — следующее)Каждый шаг удваивает разрыв. К тому времени, как вы дойдёте до наибольшего конечного double (Number.MAX_VALUE или sys.float_info.max, оба примерно ), соседние представимые значения будут отстоять друг от друга на огромные расстояния.
Ловушка цикла for
Поведение с расширяющимися разрывами, которое мы только что разобрали, имеет знаменитое практическое следствие — цикл, который выглядит завершающимся, но не завершается никогда. Возьмём максимально простой цикл «увеличивать, пока обратная величина не станет нулём», написанный на обоих языках.
Если запустить его на JavaScript:
for (let i = 1; 1/i > 0; i++) {
console.log("Count is: " + i);
}или на Python:
i = 1.0
while 1/i > 0:
print(f"Count is: {i}")
i += 1Цикл не останавливается никогда. Это поведение общее для обоих языков, потому что оба счётчика — 64-битные числа с плавающей точкой, подчинённые границе безопасных целых. Условие 1/i > 0 истинно для любого конечного положительного i, так что для остановки цикла счётчик должен был бы достичь Infinity. Но как только i доходит до , i + 1 округляется назад к : разрыв до следующего представимого значения равен 2, а +1 попадает в середину и округляется вниз. Счётчик навсегда застревает на 9007199254740992, 1/i остаётся крошечным положительным числом, и цикл крутится.
Вы можете подумать, что приращение на 2 обойдёт эту проблему — и какое-то время так и есть. С i += 2 счётчик перескакивает и продолжает шагать по представимым чётным (2^53, 2^53 + 2, 2^53 + 4, …). Но как только он доходит до , разрыв удваивается до 4, так что i + 2 округляется назад к i и цикл снова застревает. Переход на i += 4 лишь отодвигает ловушку до , где разрыв становится 8, и так далее. Суть проблемы не в +1, а в том, что по мере подъёма соседние целые перестают быть представимыми, и счётчик с небольшим постоянным целочисленным шагом в конце концов провалится в разрыв, больший его шага.
Вывод: эта ловушка — свойство Number / float, а не языка. Наткнуться на неё может любой язык, использующий double IEEE-754 в качестве типа счётчика.
NaN и Infinity
Формат резервирует два значения порядка для особых кодировок:
- Порядок из одних нулей зарезервирован для
±0(когда мантисса тоже нулевая) и для денормализованных чисел (когда мантисса ненулевая — значения, слишком малые, чтобы представить их в нормализованной форме). - Порядок из одних единиц зарезервирован для
±Infinity(мантисса нулевая) и NaN (мантисса ненулевая).
Вот почему typeof NaN в JavaScript — это 'number', а type(float('nan')) в Python — <class 'float'>: NaN является совершенно корректной битовой картиной формата с плавающей точкой, просто с порядком из одних единиц.
Битовая картина положительной бесконечности такая:
0 11111111111 0000000000000000000000000000000000000000000000000000А типичный NaN выглядит так:
0 11111111111 1000000000000000000000000000000000000000000000000000Видно, что любая ненулевая мантисса в паре с порядком из одних единиц кодирует NaN — а значит, различных битовых картин NaN довольно много, а не одна.
Правило IEEE-754 состоит в том, что NaN не равен ничему, включая самого себя. Оба языка это соблюдают. Вот JavaScript:
> NaN === NaN
false
> NaN > 0
false
> NaN < 0
false
> Number.isNaN(NaN)
trueИ такое же поведение в Python:
>>> import math
>>> float('nan') == float('nan')
False
>>> float('nan') > 0
False
>>> float('nan') < 0
False
>>> math.isnan(float('nan'))
TrueДля явной проверки используйте Number.isNaN(x) в JavaScript или math.isnan(x) для Python float. Старая глобальная функция JavaScript isNaN также преобразует аргумент в число и потому может вернуть true для строки. Неравенство значения самому себе (x !== x в JavaScript, x != x для Python float) тоже обнаруживает NaN; это корректная, но менее явная проверка.
Положительная Infinity больше любого конечного числа и равна самой себе. В арифметике есть особые случаи: Infinity + 1 даёт Infinity, 1 / Infinity — 0, а Infinity - Infinity — NaN.
> Infinity > 1e308
true
> Infinity + 1 === Infinity
true
> Infinity - Infinity
NaNИ такое же поведение в Python:
>>> math.inf > 1e308
True
>>> math.inf + 1 == math.inf
True
>>> math.inf - math.inf
nanЧто стоит запомнить
- JavaScript
Numberи обычный Pythonfloatиспользуют binary64 с 53 битами точности для нормальных значений. - В этом формате округление
0.1,0.2и их суммы даёт иной результат, чем непосредственное округление0.3. - Binary64 представляет все целые от до , но безопасный целочисленный диапазон JavaScript ограничен модулем . Для точных больших целых используйте
BigIntили Pythonint. - NaN и бесконечности имеют зарезервированные коды. Языки всё же могут по-разному обрабатывать операции вроде деления на ноль.
Эти четыре факта объясняют многие неожиданные результаты при работе с числами binary64.
Более подробный разбор того, как четыре базовые операции (сложение, вычитание, умножение, деление) на самом деле преобразуют биты — и для целых в дополнительном коде, и для чисел IEEE-754 — см. в статье Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754.