Как я могу использовать типы std :: chrono, не рискуя переполнением и неопределенным поведением? - PullRequest
0 голосов
/ 06 января 2019

Я использовал std::chrono в течение многих лет и смотрел многие из Говарда Хиннанта рассказывает о дизайне и использовании библиотеки. Мне это нравится, и я думаю, что в целом понимать это. Однако недавно я вдруг понял, что не знаю, как используйте его практически и безопасно , чтобы избежать неопределенного поведения.

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

Давайте начнем с того, что я считаю «самым простым» типом std::chrono::duration, nanoseconds. Его минимальный размер rep составляет 64 бита, что означает на практике будет std::int64_t, и, таким образом, вероятно, нет "остатка" необязательно биты представления, которые не требуются в стандарте.

Эта функция, очевидно, не всегда безопасна:

nanoseconds f1(nanoseconds value)
{ return ++value; }

Если value равно nanoseconds::max(), то это переполняется, что мы можем подтвердить с UBSan clang 7 (-fsanitize=undefined):

runtime error: signed integer overflow: 9223372036854775807 + 1 cannot be
represented in type 'std::__1::chrono::duration<long long,
std::__1::ratio<1, 1000000000> >::rep' (aka 'long long')

Но в этом нет ничего особенного. Он не отличается от типичного целочисленного случая:

std::int64_t f2(std::int64_t value)
{ return ++value; }

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

nanoseconds f3(nanoseconds value)
{
  if(value == value.max())
  {
    throw std::overflow_error{"f3"};
  }
  return ++value;
}

Если у нас есть существующее (неизвестное) nanoseconds значение, которое мы хотим добавить другое (неизвестно) nanoseconds значение для, наивный подход:

struct Foo
{
  // Pretend this can be set in other meaningful ways so we
  // don't know what it is.
  nanoseconds m_nanos = nanoseconds::max();

  nanoseconds f4(nanoseconds value)
  { return m_nanos + value; }
};

И снова у нас будут проблемы:

runtime error: signed integer overflow: 9223372036854775807 +
9223372036854775807 cannot be represented in type 'long long'
Foo{}.f4(nanoseconds::max()) = -2 ns

Итак, опять же, мы можем сделать то же самое, что и с целыми числами, но это уже хитрее, потому что это целые числа со знаком:

struct Foo
{
  explicit Foo(nanoseconds nanos = nanoseconds::max())
    : m_nanos{nanos}
  {}
  // Again, pretend this can be set in other ways, so we don't
  // know what it is.
  nanoseconds m_nanos;

  nanoseconds f5(nanoseconds value)
  {
    if(m_nanos > m_nanos.zero() && value > m_nanos.max() - m_nanos)
    {
      throw std::overflow_error{"f5+"};
    }
    else if(m_nanos < m_nanos.zero() && value < m_nanos.min() - m_nanos)
    {
      throw std::overflow_error{"f5-"};
    }
    return m_nanos + value;
  }
};

Foo{}.f5(0ns) = 9223372036854775807 ns
Foo{}.f5(nanoseconds::min()) = -1 ns
Foo{}.f5(1ns) threw std::overflow_error: f5+
Foo{}.f5(nanoseconds::max()) threw std::overflow_error: f5+
Foo{nanoseconds::min()}.f5(0ns) = -9223372036854775808 ns
Foo{nanoseconds::min()}.f5(nanoseconds::max()) = -1 ns
Foo{nanoseconds::min()}.f5(-1ns) threw std::overflow_error: f5-
Foo{nanoseconds::min()}.f5(nanoseconds::min()) threw std::overflow_error: f5-

Я думаю Я правильно понял. Становится все труднее быть уверенным, если код правильный.

Пока что все может казаться управляемым, но как насчет этого случая?

nanoseconds f6(hours value)
{ return m_nanos + value; }

У нас та же проблема, что и с f4(). Можем ли мы решить это так же, как f5() сделал? Давайте использовать то же тело, что и f5(), но просто изменим аргумент введите и посмотрите, что получится:

nanoseconds f7(hours value)
{
  if(m_nanos > m_nanos.zero() && value > m_nanos.max() - m_nanos)
  {
    throw std::overflow_error{"f7+"};
  }
  else if(m_nanos < m_nanos.zero() && value < m_nanos.min() - m_nanos)
  {
    throw std::overflow_error{"f7-"};
  }
  return m_nanos + value;
}

Это кажется нормальным, потому что мы все еще проверяем, есть ли место между nanoseconds::max() и m_nanos для добавления value. Так что же происходит, когда мы запустить это?

Foo{}.f7(0h) = 9223372036854775807 ns
/usr/lib/llvm-7/bin/../include/c++/v1/chrono:880:59: runtime error: signed
integer overflow: -9223372036854775808 * 3600000000000 cannot be represented
in type 'long long'
Foo{}.f7(hours::min()) = 9223372036854775807 ns
Foo{}.f7(1h) threw std::overflow_error: f7+
Foo{}.f7(hours::max()) DIDN'T THROW!!!!!!!!!!!!!!
Foo{nanoseconds::min()}.f7(0h) = -9223372036854775808 ns
terminating with uncaught exception of type std::overflow_error: f7-
Aborted

О боже. Это определенно не сработало.

В моем тестовом драйвере ошибка UBSan выводится над вызовом, что она отчетность, поэтому первый сбой Foo{}.f7(hours::min()). Но этот случай даже не должен бросать, так почему же он потерпел неудачу?

Ответ в том, что даже акт сравнения hours с nanoseconds включает преобразование. Это потому, что операторы сравнения реализуются через использование std::common_type, которое std::chrono определяет для duration типов в члены наибольшего общего делителя значений period. В нашем случае это nanoseconds, поэтому сначала hours преобразуется в nanoseconds. фрагмент из libc++ показывает часть этого:

template <class _LhsDuration, class _RhsDuration>
struct __duration_lt
{
  _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR
  bool operator()(const _LhsDuration& __lhs, const _RhsDuration& __rhs) const
  {
    typedef typename common_type<_LhsDuration, _RhsDuration>::type _Ct;
    return _Ct(__lhs).count() < _Ct(__rhs).count();
  }
};

Так как мы не проверяли, что наш hours value был достаточно мал, чтобы поместиться nanosecondsэто конкретная реализация стандартной библиотеки с ее определенный rep выбор типа), следующие по существу эквивалентны:

if(m_nanos > m_nanos.zero() && value > m_nanos.max() - m_nanos)

if(m_nanos > m_nanos.zero() && nanoseconds{value} > m_nanos.max() - m_nanos)

Кроме того, такая же проблема будет существовать, если hours использует 32-разрядный rep:

runtime error: signed integer overflow: 2147483647 * 3600000000000 cannot be
represented in type 'long long'

Конечно, если мы сделаем value достаточно маленьким, в том числе ограничив rep размер, мы можем в конечном итоге сделать его нужным. , , поскольку очевидно некоторые hours значений может быть представлен как nanoseconds или преобразования будут бессмысленными.

Давай пока не будем сдаваться. В любом случае, конверсии являются еще одним важным случаем, поэтому мы должен знать, как обращаться с ними безопасно. Конечно, это не может быть слишком сложно.

Первое препятствие заключается в том, что нам нужно знать, сможем ли мы даже добраться от hours до nanoseconds без переполнения типа nanoseconds::rep. И снова делай как мы будет с целыми числами и сделать проверку переполнения умножения. На момент, давайте проигнорируем отрицательные значения. Мы могли бы сделать это:

nanoseconds f8(hours value)
{
  assert(value >= value.zero());
  if(value.count()
     > std::numeric_limits<nanoseconds::rep>::max() / 3600000000000)
  {
    throw std::overflow_error{"f8+"};
  }
  return value;
}

Кажется, это работает, если мы проверим его на соответствие ограничениям нашей стандартной библиотеки выбор nanoseconds::rep:

f8(0h) = 0 ns
f8(1h) = 3600000000000 ns
f8(2562047h) = 9223369200000000000 ns
f8(2562048h) threw std::overflow_error: f8+
f8(hours::max()) threw std::overflow_error: f8+

Но есть некоторые довольно серьезные ограничения. Во-первых, мы должны были «знать», какконвертировать между hours и nanoseconds, что побеждает точку. Во-вторых, это обрабатывает только эти очень конкретные два типа с очень хорошим отношения между их period типами (где только одно умножение требуется).

Представьте, что мы хотим реализовать преобразование, не зависящее от переполнения, только стандартного именованные типы duration, поддерживающие только преобразования без потерь:

template <typename target_duration, typename source_duration>
target_duration lossless(source_duration duration)
{
  // ... ?
}

Кажется, нам нужно вычислить отношения между отношениями и принимать решения и проверить умножения на основе этого. , , и как только мы это сделаем, мы должны были понять и заново реализовать всю логику в duration операторы (но теперь с безопасностью переполнения), которые мы изначально намеревались использовать в первое место! Нам не нужно на самом деле реализовать тип только для использования типа, мы можем?

Плюс, когда мы закончим, у нас просто есть какая-то функция, lossless(), которая выполняет преобразование, если мы явно называем это вместо того, чтобы позволить естественный неявный преобразования или другая функция, которая добавляет значение, если мы явно вызываем его вместо использования operator+(), поэтому мы потеряли выразительность, которая огромна часть значения duration.

Добавьте в микс преобразования с потерями с duration_cast, и это кажется безнадежным.

Я даже не уверен, как бы я подошел к такому простому решению:

template <typename duration1, typename duration2>
bool isSafe(duration1 limit, duration2 reading)
{
  assert(limit >= limit.zero());
  return reading < limit / 2;
}

Или, что еще хуже, даже если бы я знал кое-что о grace:

template <typename duration1, typename duration2>
bool isSafe2(duration1 limit, duration2 reading, milliseconds grace)
{
  assert(limit >= limit.zero());
  assert(grace >= grace.zero());
  const auto test = limit / 2;
  return grace < test && reading < (test - grace);
}

Если duration1 и duration2 действительно могут быть duration типа (включая такие вещи, как std::chrono::duration<std::int16_t, std::ratio<3, 7>>, я не вижу способ действовать с уверенностью. Но даже если мы ограничимся "нормальным" duration типов, есть много страшных результатов.

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

Если я создаю свои duration типы на основе беззнаковых rep, я полагаю, технически избегать хотя бы некоторого неопределенного поведения из целого числа переполнение точки зрения, но я все еще могу легко получить результаты мусора из «неосторожные» расчеты. «Проблемное пространство» кажется тем же.

Меня не интересует решение, основанное на типах с плавающей запятой. я использую std::chrono, чтобы сохранить точное разрешение, которое я выбираю в каждом конкретном случае. Если я не заботился о точности или округлении ошибок, я мог легко использовать double чтобы считать секунды везде, а не смешивать юниты. Но если бы это было жизнеспособное решение для каждой проблемы, у нас не было бы std::chrono (или даже struct timespec, в этом отношении).

Итак, мой вопрос, как мне безопасно и практически использовать std::chrono, чтобы сделать что-то простое, как сложение двух значений разной продолжительности без боязнь неопределенного поведения из-за целочисленного переполнения? Или сделать без потерь конверсии безопасно? Я не нашел практического решения даже с известными простые duration типы, не говоря уже о богатой вселенной всевозможных duration типы. Чего мне не хватает?

1 Ответ

0 голосов
/ 06 января 2019

Самый эффективный ответ - знать свой домен и не программировать в диапазоне, близком к максимальному диапазону точности, которую вы используете. Если вы используете nanoseconds, диапазон составляет +/- 292 года. Не подходи так далеко. Если вам нужен больший диапазон, чем всего лишь +/- 100 лет, используйте более грубое разрешение, чем наносекунды.

Если вы можете следовать этим правилам, то можете не беспокоиться о переполнении.

Иногда ты не можешь. Например, если ваш код должен обрабатывать ненадежный ввод или общий ввод (например, библиотека общего назначения), то вам действительно нужно проверить на переполнение.

Один из методов - выбрать rep только для сравнения, который может обрабатывать больший диапазон, чем кто-либо, просто для сравнения. int128_t и double - это два инструмента, которые мне нужны в этом случае. Например, вот checked_convert, который проверяет переполнение с помощью double до фактического выполнения duration_cast:

template <class Duration, class Rep, class Period>
Duration
checked_convert(std::chrono::duration<Rep, Period> d)
{
    using namespace std::chrono;
    using S = duration<double, typename Duration::period>;
    constexpr S m = Duration::min();
    constexpr S M = Duration::max();
    S s = d;
    if (s < m || s > M)
        throw std::overflow_error("checked_convert");
    return duration_cast<Duration>(d);
}

Это значительно дороже. Но если вы пишете (например) std::thread::sleep_for, это стоит затрат.

Если по какой-то причине вы не можете использовать плавающую точку даже для проверок, я экспериментировал с lcm_type (не очень хорошее имя). Это противоположность common_type_t<Duration1, Duration2>. Вместо того, чтобы найти duration, в который оба входных duration s могут преобразовать без потерь (без деления), он находит duration, в который оба входных duration s могут преобразовать без умножения. Например, lcm_type_t<milliseconds, nanoseconds> имеет тип milliseconds. Такое преобразование не может переполниться.

template <class Duration0, class ...Durations>
struct lcm_type;

template <class Duration>
struct lcm_type<Duration>
{
    using type = Duration;
};

template <class Duration1, class Duration2>
struct lcm_type<Duration1, Duration2>
{
    template <class D>
    using invert = std::chrono::duration
                   <
                       typename D::rep,
                       std::ratio_divide<std::ratio<1>, typename D::period>
                   >;

    using type = invert<typename std::common_type<invert<Duration1>,
                                                  invert<Duration2>>::type>;
};

template <class Duration0, class Duration1, class Duration2, class ...Durations>
struct lcm_type<Duration0, Duration1, Duration2, Durations...>
{
    using type = typename lcm_type<
                     typename lcm_type<Duration0, Duration1>::type,
                     Duration2, Durations...>::type;
};

template <class ...T>
using lcm_type_t = typename lcm_type<T...>::type;

Вы можете преобразовать обе длительности ввода в lcm_type_t<Duration1, Duration2>, не опасаясь переполнения, а затем выполнить сравнение.

Проблема с этой техникой в ​​том, что она не точная. Две немного отличающиеся длительности могут преобразовать в lcm_type_t и из-за потерь на усечение сравнить равными. По этой причине я предпочитаю решение с double, но было бы хорошо иметь lcm_type в вашем наборе инструментов тоже.

...