Материал подготовлен в преддверии старта курса «C# ASP.NET Core разработчик».

Привет, Хабр!

Есть особый сорт ошибок, от которых бухгалтерию потряхивает, а разработчики разводят руками. Не упало, не сломалось, данные на месте. Просто в отчёте за месяц сумма по строкам не сходится с итогом на четырнадцать копеек, и период из‑за этого не закрывается.

Четырнадцать копеек. На фоне оборота в десятки миллионов это даже не погрешность, а просто шум. Но закрыть месяц нельзя, пока цифры не сойдутся до последнего знака,.

Искать при этом почти нечего. Открываешь код — там обычное сложение в цикле, никакой экзотики. Тестировщик берёт три строки, считает руками, всё сходится. Копейки заводятся где‑то на нескольких тысячах записей и растут от месяца к месяцу.

В статье рассмотрим шесть мест, где деньги в коде ведут себя не так, как ожидает человек, привыкший считать их на калькуляторе.

Одна десятая, которой не существует

Начну с того, что знают все и всё равно делают.

Числа с плавающей точкой хранятся в двоичной системе. Дробь, конечная в десятичной записи, в двоичной запросто окажется бесконечной, примерно как одна треть в привычной нам десятичной: 0,333 и дальше сколько хватит терпения.

Ноль целых одна десятая — как раз такая. Точно она в двоичном виде не записывается, и в памяти лежит ближайшее приближение. Не 0,1, а что‑то очень рядом.

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

Погрешность сидит в семнадцатом знаке. Прикинем масштаб: будь это рубли, ошибка составила бы примерно одну стомиллиардную копейки.

А теперь сложите десять тысяч сумм. Вместе с ними вы сложили десять тысяч таких погрешностей. Каждая по отдельности невидима, вместе они выползают в разряд, который человек уже замечает.

Фиксится это двумя способами.

  • Первый — десятичный тип с фиксированной точностью. В C# это decimal, в Java BigDecimal, в Python decimal.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». Записаться

А полный список демо-уроков на начало сентября собрали в дайджесте.