Малоизвестные детали реализации Math.Round() в .Net

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

Присказка

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

Началось всё с того, что клиенты сообщили нам о расхождении в копейку в отчете. Значение 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.

565818d711198b8d2b80ef4b17b7fd5e.png

Как можно заметить, на 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, чем всем объяснять что они всю жизнь считали не так как нужно.Вынесли логику округления в свой класс-обертку, которым можно управлять через клиентские настройки. Если какой-то клиент будет принципиально правильным, можно будет ему включить банковское округление и не морочить голову остальным.

На сладкое

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

bffb20c85755984f0d5e93ae6a730d8e.png

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

051b5ad136840d0106d3890e62fa89cc.png

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

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

c7b92d75b528be072ebb249a1cfdb83d.jpg

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

© Habrahabr.ru