Все мы знаем, что такое округление. Если кто-то забыл, то округление — это замена числа на его приближённое значение, записанное с меньшим количеством значащих цифр. Если спросить человека с ходу, что получится при округлении 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. Никаких правил округления тут нет, просто отсечение всего, что идет после запятой. Помните об этом!
Ссылки по теме:
- Про округление на wikipedia
- Про проблему конвертации
- Про округление Math.Round
- Реализация Convert.ToInt32
P.S. Спасибо Максиму Якушкину за то, что обратил внимание на этот неявный момент.
P.P.S. Кстати, в python округление по дефолту работает так же по «банковскому» принципу. Возможно, в вашем языке такая же штука, будьте бдительны с числами :)
Комментарии (61)
Pand5461
02.08.2019 16:27Дело в том, что «банковское» округление работает чуть хитрее — оно округляет число до ближайшего четного целого числа, а не до ближайшего целого по модулю
Это же только когда дробная часть в точности равна 1/2, и ближайших целых два равноудалённых.
Вполне логично, кстати, особенно когда цифры сначала округляются до десятых, а потом решаешь, что точности до целых уже достаточно. "Школьный" способ — при дробной части 1/2 округлять с повышением модуля — даёт, что какое-нибудь 3.48 округляется сначала до 3.5, а потом до 4. "Банковский", конечно, тоже, но также есть вероятность, что рядом 6.54 округлилось сначала до 6.5, а затем до 6. То есть "в среднем", действительно, можно ожидать более "несдвинутое" (unbiased) округление.
Математически, кстати, тоже логично: если двоичная дробная часть равна 0.12, то округляем не до 20, где есть неоднозначность, а до 21.LeX_KaR Автор
02.08.2019 16:38Мне тут больше понравилось «случайное округление». Чисто интуитивно почему-то кажется, что оно будет давать более нормальное распределение, чем банковское… Хотя математики наверняка уже всё просчитали :)
Pand5461
02.08.2019 16:54+1Не нормальное, а равномерное :)
На той же википедии пишут, что у случайного округления распределение лучше, но это выводит округление из категории чистых функций, к чему тоже есть претензии.
LeX_KaR Автор
02.08.2019 16:58Да, описался слегка :) А какого рода претензии могут возникнуть с функции, которая делает что-либо «якобы» случайно. Я к тому, что её случайность и есть желаемый результат работы, причем тут её «чистота» (независимости и т.д.)?
mayorovp
02.08.2019 17:00При том, что при использовании ГСЧ результат функции перестаёт быть воспроизводимым, нарушается условие
?x f(x) == f(x)
LeX_KaR Автор
02.08.2019 17:03Я скорее про то, как это может выкатиться боком на практике? Ибо получение не воспроизводимого рандомного результата «направления» округления и есть ожидаемый результат. Нарушение
?x f(x) == f(x)
в данном случае умышленное.alexs0ff
02.08.2019 17:07+2Я скорее про то, как это может выкатиться боком на практике?
Вы видно не работали с бухгалтерами, когда некоторый уже распечатанный отчет будет отличаться хоть на копейку с тем, что они видят на экране.
Pand5461
02.08.2019 17:22Никаких, при условии, что функция
round()
из стандартной библиотеки работает детерминированно.
Иначе, как уже заметили, будет много радости при отладке и объяснении пользователям, почему одни и те же входные данные им дают разные ответы.
jknight
02.08.2019 16:46За 5 лет программирования на проде (признаю, немного, да, но опыт какой-то есть), ни разу не пришлось округлять числа. Гораздо полезнее были предсказуемые операции вроде floor/ceil. Товарищи, а кто вообще хоть раз округлением занимался, расскажите, зачем! Правда, интересно :)
LeX_KaR Автор
02.08.2019 16:55Ну а как же погрешности? floor/ceil дадут большую совокупную при округлении к целым
jknight
02.08.2019 17:08Если мы боимся совокупных погрешностей, то проще вести вычисления в числах с плавающей точкой, и округление производить один раз при выдаче результата.
panteleymonov
02.08.2019 17:40floor/ceil чаше применяются для метрических расчетов чем для бухгалтерских и теоретических. В частности схема
i = floor(x);
f = x - i;
используется чаще в программировании.
mayorovp
02.08.2019 16:55+1Банки тут ни при чем, округление до чётного используется потому что оно более стабильно и не даёт "дрейфа" при сложении и умножении. Его ещё Кнут рекомендовал.
Обычное округление:
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
qw1
03.08.2019 12:04Если бы ещё кто пояснил, что такое «дрейф» и какой от него вред.
А, продолжая ваш пример с «окрулением до чётного»,
5 + 0.5 = 5.5 ? 6
5 - 0.5 = 4.5 ? 4
Никакой стабильности…
alexs0ff
02.08.2019 16:59-1Гораздо интересней решения проблем, появляющихся при округлении.
Например, классика:
есть некоторые ежедневные значения.
каждый месяц их складываем и округляем (например отбрасываем копейки любым методом — банковским /к четному целому).
в конце года округляем сумму ежедневных значений за год.
А теперь сравниваем с суммой за 12 месяцев — с большой вероятностью они будут разные.greg123
02.08.2019 20:27А в чем, собственно, проблема? Вы же сами округлили числа. Округление это уменьшение точности и точность, на которую вы уменьшили число, зависит от числа. Вы уменьшили точность разных чисел и хотите, чтобы их сумма совпала?
alexs0ff
02.08.2019 21:06Я это понимаю, но с другой стороны, бухгалтерам не нужна точность в тысячные доли копеек, однако эти доли нужно учитывать в итоговых суммах по разным периодам.
Cryvage
03.08.2019 00:24Так это же классика. Погрешность обязательно становится проблемой, если она накапливается. Накопление погрешности — это то, чего следует избегать в первую очередь, когда дело касается каких-то расчётов.
В описанном вами случае, очевидно, что округлять следует только конечный результат. Если бухгалтерам не нужна точность до тысячных долей, то их можно отбросить в каком-то отчёте, или документе. Но отчёт за более долгий период не должен быть суммой других отчётов, которые были округлены. Он должен считаться по исходным данным.alexs0ff
03.08.2019 08:24Так это же классика.
Так в стартовом сообщении я так и написал про классику.
Он должен считаться по исходным данным.
Да, так и делается. Но проблема в другом — у бухгалтеров есть уже распечатанные ежемесячные отчеты с некоторыми округленными суммами, так они хотят, чтобы они также сходились с годовым, как в программе (которая округляет исходные данные), так и при подсчете на калькуляторе округленных заранее ежемесячных сумм.mayorovp
03.08.2019 12:44Наверняка тут не "бухгалтерия хочет", а очень даже "налоговая хочет". Поэтому тут надо не решать какие-то абстрактные проблемы округления, а в точности закодировать единственный правильный порядок вычислений, который должен сообщить бухгалтер. Так что это ни разу не интересная задача.
alexs0ff
03.08.2019 12:52«бухгалтерия хочет», а очень даже «налоговая хочет»
Помимо налоговой, есть еще и контрагенты, поставщики и т.д.
а в точности закодировать единственный правильный порядок вычислений
И где такой взять бухгалтеру? Выше уже ответили, что с понижением точности, будет расти разность — это математика. Можно нагородить костылей, но на то они и костыли, что где-то можно не учесть/забыть и опять все развалится.
При проверке банков у нас, я слышал, что ЦБ дает поле для маневра в несколько тысяч на дельту. И самый простой способ — это договориться, что несколько копеек/рублей вполне допустимая разница.mayorovp
03.08.2019 13:01И где такой взять бухгалтеру?
Его должны были ему научить...
Выше уже ответили, что с понижением точности, будет расти разность — это математика.
А это не проблема. В бухгалтерии требуется не произвольная, а строго определенная точность.
Nagg
02.08.2019 20:59+1Недавно нашел забавный баг в Math(F).Round :D
https://github.com/dotnet/coreclr/issues/25857
AnisimovAndrey
02.08.2019 21:13Бывают случаи когда MidpointRounding.AwayFromZero не помогает :)
Например, 150.515 внезапно округляется до 150.51, даже если указать MidpointRounding.AwayFromZero.
Выход тут только преобразование в decimal, так как double точности не хватает и в памяти оно выглядит не просто как 150.515.
С float ещё хуже.
Зря в первый коммент минус кинули. Бывают сюрпризы.
trolley813
02.08.2019 22:41Почему бы не использовать старый добрый способ с
(int)(x + 0.5 + 1e-6)
? В реальных задачах почти не бывает кейсов, когда нужно округлять числа типа 6.4999999
DrPass
03.08.2019 01:18И вроде как незнание этой мелочи сильно не сказывается на вашей работе, но как только вам приходится работать со статистикой и расчетами показателей, базирующихся на куче всяких записей и циферок эта штука вылазит боком
Я вот тут совсем не понял аргументацию автора. Как банковское округление может «вылезти боком» при работе со статистикой и расчетами показателей, базирующихся на куче всяких записей, если это как раз тот случай, где его необходимо использовать. Математическое округление при работе с крупными массивами данных приводит к некорректным итоговым суммам, и как раз для этого и было придумано банковское округление, которое дает статистически верный результат на массивах данных :)
grafstroganov
03.08.2019 08:01decimal — не есть тип с плавающей точкой. float и double — да, decimal — нет
alexs0ff
03.08.2019 08:29decimal — не есть тип с плавающей точкой
Спецификация C# с Вами не согласна.
docs.microsoft.com/ru-ru/dotnet/api/system.decimal
Represents a decimal floating-point number.
ivanbolhovitinov
03.08.2019 08:44Для меня самая насущная проблема в округлении — это получение корректных бухгалтерских отчетов в «тысячах» рублей.
Для примера: есть три операции и итог: 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.
Таких функций округления уже нет.
Такое огругление пока делаю только руками в каких-то простых случаях. Считаю разницу, и добавляю её в первую/последнюю строку.
Или даже в этом простом подходе есть свои дырки, например если очень много мелких сумм, то разница целого отчета может дорасти до рублей и добавлять её в случайную строку — тоже глупо, потому что сумма отдельной строки может измениться до не узнаваемости. Значит надо размазывать её равномерно среди всех строк.
А если случаи сложные, например иерархия по вертикали с промежуточными итогами + месяцы по горизонтали с итогом по году, плюс суперитог в правом нижнем углу, то я не берусь такое «округлять», отвечаю дайте методику или делайте отчет без тысяч.
Есть какие-то универсальные подходы?caballero
03.08.2019 11:25-2ну например хранить и выполнять все вычисления в копейках, то есть целых числах. А уже предьявлять пользователю в рублях с копейками.
Androniy
03.08.2019 11:34В 1998ом году когда была деноминация необходимо было конвертировать базу данных бухгалтерских операций в новый формат. При этом чтобы итоговые суммы сошлись до копейки. Ни один из способов округления не позволял этого сделать, т.к. операций было очень много на мелкие суммы, и в итоге набегало несколько копеек разницы со старыми итогами (при округлении терялся один десятичный разряд). Пришлось писать свое округление, считать разницу в итоговой сумме и случайным образом добавлять по одной копейке до тех пор, пока разница с итогом не исчезнет…
Exponent
03.08.2019 14:31+1Тоже столкнулся с этой особенностью .NET. Делал отчеты и данные брал из SQL Server, а он использует арифметичное округление, а .NET банковское. Часть данных округлялась в базе, другая часть округлялась в коде C#. В итоге результаты отчетов различались в 100 000 евро. Пришлось объяснять финансистам откуда берутся эти 100 000 евро, они естественно даже не слышали что есть другой метод округления. Было неприятно.
DieSlogan
05.08.2019 11:57И напоследок пару слов о приведении типов:
var number = 6.9;
var intNumber = (int)number;
В этом примере я привожу тип с плавающей запятой (double в данном случае) к целочисленному int. Так вот, при приведении типов к целочисленному вся не целая часть просто отсекается.
Вопрос только в том, что отсекается.
Потому что такой вот код, переводяющий рубли в копейки, выдаст нам не всегда верный результат:
public static int ToInt32(float inputValue)
{ return (int)(inputValue * 100); }
Ketovdk
в целом округлять таким образом не стоит, потому-что во многих языках нет гарантии, что 6.5 — это на самом деле 6.5, а не, например 6.4999999999999 за счет погрешности округления. Ну вернее в этом случае гарантия есть, но в других может и не быть, так-что старое доброе round(x+0.0000001) работает во всех (почти) случаях
alex_zzzz
6,5 — это всегда 6,5. И десятичном виде, и в двоичном.
LeX_KaR Автор
Видимо имелось ввиду поведение JS в некоторых ситуациях после проведения мат операций.
storm_r1der
Есть `BigInt`, который решает эту проблему. Если точность операции критична — нужно использовать сторонние библиотеки или делать свое решение, если в языке его нет.
greg123
Вы явно не сталкивались с особенностями операций над числами с плавающей запятой. Это касается любых языков, потому как выполняется все на одних и тех же процессорах. Порой, после ряда операций, из-за ограниченной точности, вы можете получить как раз описанные 6.4999999999999, вместо 6.5. Усугубляется все тем, что функции преобразования числа в строку (в delphi, например) могут показать вам 6,5. Поэтому же просто сравнивать числа с плавающей запятой не рекомендуется. Всегда стоит учитывать некую погрешность.
slonopotamus
Как вот это вот всё что вы сказали, включая ad hominem к предыдущему комментатору, опровергает утверждение что 6.5 — это ровно 6.5 и в десятичном виде и в двоичном?
salas
Вы всё ещё уверены, что 6.5 — это всегда 6.5?
slonopotamus
[redacted]
В вашем примере не показано чему равен x.
Есть IEEE 754 вполне недвусмысленно описывающая работу чисел с плавающей точкой. Процессоры ей следуют. В рамках IEEE 754
13/2=6.5
5+1.5=6.5
Also, утверждение было про десятичную и двоичную системы счисления, а не про «как print в питоне неизвестно какой версии, скорее всего 2.х, печатает float'ы».
salas
Исходное утверждение было «6,5 — это всегда 6,5». У него есть два возможных уточнения:
1) что там «под капотом» — ну да, IEEE754;
2) что можно увидеть в логе, наступив на эти грабли.
Я помнил эти грабли из питона, но, оказывается, их в третьем действительно убрали. Ну, есть же в вебе два других популярных языка, в которых старые грабли многочисленны и бережно сохраняемы — и один из них превзошёл мои ожидания:
Ketovdk
чтобы восстановить это, можете сделать нечто в духе 6,5 — 0,0000000001 + 0,0000000001 или что-то такое. Идея в том, что вы никогда не знаете, как числа будут округляться в памяти, с учетом того, что помимо ограничений мантисы, в языках есть внутренние оптимизации.
Pand5461
В языках, создатели которых догадываются о подобных неоднозначностях, делают специальные флаги, как транслятор должен интерпретировать запись математических операций. В IEEE754 есть правила и по этому поводу тоже, а не только по представлению в памяти.
Ketovdk
отлично. Но вам не кажется, что в высокоуровневом коде на языке c# не стоит опираться на специальные флаги транслятора и правила стандарта, который знаете только вы?
Pand5461
Я не понял, в чём именно вопрос.
а) Если дошло до такой задачи, где важны тонкости округления, то, может, стоит брать не C#, а Фортран?
или
б) С чего бы "высокоуровневым" языкам давать программисту возможность выбора, каким образом математические выражения в них транслируются в машинный код и какие оптимизации при этом могут применяться?
Моё мнение как раз в том, что в (б) — с того, чтобы вопрос (а) не возникал. Не особо убудет от разработчиков компилятора, если будет флаг, при включении которого
x + 1e-10 - 3e-10
не будет автоматически оптимизироваться доx - 2e-10
, аfloat + int + float + int
не будет преобразовываться в(float + float) + (int + int)
для параллелизации сложений. А кому-то контроль на этом уровне может внезапно оказаться нужен.Ketovdk
мне кажется, что вместо того, чтобы вдаваться в такие не очевидные подробности, можно просто писать round(x+0.0000001) и не париться
Pand5461
А что делать, если
x
больше 109, и добавление к нему 10-7 его не меняет, т.к. это за гранью двойной точности?Стандарты, спецификации и флаги — они для того и существуют, чтобы, когда оказалось, что париться таки надо, — поведение было подконтрольно программисту и документировано, а лучше стандартизовано.
AnisimovAndrey
Кнокретно 6.5 может и всегда 6.5, но вообще, это правило не всегда работает тыц
slonopotamus
Какое "это" правило?
ainoneko
Если это именно 6.5, а не почти оно:
slonopotamus
Если число не является 6.5, то оно не является 6.5. Логично, капитан.
Обобщим: есть числа, представимые с конечной точностью в некоей системе счисления, а есть непредставимые. Компьютеры обычно используют двоичную систему счисления. Память компьютеров штука не бесконечная. Поэтому не все числа компьютер может хранить без потери точности.