Чому 0.1 + 0.2 не дорівнює 0.3
Як комп’ютер рахує гроші: примітиви, IEEE 754 і чому банки не довіряють float
Майже кожен новачок, який написав Hello World і рухається далі, бачить пости в соц.мережах про цей дивний результат:
0.1 + 0.2 == 0.30000000000000004 Перша реакція — це баг. Насправді це не баг, а закономірний наслідок того, як влаштована пам’ять комп’ютера й стандарт представлення дробових чисел. Щоб зрозуміти, чому так відбувається, варто почати здалеку — з того, що таке примітивний тип даних узагалі.
Як комп’ютер бачить примітивні типи
На фізичному рівні пам’ять — це просто послідовність бітів, нулів і одиниць. Сама по собі вона нічого не “знає” про те, що означає той чи інший набір бітів. Тип даних — це домовленість на рівні мови програмування й компілятора про те, як ці біти інтерпретувати. Коли оголошується int x = 5, компілятор резервує 4 байти й каже процесору трактувати цю послідовність бітів як ціле число у форматі доповняльного коду. Той самий набір бітів, якщо його прочитати як float, означав би зовсім інше число. Тип — це не властивість самої пам’яті, а лінза, крізь яку на неї дивляться.
Доповняльний код: як представлені від’ємні числа
Для цілих чисел майже всі сучасні мови й процесори використовують доповняльний код (two’s complement). Щоб отримати , потрібно інвертувати всі біти числа і додати 1.
Приклад для 8-бітного числа: це 00000101. Інвертуємо біти — 11111010, додаємо 1 — 11111011. Це і є .
Чому саме так, а не просто “перший біт — знак, решта — модуль” (sign-magnitude)? Бо доповняльний код дозволяє процесору використовувати одну й ту саму схему додавання як для додатних, так і для від’ємних чисел — віднімання зводиться до додавання оберненого числа, і не потрібна окрема логіка в “залізі”. Побічний ефект: для цілих чисел немає окремого “від’ємного нуля”, а діапазон асиметричний — для 8 біт це від до , бо комбінація 10000000 зайнята під , а не під .
IEEE 754: дробові числа з рухомою комою
float і double влаштовані принципово інакше за int. Вони підкоряються стандарту IEEE 754 і складаються з трьох частин: біта знаку, експоненти та мантиси (значущих цифр). Для double це 1 біт знаку, 11 біт експоненти, 52 біти мантиси. Число обчислюється приблизно так:
Це по суті науковий запис у двійковій системі — аналог у десятковій. Біт знаку тут окремий від самого числа, тобто для дробових чисел використовується sign-magnitude, а не доповняльний код, як для цілих. Саме тому у float існує і +0.0, і -0.0 як різні бітові комбінації.
Чому 0.1 + 0.2 ≠ 0.3
Ключова проблема в тому, що мантиса має скінченну кількість бітів, а число у двійковій системі — нескінченний дріб, що повторюється. Точно так само, як не можна записати скінченним десятковим дробом (), число не можна точно записати скінченним двійковим дробом:
Послідовність 0011 повторюється нескінченно. Оскільки мантиса обрізається на 52-му біті, число округлюється до найближчого представимого значення. У результаті в пам’яті зберігається не як точне , а приблизно як:
0.1000000000000000055511151231257827021181583404541015625 Аналогічно зберігається трохи більшим за справжнє . Коли ці два округлені значення додаються, сума виходить приблизно:
0.3000000000000000444089209850062616169452667236328125 А це інше число, ніж те, яке отримується при округленні справжнього до найближчого представимого double. Тобто помилка виникає не в самій операції додавання — вона накопичується ще на етапі запису і в пам’ять, компілятор вже округлив їх до найближчих можливих значень, і подальша арифметика чесно працює з цими наближеннями.
Це властивість будь-якої системи з рухомою комою за стандартом 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). Значення обчислюється за формулою:
new BigDecimal("123.45") всередині — це unscaledValue = 12345, scale = 2. Якщо unscaledValue влазить у long (до ~18-19 цифр), Java тримає число в компактному полі intCompact, не створюючи об’єкт BigInteger, — це оптимізація для типових фінансових сум.
BigInteger, на якому все це побудовано, зберігає знак окремо й масив int[] — магнітуду числа в системі з основою . Арифметика над такими масивами — це той самий “стовпчик” зі школи, тільки розряд не 0-9, а 0 до .
Додавання й віднімання вимагають вирівнювання scale. Щоб додати і , число з меншим scale спочатку домножується на степінь десяти (), і лише після цього складаються самі цілі: , scale = 2, результат .
Множення не потребує вирівнювання: unscaledValue множаться напряму, а scale додаються. , scale = 3, результат . Завжди точно.
Ділення — найскладніший випадок, бо результат не завжди скінченний (). Метод 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, бо .
Окремо варто пам’ятати про різницю між equals() і compareTo(): new BigDecimal("2.0").equals(new BigDecimal("2.00")) поверне false, бо equals() враховує scale, тоді як compareTo() поверне 0, бо порівнює числові значення. Класична пастка для HashSet/HashMap.
Банківське округлення (HALF_EVEN)
HALF_EVEN стосується лише одного граничного випадку: коли число, яке відкидається, рівне точно половині (наприклад, при округленні до цілого). Звичайне округлення (HALF_UP) у цьому випадку завжди округляє вгору: , , . HALF_EVEN натомість округляє до того сусіднього числа, яке парне: , , (вниз), .
Проблема HALF_UP — систематичне зміщення вгору. Якщо округлювати мільйони транзакцій і завжди округлювати рівно половину вгору, накопичена похибка по всій масі операцій буде систематично позитивною. HALF_EVEN чергує напрямок округлення залежно від парності, тож на великій вибірці похибки в середньому скасовують одна одну. Звідси й назва “банківське округлення” — а також звідси воно є режимом за замовчуванням в IEEE 754 для апаратних операцій з double/float, хоча там це не рятує від проблеми , бо похибка виникає ще на етапі запису числа у двійковому форматі.
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 Висновок
Проблема — не випадковий глюк конкретної мови, а пряма математична властивість двійкового представлення дробів за стандартом IEEE 754: точно так само, як не записати скінченним десятковим дробом, не записати скінченним двійковим. Фінансове ПЗ обходить цю проблему, повністю уникаючи двійкової арифметики для грошей — або працюючи з цілими копійками, або використовуючи десяткові типи на кшталт BigDecimal, які зберігають число точно і дають повний контроль над округленням на кожному кроці обчислень.
Comments
Please ensure Giscus is configured with your correct Repository ID and Category ID at giscus.app to enable comments.