Округление к целому в .NET

    Всем бородатое ку, товарищи!

    Все мы знаем, что такое округление. Если кто-то забыл, то округление — это замена числа на его приближённое значение, записанное с меньшим количеством значащих цифр. Если спросить человека с ходу, что получится при округлении 6,5 до целых, он не задумываясь ответит «7». Нас со школы учили, что числа округляются до ближайшего целого большего по модулю числа. То есть, если в округляемом числе дробная часть равна или больше половине разряда целой части, то мы округляем исходное число до ближайшего большего.

    Проще говоря:
    6,4 = 6 
    6,5 = 7 
    6,6 = 7
    и т.д.

    И вот, выходя из школы и становясь программистами мы зачастую ожидаем того же поведения от наших мощных языков программирования. Совсем забывая, что в школе нас учили «математическому округлению», а на самом деле видов этих округлений намного больше. На одной только википедии можно нарыть вот сколько вариантов округления 0,5 к ближайшему целому числу:

    • Математическое округление
    • Случайное округление
    • Чередующееся округление
    • Банковское округление

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

    А вот «банковское округление» — это уже интересненько. «Почему?» — спросите вы. В дотнете мы часто используем класс Convert, который предоставляет уйму методов для конвертации одного типа данных в другие (не путать с приведением, о нем будет ниже). И вот, оказывается, что при конвертации чисел с плавающей запятой (double, float, decimal) в целочисленный тип int через метод Convert.ToInt32 под капотом работает «банковское» округление. Оно тут используется по умолчанию!

    И вроде как незнание этой мелочи сильно не сказывается на вашей работе, но как только вам приходится работать со статистикой и расчетами показателей, базирующихся на куче всяких записей и циферок эта штука вылазит боком. Потому что мы ожидаем (от незнания), что все наши конвертации\округления в расчетах будут работать по правилам «математического» округления. И смотрим как баран на новые ворота на результат округления 6,5, который равен 6.

    Первая мысль программиста, который это видит — «Возможно округление работает в обратную сторону, и по правилам округляется до наименьшего числа?», «Может я что-то забыл из школьной математики?». Дальше он идет в google и понимает что, ничего не забыли, и что творится какая-то чернь. На этом шаге ленивый разработчик решит, что это стандартное поведение метода Convert.ToInt32, округлять до наименьшего целого, и забьет на дальнейший поиск. И будет думать, что если Convert.ToInt32(6,5) = 6, то по аналогии Convert.ToInt32(7,5) = 7. Но не тут-то было. Таких разработчиков в дальнейшем судьба бьет по голове пачкой багов от отдела QA.

    Дело в том, что «банковское» округление работает чуть хитрее — оно округляет число до ближайшего четного целого числа, а не до ближайшего целого по модулю. Этот тип округления якобы более честный в случае применения в банковских операциях — банки не будут обделять ни себя ни клиентов, из расчета, что операций с четной целой частью, сколько же, сколько и операций с нечетной целой частью. Но как по мне — всё равно мутновато :) Так вот, именно поэтому Convert.ToInt32(6,5) даст результат 6, а результат для Convert.ToInt32(7,5) будет равен 8, а не 7 :)

    Что же делать, что бы получить всем привычное «математическое» округления? У методов класса Convert нет дополнительных настроек округления. Оно и верно, ибо класс этот служит в первую очередь не для округления, а для конвертации типов. На помощь нам приходит замечательный класс Math с его методом Round. Но тут тоже будьте аккуратны, ибо по умолчанию этот метод работает так же как и округление в Convert.ToInt32() — по «банковскому» правилу. Однако, это поведение можно изменять с помощью второго аргумента, входящего в метод Round. Так, Math.Round(someNumber, MidpointRounding.ToEven) даст нам дефолтовое «банковское» округление. А вот Math.Round(someNumber, MidpointRounding.AwayFromZero) будет работать по привычным правилам «математического» округления.

    И кстати, Convert.ToInt32() не использует под капотом System.Math.Round(). Специально нарыл на github реализацию этого метода — округление считается по остаткам:

    public static int ToInt32(double value) {
    	if (value >= 0) {
    		if (value < 2147483647.5) {
    			int result = (int)value;
    			double dif = value - result;
    			if (dif > 0.5 || dif == 0.5 && (result & 1) != 0) result++;
    			return result;
    		}
    	}
    	else {
    		if (value >= -2147483648.5) {
    			int result = (int)value;
    			double dif = value - result;
    			if (dif < -0.5 || dif == -0.5 && (result & 1) != 0) result--;
    			return result;
    		}
    	}
    	throw new OverflowException(Environment.GetResourceString("Overflow_Int32"));
    }     
    

    И напоследок пару слов о приведении типов:

    var number = 6.9;
    var intNumber = (int)number;
    

    В этом примере я привожу тип с плавающей запятой (double в данном случае) к целочисленному int. Так вот, при приведении типов к целочисленному вся не целая часть просто отсекается. Соответственно, в данном примере в переменной "intNumber" будет лежать число 6. Никаких правил округления тут нет, просто отсечение всего, что идет после запятой. Помните об этом!

    Ссылки по теме:


    P.S. Спасибо Максиму Якушкину за то, что обратил внимание на этот неявный момент.

    P.P.S. Кстати, в python округление по дефолту работает так же по «банковскому» принципу. Возможно, в вашем языке такая же штука, будьте бдительны с числами :)
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 61

      0
      в целом округлять таким образом не стоит, потому-что во многих языках нет гарантии, что 6.5 — это на самом деле 6.5, а не, например 6.4999999999999 за счет погрешности округления. Ну вернее в этом случае гарантия есть, но в других может и не быть, так-что старое доброе round(x+0.0000001) работает во всех (почти) случаях
        –3

        6,5 — это всегда 6,5. И десятичном виде, и в двоичном.

          –1
          Видимо имелось ввиду поведение JS в некоторых ситуациях после проведения мат операций.
            0
            Есть `BigInt`, который решает эту проблему. Если точность операции критична — нужно использовать сторонние библиотеки или делать свое решение, если в языке его нет.
            +3
            Вы явно не сталкивались с особенностями операций над числами с плавающей запятой. Это касается любых языков, потому как выполняется все на одних и тех же процессорах. Порой, после ряда операций, из-за ограниченной точности, вы можете получить как раз описанные 6.4999999999999, вместо 6.5. Усугубляется все тем, что функции преобразования числа в строку (в delphi, например) могут показать вам 6,5. Поэтому же просто сравнивать числа с плавающей запятой не рекомендуется. Всегда стоит учитывать некую погрешность.
              0

              Как вот это вот всё что вы сказали, включая ad hominem к предыдущему комментатору, опровергает утверждение что 6.5 — это ровно 6.5 и в десятичном виде и в двоичном?

                0
                >>> type(x)
                <type 'float'>
                >>> print x
                6.5
                >>> print x==6.5
                False

                Вы всё ещё уверены, что 6.5 — это всегда 6.5?

                  +1
                  [redacted]

                  В вашем примере не показано чему равен x.

                  Есть IEEE 754 вполне недвусмысленно описывающая работу чисел с плавающей точкой. Процессоры ей следуют. В рамках IEEE 754
                  13/2=6.5
                  5+1.5=6.5

                  Also, утверждение было про десятичную и двоичную системы счисления, а не про «как print в питоне неизвестно какой версии, скорее всего 2.х, печатает float'ы».
                    +2

                    Исходное утверждение было «6,5 — это всегда 6,5». У него есть два возможных уточнения:
                    1) что там «под капотом» — ну да, IEEE754;
                    2) что можно увидеть в логе, наступив на эти грабли.


                    Я помнил эти грабли из питона, но, оказывается, их в третьем действительно убрали. Ну, есть же в вебе два других популярных языка, в которых старые грабли многочисленны и бережно сохраняемы — и один из них превзошёл мои ожидания:


                    php > echo PHP_VERSION;
                    7.3.8
                    php > $x = ((6 + 0.1 * 3 + 0.2) - 6.2 + 0.2) * 13;
                    php > echo '$x='.$x.', $x<6.5: '.($x<6.5 ? 'yes': 'no').', round($x)='.round($x);
                    $x=6.5, $x<6.5: yes, round($x)=7
                      –1
                      чтобы восстановить это, можете сделать нечто в духе 6,5 — 0,0000000001 + 0,0000000001 или что-то такое. Идея в том, что вы никогда не знаете, как числа будут округляться в памяти, с учетом того, что помимо ограничений мантисы, в языках есть внутренние оптимизации.
                        0

                        В языках, создатели которых догадываются о подобных неоднозначностях, делают специальные флаги, как транслятор должен интерпретировать запись математических операций. В IEEE754 есть правила и по этому поводу тоже, а не только по представлению в памяти.

                          0
                          отлично. Но вам не кажется, что в высокоуровневом коде на языке c# не стоит опираться на специальные флаги транслятора и правила стандарта, который знаете только вы?
                            0

                            Я не понял, в чём именно вопрос.
                            а) Если дошло до такой задачи, где важны тонкости округления, то, может, стоит брать не C#, а Фортран?
                            или
                            б) С чего бы "высокоуровневым" языкам давать программисту возможность выбора, каким образом математические выражения в них транслируются в машинный код и какие оптимизации при этом могут применяться?


                            Моё мнение как раз в том, что в (б) — с того, чтобы вопрос (а) не возникал. Не особо убудет от разработчиков компилятора, если будет флаг, при включении которого x + 1e-10 - 3e-10 не будет автоматически оптимизироваться до x - 2e-10, а float + int + float + int не будет преобразовываться в (float + float) + (int + int) для параллелизации сложений. А кому-то контроль на этом уровне может внезапно оказаться нужен.

                              0
                              мне кажется, что вместо того, чтобы вдаваться в такие не очевидные подробности, можно просто писать round(x+0.0000001) и не париться
                                0

                                А что делать, если x больше 109, и добавление к нему 10-7 его не меняет, т.к. это за гранью двойной точности?


                                Стандарты, спецификации и флаги — они для того и существуют, чтобы, когда оказалось, что париться таки надо, — поведение было подконтрольно программисту и документировано, а лучше стандартизовано.

                +1
                Кнокретно 6.5 может и всегда 6.5, но вообще, это правило не всегда работает тыц
                  +1

                  Какое "это" правило?

                    0
                    Кнокретно 6.5 может и всегда 6.5

                    Если это именно 6.5, а не почти оно:
                    >>> ((6 + 0.1 * 3 + 0.2) - 6.2 + 0.2) * 13
                    6.499999999999998
                    >>> ((6 + 0.1 * 3 + 0.2) - 6) * 13
                    6.5
                    
                      +1
                      Если число не является 6.5, то оно не является 6.5. Логично, капитан.

                      Обобщим: есть числа, представимые с конечной точностью в некоей системе счисления, а есть непредставимые. Компьютеры обычно используют двоичную систему счисления. Память компьютеров штука не бесконечная. Поэтому не все числа компьютер может хранить без потери точности.
                +1
                Дело в том, что «банковское» округление работает чуть хитрее — оно округляет число до ближайшего четного целого числа, а не до ближайшего целого по модулю

                Это же только когда дробная часть в точности равна 1/2, и ближайших целых два равноудалённых.
                Вполне логично, кстати, особенно когда цифры сначала округляются до десятых, а потом решаешь, что точности до целых уже достаточно. "Школьный" способ — при дробной части 1/2 округлять с повышением модуля — даёт, что какое-нибудь 3.48 округляется сначала до 3.5, а потом до 4. "Банковский", конечно, тоже, но также есть вероятность, что рядом 6.54 округлилось сначала до 6.5, а затем до 6. То есть "в среднем", действительно, можно ожидать более "несдвинутое" (unbiased) округление.
                Математически, кстати, тоже логично: если двоичная дробная часть равна 0.12, то округляем не до 20, где есть неоднозначность, а до 21.

                  0
                  Мне тут больше понравилось «случайное округление». Чисто интуитивно почему-то кажется, что оно будет давать более нормальное распределение, чем банковское… Хотя математики наверняка уже всё просчитали :)
                    +2

                    Не нормальное, а равномерное :)


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

                      0
                      Да, описался слегка :) А какого рода претензии могут возникнуть с функции, которая делает что-либо «якобы» случайно. Я к тому, что её случайность и есть желаемый результат работы, причем тут её «чистота» (независимости и т.д.)?
                        0

                        При том, что при использовании ГСЧ результат функции перестаёт быть воспроизводимым, нарушается условие ∀x f(x) == f(x)

                          –1
                          Я скорее про то, как это может выкатиться боком на практике? Ибо получение не воспроизводимого рандомного результата «направления» округления и есть ожидаемый результат. Нарушение ∀x f(x) == f(x) в данном случае умышленное.
                            +4
                            Я скорее про то, как это может выкатиться боком на практике?

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

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

                      +1
                      За 5 лет программирования на проде (признаю, немного, да, но опыт какой-то есть), ни разу не пришлось округлять числа. Гораздо полезнее были предсказуемые операции вроде floor/ceil. Товарищи, а кто вообще хоть раз округлением занимался, расскажите, зачем! Правда, интересно :)
                        0
                        Ну а как же погрешности? floor/ceil дадут большую совокупную при округлении к целым
                          0
                          Если мы боимся совокупных погрешностей, то проще вести вычисления в числах с плавающей точкой, и округление производить один раз при выдаче результата.
                            0
                            floor/ceil чаше применяются для метрических расчетов чем для бухгалтерских и теоретических. В частности схема
                            i = floor(x);
                            f = x - i;

                            используется чаще в программировании.
                            0
                            На сумму начислить % и округлить до копеек.
                            +4

                            Банки тут ни при чем, округление до чётного используется потому что оно более стабильно и не даёт "дрейфа" при сложении и умножении. Его ещё Кнут рекомендовал.


                            Обычное округление:


                            5 + 0.5 = 5.5 ≅ 6
                            6 - 0.5 = 5.5 ≅ 6
                            6 + 0.5 = 6.5 ≅ 7

                            Округление до чётного:


                            5 + 0.5 = 5.5 ≅ 6
                            6 - 0.5 = 5.5 ≅ 6
                            6 + 0.5 = 6.5 ≅ 6
                              0
                              Правильно. Банковское Округление — это Округление до ближайшего четного.
                                0
                                Если бы ещё кто пояснил, что такое «дрейф» и какой от него вред.
                                А, продолжая ваш пример с «окрулением до чётного»,
                                5 + 0.5 = 5.5 ≅ 6
                                5 - 0.5 = 4.5 ≅ 4

                                Никакой стабильности…
                                  +1

                                  Дрейф — это когда выражение "x + y — y + y — y + y — y + ..." уходит довольно далеко от изначального числа x.

                                    0
                                    В этом выражении нет округлений. Если предположить округление после каждого действия, то вроде понятно, о чём речь.
                                –1
                                Гораздо интересней решения проблем, появляющихся при округлении.
                                Например, классика:
                                есть некоторые ежедневные значения.
                                каждый месяц их складываем и округляем (например отбрасываем копейки любым методом — банковским /к четному целому).
                                в конце года округляем сумму ежедневных значений за год.
                                А теперь сравниваем с суммой за 12 месяцев — с большой вероятностью они будут разные.
                                  +1
                                  А в чем, собственно, проблема? Вы же сами округлили числа. Округление это уменьшение точности и точность, на которую вы уменьшили число, зависит от числа. Вы уменьшили точность разных чисел и хотите, чтобы их сумма совпала?
                                    0
                                    Я это понимаю, но с другой стороны, бухгалтерам не нужна точность в тысячные доли копеек, однако эти доли нужно учитывать в итоговых суммах по разным периодам.
                                      0
                                      Так это же классика. Погрешность обязательно становится проблемой, если она накапливается. Накопление погрешности — это то, чего следует избегать в первую очередь, когда дело касается каких-то расчётов.
                                      В описанном вами случае, очевидно, что округлять следует только конечный результат. Если бухгалтерам не нужна точность до тысячных долей, то их можно отбросить в каком-то отчёте, или документе. Но отчёт за более долгий период не должен быть суммой других отчётов, которые были округлены. Он должен считаться по исходным данным.
                                        0
                                        Так это же классика.

                                        Так в стартовом сообщении я так и написал про классику.

                                        Он должен считаться по исходным данным.

                                        Да, так и делается. Но проблема в другом — у бухгалтеров есть уже распечатанные ежемесячные отчеты с некоторыми округленными суммами, так они хотят, чтобы они также сходились с годовым, как в программе (которая округляет исходные данные), так и при подсчете на калькуляторе округленных заранее ежемесячных сумм.
                                          0

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

                                            0
                                            «бухгалтерия хочет», а очень даже «налоговая хочет»

                                            Помимо налоговой, есть еще и контрагенты, поставщики и т.д.
                                            а в точности закодировать единственный правильный порядок вычислений

                                            И где такой взять бухгалтеру? Выше уже ответили, что с понижением точности, будет расти разность — это математика. Можно нагородить костылей, но на то они и костыли, что где-то можно не учесть/забыть и опять все развалится.
                                            При проверке банков у нас, я слышал, что ЦБ дает поле для маневра в несколько тысяч на дельту. И самый простой способ — это договориться, что несколько копеек/рублей вполне допустимая разница.
                                              0
                                              И где такой взять бухгалтеру?

                                              Его должны были ему научить...


                                              Выше уже ответили, что с понижением точности, будет расти разность — это математика.

                                              А это не проблема. В бухгалтерии требуется не произвольная, а строго определенная точность.

                                  +3

                                  Недавно нашел забавный баг в Math(F).Round :D
                                  https://github.com/dotnet/coreclr/issues/25857

                                  0
                                  Бывают случаи когда MidpointRounding.AwayFromZero не помогает :)
                                  Например, 150.515 внезапно округляется до 150.51, даже если указать MidpointRounding.AwayFromZero.
                                  Выход тут только преобразование в decimal, так как double точности не хватает и в памяти оно выглядит не просто как 150.515.
                                  С float ещё хуже.
                                  Зря в первый коммент минус кинули. Бывают сюрпризы.
                                    0

                                    Ну да, если говорить об округлении не до целых, то всё немного сложнее

                                      0
                                      При округлении до целых это возникает про бОльших числах: попробуйте в Javascript получить число 9007199254740993 ¯\_(ツ)_/¯
                                    –1

                                    Почему бы не использовать старый добрый способ с (int)(x + 0.5 + 1e-6)? В реальных задачах почти не бывает кейсов, когда нужно округлять числа типа 6.4999999

                                      0
                                      И вроде как незнание этой мелочи сильно не сказывается на вашей работе, но как только вам приходится работать со статистикой и расчетами показателей, базирующихся на куче всяких записей и циферок эта штука вылазит боком

                                      Я вот тут совсем не понял аргументацию автора. Как банковское округление может «вылезти боком» при работе со статистикой и расчетами показателей, базирующихся на куче всяких записей, если это как раз тот случай, где его необходимо использовать. Математическое округление при работе с крупными массивами данных приводит к некорректным итоговым суммам, и как раз для этого и было придумано банковское округление, которое дает статистически верный результат на массивах данных :)
                                        0
                                        decimal — не есть тип с плавающей точкой. float и double — да, decimal — нет
                                        0
                                        Для меня самая насущная проблема в округлении — это получение корректных бухгалтерских отчетов в «тысячах» рублей.
                                        Для примера: есть три операции и итог: 3 333.33 + 3 333.34 + 3 333.33 = 10 000.00
                                        Делим на тысячу, округляем, получаем: 3.3 + 3.3 + 3.3 = 10.0. Как-то глупо.
                                        Пробуем еще: 3.3 + 3.3 + 3.3 = 9.9. Тоже глупо, потому что отчет проверяется через сальдо счета 10 000.00, а отчет показывает 9.9.

                                        Значит надо добиться 3.4 + 3.3 + 3.3 = 10.0.
                                        Таких функций округления уже нет.
                                        Такое огругление пока делаю только руками в каких-то простых случаях. Считаю разницу, и добавляю её в первую/последнюю строку.

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

                                        А если случаи сложные, например иерархия по вертикали с промежуточными итогами + месяцы по горизонтали с итогом по году, плюс суперитог в правом нижнем углу, то я не берусь такое «округлять», отвечаю дайте методику или делайте отчет без тысяч.

                                        Есть какие-то универсальные подходы?
                                          –1
                                          ну например хранить и выполнять все вычисления в копейках, то есть целых числах. А уже предьявлять пользователю в рублях с копейками.

                                          0
                                          В 1998ом году когда была деноминация необходимо было конвертировать базу данных бухгалтерских операций в новый формат. При этом чтобы итоговые суммы сошлись до копейки. Ни один из способов округления не позволял этого сделать, т.к. операций было очень много на мелкие суммы, и в итоге набегало несколько копеек разницы со старыми итогами (при округлении терялся один десятичный разряд). Пришлось писать свое округление, считать разницу в итоговой сумме и случайным образом добавлять по одной копейке до тех пор, пока разница с итогом не исчезнет…
                                            +1
                                            Тоже столкнулся с этой особенностью .NET. Делал отчеты и данные брал из SQL Server, а он использует арифметичное округление, а .NET банковское. Часть данных округлялась в базе, другая часть округлялась в коде C#. В итоге результаты отчетов различались в 100 000 евро. Пришлось объяснять финансистам откуда берутся эти 100 000 евро, они естественно даже не слышали что есть другой метод округления. Было неприятно.
                                              0
                                              Отлично, спасибо большое за информацию, новичкам пригодится
                                                0
                                                И напоследок пару слов о приведении типов:

                                                var number = 6.9;
                                                var intNumber = (int)number;

                                                В этом примере я привожу тип с плавающей запятой (double в данном случае) к целочисленному int. Так вот, при приведении типов к целочисленному вся не целая часть просто отсекается.


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

                                                public static int ToInt32(float inputValue)
                                                { return (int)(inputValue * 100); }

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

                                                Самое читаемое