Материал подготовлен в преддверии старта курса «C# ASP.NET Core разработчик».
Привет, Хабр!
Есть особый сорт ошибок, от которых бухгалтерию потряхивает, а разработчики разводят руками. Не упало, не сломалось, данные на месте. Просто в отчёте за месяц сумма по строкам не сходится с итогом на четырнадцать копеек, и период из‑за этого не закрывается.
Четырнадцать копеек. На фоне оборота в десятки миллионов это даже не погрешность, а просто шум. Но закрыть месяц нельзя, пока цифры не сойдутся до последнего знака,.
Искать при этом почти нечего. Открываешь код — там обычное сложение в цикле, никакой экзотики. Тестировщик берёт три строки, считает руками, всё сходится. Копейки заводятся где‑то на нескольких тысячах записей и растут от месяца к месяцу.
В статье рассмотрим шесть мест, где деньги в коде ведут себя не так, как ожидает человек, привыкший считать их на калькуляторе.
Одна десятая, которой не существует
Начну с того, что знают все и всё равно делают.
Числа с плавающей точкой хранятся в двоичной системе. Дробь, конечная в десятичной записи, в двоичной запросто окажется бесконечной, примерно как одна треть в привычной нам десятичной: 0,333 и дальше сколько хватит терпения.
Ноль целых одна десятая — как раз такая. Точно она в двоичном виде не записывается, и в памяти лежит ближайшее приближение. Не 0,1, а что‑то очень рядом.
>>> 0.1 + 0.2 0.30000000000000004 >>> 0.1 + 0.2 == 0.3 False
Погрешность сидит в семнадцатом знаке. Прикинем масштаб: будь это рубли, ошибка составила бы примерно одну стомиллиардную копейки.
А теперь сложите десять тысяч сумм. Вместе с ними вы сложили десять тысяч таких погрешностей. Каждая по отдельности невидима, вместе они выползают в разряд, который человек уже замечает.
Фиксится это двумя способами.
Первый — десятичный тип с фиксированной точностью. В C# это
decimal, в JavaBigDecimal, в Pythondecimal.Decimal. Он хранит число в десятичной системе, и одна десятая в нём именно одна десятая, безо всяких приближений.
from decimal import Decimal Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
Обратите внимание на кавычки.
>>> Decimal(0.1) Decimal('0.1000000000000000055511151231257827021181583404541015625')
Передали в конструктор обычное число с плавающей точкой — получили десятичную запись уже испорченного двоичного. Погрешность никуда не делась, она просто теперь расписана до последнего знака.
Второй способ проще, и мне он нравится больше: хранить не рубли, а копейки. Целые числа.
Такие мелочи хорошо показывают, насколько глубоко знаешь инструменты, которыми пользуешься каждый день. Проверить себя можно, пройдя бесплатный вступительный тест для C# ASP.NET Core разработчиков — он подсветит темы, которые стоит освежить.
Пять и больше — вверх? Не всегда
Правило из школы — пять и больше округляем вверх при массовых расчётах даёт систематический перекос. Половинных случаев в среднем поровну, но уезжают они все в одну сторону. Копейка туда, копейка сюда, на миллионе операций набегает вполне осязаемая сумма. И всегда в чью‑то пользу.
Есть другое правило, банковское: половина отправляется к ближайшему чётному. Двойка с половиной округляется до двух, тройка с половиной — до четырёх.
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP Decimal("2.5").quantize(Decimal("1"), ROUND_HALF_EVEN) # 2 Decimal("3.5").quantize(Decimal("1"), ROUND_HALF_EVEN) # 4 Decimal("2.5").quantize(Decimal("1"), ROUND_HALF_UP) # 3
На больших объёмах половинки разбегаются примерно поровну вверх и вниз, перекос компенсируется сам.
Какое правило применять — решает не программист. Обычно это записано в договоре, в учётной политике или в регламенте платёжной системы. Причём в разных частях одной системы вполне может требоваться разное, и это нормально.
Плохо другое: когда правило выбрано молча, по умолчанию языка, и никто в команде не знает, какое именно там стоит.
В том же Python встроенная round для чисел с плавающей точкой округляет к чётному. Контекст decimal по умолчанию тоже стоит в ROUND_HALF_EVEN. Режим одинаковый, значит и результат одинаковый — логично?
>>> round(2.675, 2) 2.67 >>> Decimal("2.675").quantize(Decimal("0.01")) Decimal('2.68')
Нелогично. Виновато тут не округление, а то, что в двоичном виде 2,675 оказывается чуть меньше двух целых шестисот семидесяти пяти тысячных.
Те самые четырнадцать копеек
Считаем строку заказа: цена, количество, скидка, налог.
Каждую промежуточную величину округляем до копеек — деньги же, дробных копеек не бывает.
Потом складываем строки и получаем итог. А рядом кто‑то считает тот же итог от общей суммы, одним умножением. Результаты расходятся.
# округляем на каждом шаге total_a = sum( (item.price * item.qty * Decimal("0.9")).quantize(CENT, ROUND_HALF_EVEN) for item in items ) # округляем один раз в конце total_b = sum(item.price * item.qty * Decimal("0.9") for item in items) \ .quantize(CENT, ROUND_HALF_EVEN)
Оба куска дают арифметически правильные числа. Просто числа разные, и на тысяче строк разница дорастает до тех самых копеек.
Общее правило звучит так: держите полную точность внутри вычисления, округляйте один раз — там, где сумма показывается человеку или уходит наружу.
Правило хорошее, но у него есть исключение.
Если по договору или по закону округление предписано на каждой строке документа, оно и должно быть на каждой строке. Тогда итог считается сложением уже округлённых строк, и никак иначе.
Сто рублей на троих
Сто рублей делим на троих. Тридцать три рубля тридцать три копейки каждому. Складываем обратно: девяносто девять рублей девяносто девять копеек. Одна копейка зависла в воздухе.
Выбросить её нельзя, деньги обязаны сходиться до последнего знака. Размазать делением тоже нельзя, мы только что видели, чем это кончается.
Остаётся раздать так:
def allocate(total_cents: int, weights: list[int]) -> list[int]: """Делит сумму по весам, не теряя ни одной минорной единицы.""" total_weight = sum(weights) shares = [total_cents * w // total_weight for w in weights] remainder = total_cents - sum(shares) for i in range(remainder): # раздаём остаток по одной копейке shares[i % len(shares)] += 1 return shares allocate(10000, [1, 1, 1]) # [3334, 3333, 3333] allocate(10000, [1, 1, 1, 1]) # [2500, 2500, 2500, 2500]
Вся функция на целых числах, поэтому сумма долей равна исходной сумме всегда.
А вот кому достанется лишняя копейка? Первому в списке? Последнему? Самому крупному участнику? Тому, кому не повезло в прошлый раз? Всё это решается правилами предметной области, и любой ответ имеет право на жизнь.
Плохо только одно: когда ответ получается сам собой, из порядка элементов в списке, пришедшем из базы без сортировки. Тогда лишняя копейка каждый месяц достаётся тому, кого база выдала первым.
Рубли, доллары и молчаливый компилятор
Сумма без валюты — просто число. С числом можно делать что угодно, в том числе сложить рубли с долларами. Компилятор не возразит, для него обе величины совершенно одинаковые.
from dataclasses import dataclass @dataclass(frozen=True) class Money: amount: int # в минорных единицах currency: str # код по ISO 4217 def __add__(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError(f"нельзя сложить {self.currency} и {other.currency}") return Money(self.amount + other.amount, self.currency)
Тип, который отказывается складывать разные валюты, ловит ошибку в момент написания кода.
Заодно он закрывает вторую проблему, о которой обычно вспоминают при выходе на новый рынок. Не у всех валют два знака после запятой. У японской иены ноль, у кувейтского динара три.
Код, где количество копеек зашито двойкой, при добавлении такой валюты начинает считать неправильно сразу везде. Причём не падает, а тихо считает неправильно, что заметно хуже.
EXPONENT = {"RUB": 2, "USD": 2, "JPY": 0, "KWD": 3} def to_minor(amount: Decimal, currency: str) -> int: exp = EXPONENT[currency] return int(amount.scaleb(exp).quantize(Decimal("1"), ROUND_HALF_EVEN))
Подозреваю, что у трехзначных валют свои сюрпризы помимо самого экспонента.
База знает лучше
Тип float или double в схеме таблицы возвращает нас к первой ошибке, только заходит с другой стороны. В PostgreSQL для денег берут numeric с указанной точностью:
CREATE TABLE payments ( id bigserial PRIMARY KEY, amount numeric(19, 4) NOT NULL, currency char(3) NOT NULL, CONSTRAINT amount_non_negative CHECK (amount >= 0) );
Промежуточные величины — цена за единицу, курс пересчёта, ставка налога — требуют больше разрядов, чем итоговая сумма, и запас в два знака обычно выручает.
Тип с говорящим названием money для денег как раз не подходит. Он привязан к региональной настройке базы, и при её смене значения начинают читаться иначе.
Второй способ потерять точность — драйвер. Многие клиентские библиотеки по умолчанию превращают числовой тип базы в число с плавающей точкой языка, и вся аккуратность схемы теряется по дороге к вашему коду.
cur.execute("SELECT amount FROM payments WHERE id = %s", (pid,)) amount = cur.fetchone()[0] assert isinstance(amount, Decimal) # проверять стоит явно
Такую проверку имеет смысл поставить тестом, один раз.
Из шести разобранных ошибок пять связаны не с деньгами как таковыми, а с тем, что человеческий счёт приходится укладывать в машинную арифметику. Деньги считали тысячи лет до появления компьютеров, и одна десятая никому не мешала.
Спасибо, что дочитали.

Если хочется продолжить разбираться с теми ошибками, которые «вроде бы ничего не ломают», но потом всплывают в production, можно копнуть ещё в две стороны: нагрузка и надёжная доставка сообщений.
На бесплатных уроках OTUS разберём, где именно система начинает вести себя не так, как от неё ждёшь:
3 сентября в 20:00. «ASP.NET Core API под нагрузкой: как сделать сервис устойчивым к сбоям и росту трафика». Записаться
17 сентября в 20:00. «RabbitMQ в Production: Transactional Outbox, идемпотентность и DLQ в ASP.NET Core». Записаться
А полный список демо-уроков на начало сентября собрали в дайджесте.

