Чому 0.1 + 0.2 не дорівнює 0.3

⏱ 7 min read

Як комп’ютер рахує гроші: примітиви, IEEE 754 і чому банки не довіряють float

Майже кожен новачок, який написав Hello World і рухається далі, бачить пости в соц.мережах про цей дивний результат:

0.1 + 0.2 == 0.30000000000000004

Перша реакція — це баг. Насправді це не баг, а закономірний наслідок того, як влаштована пам’ять комп’ютера й стандарт представлення дробових чисел. Щоб зрозуміти, чому так відбувається, варто почати здалеку — з того, що таке примітивний тип даних узагалі.

Як комп’ютер бачить примітивні типи

На фізичному рівні пам’ять — це просто послідовність бітів, нулів і одиниць. Сама по собі вона нічого не “знає” про те, що означає той чи інший набір бітів. Тип даних — це домовленість на рівні мови програмування й компілятора про те, як ці біти інтерпретувати. Коли оголошується int x = 5, компілятор резервує 4 байти й каже процесору трактувати цю послідовність бітів як ціле число у форматі доповняльного коду. Той самий набір бітів, якщо його прочитати як float, означав би зовсім інше число. Тип — це не властивість самої пам’яті, а лінза, крізь яку на неї дивляться.

Доповняльний код: як представлені від’ємні числа

Для цілих чисел майже всі сучасні мови й процесори використовують доповняльний код (two’s complement). Щоб отримати x-x, потрібно інвертувати всі біти числа xx і додати 1.

Приклад для 8-бітного числа: 55 це 00000101. Інвертуємо біти — 11111010, додаємо 1 — 11111011. Це і є 5-5.

Чому саме так, а не просто “перший біт — знак, решта — модуль” (sign-magnitude)? Бо доповняльний код дозволяє процесору використовувати одну й ту саму схему додавання як для додатних, так і для від’ємних чисел — віднімання зводиться до додавання оберненого числа, і не потрібна окрема логіка в “залізі”. Побічний ефект: для цілих чисел немає окремого “від’ємного нуля”, а діапазон асиметричний — для 8 біт це від 128-128 до 127127, бо комбінація 10000000 зайнята під 128-128, а не під 0-0.

IEEE 754: дробові числа з рухомою комою

float і double влаштовані принципово інакше за int. Вони підкоряються стандарту IEEE 754 і складаються з трьох частин: біта знаку, експоненти та мантиси (значущих цифр). Для double це 1 біт знаку, 11 біт експоненти, 52 біти мантиси. Число обчислюється приблизно так:

число=(1)знак×1.мантиса×2(експонента1023)\text{число} = (-1)^{\text{знак}} \times 1.\text{мантиса} \times 2^{(\text{експонента} - 1023)}

Це по суті науковий запис у двійковій системі — аналог 1,23×1041{,}23 \times 10^4 у десятковій. Біт знаку тут окремий від самого числа, тобто для дробових чисел використовується sign-magnitude, а не доповняльний код, як для цілих. Саме тому у float існує і +0.0, і -0.0 як різні бітові комбінації.

Чому 0.1 + 0.2 ≠ 0.3

Ключова проблема в тому, що мантиса має скінченну кількість бітів, а число 0,10{,}1 у двійковій системі — нескінченний дріб, що повторюється. Точно так само, як 1/31/3 не можна записати скінченним десятковим дробом (0,3330{,}333\ldots), число 0,10{,}1 не можна точно записати скінченним двійковим дробом:

0,110=0,000110011001100110020{,}1_{10} = 0{,}0001100110011001100\ldots_2

Послідовність 0011 повторюється нескінченно. Оскільки мантиса обрізається на 52-му біті, число округлюється до найближчого представимого значення. У результаті в пам’яті 0,10{,}1 зберігається не як точне 0,10{,}1, а приблизно як:

0.1000000000000000055511151231257827021181583404541015625

Аналогічно 0,20{,}2 зберігається трохи більшим за справжнє 0,20{,}2. Коли ці два округлені значення додаються, сума виходить приблизно:

0.3000000000000000444089209850062616169452667236328125

А це інше число, ніж те, яке отримується при округленні справжнього 0,30{,}3 до найближчого представимого double. Тобто помилка виникає не в самій операції додавання — вона накопичується ще на етапі запису 0,10{,}1 і 0,20{,}2 в пам’ять, компілятор вже округлив їх до найближчих можливих значень, і подальша арифметика чесно працює з цими наближеннями.

Це властивість будь-якої системи з рухомою комою за стандартом IEEE 754 — Java, Python, JavaScript, C++ наступають на ці самі граблі, бо апаратно це той самий формат у процесорі (FPU).

Як банки вирішують цю проблему

Фінансове ПЗ принципово уникає float/double для грошових сум. Підхід складається з кількох доповняльних практик.

Десяткова арифметика з фіксованою точністю замість двійкової. У Java це BigDecimal, у C# — decimal, у Python — Decimal, у базах даних — типи NUMERIC/DECIMAL. Такі типи зберігають число не як степінь двійки, а як пару “ціле число + позиція коми” в десятковій системі, тож десяткові дроби представляються точно, без жодного округлення при конвертації. Плата за це — швидкість: операції на порядки повільніші за double, бо це програмна емуляція довгої арифметики, а не апаратна інструкція процесора.

Робота в найменших одиницях валюти. Дуже поширена практика — зберігати суми не в гривнях чи доларах, а в копійках/центах, використовуючи звичайні цілі числа (long). Замість 100.50 USD зберігається ціле 10050. Це повністю усуває проблему дробових округлень, бо цілі числа в доповняльному коді — точні за визначенням.

Контрольовані правила округлення. Коли округлення все ж потрібне, явно задається режим округлення замість того, щоб покладатись на поведінку мови за замовчуванням.

Подвійний запис (double-entry bookkeeping). Кожна транзакція одночасно записується як дебет в одному рахунку і кредит в іншому на ту саму суму — додатковий запобіжник: якщо десь закралася похибка округлення, вона стане видимою як розбіжність балансу.

BigDecimal зсередини

BigDecimal зберігає два числа: unscaledValue (тип BigInteger, ціле необмеженого розміру) і scale (звичайний int). Значення обчислюється за формулою:

значення=unscaledValue×10scale\text{значення} = \text{unscaledValue} \times 10^{-\text{scale}}

new BigDecimal("123.45") всередині — це unscaledValue = 12345, scale = 2. Якщо unscaledValue влазить у long (до ~18-19 цифр), Java тримає число в компактному полі intCompact, не створюючи об’єкт BigInteger, — це оптимізація для типових фінансових сум.

BigInteger, на якому все це побудовано, зберігає знак окремо й масив int[] — магнітуду числа в системі з основою 2322^{32}. Арифметика над такими масивами — це той самий “стовпчик” зі школи, тільки розряд не 0-9, а 0 до 23212^{32}-1.

Додавання й віднімання вимагають вирівнювання scale. Щоб додати 1,51{,}5 і 2,252{,}25, число з меншим scale спочатку домножується на степінь десяти (1,51,501{,}5 \to 1{,}50), і лише після цього складаються самі цілі: 150+225=375150 + 225 = 375, scale = 2, результат 3,753{,}75.

Множення не потребує вирівнювання: unscaledValue множаться напряму, а scale додаються. 1,5×2,2515×225=33751{,}5 \times 2{,}25 \to 15 \times 225 = 3375, scale = 3, результат 3,3753{,}375. Завжди точно.

Ділення — найскладніший випадок, бо результат не завжди скінченний (1÷3=0,3331 \div 3 = 0{,}333\ldots). Метод divide(BigDecimal) без додаткових параметрів кидає ArithmeticException, якщо точний результат нескінченний:

BigDecimal a = new BigDecimal("1");
BigDecimal b = new BigDecimal("3");
a.divide(b); // ArithmeticException
a.divide(b, 10, RoundingMode.HALF_UP); // 0.3333333333

Десятковий дріб термінується тільки якщо знаменник (після скорочення) має простими множниками виключно 2 і 5, бо 10=2×510 = 2 \times 5.

Окремо варто пам’ятати про різницю між equals() і compareTo(): new BigDecimal("2.0").equals(new BigDecimal("2.00")) поверне false, бо equals() враховує scale, тоді як compareTo() поверне 0, бо порівнює числові значення. Класична пастка для HashSet/HashMap.

Банківське округлення (HALF_EVEN)

HALF_EVEN стосується лише одного граничного випадку: коли число, яке відкидається, рівне точно половині (наприклад, 2,52{,}5 при округленні до цілого). Звичайне округлення (HALF_UP) у цьому випадку завжди округляє вгору: 0,510{,}5 \to 1, 1,521{,}5 \to 2, 2,532{,}5 \to 3. HALF_EVEN натомість округляє до того сусіднього числа, яке парне: 0,500{,}5 \to 0, 1,521{,}5 \to 2, 2,522{,}5 \to 2 (вниз), 3,543{,}5 \to 4.

Проблема HALF_UP — систематичне зміщення вгору. Якщо округлювати мільйони транзакцій і завжди округлювати рівно половину вгору, накопичена похибка по всій масі операцій буде систематично позитивною. HALF_EVEN чергує напрямок округлення залежно від парності, тож на великій вибірці похибки в середньому скасовують одна одну. Звідси й назва “банківське округлення” — а також звідси воно є режимом за замовчуванням в IEEE 754 для апаратних операцій з double/float, хоча там це не рятує від проблеми 0,1+0,20{,}1+0{,}2, бо похибка виникає ще на етапі запису числа у двійковому форматі.

new BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP);   // 3
new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN); // 2
new BigDecimal("3.5").setScale(0, RoundingMode.HALF_UP);   // 4
new BigDecimal("3.5").setScale(0, RoundingMode.HALF_EVEN); // 4

Висновок

Проблема 0,1+0,20,30{,}1 + 0{,}2 \neq 0{,}3 — не випадковий глюк конкретної мови, а пряма математична властивість двійкового представлення дробів за стандартом IEEE 754: точно так само, як 1/31/3 не записати скінченним десятковим дробом, 0,10{,}1 не записати скінченним двійковим. Фінансове ПЗ обходить цю проблему, повністю уникаючи двійкової арифметики для грошей — або працюючи з цілими копійками, або використовуючи десяткові типи на кшталт BigDecimal, які зберігають число точно і дають повний контроль над округленням на кожному кроці обчислень.

Comments

Please ensure Giscus is configured with your correct Repository ID and Category ID at giscus.app to enable comments.