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

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

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

Мантисса (significand) показывает значащие цифры числа — её также часто называют significand или точностью. Нули не считаются значащими; они просто занимают место. Основание задаёт базу системы счисления, то есть 10 для десятичной системы и 2 для двоичной. Порядок (экспонента) определяет, на сколько разрядов нужно сдвинуть точку влево или вправо, чтобы получить исходное число.

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

710=71001112=11120\begin{aligned} 7_{10} &= 7 \cdot 10^{0} \\ 111_{2} &= 111 \cdot 2^{0} \end{aligned}

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

0.00000022=0.00000022100=0.000000221001=0.00000022100(108108)=(0.00000022108)(100108)=22108\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\cdot\, 1 в середине выглядит как пустая операция — и в этом весь смысл: это заглушка. В следующей же строке мы заменяем 11 на эквивалентное выражение 10810810^{-8} \cdot 10^{8}, которое по-прежнему равно 11, но в форме, которую можно разделить. Затем 10810^{8} поглощает восемь сдвигов ведущих нулей в 0.000000220.00000022 (превращая его в целое 2222), а оставшийся 10810^{-8} становится новым порядком. Эти два множителя взаимно обратны, поэтому значение всего выражения не меняется — мы лишь перегруппировали его в нужную нам форму.

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

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

Поскольку мы использовали умножение на 8, пришлось компенсировать его делением — отсюда и берётся отрицательный порядок 8. Тот же процесс, только с делением для получения значащих цифр, можно выполнить над числом 22300000:

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

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

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

0.00000022=2.210722,300,000=2.23107\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.0201.0 \cdot 2^{0}

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

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

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

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

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

11100=1.110111 \cdot 10^{0} = 1.1 \cdot 10^{1}

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

K=2n11=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).
  • Мантисса числа с плавающей точкой: биты стоят после точки в нормализованной форме и представляют степени 21,22,23,2^{-1}, 2^{-2}, 2^{-3}, \ldots, убывающие от точки. Самый левый хранимый бит — это 212^{-1}, следующий — 222^{-2}, и так далее. Поэтому мантисса для 1.1121.11_{2} хранит 11 слева направо, помещая первую 1 в 212^{-1}, а вторую — в 222^{-2}: выравнивание по левому краю, потому что именно там живут наибольшие дробные величины.

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

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

0.110=0.00011220=1.100112240.1_{10} = 0.0\overline{0011}_{2} \cdot 2^{0} = 1.1\overline{0011}_{2} \cdot 2^{-4}

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

1.1001124  =  1.100110011001100152 бита10011241.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.100110011001101052 бита241.\underbrace{1001\,1001\,\ldots\,1001\,1010}_{52\text{ бита}} \cdot 2^{-4}

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

K=21111=10234+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.100112230.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.1101.1001100110011001100110011001100110011001100110011010240.2101.100110011001100110011001100110011001100110011001101023\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.1100.11001100110011001100110011001100110011001100110011010230.1_{10} \approx 0.11001100110011001100110011001100110011001100110011010 \cdot 2^{-3}

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

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

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

10.011001100110011001100110011001100110011001100110011123=1.001100110011001100110011001100110011001100110011001115322\begin{aligned} &10.0110011001100110011001100110011001100110011001100111 \cdot 2^{-3} = \\ &1.\underbrace{00110011001100110011001100110011001100110011001100111}_{53} \cdot 2^{-2} \end{aligned}

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

1.001100110011001100110011001100110011001100110011010052221.\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 общий, но языки совпадают не в каждой операции, которая использует 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}".

Правило большого пальца: когда два языка расходятся насчёт числа, подозревайте обёртку, а не float. Те 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            // любая арифметическая операция с 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, она никогда не важна.

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

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

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

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

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

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

1.000052 нуля×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. В этом диапазоне представимы только целые с LSB=0 (то есть чётные).
  2. Соседи отстоят на 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, оба примерно 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

Оба языка предоставляют отдельные 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

Что взять с собой

  1. Почти каждый современный язык использует одни и те же 64 бита для дробных чисел. Number в JavaScript, float в Python, double в Java, double в C — всё это IEEE-754 binary64. Та же раскладка, та же арифметика, те же странности. Различия между языками живут в обёртке, а не во float.
  2. 0.1 + 0.2 != 0.3 — свойство формата. Так происходит потому, что 0.1 и 0.2 не конечны в двоичном виде и накапливают три ошибки округления при сложении, тогда как прямое сохранение 0.3 накапливает только одну. Битовые картины различаются.
  3. Граница «безопасных целых» находится на 2532^{53}. За ней уже не все целые значения представимы, и соседние целые схлопываются в одно и то же число с плавающей точкой. JavaScript выставляет её как Number.MAX_SAFE_INTEGER, потому что другого целочисленного типа у него нет; пользователи Python видят её только после приведения к float.
  4. NaN и Infinity — реальные битовые картины, а не исключения. Они живут внутри формата, и для них зарезервирован порядок из одних единиц. Правило «NaN не равен ничему, включая самого себя» — это IEEE-754, а не выбор языка.

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

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