Недавно довелось разбирать багрепорт одного клиента на нашу программу, где клиент указал на ошибку в отчете в одну копейку. Казалось бы, сложно себе представить программиста или вообще IT-шника, который не знает как работает функция округления. Тем не менее, почти двадцатилетний опыт разработки в данном случае не панацея. Разобравшись с корнями проблемы, я поискал материалы в русскоязычном и англоязычном интернете, и если на английском ещё есть тематические подборки материалов, но на русском и тем более на Хабре я этого не нашёл. Поэтому спешу поделиться с читателями Хабра собранным и систематизированным материалом.

(UPD)Мини дисклеймер — статья изначально написана в 2013 году, лежала в песочнице, недавно я её по случаю откопал и опубликовал. С тех пор кое-что немного изменилось, например, начиная с .Net Core 3.0 режимов округления стало не 2, а 5. А ещё в Excel 2016 проблема уже не повторяется, хотя повторяется другой.

Присказка

Если не любите детали, пролистывайте до следующего раздела.

Началось всё с того, что клиенты сообщили нам о расхождении в копейку в отчете. Значение 7.145 программа округлила до 7.14. Проверил на калькуляторе все вычисления, получил 7.145, обрадовался, что, как минимум, баг повторяется.

Первое подозрение пало на использование некорректных типов данных (float или double вместо decimal). Перепроверил ещё раз код, проблемы не нашёл. Создал новый юнит-тест на данную ситуацию, прогнал его, тест вполне ожидаемо упал. Потом запустил отладчик и стал смотреть все этапы вычислений (там довольно замороченная формула используется, значение округляется в самом конце, решил проверить, что было до округления). Перед округлением вижу вполне корректное значение 7.145. Глазам своим не верю, загоняю значение в watch-окно, проверяю ещё раз тип (Decimal, всё как положено), добавляю в watch-окне округление, получаю 7.14. Ущипнул себя на всякий случай и стал подставлять в Math.Round разные значения руками, проверять, что ещё может быть не так. Когда 7.155 вполне ожидаемо округлилось до 7.16, а 7.165 тоже до 7.16 начал подозревать, что это не я схожу с ума. Залез в MSDN и нашел…

Как реализовано округление в .Net

Math.Round поддерживает два режима округления:

  1. System.MidpointRounding.AwayFromZero — это привычный способ округления, +0.5 округляется до 1, а -0.5 округляется до -1. Т.е. в большую по модулю сторону.

  2. System.MidpointRounding.ToEven — округление «к четному», 0.5 округляется до 0, а 1.5 до 2.

Сам метод Math.Round имеет несколько перегруженных вариантов: для Double и для Decimal, с указанием количества знаков после запятой в результате и без, с указанием типа округления и без.

Самое интересное, что по умолчанию используется алгоритм округления System.MidpointRounding.ToEven. Про это в MSDN, конечно, написано, но лично я, например, считаю, что принцип «если ничего не помогает, прочтите, наконец, инструкцию» вполне логичен и следую ему по мере сил.

Покопавшись ещё в интернете, собрал информацию о том,

Для чего нужны несколько режимов округления

MSDN ограничивается замечанием «It conforms to IEEE Standard 754, section 4.» (Он соответствует стандарту IEEE 754, раздел 4.) Сам по себе стандарт посвящен вообще представлению дробных чисел в памяти компьютера и не так уж и много информации содержит о том, зачем нужны эти режимы округления. Более того, только текущая версия от 2008 года содержит упоминание о режиме округления «к четному», а вот предыдущая (от 1985 года) про него ещё была не в курсе. Википедия в статье про округление называет округление к четному «банковским» и рассказывает о том, какую проблему оно решает.

Если взять последовательный ряд чисел (сделать предположение, что входные значения для функции округления распределены более-менее равномерно и случайно), то с обычным округлением возникает кумулятивная ошибка округления. Ниже пример, который я набросал в Excel.

Как можно заметить, на 20 числах привычный нам режим округления дает погрешность в 1 (почти 5%). Лично для меня это ещё раз напомнило, что «самоочевидные выводы» обычно неверны — обыденным сознанием я всегда считал, что в 5 случаях округление происходит к 0 и в 5 случаях к 1, так что суммы исходной последовательности и округлённой должны сходиться. Это не так.

Ещё одну интересную историю про данный режим округления рассказала мне та самая клиентка, которая сообщила об этом баге. Раньше она долгое время работала учителем математики в школе и лет 30 назад застала момент, когда поменяли программу обучения и, в частности, преподавание правил округления. До того момента в школе давали округление «к четному», а потом всем стали давать более простую и менее правильную схему. Советская школа тогда была впереди сообщества американских инженеров.

Извлечение уроков

Если честно, после погружения в проблему я был немного обескуражен ;) На мой взгляд, здесь разработчики .Net прикопали бомбу-вонючку. Такое поведение совершенно не вписывается в общую схему остальных продуктов того же Microsoft.

  • Функция ROUND в MSSQL умеет производить округление только в привычном режиме «от нуля».

  • Функция ROUND в MS Excel умеет производить округление только в привычном режиме «от нуля».

  • MS Excel имеет функцию ODD, которая округляет «к четному», но она округляет только до целых, точность указать нельзя.

  • Функция Math.Round в .Net по умолчанию внезапно округляет «к четному». Чуть менее, чем все программисты используют округление без указания режима, а чуть менее чем все клиенты при проверке продукта сверяют его с расчетами в Excel, где используют ROUND. Веселье с особо дотошными клиентами гарантировано.

  • Механизма глобального управления округлением (как настройками локали, форматом дат, например) в .Net отсутствует.

Для себя в продукте мы решили, что погрешность небольшая и редкая, проще сделать округление как в Excel, чем всем объяснять что они всю жизнь считали не так как нужно. Вынесли логику округления в свой класс-обертку, которым можно управлять через клиентские настройки. Если какой-то клиент будет принципиально правильным, можно будет ему включить банковское округление и не морочить голову остальным.

На сладкое

Чуть позже от тех же клиентов пришёл ещё багрепорт про округление.

Если посчитать на калькуляторе, то 1.58*25%=0.395, должно округляться до 0.40 в Excel, но этого не происходит. Если число ввести руками, то оно округляется верно. В данном случае, конечно же, ошибка возникает из-за того, что вычисление формул в Excel производится с использованием «неточных» типов данных. Если показать больше знаков, то картина получается более наглядной.

Всё вроде понятно и объяснимо, но доверие к Excel заметно уменьшилось. Получается, что инструмент, который то самое «обыденное сознание» воспринимало как источник эталонных данных, к которым должны сходиться результаты программы, больше не является таковым.

UPD (23.03.2024):

Пример выше повторяется не во всех версиях Excel (есть статья от Microsoft), например, в настольной версии Excel 2016 (сборка 17328.20184) не повторяется, зато прекрасно воспроизводится результат, отличный от нуля при умножении 1 на 0:

Мораль же всей этой истории такова:

Уважаемые коллеги, будьте внимательны и осторожны!

Комментарии (8)


  1. ColdPhoenix
    21.03.2024 07:47
    +4

    Math.Round поддерживает два режима округления:

    Точно 2?


    1. igolets Автор
      21.03.2024 07:47

      Спасибо за наблюдение, добавил в начале статьи уточнение. Когда писал статью, их было 2, с тех пор кое-что в жизни поменялось ;)


  1. dyadyaSerezha
    21.03.2024 07:47

    При округлении 0.5 к 1 получается не 5 на 5 (5 вверх и 5 вниз), а 4 на 5, потому что 0 к 0 не округляется. Но чем больше знаков после запятой будем добавлять к исходным числам, тем более точным будет округление "0.5 к 1".


  1. domix32
    21.03.2024 07:47
    +1

    Там тех округлений на самом деле довольно немало и дествительно проблем с ними не оберёшься. Поэтому в своё время для избегания ошибок погрешности сложения/перемножения вообще приводили к некоторым условным перецентиль центам и округляли копейки в сторону клиента при рассчёте уже после применения всех операций: штуки * цену * мультипликаторы - налоги - комиссии/акцизы/пр. Тоже долго и больно шли к этому.


  1. BoxaShu
    21.03.2024 07:47

    Было бы здорово указать версию Excel для "На сладкое", т.к. в 2016 повторить не получилось


    1. Khabrovchanin
      21.03.2024 07:47

      Предполагаю, что это не версия виновата, а настройка точности расчетов в Экселе, полная или как на экране. Результат и будет разным.


      1. igolets Автор
        21.03.2024 07:47

        Я поискал настройки, не нашёл. См. дополнение к статье и ответ BoxaShu с деталями.


    1. igolets Автор
      21.03.2024 07:47

      Да, проверил в своей 2016, дописал немного статью. Нашёл список версий, в которых повторяется. В списке 2010, 2013 и, приготовьтесь, 365(!).

      Заодно нашёл пример, который прекрасно повторяется, 1*0 != 0