Округление дробных чисел в C++

В C++ есть несколько способов округлить дробное число (float, double) до целого. Сегодня я кратко опишу эти способы и покажу, чем они отличаются друг от друга.

Обзор

Ниже приводится список рассматриваемых способов с их кратким описанием.

  • Явное приведение (static_cast). Просто отбрасывает дробную часть.
  • std::ceil. Ищет наименьшее целое число, которое больше исходного значения [1].
  • std::floor. Ищет наибольшее целое число, которое меньше исходного значения [2].
  • std::round. Округляет по тем правилам, которым нас учили в школе. При этом 0.5 становится единицей, а -0.5 — минус единицей [3].
  • std::trunc. Ищет ближайшее целое с наименьшим значением по модулю [4].

Функции std::round и std::trunc появились в C++11. Функции std::ceil и std::floor унаследованы от языка C.

Существуют еще функции std::nearbyint (унаследована от C99) [5] и std::rint (появилась в C++11) [6]. Результат их работы зависит от текущего режима округления (current rounding mode). Их мы рассмотрим позже.

Все функции возвращают число (float, double или long double в зависимости от конкретной перегрузки) с дробной частью равной нулю.

Для всех функций есть их аналоги с f или l на конце (например, std::ceilf и std::ceill, или std::floorf и std::floorl, и т.д.). Функции, чьи имена оканчиваются на f (std::ceilf, std::floorf, std::roundf и т.д.), принимают и возвращают значение типа float. Функции, имена которых оканчиваются на l (std::ceill, std::floorl, std::roundl и т.д.), принимают и возвращают значение типа long double.

Начиная с C++23 все функции кроме std::nearbyint и std::rint имеют спецификатор constexpr.

Если функции в качестве входного аргумента получают ±∞, ±0 или ±NaN, полученное значение возвращается в качестве результата как есть (unmodified в оригинале)

Ни у одной из функций нет спецификатора noexcept. При этом исключения, которые может бросать та или иная функция не приводятся. Вообще обработка ошибок для этих функций организована так же, как и для других математических функций. Она описывается на странице [7]. Я не буду рассматривать её подробно, но нам придется с ней столкнуться ниже при обсуждении различий между функциями std::nearbyint и std::rint.

Пример

Чтобы наглядно продемонстрировать работу этих способов округления я набросал небольшую программу. Ниже приводится её полный исходный текст.

#include <array>
#include <cmath>
#include <iostream>
#include <string_view>

constexpr std::array<float, 6> numbers
    {-0.8, -0.5, -0.3, 0.3, 0.5, 0.8};

template <typename F>
void print(std::string_view prefix, F transformer)
{
    std::cout << prefix;

    for(auto length = prefix.size(); length < 8; ++length)
        std::cout << ' ';

    for(auto number : numbers)
        std::cout << transformer(number) << '\t';

    std::cout << '\n';
}

int main()
{
    print(""  , [](auto f) { return f; });
    print("ceil"   , [](auto f) { return std::ceil(f); });
    print("floor"  , [](auto f) { return std::floor(f); });
    print("int()"  , [](auto f) { return static_cast<int>(f); });
    print("round"  , [](auto f) { return std::round(f); });
    print("trunc"  , [](auto f) { return std::trunc(f); });

    return 0;
}

Основную работу выполняет шаблонная функция print (согласен, название не очень удачное). Она принимает строковый префикс, по которому мы понимаем какой способ округления использовался, и функциональный объект, который собственно и выполняет округление. Для простоты я не стал заморачиваться с лишними символами табуляции на концах строк, но по-хорошему их следовало бы убрать.

Ниже приводится результат работы этой программы.

        -0.8    -0.5    -0.3    0.3     0.5     0.8
ceil    -0      -0      -0      1       1       1
floor   -1      -1      -1      0       0       0
int()   0       0       0       0       0       0
round   -1      -1      -0      0       1       1
trunc   -0      -0      -0      0       0       0

Приведение к целочисленному типу

Часто при выполнении округления дробного числа мы хотим получить значение целочисленного типа. Но если мы напишем так:

float f = 1.25;
auto i = std::round(f);

Переменная i будет иметь тип float. Можно написать так:

float f = 1.25;
int i = std::round(f);

Этот приём работает. Более того многие компиляторы (проверял gcc и clang) к моему удивлению даже не выдают никакого предупреждения. Но есть и другой способ.

Функции std::round и std::rint имеют специальные версии, начинающиеся с префиксов l (std::lround, std::lrint и т.д.) и ll (std::llround, std::llrint и т.д.). Функции, имена которых начинаются с префикса l, возвращают значение типа long. А функции, чьи имена начинаются с ll, возвращают значение типа long long. Таким образом для примера ниже

float f = 1.25;
auto l = std::lround(f);
auto ll = std::llround(f);

Переменная l будет иметь тип long, а переменная lllong long.

Важно отметить, что в случае ошибки (например, аргумент функции является «не числом», или результат округления не умещается в требуемый целочисленный тип) функции с префиксами l и ll возвращают значение, зависящее от реализации (implementation-defined value). Оператор static_cast в этом случае приводит к неопределенному поведению. На странице [8] приводится обсуждение этой проблемы.

Префиксно-суффиксное разнообразие

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

Все рассматриваемые выше функции можно разделить на две группы. В первую группу входят функции: std::ceil, std::floor, std::trunc и std::nearbyint. Они не имеют префиксов l и ll. Ниже приводятся прототипы этих функций на примере std::ceil.

float ceil(float);
double ceil(double);
long double ceil(long double);

float ceilf(float);
long double ceill(long double);

Во вторую группу входят функции std::round и std::rint. Ниже приводятся их прототипы на примере std::round.

float round(float);
double round(double);
long double round(long double);

float roundf(float);
long double roundl(long double);

long lround(float);
long lround(double);
long lround(long double);
long lroundf(float);
long lroundl(long double);

long long llround(float);
long long llround(double);
long long llround(long double);
long long llroundf(float);
long long llroundl(long double);

Режимы округления

Режимы округления описаны на странице [9]. Они были добавлены в C++11 и управляют поведением функций std::nearbyint и std::rint. Ниже даётся краткое описание этих режимов на примере функции std::rint [6]:

  • FE_DOWNWARD — std::rint ведет себя так же, как и std::floor.
  • FE_UPWARD — std::rint ведет себя так же, как и std::ceil.
  • FE_TOWARDZERO — std::rint ведет себя так же, как и std::trunc.
  • FE_TONEAREST — единственное отличие std::rint от std::round в том, что случаи с 0.5 округляются к ближайшему четному, а не «от нуля» (так называемое «банковское округление» или «округление банкира»).

Интересно, что это не константы как можно было бы ожидать, а… макросы. Но для них дается уточнение [9]:

Each of these macro constants expands to a nonnegative integer constant expression, which can be used with std::fesetround and std::fegetround to indicate one of the supported floating-point rounding modes

Или в моём переводе:

Каждый из этих макросов раскрывается в неотрицательное целочисленное константное выражение, которое может быть использовано с функциями std::fesetround и std::fegetround для указания одного из поддерживаемых режимов округления чисел с плавающей точкой.

Согласно стандарту могут существовать и дополнительные режимы округления [9].

Функции std::fesetround и std::fegetround были добавлены в C++11 и описаны на странице [10]. Первая устанавливает режим округления, а вторая — возвращает текущий режим округления. Обе они воспринимают режим округления как значение типа int.

Изменение режима округления функцией std::fesetround влияет только на текущий поток [22]. Установленный режим округления наследуется от текущего потока тому потоку, который он только что создал.

Флаг -frounding-math

Если Вы используете функцию std::fesetround в компиляторе gcc, то при вызове std::rint Вы можете получить неправильный результат [11]. Дело в том, что по умолчанию компилятор считает, что программа не меняет режим округления и применяет некоторые оптимизации. Для того чтобы отключить их, при компиляции нужно указать флаг -frounding-math. Вот что говорится в его описании [12]:

This option should be specified for programs that change the FP rounding mode dynamically, or that may be executed with a non-default rounding mode.

Или в моём переводе:

Этот флаг должен быть указан для программ, которые динамически меняют режим округления, или которые должны исполняться с режимом округления отличным от того, что используется по умолчанию.

#pragma STDC FENV_ACCESS ON

На странице [9] говорится:

As with any floating-point environment functionality, rounding is only guaranteed if #pragma STDC FENV_ACCESS ON is set.

Или в моём переводе:

Как и в случае с функциональностью окружения чисел с плавающей запятой, округление гарантируется только если задано #pragma STDC FENV_ACCESS ON

И здесь возникает противоречие, потому что согласно стандарту компилятор не обязан поддерживать прагмы [13]. Так, используемый мной компилятор gcc (13.3.0) выдает предупреждение:

main.cpp:7: warning: ignoring ‘#pragma STDC FENV_ACCESS’ [-Wunknown-pragmas]
    7 | #pragma STDC FENV_ACCESS ON

Компилятор clang (18.1.3) в моих тестах с функциями округлениями правильно работает вне зависимости от того задана прагма или нет. Но и при её наличии не выдает никаких предупреждений.

Компилятор от компании Microsoft так же правильно работает и без прагмы, а при её наличии выдает предупреждение:

warning C4068: неизвестная прагма "STDC"

Должен сделать важное замечание. Под «правильно работает» я понимаю правильно работает на моих тестах и на моём аппаратном обеспечении. Я не гарантирую, что эта правильность сохранится на других входных данных и на другом аппаратном обеспечении.

Пример

Чтобы наглядно продемонстрировать работу с режимами округления я набросал небольшой пример. Ниже приводится полный исходный текст демонстрационной программы.

#include <array>
#include <cfenv>
#include <cmath>
#include <iostream>
#include <string_view>

// ????
//#pragma STDC FENV_ACCESS ON

constexpr std::array<float, 6> numbers
    {-0.8, -0.5, -0.3, 0.3, 0.5, 0.8};

std::string_view round_mode_to_string(int mode)
{
    switch(mode)
    {
        case FE_DOWNWARD   : return "FE_DOWNWARD";
        case FE_TONEAREST  : return "FE_TONEAREST";
        case FE_TOWARDZERO : return "FE_TOWARDZERO";
        case FE_UPWARD     : return "FE_UPWARD";
    }
    return "Unknown";
}

template <typename F>
void print(std::string_view prefix, F transformer)
{
    std::cout << prefix;

    for(auto length = prefix.size(); length < 16; ++length)
        std::cout << ' ';

    for(auto number : numbers)
        std::cout << transformer(number) << '\t';

    std::cout << '\n';
}

int main()
{
    std::cout << "Current rounding mode is "
        << round_mode_to_string(std::fegetround()) << "\n\n";

    print("", [](auto f) { return f; });
    for(auto mode :
        {FE_DOWNWARD, FE_TONEAREST, FE_TOWARDZERO, FE_UPWARD})
    {
        std::fesetround(mode);
        print(round_mode_to_string(mode),
           [](auto f) { return std::rint(f); });
    }

    return 0;
}

Ниже приводится вывод этой программы на моём домашнем компьютере:

Current rounding mode is FE_TONEAREST

                -0.8    -0.5    -0.3    0.3     0.5     0.8
FE_DOWNWARD     -1      -1      -1      0       0       0
FE_TONEAREST    -1      -0      -0      0       0       1
FE_TOWARDZERO   -0      -0      -0      0       0       0
FE_UPWARD       -0      -0      -0      1       1       1

Для большей наглядности дополнительно продублирую здесь вывод первого примера из нашего сегодняшнего поста.

        -0.8    -0.5    -0.3    0.3     0.5     0.8
ceil    -0      -0      -0      1       1       1
floor   -1      -1      -1      0       0       0
int()   0       0       0       0       0       0
round   -1      -1      -0      0       1       1
trunc   -0      -0      -0      0       0       0

Отличие std::rint от std::nearbyint

Функции std::rint и std::nearbyint делают по сути одно и тоже: округляют число в соответствии с текущим режимом округления. Поэтому неудивительно, что возникает вопрос о том, чем они отличаются друг от друга [14]. На страницах [5,6] говорится:

The only difference between std::rint and std::nearbyint is that std::nearbyint never raises FE_INEXACT.

Или в моём переводе:

Единственное различие между функциями std::rint и std::nearbyint в том, что std::nearbyint никогда не взводит FE_INEXACT.

FE_INEXACT — это флаг, обозначающий некоторую ситуацию при выполнении математической функции. Он описан на странице [15] следующим образом:

rounding was necessary to store the result of an earlier floating-point operation

Или в моём переводе:

для сохранения результата предыдущей операции с числом с плавающей точкой потребовалось округление

Для проверки состояния флага может использоваться функция std::fetestexcept [16], а для его сброса std::feclearexcept [17].

Пример

Для демонстрации различия между функциями std::rint и std::nearbyint я набросал небольшой пример. В нём также демонстрируется использование функций std::fetestexcept и std::feclearexcept. Ниже приводится полный исходный текст примера.

#include <cfenv>
#include <cmath>
#include <iostream>
#include <string_view>

// ????
//#pragma STDC FENV_ACCESS ON

template <typename F>
void print(std::string_view prefix, F transformer)
{
    std::cout << prefix;
    for(auto length = prefix.size(); length < 12; ++length)
        std::cout << ' ';

    for(auto n : {2.0, 2.3})
        std::cout << transformer(n) << '\t';

    std::cout << '\n';
}

template<typename R>
auto make_transformer(R rounder)
{
    return [=](auto n) -> std::string_view
        {
            std::feclearexcept(FE_INEXACT);
            rounder(n);
            if(std::fetestexcept(FE_INEXACT))
                return "R";
            else
                return "NR";
        };
}

int main()
{
    print("", [](auto f) { return f; });

    print("rint",
        make_transformer(
            [](auto f) { return std::rint(f); } ));

    print("nearbyint",
        make_transformer(
            [](auto f) { return std::nearbyint(f); }));

    return 0;
}

В этом примере для чисел 2.0 и 2.3 вызываются наши сравниваемые функции std::rint и std::nearbyint. После каждого такого вызова проверяется состояние флага FE_INEXACT. Если он взведён выводится "R", иначе — "NR".

Ниже приводится результат работы этой программы.

            2   2.3
rint        NR  R
nearbyint   NR  NR

Видно, что функция std::rint взвела этот флаг для числа 2.3, для которого произошло округление. Функция std::nearbyint в свою очередь не взвела его ни для какого числа.

Перечисление std::float_round_style

Шаблонная структура std::numeric_limits [18] содержит статическое константное поле round_style [19], содержащее стиль округления (rounding style), применяемый к типу, по которому инстанцирована структура. Начиная с C++11 оно имеет спецификатор constexpr.

Стиль округления представляет собой перечисление типа std::float_round_style [20]. Ниже приводится краткое описание возможных значений.

  • std::round_indeterminate — стиль не определен;
  • std::round_toward_zero — по направлению к нулю;
  • std::round_to_nearest — по направлению к ближайшему представимому (nearest representable) значению;
  • std::round_toward_infinity — по направлению к положительной бесконечности;
  • std::round_toward_neg_infinity — по направлению к отрицательной бесконечности.

В описании константного статического поля round_style [19] говорится:

These values are constants, and do not reflect the changes to the rounding made by std::fesetround.

Или в моём переводе:

Эти значения являются константами и не отражают изменений режима округления, вносимых функцией std::fesetround.

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

FLT_ROUNDS

Существует еще макрос FLT_ROUNDS [21], который содержит текущий режим округления, установленный функцией std::fesetround. Он раскрывается в одно из следующих значений:

  • -1 — режим не задан;
  • 0 — аналогично FE_TOWARDZERO;
  • 1 — аналогично FE_TONEAREST;
  • 2 — аналогично FE_UPWARD;
  • 3 — аналогично FE_DOWNWARD.

В зависимости от реализации могут возвращаться и другие значения для дополнительных режимов округления.

Заключение

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

Тем не менее я надеюсь, что мне удалось осветить основные моменты вопроса округления дробных чисел в С++. Если вдруг я что-то упустил — пишите в комментариях, или в форме обратной связи (https://norseev.ru/contacts/).

Ссылки

  1. Функция std::ceil: https://en.cppreference.com/w/cpp/numeric/math/ceil.html
  2. Функция std::floor: https://en.cppreference.com/w/cpp/numeric/math/floor.html
  3. Функция std::round: https://en.cppreference.com/w/cpp/numeric/math/round.html
  4. Функция std::trunc: https://en.cppreference.com/w/cpp/numeric/math/trunc.html
  5. Функция std::nearbyint: https://en.cppreference.com/w/cpp/numeric/math/nearbyint.html
  6. Функция std::rint: https://en.cppreference.com/w/cpp/numeric/math/rint.html
  7. Обработка ошибок математических функций: https://en.cppreference.com/w/cpp/numeric/math/math_errhandling.html
  8. Обсуждение того, что приведение float к int может приводить к неопределенному поведению: https://www.reddit.com/r/cpp/comments/rsldwl/converting_float_to_int_can_be_undefined_behavior/
  9. Режимы округления: https://en.cppreference.com/w/cpp/numeric/fenv/FE_round.html
  10. Функции std::fegetround и std::fesetround: https://en.cppreference.com/w/cpp/numeric/fenv/feround.html
  11. Вопрос о неправильной работе функции std::rint: https://stackoverflow.com/questions/78190410/why-does-gcc-o1-affects-stdrint
  12. Некоторые флаги gcc включая -frounding-mode: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
  13. Вопрос о согласованности требования #pragma STDC FENV_ACCESS со стандартом: https://stackoverflow.com/questions/79395182/pragma-stdc-fenv-access
  14. Вопрос об отличиях функции std::rint от std::nearbyint: https://stackoverflow.com/questions/67515507/difference-between-rint-and-nearbyint
  15. Математические исключения (на самом деле флаги): https://en.cppreference.com/w/cpp/numeric/fenv/FE_exceptions.html
  16. Функция std::fetestexcept: https://en.cppreference.com/w/cpp/numeric/fenv/fetestexcept.html
  17. Функция std::feclearexcept: https://en.cppreference.com/w/cpp/numeric/fenv/feclearexcept.html
  18. Шаблонная структура std::numeric_limits: https://en.cppreference.com/w/cpp/types/numeric_limits.html
  19. Статическое поле std::numeric_limits<T>::round_style: https://en.cppreference.com/w/cpp/types/numeric_limits/round_style.html
  20. Перечисление std::float_round_style: https://en.cppreference.com/w/cpp/types/numeric_limits/float_round_style.html
  21. Макрос FLT_ROUNDS: https://en.cppreference.com/w/cpp/types/climits/FLT_ROUNDS.html
  22. Вопрос об области действия функции fesetround (справедливо и для C++): https://stackoverflow.com/questions/79485550/what-is-the-scope-of-fesetround

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *