<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
CORE PHP · JUNIOR ЧАСТО ПИТАЮТЬ

Чому гроші не зберігають у float і як правильно рахувати суми в PHP?

Float у PHP - це двійковий double за IEEE 754, у якому 0.1, 0.2 чи 19.99 не мають точного представлення, тому суми накопичують похибку. Гроші тримають у цілих копійках (int) або в рядках з DECIMAL/numeric у базі, рахують через bcmath чи `BcMath\Number` з PHP 8.4, а валюту й правило округлення вважають частиною значення.

Чому `0.1 + 0.2 != 0.3`, якщо `echo 0.1 + 0.2` друкує рівно 0.3?
У таблиці замовлень колонка `total DOUBLE`. Що з цим не так і на що міняти?
Ціна 19.99, я роблю `(int) (19.99 * 100)` і отримую 1998 копійок. Де помилка?
Сума трьох рядків у чеку на копійку не збігається з підсумком. Як таке лагодять?
float IEEE 754 bcmath BcMath\Number DECIMAL money rounding

Float у PHP - це C-шний double, тобто 64-бітне число за IEEE 754: знак, 11 біт експоненти і 53 біти мантиси. Мантиса двійкова, тож точно представляються тільки дроби зі знаменником-степенем двійки: 0.5, 0.25, 0.125. Десяткова 0.1 у двійковій системі періодична так само, як 1/3 у десятковій, тому в памʼять лягає найближче доступне наближення. Складіть два наближення, отримаєте третє: 0.1 + 0.2 дорівнює 0.30000000000000004, а не 0.3. Помітити це заважає форматування. echo друкує число з ini-директивою precision=14 і чесно показує 0.3, тоді як var_dump() і json_encode() працюють через serialize_precision=-1, тобто виводять найкоротший рядок, який при зворотному читанні дає те саме число, і хвіст виринає. Дві константи тут згодяться: PHP_FLOAT_DIG (15 надійних десяткових цифр) і PHP_FLOAT_EPSILON (2.22e-16).

Для грошей це болить двічі. Похибка накопичується: тисяча додавань у циклі зсуває підсумок, і звірка з банком не сходиться. А на межах вона ще й асиметрична: (int) (19.99 * 100) дає 1998, бо добуток трохи менший за 1999, а зведення до int не округлює, а відкидає дробову частину. Причому та сама операція для 9.99 чи 1.15 може спрацювати правильно, тож баг живе в коді місяцями і стріляє на конкретних цінах. Якщо float уже прийшов ззовні, мінімальний захист - (int) round($value * 100), хоч це й лікування симптому.

Правильних варіантів зберігання небагато. Найпростіший - цілі копійки в int: PHP_INT_MAX на 64-бітних системах дорівнює 9223372036854775807, запасу вистачить на будь-який бюджет, а арифметика лишається нативною і швидкою. У базі це BIGINT, у коді - маленький value object на кшталт Money з прикладу. Альтернатива - десятковий тип: DECIMAL(13,2) у MySQL або numeric у PostgreSQL. Тут точне десяткове зберігання, SUM() по таких колонках теж точний, а драйвери PDO повертають значення рядком, тому кастити результат у (float) не варто: це знищує все, заради чого колонку робили десятковою. Чого робити не можна, так це тримати суму в FLOAT/DOUBLE чи в PostgreSQL-типі money, вивід якого залежить від lc_monetary.

Коли дроби в обчисленнях таки потрібні (відсотки, курс валют, ПДВ), у PHP є bcmath - арифметика довільної точності над рядками. Класичні bcadd(), bcsub(), bcmul(), bcdiv(), bccomp() приймають третім аргументом scale, і найчастіше його просто забувають: типовий scale дорівнює нулю, тому bcadd('0.1', '0.2') повертає '0'. У PHP 8.4 розширення нарешті стало зручним. Зʼявився клас BcMath\Number: незмінний обʼєкт із readonly-полями value і scale, приймає string|int, підтримує оператори +, -, *, /, %, ** і порівняння, сам обчислює масштаб результату та реалізує Stringable і JsonSerializable. Арифметика з float для нього заборонена і кидає TypeError, щоб похибка не пролізла обхідним шляхом. Разом із класом додали bcround(), bcfloor() і bcceil().

Далі йде шар, до якого джуни зазвичай не доходять: валюта і момент округлення. Сума без валюти неповна, додати 100 UAH до 100 USD не можна, і клас, який дозволяє це зробити мовчки, рано чи пізно породить неправильний рахунок. Кількість дробових знаків теж не універсальна: ISO 4217 задає нуль для JPY, три для KWD і BHD, два для UAH, тому «множимо на 100» - припущення, а не закон. Правило округлення обирайте свідомо: round() у PHP типово округлює половину від нуля, а з PHP 8.4 приймає RoundingMode і вміє банківське HalfEven, яке не зміщує похибку в один бік на великих обсягах. Округлюють один раз, у точці, де число стає платежем. А розбиття суми між кількома отримувачами - це intdiv() плюс роздача залишку по копійці, а не ділення з наступним округленням кожної частки.

Забороняти float назовсім теж не варто. Для аналітики, графіків, середніх і прогнозів він доречний і швидкий, бо там похибка в шістнадцятому знаку нікому не заважає. І точність має свою ціну: bcmath рахує рядками й помітно повільніший за нативний int, тому в гарячому циклі копійки в int виграють. Звідси звичний продакшн-компроміс: зберігати в BIGINT або DECIMAL, рахувати в цілих копійках, вмикати bcmath чи BcMath\Number лише там, де потрібні відсотки й курси, а форматувати для користувача через NumberFormatter з ext-intl у режимі CURRENCY. Якщо проєкт на PHP до 8.4 або потрібна готова модель із валютою, курсами й розподілом залишків, беріть moneyphp/money замість власного велосипеда.

declare(strict_types=1);

use BcMath\Number;

var_dump(0.1 + 0.2 === 0.3);        // false: точних 0.1 і 0.2 у double немає
echo 0.1 + 0.2, PHP_EOL;            // 0.3 - хвіст ховає ini-директива precision=14
var_dump(0.1 + 0.2);                // float(0.30000000000000004)
var_dump((int) (19.99 * 100));      // 1998, бо 19.99 * 100 = 1998.9999999999998

// Варіант 1: цілі копійки, уся арифметика в int
final readonly class Money
{
    public function __construct(public int $cents, public string $currency) {}

    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException('UAH і USD не додаються');
        }

        return new self($this->cents + $other->cents, $this->currency);
    }

    /** @return list<self> ділимо суму на частини, не загубивши залишок */
    public function split(int $parts): array
    {
        $base = intdiv($this->cents, $parts);
        $rest = $this->cents % $parts;   // роздаємо по одній копійці

        return array_map(
            fn (int $i): self => new self($base + ($i < $rest ? 1 : 0), $this->currency),
            range(0, $parts - 1),
        );
    }
}

$total = (new Money(1999, 'UAH'))->add(new Money(50, 'UAH'));
echo $total->cents, PHP_EOL;         // 2049, без жодного float на шляху

// Варіант 2: BcMath\Number з PHP 8.4 - десятковий дріб як обʼєкт
$price = new Number('19.99');
$vat = $price * new Number('0.20');  // 3.9980: масштаб порахувався сам
echo $vat->round(2, RoundingMode::HalfEven), PHP_EOL;  // 4.00
var_dump($price->compare(new Number('19.990')));       // int(0): значення рівні
Що причина не в PHP, а в двійковому double: 0.1 і 0.2 - нескінченні періодичні дроби у двійковій системі, у 53 біти мантиси влазить лише наближення.
Що `echo` показує красиве 0.3 через ini-директиву `precision=14`, а `var_dump()` і `json_encode()` з `serialize_precision=-1` друкують 0.30000000000000004: число одне, форматування різне.
Що робочих варіантів зберігання два - цілі копійки в `int` (`BIGINT` у базі) або `DECIMAL(13,2)`/`numeric`, і що `FLOAT`/`DOUBLE` у грошовій колонці вважається багом.
Що для дробових обчислень у PHP є bcmath, який працює з рядками й параметром `scale`, а PHP 8.4 додав обʼєкт `BcMath\Number` з операторами `+`, `-`, `*`, `/` і функції `bcround()`, `bcfloor()`, `bcceil()`.
Що сума без валюти неповна: 100 UAH і 100 USD не додаються, а кількість знаків після коми задає ISO 4217 (JPY - 0, KWD - 3), і що момент округлення треба фіксувати свідомо.
Писати `(int) ($price * 100)` для переведення в копійки: `19.99 * 100` дає 1998.9999999999998, і цілочислове зведення обрізає це до 1998. Потрібен щонайменше `round()` перед `(int)`.
Порівнювати гроші через `==` або `===` на float і дивуватися, що `0.1 + 0.2 === 0.3` хибне. Для float є `abs($a - $b) < PHP_FLOAT_EPSILON`, для bcmath - `bccomp()`.
Вважати, що `DECIMAL` у MySQL - це просто «float з обмеженням»: це точний тип із десятковим зберіганням, і в тому вся суть заміни.
Отримати `DECIMAL` з PDO (драйвер віддає його рядком), кинути в `(float)` заради «зручності» і втратити точність одразу після `SELECT`.
Округлювати кожен проміжний крок: порахувати ПДВ по кожному рядку з `round(..., 2)`, підсумувати і отримати розбіжність із ПДВ від загальної суми.
Викликати `bcadd('0.1', '0.2')` без третього аргументу й отримати `'0'`: типовий scale дорівнює нулю, поки його не змінити через `bcscale()`.
ПОРАДА

Одна фраза, яка закриває питання: «float - це двійковий дріб, а гривня з копійками - десятковий, тому 19.99 у double не існує; я тримаю суму в цілих копійках або в DECIMAL і рахую bcmath». Далі назвіть `BcMath\Number` із PHP 8.4 і скажіть, що валюта й правило округлення для вас частина типу, а не сусіднє поле, - це вже рівень, який рідко чують від джуна.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Десятковий 19.99 не має точного двійкового представлення, тому в змінній лежить трохи менше число. Після множення на 100 виходить 1998.9999999999998, а `(int)` не округлює, а відкидає дробову частину. Робочі варіанти - `(int) round($price * 100)` або взагалі не пускати float у грошовий шлях.