我如何实际使用 std::chrono 类型而不冒溢出和未定义行为的风险?

How can I actually use std::chrono types without risking overflow and undefined behavior?

我用 std::chrono 很多年了,看过很多 Howard Hinnant 的作品 谈论图书馆的设计和使用。我喜欢它,我想我一般 明白它。然而,最近,我突然意识到,我不知道如何 实用地安全地使用它来避免未定义的行为。

请耐心等待我通过几个案例来为我的 问题。

先说我认为是"simplest"std::chrono::duration类型的, nanoseconds。它的最小 rep 大小是 64 位,这意味着实际上它 将是 std::int64_t 因此可能没有 "leftover" 可选 标准不需要的代表性位。

这个函数显然并不总是安全的:

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

如果valuenanoseconds::max(),则溢出,我们可以确认 使用 clang 7 的 UBSan (-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 值,我们想添加另一个 (unknown) 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())。但是那个案子 甚至不应该抛出,为什么会失败?

答案是即使 比较 hoursnanoseconds 的行为也涉及 转换。这是因为比较运算符是通过 使用 std::common_type,其中 std::chronoduration 类型定义 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(关于 this 特定的标准库实现,及其 特定 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 否则转换将毫无意义。

我们还没有放弃。无论如何,转换是另一个重要的案例,所以我们 应该知道如何安全地处理它们。当然这不会太难。

第一个障碍是我们需要知道我们是否可以从 hoursnanoseconds 而不会溢出 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+

但是,有一些非常严重的限制。首先,我们必须 "know" 如何 在 hoursnanoseconds 之间转换,这有点不合时宜。 其次,这只处理这两种非常特殊的类型,并且非常好 它们的 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);
}

如果duration1duration2真的可以是任何duration类型(包括 std::chrono::duration<std::int16_t, std::ratio<3, 7>> 之类的东西,我看不到 一种充满信心地进行的方法。但即使我们将自己限制在 "normal" duration种,有很多可怕的结果。

在某些方面,这种情况并不 "worse" 处理正常的固定大小 整数,就像每个人每天都做的那样,你经常 "ignore" 的可能性 溢出,因为您 "know" 您正在使用的值域。但, 令我惊讶的是,这些类型的解决方案似乎 "worse" 和 std::chrono 比他们对普通整数所做的要多,因为一旦您尝试确保安全 关于溢出,你最终会失去使用 std::chrono 的好处 第一名。

如果我根据无符号 rep 创建自己的 duration 类型,我想我 从技术上至少避免整数的一些未定义行为 溢出的观点,但我仍然可以很容易地从 "careless" 计算。 "problem space"好像是一样的

我对基于浮点类型的解决方案不感兴趣。我在用着 std::chrono 以保持我在每种情况下选择的精确分辨率。如果我 不关心精确或舍入误差,我可以很容易地使用 double 到处都计算秒数,而不是混合单位。但如果那是一个 每个问题的可行解决方案,我们不会有 std::chrono(甚至 struct timespec,就此而言)。

所以我的问题是,如何安全实用地使用 std::chrono 来做 就像将两个不同持续时间的值加在一起一样简单 害怕因整数溢出而导致未定义的行为?或者做无损 安全转换?即使已知,我也没有想出一个实用的解决方案 简单的 duration 类型,更不用说所有可能的丰富宇宙 duration 类型。我错过了什么?

最好的答案是了解您的域,并且不要在您使用的最大精度范围附近的任何地方编程。如果您使用 nanoseconds,范围是 +/- 292 年。不要靠近那么远。如果您需要的范围不仅仅是 +/- 100 年,请使用比纳秒更粗略的分辨率。

如果你能遵守这些规则,那么你就不用担心溢出了。

有时你不能。例如,如果您的代码必须处理不受信任的输入或一般输入(例如通用库),那么您确实需要检查溢出。

一种技术是选择一个 rep 仅用于比较,它可以处理比任何人需要的范围更大的范围,仅用于比较。 int128_tdouble 是我在这种情况下使用的两个工具。例如,这里有一个 checked_convert 在实际执行 duration_cast:

之前使用 double 检查溢出
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,而是找到两个输入 duration 可以无损转换为的 duration一个乘法。例如 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