Як Number у JavaScript і float у Python зберігають числа
Багато мов зі статичною типізацією пропонують кілька числових типів. Java виправляє byte, int і long на 8, 32 і 64 біти, а float і double на 32 і 64 біти. Розміри типу C залежать від реалізації: простий char може бути зі знаком або без знаку, а long не завжди має 64 біти. Тут ми зосереджуємося на IEEE-754 binary64, який використовується Number JavaScript і float Python на звичайних платформах.
JavaScript і Python не дотримуються цієї моделі. Довгий час JavaScript мав лише один загальний тип Number, який обробляв кожне числове значення — як цілі, так і дробові — з BigInt, доданим пізніше як окремий цілий тип довільної точності.
Еквівалентами Python є int для цілих чисел довільної точності та float для дробових значень.
Можливо, ви бачили таку дивну поведінку раніше — введіть 0.1 + 0.2 у консоль JavaScript, і відповідь повернеться як 0.30000000000000004, а не 0.3. Існує незліченна кількість тем StackOverflow, проблем 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, проведемо однакові експерименти на обох мовах, щоб побачити, як вони створюють ідентичні бітові шаблони, і визначимо, яка поведінка походить від числа з плаваючою точкою, а яка — від мови, оберненої навколо нього.
Типи чисел, доступні кожній мові
Перш ніж відкривати біти, це допоможе викласти те, що кожна мова дає вам на поверхні. Обидва мають окремі типи для цілих і дробових чисел, з різними назвами та різними значеннями за замовчуванням:
| Мова | Добрий | Тип | Подробиці |
|---|---|---|---|
| Python | Ціле число (без десяткових знаків, точне) | внутр | Довільно-точний. CPython збільшує резервне сховище за потреби, тому 2**1000 просто працює і результат точний. |
| Float (64-розрядний IEEE-754 подвійний) | плавати | Корисне навантаження binary64 використовує 8 байтів; плаваючий об’єкт Python займає додатковий простір для метаданих об’єкта. | |
| JavaScript | Ціле число (без десяткових знаків, точне) | BigInt | Ціле число довільної точності, додане пізніше як окремий тип. Написано з an n суфікс (наприклад, 5n). Найближчий еквівалент Python внутр. |
| Float (64-розрядний IEEE-754 подвійний) | Номер | Той самий формат, що й у Python плавати. JavaScript використовує його як для цілих чисел, так і для дробів; немає окремого цілого типу за замовчуванням. |
Цілі літерали Python, такі як 5, створюють int. Маркер десяткової коми або експоненти створює літерал із плаваючою комою: і 5.2, і 5e0 створюють float.
>>> type(5)
<class 'int'>
>>> type(5.2)
<class 'float'>
>>> type(5 + 0.0) # int + float promotes to 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 // the other direction works too, at the cost of precision
6Python за замовчуванням цілі літерали int; JavaScript за замовчуванням безсуфіксованих числових літералів має Number. З цього моменту ми зосередимося на стороні з плаваючою точкою: float Python, Number JavaScript і їх двійкове представлення 64.
Представлення чисел у науковій нотації
Перш ніж ми почнемо говорити про число з плаваючою комою та стандарт IEEE754, нам потрібно спочатку поглянути на те, що означає представлення числа в науковій нотації. Ви, напевно, бачили такі значення, як (число Авогадро) або (форма електронної нотації, яка часто використовується в коді) — вони зазвичай представляють величини, які є надто великими чи надто малими, щоб записувати їх цифра за цифрою, і саме цю проблему вирішує наукова нотація.
У загальному вигляді число в науковому записі можна представити так:
Значне – це числовий множник, помножений на основу, піднесену до показника степеня; точність – це кількість доступних значущих цифр або бітів. «Мантиса» часто використовується для значущого, хоча нижче ми також неофіційно використовуємо його для поля збережених дробів. Нулі в значущому можуть бути значущими: 1.01 відрізняється від 1.1. Базою є 10 для десяткового числа та 2 для двійкового; експонента визначає масштаб.
Будь-яке число може бути представлено в науковому записі. Наприклад, число 7 в десятковій і двійковій системах можна представити так:
Експонента 0 просто показує нам, що для отримання вихідного числа не потрібно виконувати жодних додаткових операцій. Давайте подивимося інший приклад — число 0.00000022. Значущі числа тут 22, тому давайте приберемо нулі:
Ми множимо на , тому значення залишається незмінним. Множення на пересуває десяткову кому на вісім знаків праворуч і дає . Фактор , що залишився, компенсує цей зсув.
Розрахунок вище демонструє, чому експонента основи зменшується, якщо точку вирівнювання переміщують праворуч. Отже, виконуючи множення, ми вдосконалили наше початкове число, щоб воно мало лише значущі цифри:
Ми помножили на , тому компенсуємо . Для 22300000 ділення на замість цього дає значущий 223:
Цього разу точку підрахунку було переміщено ліворуч і, отже, експонента збільшилася. Як бачите, наукова нотація — це спосіб легко працювати з дуже великими чи малими числами. Залежно від показника, значуще може представляти ціле число або число з дробовою частиною. При перетворенні на початкове число від’ємний показник степеня вимагає зміщення точки основи вліво. Додатний показник вимагає зсуву вправо і зазвичай позначає великі цілі числа.
Також важливо розуміти, що таке нормалізована форма числа. Число вважається нормалізованим, якщо воно записане в науковій системі нотації з однією ненульовою десятковою цифрою перед точкою основи. Отже, якщо ми візьмемо наші вихідні числа та представимо їх у нормалізованій формі, вони матимуть такі представлення:
Наявність чисел, представлених у нормалізованій формі, дозволяє легко порівнювати числа за порядком величини.
Як ви вже здогадалися, двійкові числа завжди будуть мати 1 перед точкою основи.
Оскільки двійкова система має лише дві цифри — 0 і 1 — і нормалізація вимагає, щоб початкова цифра була відмінною від нуля, цифра перед точкою основи в нормалізованій двійковій системі завжди дорівнює 1.
Наукову нотацію можна розглядати як представлення числа з плаваючою комою. Термін «плаваюча кома» стосується того факту, що точка основи числа може «плавати» — її можна розмістити будь-де відносно значущих цифр числа. І як ми дізналися, вихідна позиція позначається експонентою.
Число з плаваючою комою згідно з IEEE754
Стандарт IEEE для арифметики з плаваючою комою (IEEE 754) визначає багато речей, пов’язаних з арифметикою з плаваючою комою, але для цілей нашого дослідження нас цікавить лише те, як числа зберігаються, округлюються та додаються. Я написав дуже детальну статтю, пояснюючи як округлити двійкові числа. Округлення є частою операцією, яка виникає, коли вибраний формат не дозволяє зберегти достатньо бітів для збереження числа. Це важлива тема, тому добре зрозумійте її механізми. Тепер давайте розглянемо, як зберігаються числа. Усі наступні приклади стосуватимуться здебільшого чисел у двійковій системі.
Розуміння того, як зберігаються числа
Існує два формати, визначені стандартом, які найчастіше використовуються — одинарна і подвійна точність. Вони відрізняються кількістю бітів, які кожен займає, і, отже, діапазоном чисел, які кожен формат може зберігати. Підхід до перекладу числа в науковій нотації у форму IEEE754 однаковий для всіх форматів, відрізняється лише кількість бітів, виділених для мантиси (значущих цифр) і експоненти.
Число з плаваючою комою IEEE754 виділяє біти для зберігання знака числа, його мантиси (значущих цифр) і експоненти. Ось як він розподіляє ці біти у форматі подвійної точності (64 біти для кожного числа), який використовується як Number JavaScript, так і float Python:
63 62 52 51 0 ← bit position
0 00000000000 0000000000000000000000000000000000000000000000000000
│ │ │
sign(1) exponent(11) mantissa(52) (significand)Знаковий біт отримує 1 біт, експонента — 11 біт, а для мантиси (значущої) відводиться 52 біта. Ось таблиця, яка показує кількість бітів, виділених для кожного формату:
| Ім’я | Всього біт | Експонента | Збережені дробові біти |
|---|---|---|---|
| Одинарна точність | 32 | 8 | 23 |
| Подвійна точність | 64 | 11 | 52 |
Експонента зберігається у двійковому форматі зміщення. Я написав детальна стаття, що пояснює цей формат і його відмінності від доповнення двох. Будь ласка, приділіть трохи часу, щоб зрозуміти цю тему, оскільки я буду використовувати її під час перекладу чисел у формат із плаваючою комою.
Приклади того, як зберігаються цілі числа
Перш ніж детально переглядати конкретні випадки, ось конкретне наскрізне зображення для цілого числа 7 (111 у двійковому форматі) — вихідні біти, нормалізована наукова форма та остаточний 64-розрядний пакетний макет IEEE-754:
111 → 1.11 × 2^2 → 0 10000000001 11 0...0
^^^ ^ ^^ ^ ^ ^^^^^^^^^^^ ^^ ^^^^^
│ │ │ │ │ │ │ └── 50 padding zeros (mantissa is 52 bits total)
│ │ │ │ │ │ └── stored mantissa: "11" (bits after the leading 1)
│ │ │ │ │ └── stored exponent: 2 + 1023 (bias) = 1025 = 10000000001
│ │ │ │ └── sign bit: 0 (positive)
│ │ │ └── exponent: how far to shift the radix back
│ │ └── mantissa (the bits after the implicit leading 1)
│ └── the implicit leading 1 (not stored)
└── original integer bitsТепер давайте підемо крок за кроком і побачимо, як цілі числа 1 і 3 зберігаються за тією ж схемою.
Число 1 представлено в усіх системах числення як 1, тому конвертація не потрібна. У науковій формі це можна представити так:
Тут ми маємо мантису 1 і експоненту 0. Використовуючи цю інформацію, ви можете припустити, що число представлено у формі з плаваючою комою так:
63 62 52 51 0 ← bit position
0 00000000000 0000000000000000000000000000000000000000000000000001
│ │ │
sign exponent mantissa (significand)Оглянемо біти. Цей помічник JavaScript записує число у вигляді байтів у порядку старшого порядку за допомогою DataView, а потім форматує ці байти по порядку. Вказівка порядку байтів робить його незалежним від порядку байтів хоста:
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 ← bit position
0 01111111111 0000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)Це повністю відрізняється від наведених вище припущень. У нас немає цифр у мантисі, а в експоненті є 1. Тепер розберемося, чому це так.
Ключове розуміння полягає в тому, що IEEE-754 не зберігає число безпосередньо — він спочатку перетворює його в нормалізовану наукову форму (одна ненульова цифра перед точкою основи, решта після), а потім зберігає ці частини.
І, як ми встановили раніше, початкова цифра перед точкою основи в нормалізованому двійковому коді завжди дорівнює 1 — тому формат не заважає зберігати її.
Це трюк із прихованим бітом: неявний 1 апаратно додає назад щоразу, коли значення зчитується, що купує нам один додатковий біт точності, по суті, безкоштовно.
Зокрема, для числа 1 нормалізованою формою є 1.0 × 2^0 — після точки основи немає цифр, а цифра перед нею (неявний 1) не зберігається.
Тож мантисі більше нічого записувати, тому всі нулі.
Тепер давайте подивимося, звідки 1 в експоненті. Раніше я згадував, що експонента зберігається у двійковому вигляді зсуву. Якщо розрахувати зсув:
ми бачимо, що це саме те, що ми маємо в представленні. Отже, у двійковому коді зміщення значення, яке зберігається там, справді є 0. Якщо незрозуміло, як зсув дає нам 0, прочитайте мою статтю про зсув двійковий.
Давайте використаємо інформацію, яку ми дізналися вище, і спробуємо представити число 3 у формі з плаваючою комою. У двійковій формі це представлено як 11. Якщо ви не пам’ятаєте чому, перегляньте мою дуже детальну статтю про алгоритми десятково-двійкового перетворення. А при нормалізації число 3 має такий вигляд (числа в двійковій системі):
Після точки основи у нас є лише одна цифра 1, яка буде зберігатися в мантисі. Як пояснювалося раніше, перша цифра перед точкою основи не зберігається. Крім того, нормалізація також дала нам експоненту 1.
Давайте обчислимо, як це представлено у двійковій системі зміщення, і тоді ми отримаємо всю необхідну інформацію:
Про мантису слід пам’ятати, що цифри зберігаються в тому порядку, в якому вони розміщені в науковій формі — зліва направо від точки основи. Маючи це на увазі, давайте розмістимо всі числа в представленні з плаваючою комою:
63 62 52 51 0 ← bit position
0 10000000000 1000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)Якщо ви запустите будь-який помічник зверху — to64bitFloat(3) у JavaScript або to64bit_float(3.0) у Python — ви побачите, що ми знайшли правильне представлення.
Примітка щодо впорядкування бітів
Можливо, ви помітили, що біти мантиси закріплені ліворуч — для 3 ми отримали 1000…000, а для 7 (з яким ми працювали раніше) ми отримали б 1100…000. Це може здатися неправильним, оскільки ті самі числа, що зберігаються як звичайні 8-розрядні цілі числа, є 00000011 і 00000111 — 1s праворуч. Ці два формати не віддзеркалюються; вони просто прив’язують біти до різних опорних точок.
- Зберігання цілих чисел: позиції бітів представляють ступені , що піднімаються справа. Біти цілих чисел кластеризовані праворуч, тому що там знаходяться найменші величини ().
- Мантиса з плаваючою точкою: біти розташовуються після точки основи в нормалізованій формі, представляючи степені , що йдуть вниз від точки основи. Крайній лівий збережений біт – , наступний – і так далі. Таким чином, мантиса для зберігає
11зліва направо, розміщуючи перший1у , а другий у — прив’язаний ліворуч, оскільки саме там знаходяться найбільші дробові величини.
Аналогія з десятковою системою показує те ж саме: ціле число 123 прив’язане праворуч (3 — це розряд одиниць). Частка 0.123 має лівий прив’язок (1 – десяте місце). Обидва все ще розглядають ліву сторону як найважливішу — вони просто вимірюють від різних контрольних точок (правий край проти точки радікса).
Чому 0,1+0,2 не є 0,3
Тепер, коли ми знаємо, як зберігаються числа, давайте подивимося, що відбувається в цьому часто цитованому прикладі. Коротке пояснення зводиться до того, які дроби взагалі можна представити в двійковій системі.
Після зведення до найнижчих членів дріб має скінченний двійковий розклад саме тоді, коли його знаменник є степенем двійки. Таким чином, 0.1 і 0.2 повинні бути апроксимовані у двійковій формі з плаваючою комою. У binary64 із округленням із рівнем прив’язки їхня сума відрізняється від збереженого значення 0.3. Незавершення саме по собі не доводить цю нерівність для кожної точності; кроки округлення мають значення.
Цього пояснення може бути достатньо для деяких розробників, але найкращий спосіб побачити, що відбувається під капотом, це виконати всі обчислення, які виконує комп’ютер самостійно. Ось що я зараз збираюся зробити.
Представлення 0,1 і 0,2 у форматі з плаваючою комою
Давайте подивимося на шаблон бітів для 0.1 у формі з плаваючою комою. Перше, що нам потрібно зробити, це перетворити 0.1 у двійковий код. Це можна зробити за допомогою алгоритму множення на 2. Я пояснюю його механізм у моїй статті про алгоритми десятково-двійкового перетворення. Якщо ми перетворимо 0.1 у двійковий, ми отримаємо нескінченний дріб:
0.1 · 2 = 0.2 0.0...
0.2 · 2 = 0.4 0.00...
0.4 · 2 = 0.8 0.000...
0.8 · 2 = 1.6 0.0001...
0.6 · 2 = 1.2 0.00011...
0.2 · 2 = 0.4 0.000110...Наступним кроком є представлення цього числа в нормалізованій науковій нотації:
Оскільки мантиса може мати лише 52 біти, нам потрібно округлити наше нескінченне число до 52 бітів після точки основи.
Використовуючи правила округлення, визначені стандартом IEEE-754 і пояснені в моїй статті про округлення двійкових чисел, нам потрібно округлити число в більшу сторону до:
Останнє, що залишилося, це обчислити представлення експоненти у двійковому зсуві:
І коли число 0.1 представлено у форматі з плаваючою комою, воно має такий шаблон бітів:
63 62 52 51 0 ← bit position
0 01111111011 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Я закликаю вас обчислити плаваюче представлення 0.2 самостійно. Ви маєте отримати такі представлення в науковій нотації та двійковій системі:
63 62 52 51 0 ← bit position
0 01111111100 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Обчислення результату 0,1 + 0,2
Якщо ми повернемо числа з їх представлення з плаваючою комою в наукову форму, ми отримаємо ось що:
Щоб додати числа, вони повинні мати рівні показники. Правило говорить, що нам потрібно привести число з меншим показником до більшого. Отже, давайте налаштуємо експонент -4 першого числа, щоб мати показник -3, як і друге число:
Тепер ми можемо додати числа:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
─────────────────────────────────────────────────────────
10.0110011001100110011001100110011001100110011001100111Тепер результат обчислення зберігається у форматі з плаваючою комою, тому нам потрібно нормалізувати результат, за потреби округлити та обчислити експоненту у двійковому форматі зміщення.
Нормалізоване число знаходиться прямо посередині між параметрами округлення, тому ми застосовуємо правило розрізнення результатів і округлюємо до парного. Це дає наступне результуюче число в нормалізованій науковій формі:
А при перетворенні у формат з плаваючою комою для зберігання він має наступний шаблон бітів:
63 62 52 51 0 ← bit position
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Щоб отримати Infinity у Python, ви запитуєте це явно: math.inf або float('inf'). Формат float має ідеальне кодування для нескінченності (ми побачимо це нижче) — Python просто вирішує не створювати його з буквального 1/0.
Модуль мінусів. % JavaScript слідує за знаком діленого.
> -7 % 3
-1% Python слідує за знаком дільника.
>>> -7 % 3
2Напівціле округлення. Math.round JavaScript округлює половину вгору (у бік ).
> Math.round(2.5)
3
> Math.round(3.5)
4Вбудований у Python round використовує «банкірське округлення» — прив’язки до рівності — що відповідає типовому значенню IEEE-754 і правилу, яке FPU використовує всередині себе.
>>> round(2.5)
2
>>> round(3.5)
4Десяткове відображення за замовчуванням. JavaScript показує рівно стільки цифр, щоб повернутися до того самого числа з плаваючою точкою. 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 // not 9007199254740993!
> 9007199254740992 === 9007199254740993
trueВираз 2 ** 53 + 1 не дає 9007199254740993. Формат не може представити це значення, тому він округляється до найближчого представленого значення — 9007199254740992.
Варто бути точним щодо того, що насправді означає MAX_SAFE_INTEGER: це не найбільше ціле число, яке може містити JavaScript. JavaScript може зберігати набагато більші значення — Number.MAX_VALUE досягає аж до 1.7976931348623157e+308, найбільшого кінцевого подвійного коду. Фактично 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, stays exact — the n suffix matters
> 9007199254740993n === 9007199254740992n
false // distinct values, unlike with Number
> 2n ** 53n + 1n
9007199254740993n // exact integer addition
> 2n ** 1000n // limited by available memory and implementation limits
10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376nТака ж межа для зберігання цілих чисел у float також існує в Python, але вона невидима, якщо ви не перейдете від int до float.
Так само, як BigInt JavaScript, int Python має довільну точність, тому ціла арифметика є точною:
>>> 2**53 + 1
9007199254740993 # exact, because both sides are 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 є плаваючою точкою — BigInt було додано пізніше як аварійний люк, але він вимагає явного суфікса n для включення — тому мова позначає межу назвою, щоб попередити вас. Python перевертає значення за замовчуванням: чистий цілочисельний літерал уже є int довільної точності, тому межа невидима, доки ви явно не приведете до float. Ось чому MAX_SAFE_INTEGER не з’являється у стандартній бібліотеці Python — у коді лише для int це не має значення.
Чому саме ?
Подивіться на бітовий шаблон для 9007199254740991 (який є ). Це ціле число, двійкове представлення якого складається з п’ятдесяти трьох 1 поспіль, нормалізованих як
який повністю заповнює мантису 1s. Щоб зберегти наступне ціле число — — ми додаємо 1, який каскадує перенесення через усі 52 біти мантиси (кожен 1 перетворюється на 0), залишаючи нам
Повторна нормалізація зсуває точку виразу на одну позицію вліво, а експоненту збільшує на одиницю, даючи мантису, яка складається з нуля:
Для наступного цілого числа, , нам потрібен біт мантиси в позиції 53 — але мантиса має лише 52 біти. Немає місця. Формат мовчки округляє до найближчого значення, яке можна представити (або , або залежно від рівноваги; для +1 воно потрапляє на ).
Механічна причина цього: у показнику 52 мантиса точно фіксує кожне ціле число до . Щоб зберегти щось більше, ми повинні збільшити експоненту, але підвищення її до 53 означає, що точка основи зміщується на 53 позиції вправо, тоді як мантиса все ще дає нам лише 52 збережені біти, тому позиція 53-го біта завжди неявно 0. До показника 54 формат додає два нулі; у 55 років три; і так далі.
Щоб побачити це конкретно, подивіться на позиції бітів перших кількох цілих чисел після . Кожен має 54 біти (біт 53 до біта 0), але формат має слоти лише для 53 із них — неявного початкового 1 плюс 52 збережених бітів мантиси.
Наймолодший біт (крайній правий, біт 0 — називайте його LSB) не має слота, тому формат фізично не може помістити туди 1. З цього випливають два наслідки:
- У цьому діапазоні можна представити лише парні цілі числа.
- Сусіди
2окремо. Додавання1є рівною: воно може округлятися до вихідного значення або до наступного парного цілого числа, залежно від останнього збереженого біта. Наприклад,(2 ** 53 + 2) + 1округлює до2 ** 53 + 4.
2^53 = 9007199254740992 = 1 0000…0000 0 ← LSB=0 (even) ✓ representable
2^53 + 1 = 9007199254740993 = 1 0000…0000 1 ← LSB=1 (odd) ✗ no slot for the LSB
2^53 + 2 = 9007199254740994 = 1 0000…0001 0 ← LSB=0 (even) ✓ representable
2^53 + 3 = 9007199254740995 = 1 0000…0001 1 ← LSB=1 (odd) ✗ no slot for the LSB
↑ └─── 52 ───┘ ↑
│ mantissa │
│ bits │
│ └── the LSB has no mantissa slot
│ → always 0 → integer always even
└── implicit leading 1 (not stored)Наслідок вражаючий: кожне ціле число, більше за MAX_SAFE_INTEGER, закінчується принаймні одним неявним нулем, тому непарне ціле число вище MAX_SAFE_INTEGER взагалі не може бути представлено — лише парні.
Підніміться далі за експонентою, і можна представити лише кратні 4, потім кратні 8, потім 16, подвоюючи розрив на кожному кроці. Ви можете спостерігати за цим безпосередньо в REPL: тут ^ позначає піднесення до степеня в математичній нотації. У JavaScript використовуйте **; у Python використовуйте базу з плаваючою комою, таку як 2.0 ** 53, щоб спостерігати округлення з плаваючою комою.
2^53 → 9007199254740992
2^53 + 1 == 2^53 → true (gap of 2 here, +1 collapses)
2^53 + 2 > 2^53 → true (+2 is the next representable)
2^54 + 2 == 2^54 → true (gap of 4, even +2 collapses)
2^54 + 4 > 2^54 → true (+4 is next)
2^55 + 4 == 2^55 → true (gap of 8 now)
2^55 + 8 > 2^55 → true (+8 is next)Кожен крок подвоює розрив. Коли ви досягаєте найбільшого кінцевого подвійного числа (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, а не мови. Будь-яка мова, яка використовує подвійний IEEE-754 як тип лічильника, може вразити її.
NaN і Infinity
Формат резервує два значення експоненти для спеціальних кодувань:
- Повністю нульовий показник зарезервований для
±0(коли мантиса також дорівнює нулю) і для субнормальних значень (коли мантиса відмінна від нуля — значення занадто малі для представлення в нормалізованій формі). - Единичний показник зарезервований для
±Infinity(нульова мантиса) і NaN (ненульова мантиса).
Ось чому typeof NaN — це 'number' у JavaScript, а type(float('nan')) — <class 'float'> у Python — 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 з плаваючою речовиною, щоб виразити тест безпосередньо. Старіший глобальний isNaN JavaScript також перетворює свій аргумент на число, тому він може повертати true як рядок. Самонерівність (x !== x у JavaScript, x != x для Python floats) також виявляє 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Що забрати
NumberJavaScript і звичайнийfloatPython використовують binary64 з точністю 53 біти для нормальних значень.- У цьому форматі округлення
0.1,0.2та їх суми дає значення, відмінне від прямого округлення0.3. - Binary64 представляє кожне ціле число від до , але безпечний діапазон цілих чисел JavaScript закінчується на за величиною. Використовуйте
BigIntабо Pythonintдля точних більших цілих чисел. - NaN і infinities мають зарезервовані кодування. Мови все ще можуть вибрати іншу поведінку для таких операцій, як ділення на нуль.
Ці чотири факти пояснюють багато типових несподіванок під час роботи з двійковими числами 64.
Для глибшого занурення в те, як чотири основні операції (додавання, віднімання, множення, ділення) фактично перетворюють біти — як для цілих чисел із доповненням до двох, так і для чисел з плаваючою точкою IEEE-754 — дивіться Як працює двійкова арифметика: цілі числа в додатковому коді та числа з рухомою комою IEEE-754.