Привет, Хабр! Меня зовут Олег, я PHP-и-не-только-разработчик в Badoo. Меня часто удивляет, насколько по-разному в языках программирования подходят к составлению стандартной библиотеки. Go — не исключение: отсутствие функции math.Round() меня удивило. Однако, покопавшись в этих ваших интернетах, я выяснил, в чём причина. Этими знаниями я и хотел бы поделиться в своём вольном переводе.
Округление (round) в Go нелегко сделать правильно. Казалось бы, берём float64, отбрасываем дробную часть и прибавляем единицу к получившемуся значению, если дробная часть была >= 0,5. Есть и другие варианты, доступные при поиске “golang round” в Google, многие из которых также можно найти в закрытом тикете на GitHub, где Round() отказались включать в стандартный пакет math. В этой статье я хотел бы изучить эти и другие реализации и проверить их на корректность. Мы увидим, что почти во всех есть ошибки, препятствующие их использованию.
Выбор способа округления
Существует несколько способов округления в зависимости от способа применения результата: округление к меньшему/ большему, округление к меньшему/ большему по модулю, округление к ближайшему целому, округление к ближайшему чётному и т. д… Округление к ближайшему целому, в свою очередь, можно делать по-разному в зависимости от того, какой результат должен получиться, если дробная часть равна 0,5. Я буду рассматривать округление к ближайшему целому, причём 0,5 будет округляться в большую (по модулю) сторону.
Требования к корректной реализации Round() заключаются в следующем:
- правильно округляет до ближайшего целого все конечные числа;
- поддерживает специальные значения (NaN, Inf, -0), возвращая их без изменений.
Я буду использовать следующие тестовые примеры для проверки корректности, в каждой паре содержатся исходное значение и предполагаемый результат выполнения функции Round():
tests := [][2]float64{
{-0.49999999999999994, negZero}, // -0.5+epsilon
{-0.5, -1},
{-0.5000000000000001, -1}, // -0.5-epsilon
{0, 0},
{0.49999999999999994, 0}, // 0.5-epsilon
{0.5, 1},
{0.5000000000000001, 1}, // 0.5+epsilon
{1.390671161567e-309, 0}, // denormal
{2.2517998136852485e+15, 2.251799813685249e+15}, // 1 bit fraction
{4.503599627370497e+15, 4.503599627370497e+15}, // large integer
{math.Inf(-1), math.Inf(-1)},
{math.Inf(1), math.Inf(1)},
{math.NaN(), math.NaN()},
{negZero, negZero},
}
В этом списке есть обычные числа, специальные значения и некоторые граничные случаи, с которыми простым алгоритмам сложно справиться. Обратите внимание, что, поскольку мы используем float, мы не можем использовать число 0,49999999999999999 в качестве ближайшего к 0,5, так как из-за ограниченной точности float это число в точности равно 0,5. Вместо этого я использую 0,49999999999999994.
Реализации, предложенные в закрытом тикете, явно не были проверены на подобных данных, часто не работали даже те из них, которые были предложены известными людьми. Это лишний раз доказывает, насколько сложно написать Round().
int(f + 0.5)
Первая реализация, предложенная rsc, выглядела следующим образом:
return int(f + 0.5)
Она некорректно работает с особыми значениями, отрицательными числами, числами больше math.MaxInt64 и числами, близкими к 0,5:
round(-0.5): got: 0, want -1
round(-0.5000000000000001): got: 0, want -1
round(0.49999999999999994): got: 1, want 0
round(4.503599627370497e+15): got: 4.503599627370498e+15, want 4.503599627370497e+15
round(-Inf): got: -9.223372036854776e+18, want -Inf
round(+Inf): got: -9.223372036854776e+18, want +Inf
round(NaN): got: -9.223372036854776e+18, want NaN
Floor() or Ceil()
Второй предложенный вариант учитывал отрицательные числа:
if f < 0 {
return math.Ceil(f - 0.5)
}
return math.Floor(f + 0.5)
однако продолжал некорректно работать в некоторых случаях:
round(-0.49999999999999994): got: -1, want -0
round(0.49999999999999994): got: 1, want 0
round(4.503599627370497e+15): got: 4.503599627370498e+15, want 4.503599627370497e+15
Первые два теста не проходят, потому что результат разности n — 0,5 равен в точности -1,0, тогда как мы ожидаем получить что-то точно большее, чем -1,0. Если посмотреть на реализацию round в Postgres, можно понять, как решить эту проблему.
Самое интересное, что эта ошибка не является такой уж редкой. До версии 6 точно такая же присутствовала в Java. Хорошо, что с тех пор реализация улучшилась.
int и Copysign
В третьем предложении от minux была предпринята другая попытка решить проблему отрицательных чисел:
return int(f + math.Copysign(0.5, f))
И этот вариант всё равно ломает тесты:
round(-0.49999999999999994): got: -1, want -0
round(0.49999999999999994): got: 1, want 0
round(4.503599627370497e+15): got: 4.503599627370498e+15, want 4.503599627370497e+15
round(-Inf): got: -9.223372036854776e+18, want -Inf
round(+Inf): got: -9.223372036854776e+18, want +Inf
round(NaN): got: -9.223372036854776e+18, want NaN
Как видно, часть тестов стала проходить, однако другие начали падать. Была предпринята попытка улучшить этот алгоритм:
if math.Abs(f) < 0.5 {
return 0
}
return int(f + math.Copysign(0.5, f))
Однако и она провалилась:
round(4.503599627370497e+15): got: 4.503599627370498e+15, want 4.503599627370497e+15
round(-Inf): got: -9.223372036854776e+18, want -Inf
round(+Inf): got: -9.223372036854776e+18, want +Inf
round(NaN): got: -9.223372036854776e+18, want NaN
Этот вариант выглядит лучше остальных, но и он некорректно обрабатывает особые значения и большие числа. Первую проблему можно решить с помощью дополнительных условий, но со второй справиться не так просто.
Мы рассмотрели уже четыре варианта, и в каждом из них нашлись изъяны. Настало время посмотреть, как Round() реализуют авторы различных пакетов.
Kubernetes
Kubernetes 1.7 содержит такую реализацию:
if a < 0 {
return int32(a - 0.5)
}
return int32(a + 0.5)
Она ломает следующие тесты:
round(-0.49999999999999994): got: -1, want -0
round(0.49999999999999994): got: 1, want 0
round(4.503599627370497e+15): got: 4.503599627370498e+15, want 4.503599627370497e+15
round(-Inf): got: -9.223372036854776e+18, want -Inf
round(+Inf): got: -9.223372036854776e+18, want +Inf
round(NaN): got: -9.223372036854776e+18, want NaN
Судя по тому, что функция возвращает int32, она не предназначена для работы с большими числами. Однако она некорректно работает и с числами, которые близки к 0,5.
Работающие реализации на Go
Round(), используемая в Postgres
Выше я уже упоминал, что в Postgres содержится код функции Round() на C, который работает для всех тестируемых значений. В CockroachDB мы переписали этот код на Go, без комментариев он выглядит следующим образом:
func round(x float64) float64 {
if math.IsNaN(x) {
return x
}
if x == 0.0 {
return x
}
roundFn := math.Ceil
if math.Signbit(x) {
roundFn = math.Floor
}
xOrig := x
x -= math.Copysign(0.5, x)
if x == 0 || math.Signbit(x) != math.Signbit(xOrig) {
return math.Copysign(0.0, xOrig)
}
if x == xOrig-math.Copysign(1.0, x) {
return xOrig
}
r := roundFn(x)
if r != x {
return r
}
return roundFn(x*0.5) * 2.0
}
Давайте разберёмся, как он работает. Первые шесть строк обрабатывают особые случаи. Далее мы выбираем roundFn из Ceil и Floor в зависимости от того, положительное число или отрицательное. Далее начинается самое интересное:
x -= math.Copysign(0.5, x)
Этим кодом мы сдвигаем x ближе к нулю.
if x == 0 || math.Signbit(x) != math.Signbit(xOrig) {
return math.Copysign(0.0, xOrig)
}
Далее мы проверяем, не стал ли x в точности нулём и не поменялся ли у него знак. Это означает что исходное число <= 0,5, в этом случае мы возвращаем ноль с нужным знаком.
if x == xOrig-math.Copysign(1.0, x) {
return xOrig
}
Эта проверка нужна для очень больших чисел, для которых x-0,5 == x-1,0, в этих случаях мы можем вернуть число неизменённым.
r := roundFn(x)
if r != x {
return r
}
Далее мы округляем число с помощью Floor() или Ceil() и возвращаем это значение, если оно отличается от x, что может случиться, только если дробная часть входного значения не равна в точности 0,5, так как выше мы вычли 0,5 из него.
return roundFn(x*0.5) * 2.0
Теперь мы знаем, что дробная часть равна 0,5, поэтому нам нужно округлить до ближайшего чётного числа (реализация Round() в Postgres в этом месте отличается от приведённых выше вариантов). Комментарий в коде лучше это описывает:
Dividing input+0.5 by 2, taking the floor and multiplying by 2 yields the closest even number. This part assumes that division by 2 is exact, which should be OK because underflow is impossible here: x is an integer.
Чтобы сохранить оригинальное поведение, этот код можно заменить на следующий:
return xOrig + math.Copysign(0.5, xOrig)
github.com/montanaflynn/stats
Ещё одна работающая реализация содержится в пакете github.com/montanaflynn/stats. Без комментариев она выглядит следующим образом:
func round(input float64) {
if math.IsNaN(input) {
return math.NaN()
}
sign := 1.0
if input < 0 {
sign = -1
input *= -1
}
_, decimal := math.Modf(input)
var rounded float64
if decimal >= 0.5 {
rounded = math.Ceil(input)
} else {
rounded = math.Floor(input)
}
return rounded * sign
}
Ключевое отличие от предыдущих решений заключается в использовании функции Modf(), которая корректно разделяет целую и дробную части чисел.
Round() в Go 1.10
Спустя несколько месяцев после выхода Go 1.8 появился очередной тикет с просьбой добавить math.Round в Go. В комментариях к нему продолжали появляться некорректно работающие реализации, их число увеличилось до восьми. К счастью, команда Go согласилась добавить math.Round в Go 1.10! И даже появилась работающая реализация:
func Round(x float64) float64 {
const (
mask = 0x7FF
shift = 64 - 11 - 1
bias = 1023
signMask = 1 << 63
fracMask = (1 << shift) - 1
halfMask = 1 << (shift - 1)
one = bias << shift
)
bits := math.Float64bits(x)
e := uint(bits>>shift) & mask
switch {
case e < bias:
// Round abs(x)<1 including denormals.
bits &= signMask // +-0
if e == bias-1 {
bits |= one // +-1
}
case e < bias+shift:
// Round any abs(x)>=1 containing a fractional component [0,1).
e -= bias
bits += halfMask >> e
bits &^= fracMask >> e
}
return math.Float64frombits(bits)
}
Для тех, кто не знаком с устройством float (я в их числе), этот код выглядит совершенно непонятно. Попробуем разобраться, что же он делает:
bits := math.Float64bits(x)
e := uint(bits>>shift) & mask
Похоже, что мы берём битовое представление числа, сдвигаем его и применяем маску. Согласно спецификации IEEE 754:
The encoding scheme for these binary interchange formats is the same as that of IEEE 754-1985: a sign bit, followed by w exponent bits that describe the exponent offset by a bias, and p?1 bits that describe the significand.
Рассматривая приведённые выше константы, мы видим, что сдвиг составляет 64 — 11 — 1, что означает 64 бита на число, 11 из которых используются для показателя степени, один — для знака и 52 оставшихся бита — для мантиссы. Это означает, что используемый сдвиг удаляет биты мантиссы, а маска удаляет бит знака, оставляя нас только с показателем степени.
switch {
case e < bias:
В полученном числе показатель степени записан не как он есть, а с прибавлением числа 1023 (это делается для того чтобы записывать отрицательные показатели для очень маленьких чисел), что означает, что мы должны вычесть 1023 из e, вычисленного выше, чтобы получить фактический показатель. Иными словами, если e < bias, то мы имеем отрицательный показатель степени, что означает, что абсолютное значение float должно быть < 1. Действительно, дальше мы видим:
// Round abs(x)<1 including denormals.
bits &= signMask // +-0
if e == bias-1 {
bits |= one // +-1
}
Здесь бит маскируется знаковым битом, это используется только для сохранения правильного знака: теперь мы можем полностью игнорировать мантиссу. Мы можем это сделать, потому что в этом случае нас интересует только показатель степени. Так как используется основание степени 2, а e < bias, мы знаем, что наименьший показатель, который может быть, равен -1, а 2 ^ -1 = 0,5. Кроме того, мантисса имеет некоторое значение 1.X. Таким образом, в зависимости от показателя наше число находится либо в диапазоне (0,5, 1), либо в диапазоне (0, 0,5). Поэтому во втором случае для правильного округления нам нужно добавить к числу единицу. Фух. Подробнее это описано в википедии.
Теперь разберём второй случай:
case e < bias+shift:
Наверное, вы думаете, что условие в этой ветке должно быть e > bias, чтобы покрыть все случаи с положительным показателем степени. Но вместо этого тут используется только их часть. Использование сдвига здесь особенно интересно, потому что кажется, что оно несравнимо с bias. Первое — это число битов смещения, а второе — численное смещение. Но, поскольку числа с плавающей точкой представлены как (1.мантисса) * 2 ^ X, то если X больше числа битов в мантиссе, мы гарантированно получим значение без дробной части. То есть показатель степени сместил десятичную точку вправо настолько, что мантисса окончательно пропала. Таким образом, выражение в этой ветке игнорирует числа с плавающей точкой, которые уже округлены.
// Round any abs(x)>=1 containing a fractional component [0,1).
e -= bias
bits += halfMask >> e
bits &^= fracMask >> e
Первая строка тут простая: вычитаем bias из e и получаем реальное значение показателя степени. Вторая строка добавляет к значению 0,5. Это работает, потому что старший бит мантиссы добавляет 0,5 к финальной сумме (см. представление в статье “Википедии” ниже). В этом случае эта сумма переполняет 52-битные границы мантиссы, показатель степени будет увеличен на 1. Значение показателя степени не сможет переполниться до знакового бита, так как оно не может быть больше bias+shift из примера выше. В любом случае, дробная часть очищается. Таким образом, если дробная часть была больше или равна 0,5, она будет увеличена на 1, в противном случае будет отброшена. Хитро и не очевидно до тех пор, пока мы не посмотрим глубже.
Заключение
В этой статье я рассказывал в основном об округлении к меньшему по модулю, но есть много других вариантов. В некоторых случаях подходят именно они, и я оставлю читателю возможность изучить их и попробовать реализовать на Go. Но я надеюсь, что теперь вам стало понятно, как устроено округление в Go и как нужно тестировать реализации округления.
Думаю, команда Go приняла правильное решение, добавив функцию Round() в стандартную библиотеку. Без этого мы бы продолжали пользоваться различными некорректными реализациями.
Надеюсь, теперь вам стало ясно, что при работе с float есть много подводных камней, про которые порой забывают даже эксперты. Легко придумать или скопировать откуда-то однострочную реализацию, но сложно написать действительно корректную. Неудивительно, что корректно работающее округление появилось лишь в шестой мажорной версии Java (через 15 лет, прошедших с релиза Java 1.0 до выхода Java 7), и я рад, что Go прошёл этот путь быстрее.
Комментарии (24)
zamsky
28.12.2017 16:52-1Округление (round) в Go нелегко сделать правильно
А деление вычитанием в этом фантастическом языке делать не надо?
maxzhurkin
28.12.2017 20:37Откуда взялась десятичная точка в двоичном мире?
maxzhurkin
29.12.2017 10:45Для тех, кто не понял: называть двоичную точку десятичной и странно и некорректно
Starche
28.12.2017 20:47
{-0.49999999999999994, negZero}, // -0.5+epsilon
{-0.5, -1},
{-0.5000000000000001, -1}, // -0.5-epsilon
Или в фразе
Я буду рассматривать округление к ближайшему целому, причём 0,5 будет округляться в большую сторону.
имелось в виду в «большую по модулю»?netch80
29.12.2017 01:56Но почему округление -0.5 должно приводить к -1?
По крайней мере у round() в C такое же правило — цитирую по линуксовому ману:
These functions round x to the nearest integer, but round halfway cases away from zero (regardless of the current rounding direction, see fenv(3)), instead of to the nearest even integer like rint(3). For example, round(0.5) is 1.0, and round(-0.5) is -1.0.
В доступных текстах стандартов то же самое по сути.
Так что если это решение не идеальное, то по крайней мере традиционное.
(В идеале, я бы предпочёл видеть или дополнительный параметр направления округления, или раздельные функции для каждого режима округления. Но сейчас и введение только одного варианта — уже успех.)
alexxz
29.12.2017 10:34Вообще говоря, округление — это чуть более сложная и разнообразная операция с числами, чем это преподаётся в школе. И самый спорный фактор тут, как правило, куда сдвигать середину (0.5). Вот лишь возможные очевидные ответы: большему/меньшему целому, большему/меньшему целому по модулю, а также к ближайшему чётному/нечётному. У каждого из этих подходов есть плюсы и минусы.
Округление к меньшему по модулю имеет вот такое замечательное свойство: round(x) + round(-x) == 0
В большинстве случаев, встречается округление к меньшему как поведение по умолчанию.
Статья на викиnetch80
29.12.2017 11:38Округление к меньшему по модулю имеет вот такое замечательное свойство: round(x) + round(-x) == 0
Округление к чётному, к нечётному, к нулю (к меньшему по модулю) и от нуля — все одновременно имеют это свойство.
В большинстве случаев, встречается округление к меньшему как поведение по умолчанию.
Хм… вообще-то в большинстве случаев или к ближайшему, а середину — от нуля (как в обсуждаемой реализации), или к ближайшему, а посредине — к чётному (банкирское, оно же гауссово округление). В школе нас учили единственному варианту, что округление — к ближайшему, а середину — от нуля.
В терминах IEEE754-2008 это соответственно roundToNearestTiesAway и roundToNearestTiesToEven.
Статья на вики
Статья хорошая как вводной обзор, но сильно неполная.
Нет, например, фоннеймановского округления, про которое см. документацию IBM:
Round to prepare for shorter precision: For a BFP or HFP permissible set (двоичная арифметика), the candidate selected is the one whose voting digit has an odd value. For a DFP permissible set (десятичная арифметика), the candidate that is smaller in magnitude is selected, unless its voting digit has a value of either 0 or 5; in that case, the candidate that is greater in magnitude is selected.
alexxz
29.12.2017 12:49Часто или редко встречается округление к меньшему, это, конечно, открытый вопрос. решил проинспектировать свой инструментарий. Получилось примерно поровну
PHP round(-0.5) = -1
Exasol select round(-0.5) = -1
JS Math.round(-0.5) = 0
Java Math.round(-0.5) = 0
MySQL select round(-0.5) = -1
C round(-0.5) is -1.0
R round(-0.5) = 0
Erlang round(-0.5) = -1
netch80
29.12.2017 13:24+2Чтобы понять, какой именно вариант работает в каждом конкретном случае, надо сравнивать не один случай -0.5, а несколько: например: -1.5, -0.5, +0.5, +1.5.
Тогда получается соответственно для этих значений (в том же порядке, что у Вас; Exasol не знаю, пропускаю):
PHP round(): -2, -1, 1, 2 (середина — от нуля)
Javascript Math.round(): -1, 0, 1, 2 (середина — к +INF)
Java Math.round(): -1, 0, 1, 2 (середина — к +INF)
(но рядом есть rint(), который округляет середину к чётному: -2, 0, 0, 2)
MySQL select round(): -2, -1, 1, 2 (середина — от нуля)
C round(): -2, -1, 1, 2 (середина — от нуля)
R round(): -2, 0, 0, 2 (середина — к чётному)
Erlang round(): -2, -1, 1, 2 (середина — от нуля)
Кроме того:
Python3 round(): -2, 0, 0, 2 (середина — к чётному)
Perl POSIX::round: как в C (середина — от нуля)
как видите, до сих пор ни одного случая ни «середина к нулю», ни «середина к меньшему». Особый случай — Java и склонировавший её Javascript, где, наоборот, середина — к +INF (интересно, кто и зачем такое выбрал?)
esudnik
28.12.2017 22:16Пользуясь случаем хочу спросить, почему в этом коде все ок, на выходе будет 7000
$x = 0.00007; $y = $x * 100000000; echo($y); //outputs 7000
… а вот здесь на выходе будет 6999?
$x = 0.00007; $y = $x * 100000000; $y = (int)$y; echo($y); //outputs 6999, why??
lagranzh
29.12.2017 01:11Могу предположить следующее: $y равен 6999.999999… после умножения.
Функция которая печатает, округляет флоаты до определенного знака.
Приведение к инту до округления просто отбрасывает друбную часть (хоть она и почти единица), и на выходе имеем — то что имеем.
как-то так.netch80
29.12.2017 01:58Могу предположить следующее: $y равен 6999.999999… после умножения.
Функция которая печатает, округляет флоаты до определенного знака.Так и есть (по крайней мере в double == float64).
serg_deep
29.12.2017 14:20В php cast (int) никогда не округлял. Его задача сконвертировать правую часть в int, а справа может стоять не только float, но и bool, string, array и да же closure. Конкретно с float — просто отбрсывается дробная часть.
DistortNeo
29.12.2017 03:20Меня несколько удивляют подобные реализации. Я так понимаю, весь сыр-бор из-за платформ, где нет соответствующих инструкций? Но ведь представление чисел с плавающей точкой стандартизировано, правильнее действительно будет работать напрямую с представлением.
laughman
29.12.2017 09:20«Но я надеюсь, что теперь вам стало понятно, как устроено округление в Go и как нужно тестировать реализации округления»
да использовать их надо, а не тестировать
программист он для заказчика программист, а для языка программирования пользователь
«корректно работающее округление появилось лишь в шестой мажорной версии Java (через 15 лет, прошедших с релиза Java 1.0 до выхода Java 7)»
15 лет… пендосиннопром жжет…
мне просто интересно, если бы такие базовые штуки, как округление, я выдавал бы своим пользователям через 15 лет,… да нет, я бы не дождался результатов теста, столько не проработал бы.
и ведь на этом языке до сих пор большая часть вакансий в нашем замкадье-захолустье
gks
29.12.2017 12:31А чего, такая штука не проходит
if math.IsNaN(x) {
return x
}
return math.Copysign(1.0, x) * math.Floor(math.Copysign(1.0, x) * x + 0.5)
klimenko_serj
29.12.2017 12:31странно… ваша функция (переписанная из postgres) фейлится на ваших же тестах:
=== RUN TestRound7 --- FAIL: TestRound7 (0.00s) main_test.go:37: round( -0.5 ) = -0 != -1 main_test.go:37: round( 0.5 ) = 0 != 1 main_test.go:37: round( 2.2517998136852485e+15 ) = 2.251799813685248e+15 != 2.251799813685249e+15
Тесты я брал из статьи. В builtins_test.go теста для round не нашел.Semenar
29.12.2017 12:55+1Там округление к ближайшему чётному, если дробная часть равна 0.5, тесты в статье же на округление к большему по модулю.
tumbler
больше никогда не буду писать от лени int(x+0.5)
DistortNeo
Почему бы и нет, если заведомо известно, что аргумент неотрицателен и влезет в int, а нам нужно работать именно с int? В этом случае `int(x+0.5)` будет значительно эффективнее по производительности, чем `int(round(x))` (операция roundss имеет больший latency, чем простое сложение).
tumbler
Как вариант, потому что вот это "заведомо известно" потом превращается в изменения исходных требований, и опа — вылезли за границы допустимых входных данных :( С другой стороны, производительность это тоже серьезный аргумент.