Как 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, нужно сначала разобраться, что значит представить число в научной нотации. Вы наверняка видели значения вроде 6.022×10236.022 \times 10^{23} (число Авогадро) или 2.5e-72.5\text{e-}7 (форма e-нотации, часто используемая в коде) — обычно они обозначают величины, слишком большие или слишком маленькие, чтобы выписывать их цифра за цифрой, и именно эту задачу решает научная нотация.

В общем виде число в научной нотации можно представить так:

мантисса×основаниепорядок\text{мантисса} \times \text{основание}^{\text{порядок}}

Значащая часть — числовой множитель перед основанием в степени; точность — количество доступных значащих цифр или битов. Значащую часть часто называют «мантиссой»; ниже это слово также неформально обозначает хранимое дробное поле. Нули внутри значащей части могут быть значимыми: 1.01 отличается от 1.1. Основание равно 10 для десятичной системы и 2 для двоичной; порядок задаёт масштаб.

Любое число можно представить в научной нотации. Например, число 7 в десятичной и двоичной системах можно представить так:

710=7⋅1001112=111⋅20\begin{aligned} 7_{10} &= 7 \cdot 10^{0} \\ 111_{2} &= 111 \cdot 2^{0} \end{aligned}

Порядок 0 просто показывает нам, что для получения исходного числа никаких дополнительных операций делать не нужно. Посмотрим на другой пример — число 0.00000022. Значащие цифры здесь — 22, так что уберём нули:

0.00000022=0.00000022⋅100=0.00000022⋅100⋅1=0.00000022⋅100⋅(10−8⋅108)=(0.00000022⋅108)⋅(100⋅10−8)=22⋅10−8\begin{aligned} 0.00000022 &= 0.00000022 \cdot 10^{0} \\ &= 0.00000022 \cdot 10^{0} \cdot 1 \\ &= 0.00000022 \cdot 10^{0} \cdot (10^{-8} \cdot 10^{8}) \\ &= (0.00000022 \cdot 10^{8}) \cdot (10^{0} \cdot 10^{-8}) \\ &= 22 \cdot 10^{-8} \end{aligned}

Мы умножаем на 1=10−8⋅1081 = 10^{-8} \cdot 10^8, поэтому значение не меняется. Умножение 0.000000220.00000022 на 10810^8 сдвигает десятичную запятую на восемь позиций вправо и даёт 2222. Оставшийся множитель 10−810^{-8} компенсирует этот сдвиг.

Расчёт выше показывает, почему порядок основания уменьшается, если точка сдвигается вправо. Итак, выполнив умножение, мы привели исходное число к виду, где остались только значащие цифры:

0.00000022  =  22⏟мантисса×10⏟основание −8⏞порядок0.00000022 \;=\; \underbrace{22}_{\text{мантисса}} \times \underbrace{10}_{\text{основание}}{}^{\,\overbrace{-8}^{\text{порядок}}}

Мы умножили на 10810^8, поэтому компенсируем это множителем 10−810^{-8}. Для 22300000 деление на 10510^5 даёт значащую часть 223:

22,300,000=223⋅10522{,}300{,}000 = 223 \cdot 10^{5}

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

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

0.00000022=2.2⋅10−722,300,000=2.23⋅107\begin{aligned} 0.00000022 &= 2.2 \cdot 10^{-7} \\ 22{,}300{,}000 &= 2.23 \cdot 10^{7} \end{aligned}

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

Как вы, возможно, догадались, у двоичных чисел перед точкой всегда будет 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). Вот таблица с числом бит, выделяемых в каждом формате:

НазваниеВсего битПорядокХранимые биты дроби
Одинарная точность32823
Двойная точность641152

Порядок хранится в формате смещённого двоичного кода. Я написал подробную статью с объяснением этого формата и его отличий от дополнительного кода. Пожалуйста, потратьте немного времени на эту тему, потому что я буду опираться на неё при переводе чисел в формат с плавающей точкой.

Примеры хранения целых чисел

Прежде чем разбирать конкретные случаи бит за битом, вот наглядная сквозная картина для целого числа 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⋅201.0 \cdot 2^{0}

Здесь у нас мантисса 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) не хранится. Так что мантиссе нечего записывать — вот почему она полностью из нулей.

Теперь посмотрим, откуда в порядке взялись единицы. Я упоминал выше, что порядок хранится в смещённом двоичном коде. Если вычислить смещение:

K=2n−1−1=102310=011111111112K = 2^{n-1} - 1 = 1023_{10} = 01111111111_{2}

то видно, что это ровно то, что у нас в представлении. То есть в смещённом двоичном коде хранимое там значение на самом деле равно 0. Если непонятно, как смещение даёт нам 0, прочитайте мою статью о смещённом двоичном коде.

Воспользуемся тем, что мы узнали выше, и попробуем представить в форме с плавающей точкой число 3. В двоичном виде оно записывается как 11. Если вы не помните почему, посмотрите мою очень подробную статью об алгоритмах преобразования десятичных чисел в двоичные. А после нормализации число 3 принимает такой вид (числа в двоичной системе):

11⋅100=1.1⋅10111 \cdot 10^{0} = 1.1 \cdot 10^{1}

После точки у нас всего одна цифра 1, которая и будет храниться в мантиссе. Как объяснялось выше, первая цифра перед точкой не хранится. Кроме того, нормализация дала нам порядок 1. Вычислим, как он представляется в смещённом двоичном коде, и тогда у нас будет вся необходимая информация:

K=2n−1−1=10231+1023=1024102410=100000000002\begin{aligned} K &= 2^{n-1} - 1 = 1023 \\ 1 + 1023 &= 1024 \\ 1024_{10} &= 10000000000_{2} \end{aligned}

Одно, что нужно помнить о мантиссе: цифры хранятся ровно в том порядке, в каком они стоят в научной форме — слева направо от точки. С учётом этого сложим всё в представление с плавающей точкой:

 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 — с единицами справа. Эти два формата не зеркальны; они просто привязывают биты к разным точкам отсчёта.

  • Хранение целых: позиции бит представляют степени 20,21,22,…2^{0}, 2^{1}, 2^{2}, \ldots, возрастающие справа налево. Биты целых чисел скапливаются справа, потому что именно там живут наименьшие величины (20=12^{0} = 1).
  • Мантисса числа с плавающей точкой: биты стоят после точки в нормализованной форме и представляют степени 2−1,2−2,2−3,…2^{-1}, 2^{-2}, 2^{-3}, \ldots, убывающие от точки. Самый левый хранимый бит — это 2−12^{-1}, следующий — 2−22^{-2}, и так далее. Поэтому мантисса для 1.1121.11_{2} хранит 11 слева направо, помещая первую 1 в 2−12^{-1}, а вторую — в 2−22^{-2}: выравнивание по левому краю, потому что именно там живут наибольшие дробные величины.

Десятичная аналогия показывает то же самое: целое 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...
0.110=0.0001100110011…2=0.00011‾0.1_{10} = 0.0001100110011\ldots_{2} = 0.0\overline{0011}

Следующий шаг — представить это число в нормализованной научной нотации:

0.110=0.00011‾2⋅20=1.10011‾2⋅2−40.1_{10} = 0.0\overline{0011}_{2} \cdot 2^{0} = 1.1\overline{0011}_{2} \cdot 2^{-4}

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

1.10011‾⋅2−4  =  1.1001 1001 … 1001 1001⏟52 бита 10011‾…⋅2−41.1\overline{0011} \cdot 2^{-4} \;=\; 1.\underbrace{1001\,1001\,\ldots\,1001\,1001}_{52\text{ бита}}\,1\overline{0011}\ldots \cdot 2^{-4}

По правилам округления, заданным стандартом IEEE-754 и разобранным в моей статье об округлении двоичных чисел, нам нужно округлить число вверх до:

1.1001 1001 … 1001 1010⏟52 бита⋅2−41.\underbrace{1001\,1001\,\ldots\,1001\,1010}_{52\text{ бита}} \cdot 2^{-4}

Остаётся вычислить представление порядка в смещённом двоичном коде:

K=211−1−1=1023−4+1023=101910101910=011111110112\begin{aligned} K &= 2^{11-1} - 1 = 1023 \\ -4 + 1023 &= 1019_{10} \\ 1019_{10} &= 01111111011_{2} \end{aligned}

И в представлении с плавающей точкой число 0.1 имеет такую битовую картину:

 63 62        52 51                                                 0    ← позиция бита
 0  01111111011  1001100110011001100110011001100110011001100110011010
 │       │                                  │
sign  exponent                  mantissa (significand)

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

0.210=1.10011‾2⋅2−30.2_{10} = 1.1\overline{0011}_{2} \cdot 2^{-3}
 63 62        52 51                                                 0    ← позиция бита
 0  01111111100  1001100110011001100110011001100110011001100110011010
 │       │                                  │
sign  exponent                  mantissa (significand)

Вычисление результата 0.1 + 0.2

Если собрать числа назад из их представления с плавающей точкой в научную форму, вот что мы получим:

0.110≈1.1001100110011001100110011001100110011001100110011010⋅2−40.210≈1.1001100110011001100110011001100110011001100110011010⋅2−3\begin{aligned} 0.1_{10} &\approx 1.1001100110011001100110011001100110011001100110011010 \cdot 2^{-4} \\ 0.2_{10} &\approx 1.1001100110011001100110011001100110011001100110011010 \cdot 2^{-3} \end{aligned}

Чтобы складывать числа, у них должны быть равные порядки. Правило гласит, что нужно подогнать число с меньшим порядком к числу с большим. Итак, приведём порядок -4 первого числа к порядку -3, как у второго:

0.110≈0.11001100110011001100110011001100110011001100110011010⋅2−30.1_{10} \approx 0.11001100110011001100110011001100110011001100110011010 \cdot 2^{-3}

Теперь можно складывать:

   0.1100110011001100110011001100110011001100110011001101
+  1.1001100110011001100110011001100110011001100110011010
   ─────────────────────────────────────────────────────────
  10.0110011001100110011001100110011001100110011001100111

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

10.0110011001100110011001100110011001100110011001100111⋅2−3=1.00110011001100110011001100110011001100110011001100111⏟53⋅2−2\begin{aligned} &10.0110011001100110011001100110011001100110011001100111 \cdot 2^{-3} = \\ &1.\underbrace{00110011001100110011001100110011001100110011001100111}_{53} \cdot 2^{-2} \end{aligned}

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

1.0011001100110011001100110011001100110011001100110100⏟52⋅2−21.\underbrace{0011001100110011001100110011001100110011001100110100}_{52} \cdot 2^{-2}

А при переводе в формат с плавающей точкой для хранения оно имеет такую битовую картину:

 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
-Infinity

Python выбрасывает 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 округляет половину вверх (в сторону +∞+\infty).

> 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-битный формат у них одинаков.

Граница 2532^{53} — в обоих языках

Хранение целых чисел в float натыкается на жёсткий предел. Мантисса содержит 52 бита плюс неявную ведущую 1 — итого 53 бита точности. Так что любое целое до 2532^{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, она никогда не важна.

Почему именно 2532^{53}?

Посмотрите на битовую картину числа 9007199254740991 (это 253−12^{53} - 1). Это целое, двоичное представление которого — пятьдесят три 1 подряд, нормализуемое как

1.11…11⏟52 единицы×2521.\underbrace{11\ldots11}_{52 \text{ единицы}} \times 2^{52}

что полностью заполняет мантиссу единицами. Чтобы сохранить следующее целое — 2532^{53} — мы прибавляем 1, что прокатывает перенос через все 52 бита мантиссы (каждая 1 переворачивается в 0), и остаётся

10.00…00⏟52 нуля×25210.\underbrace{00\ldots00}_{52 \text{ нуля}} \times 2^{52}

Повторная нормализация сдвигает точку на одну позицию влево и увеличивает порядок на единицу, давая нулевую мантиссу:

1.00…00⏟52 нуля×253=9,007,199,254,740,9921.\underbrace{00\ldots00}_{52 \text{ нуля}} \times 2^{53} = 9{,}007{,}199{,}254{,}740{,}992

Для следующего целого, 253+12^{53} + 1, нам понадобился бы установленный бит мантиссы на позиции 53 — но мантисса всего 52 бита шириной. Места нет. Формат молча округляет до ближайшего представимого значения (либо 2532^{53}, либо 253+22^{53} + 2 в зависимости от правила для равных расстояний; для +1 попадает на 2532^{53}).

Механическая причина, по которой это происходит: при порядке 52 мантисса ровно вмещает каждое целое до 2532^{53}. Чтобы сохранить что-то большее, приходится увеличить порядок — но подъём его до 53 означает, что точка сдвигается на 53 позиции вправо, тогда как мантисса по-прежнему даёт нам только 52 хранимых бита, так что 53-я битовая позиция всегда неявно 0. При порядке 54 формат приписывает два нуля; при 55 — три; и так далее.

Чтобы увидеть это наглядно, посмотрите на битовые позиции первых нескольких целых после 2532^{53}. Каждое шириной 54 бита (с бита 53 по бит 0), но у формата есть места только для 53 из них — неявная ведущая 1 плюс 52 хранимых бита мантиссы. Младшему биту (самому правому, биту 0 — назовём его LSB) места не досталось, так что формат физически не может поставить туда 1. Отсюда следуют два вывода:

  1. В этом диапазоне представимы только чётные целые.
  2. Соседи отличаются на 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, оба примерно 1.8×103081.8 \times 10^{308}), соседние представимые значения будут отстоять друг от друга на огромные расстояния.

Ловушка цикла 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 доходит до 2532^{53}, i + 1 округляется назад к 2532^{53}: разрыв до следующего представимого значения равен 2, а +1 попадает в середину и округляется вниз. Счётчик навсегда застревает на 9007199254740992, 1/i остаётся крошечным положительным числом, и цикл крутится.

Вы можете подумать, что приращение на 2 обойдёт эту проблему — и какое-то время так и есть. С i += 2 счётчик перескакивает 2532^{53} и продолжает шагать по представимым чётным (2^53, 2^53 + 2, 2^53 + 4, …). Но как только он доходит до 2542^{54}, разрыв удваивается до 4, так что i + 2 округляется назад к i и цикл снова застревает. Переход на i += 4 лишь отодвигает ловушку до 2552^{55}, где разрыв становится 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

Что стоит запомнить

  1. JavaScript Number и обычный Python float используют binary64 с 53 битами точности для нормальных значений.
  2. В этом формате округление 0.1, 0.2 и их суммы даёт иной результат, чем непосредственное округление 0.3.
  3. Binary64 представляет все целые от −253-2^{53} до 2532^{53}, но безопасный целочисленный диапазон JavaScript ограничен модулем 253−12^{53}-1. Для точных больших целых используйте BigInt или Python int.
  4. NaN и бесконечности имеют зарезервированные коды. Языки всё же могут по-разному обрабатывать операции вроде деления на ноль.

Эти четыре факта объясняют многие неожиданные результаты при работе с числами binary64.

Более подробный разбор того, как четыре базовые операции (сложение, вычитание, умножение, деление) на самом деле преобразуют биты — и для целых в дополнительном коде, и для чисел IEEE-754 — см. в статье Как работает двоичная арифметика: целые в дополнительном коде и числа IEEE-754.