Как Number в JavaScript и float в Python хранят числа
В большинстве статически типизированных языков, таких как Java или C, для чисел есть разные типы данных.
Например, если нужно хранить целое число из диапазона [-128;127], можно взять byte в Java и char в C — оба занимают всего 1 байт.
Если нужно хранить целое побольше, можно взять типы int или long, которые занимают 4 и 8 байт соответственно.
Есть и отдельные типы для хранения чисел с дробной частью — float, занимающий 4 байта, и double с 8 байтами. Их обычно называют форматом с плавающей точкой, и дальше мы увидим, откуда взялось это название.
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 | Тот самый формат, разбору которого посвящена вся остальная статья. Литерал 0.5 в скрипте на Python — это 8 байт. | |
| JavaScript | Целое (без дробной части, точное) | BigInt | Целое произвольной точности, добавленное позже как отдельный тип. Записывается с суффиксом n (например, 5n). Ближайший аналог питоновского int. |
| Число с плавающей точкой (64-битный double IEEE-754) | Number | Тот же формат, что и float в Python. JavaScript использует его и для целых, и для дробных; отдельного целочисленного типа по умолчанию нет. |
Кроме того, при записи числового литерала каждый язык по умолчанию выбирает разный тип. Python по умолчанию берёт int и переключается на 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Разделение на точное целое и float-по-умолчанию в двух языках примерно зеркально, но значения по умолчанию противоположны:
в Python нужно осознанно выбрать float, поставив десятичную точку; в JavaScript нужно осознанно отказаться от float, написав n для BigInt.
Всё, что мы будем обсуждать дальше, живёт на стороне float — float в Python, Number в JavaScript и один и тот же формат IEEE-754 binary64 под ними обоими.
Представление чисел в научной нотации
Прежде чем говорить о плавающей точке и стандарте IEEE754, нужно сначала разобраться, что значит представить число в научной нотации. Вы наверняка видели значения вроде (число Авогадро) или (форма e-нотации, часто используемая в коде) — обычно они обозначают величины, слишком большие или слишком маленькие, чтобы выписывать их цифра за цифрой, и именно эту задачу решает научная нотация.
В общем виде число в научной нотации можно представить так:
Мантисса (significand) показывает значащие цифры числа — её также часто называют significand или точностью.
Нули не считаются значащими; они просто занимают место.
Основание задаёт базу системы счисления, то есть 10 для десятичной системы и 2 для двоичной.
Порядок (экспонента) определяет, на сколько разрядов нужно сдвинуть точку влево или вправо, чтобы получить исходное число.
Любое число можно представить в научной нотации. Например, число 7 в десятичной и двоичной системах можно представить так:
Порядок 0 просто показывает нам, что для получения исходного числа никаких дополнительных операций делать не нужно. Посмотрим на другой пример — число 0.00000022. Значащие цифры здесь — 22, так что уберём нули:
Строка с в середине выглядит как пустая операция — и в этом весь смысл: это заглушка. В следующей же строке мы заменяем на эквивалентное выражение , которое по-прежнему равно , но в форме, которую можно разделить. Затем поглощает восемь сдвигов ведущих нулей в (превращая его в целое ), а оставшийся становится новым порядком. Эти два множителя взаимно обратны, поэтому значение всего выражения не меняется — мы лишь перегруппировали его в нужную нам форму.
Расчёт выше показывает, почему порядок основания уменьшается, если точка сдвигается вправо. Итак, выполнив умножение, мы привели исходное число к виду, где остались только значащие цифры:
Поскольку мы использовали умножение на 8, пришлось компенсировать его делением — отсюда и берётся отрицательный порядок 8. Тот же процесс, только с делением для получения значащих цифр, можно выполнить над числом 22300000:
На этот раз точка сдвинулась влево, и потому порядок увеличился. Как видите, научная нотация — это способ легко работать с очень большими или очень маленькими числами. В зависимости от порядка мантисса может представлять целое число или число с дробной частью. При обратном преобразовании к исходному числу отрицательный порядок требует сдвига точки влево. Положительный порядок требует сдвига вправо и обычно обозначает большие целые числа.
Важно также понимать, что такое нормализованная форма числа. Число нормализовано, когда оно записано в научной нотации с одной ненулевой десятичной цифрой перед точкой. Так что если взять наши исходные числа и представить их в нормализованной форме, они получат такой вид:
Представление чисел в нормализованной форме позволяет легко сравнивать их по порядку величины.
Как вы, возможно, догадались, у двоичных чисел перед точкой всегда будет 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, ни в Python нет встроенной функции для печати «сырых» бит хранимого числа с плавающей точкой. Но её легко написать на любом из языков. Вот вспомогательная функция на JavaScript, которая сама учитывает порядок байт вашего компьютера: она записывает число в Float64Array, смотрит на нижележащую память как на байты и проходит их в обратном порядке, собирая битовую строку:
function to64bitFloat(number) {
var f = new Float64Array(1);
f[0] = number;
var view = new Uint8Array(f.buffer);
var i, result = "";
for (i = view.length - 1; i >= 0; i--) {
var bits = view[i].toString(2);
if (bits.length < 8) {
bits = new Array(8 - bits.length).fill('0').join("") + bits;
}
result += bits;
}
return result;
}Эквивалент на 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 (1/10) и 0.2 (1/5) степенями двойки не являются, эти числа нельзя конечно представить в двоичном формате. Чтобы сохранить их как число с плавающей точкой IEEE-754, их приходится округлить до доступного числа бит мантиссы — 10 бит для половинной точности, 23 бита для одинарной или 52 бита для двойной. В зависимости от того, сколько бит точности доступно, приближения 0.1 и 0.2 с плавающей точкой могут оказаться чуть меньше или чуть больше соответствующих десятичных представлений, но никогда не равны им. Именно из-за этого вы никогда не получите 0.1 + 0.2 == 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 общий, но языки совпадают не в каждой операции, которая использует 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}".
Правило большого пальца: когда два языка расходятся насчёт числа, подозревайте обёртку, а не float. Те 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 // любая арифметическая операция с BigInt остаётся точной
> 2n ** 1000n // и верхнего предела нет
107150860718626732094842504906000181056140481170553360744375038837035105112493612249319837881569585812759467291755314682518714528569231404359845775746985748039345677748242309854210746050623711418779541821530464749835819412673987675591655439460770629145711964776865421676604298316526243868372056680693100nТа же граница точного хранения целых во 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. Отсюда следуют два вывода:
- В этом диапазоне представимы только целые с LSB=
0(то есть чётные). - Соседи отстоят на
2, поэтому приращение+1округляется назад к тому же значению — чтобы попасть на следующее представимое целое, нужно как минимум+2. (А+1— это ровно середина между двумя соседями, так что правило округления к чётному выбирает того, у кого мантисса заканчивается на0.)
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:
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Оба языка предоставляют отдельные isNaN / math.isnan, потому что — по правилу IEEE-754 — x == x является неправильным способом обнаружить NaN: он возвращает false для NaN и true для всего остального, но это скорее трюк с обратной логикой, чем понятный API. (Кстати, x !== x действительно распространённая идиома в старом JS-коде — именно потому, что она однозначно определяет 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Что взять с собой
- Почти каждый современный язык использует одни и те же 64 бита для дробных чисел.
Numberв JavaScript,floatв Python,doubleв Java,doubleв C — всё это IEEE-754 binary64. Та же раскладка, та же арифметика, те же странности. Различия между языками живут в обёртке, а не во float. 0.1 + 0.2 != 0.3— свойство формата. Так происходит потому, что0.1и0.2не конечны в двоичном виде и накапливают три ошибки округления при сложении, тогда как прямое сохранение0.3накапливает только одну. Битовые картины различаются.- Граница «безопасных целых» находится на . За ней уже не все целые значения представимы, и соседние целые схлопываются в одно и то же число с плавающей точкой. JavaScript выставляет её как
Number.MAX_SAFE_INTEGER, потому что другого целочисленного типа у него нет; пользователи Python видят её только после приведения кfloat. - NaN и Infinity — реальные битовые картины, а не исключения. Они живут внутри формата, и для них зарезервирован порядок из одних единиц. Правило «NaN не равен ничему, включая самого себя» — это IEEE-754, а не выбор языка.
Зная эти четыре вещи, вы сможете предсказать, как поведёт себя число с плавающей точкой в любом языке, который его использует, — а это, на практике, все языки.
Более глубокое погружение в то, как четыре базовые операции (сложение, вычитание, умножение, деление) на самом деле преобразуют биты — и для целых в дополнительном коде, и для чисел IEEE-754 — см. в статье Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754.