之前断断续续使用过chrono中的方法, 对于其中的概念总是一知半解, 导致每次查cppreference总是抓不到重点. 这次就试着用直白的语言, 描述下chrono的概念和原理. 这一篇主要着重于介绍chrono的核心, duration.

Chrono

为什么要引入chrono? 在chrono之前, 我们几乎都用一个数字来表示时间, 所有时间单位的转换也需要自己来进行. 尽管这些单位换算在数学上很简单, 但这些单位换算和单位修改会影响大量代码, 容易出错和遗漏. 比如某个函数的参数期望单位是秒, 而有一天其他人改成了毫秒, 所有这个函数的调用方都要进行相应修改, 否则可能会出现逻辑上的错误.

另外一块的复杂度在于, 一旦使用数字来表示时间, 可能会受到C++类型隐式转换的影响

chrono的出现就是为了避免用数字表现时间带来的各种隐式问题, 其本质是定义通过模版定义了不同单位的时间, 从而让编译器帮助程序员在编译期就能发现逻辑上的错误.

Duration

要理解chrono, 其基础就在于理解duration. duration的定义是一段时间, 可以是任意单位:

  • 3秒
  • 3分钟
  • 3小时

chrono中这些时间单位本质上都是一个duration:

std::chrono::hours
std::chrono::minutes
std::chrono::seconds
std::chrono::milliseconds
std::chrono::microseconds
std::chrono::nanoseconds

所有duration有以下特点, 我们以seconds为例:

  • seconds is an arithmetic-like type.
  • sizeof(seconds) == 8.
  • It is trivially destructible.
  • It is trivially default constructible.
  • It is trivially copy constructible.
  • It is trivially copy assignable.
  • It is trivially move constructible.
  • It is trivially move assignable.

可以看到它本身是traival的, 本质上和一个long long或者int64_t没有任何区别. 比如, seconds本质上就等同于如下实现(实际实现下面会重新说明)

class seconds {
  int64_t sec_;
 public:
  seconds() = default;
  // ...
};

所有duration的初始化可以有default initialization和zero initialization. 但不允许数字类型向duration类型的转换.

seconds s;      // no initialization
seconds s{};    // zero initialization
seconds s{3};   // 3 seconds

所有整形或者算数类型向duration的隐式转换都被编译器禁止

void f(seconds d) { cout << d.count() << "s\n"; }

seconds s = 3;  // error: Not implicitly constructible from int
f(3);           // error: Not implicitly constructible from int
f(seconds{3});  // ok, 3s
f(3s);          // ok, 3s, Requires C++14
seconds x{3};
f(x);           // ok, 3s

duration也支持逻辑运算符和一些算术运算符

auto x = 3s;
x += 2s;
if (x < 10s) {
  cout << "orz" << "\n";
}

所有duration都有一个count方法, 用于输出其数字表示形式. 但这个方法一般只用于IO输入输出, 而不应该依赖于这个数字进行计算.

以上就是duration的基本用法, 它本质上就是一个整数的wrapper, 其对外表现和一个整数没有很大区别. (除了禁止整数向duration的隐式转换)

Duration的作用

那么有人要问, chrono定义这么多duration的意义何在? 仅仅是为了禁止整形向时间类型的转换吗? duration的一个重要作用就是能够在自动完成不同时间单位的换算.

比如文章一开始提到的例子, 有一个函数接收一个单位为秒的函数如下所示:

void f(seconds d) { cout << d.count() << "s\n"; }

当有一天修改单位为毫秒后, 代码要么能够正常无误的运行, 要么在编译期就报错. 其背后的原理和编译器处理隐式转换的逻辑是一样的(比如允许float → double, int32_t → int64_t, 而反之通常会提示warning或者报错), chrono只允许向更精细类型的隐式转换.

void f(milliseconds d) { cout << d.count() << "ms\n"; }

f(seconds{3});  // ok, no change needed! 3000ms
f(3h);          // ok, no change needed! 3000ms
f(3456us);      // error: no conversion

auto x = 2s;
auto y = 3ms;
f(x + y);       // ok, 2003ms
f(y - x);       // ok, -1997ms

这样设计的原因在于, 不同时间单位的换算关系是已知的, 当执行不损失精度的单位换算时, 就不需要手动来完成这些单位换算. 当且仅当出现损失精度的类型转换时, 需要通过duration_cast手动完成.

void f(milliseconds d) { cout << d.count() << "ms\n"; }

f(3456us);                                // error
f(duration_cast<milliseconds>(3456us));   // ok, same as 3ms

duration_cast后面的模版参数代表目标类型, duration_cast永远都是往0的方向进行取整, 所以上面的3456us转换为了3ms, 而-3456us则会被转换为-3ms. (C++17中又引入了floor, ceil, round三种取整方式)

当我们使用chrono时需要进行不同单位之间的换算时:

  • 应当尽量使用隐式转换. 因为如果编译能够通过, 那就说明chrono能够正确处理
  • 当且仅当编译无法通过时, 此时需要通过duration_cast来进行显式转换, 并且强制要求程序员思考取整方式

隐式转换的性能, 是不亚于手动通过数字进行单位换算的, 编译时如果开了-O2的优化, 隐式转换和手动转换生成的汇编代码是相同的.

深入理解Duration

之前简单提过seconds的可能实现, 为了支持在编译器能够检查类型转换是否合法, duration的实现实际是通过模版完成的, chrono中内置的duration类型实际就是一个模版的实例化结果.

using std::chrono::nanoseconds  = std::chrono::duration<int64_t, std::nano>;
using std::chrono::microseconds = std::chrono::duration<int64_t, std::micro>;
using std::chrono::milliseconds = std::chrono::duration<int64_t, std::milli>;
using std::chrono::seconds      = std::chrono::duration<int64_t>;
using std::chrono::minutes      = std::chrono::duration<int64_t, std::ratio<60>>;
using std::chrono::hours        = std::chrono::duration<int64_t, std::ratio<3600>>;

模版原型如下:

template <class Rep, class Period = ratio<1>>
class duration {
public:
    using rep = Rep;
    using period = Period;
    // ...
};

第一个模版参数Rep是这个duration用什么数字类型来表示, 可以看到内置的几个类型都是int64_t. 对于seconds来说, int64_t足以可以表示2924亿年长度的时间, 而milliseconds可以表示2.924亿年长度的时间. 如果我们不需要表示这么长的时间怎么办? 比如, 当只需要用32位整形来表示seconds时, 我们就可以如此定义:

using seconds32 = std::chrono::duration<int32_t>;

我们甚至可以用浮点数类型来表示时间

using fseconds = duration<float>;
void f(fseconds d) {
  cout << d.count() << "s\n";
}
f(45ms + 63us);  // 0.045063s

void g(seconds d) {
  cout << d.count() << "s\n";
}
g(45ms + 63us);  // error: no known conversion

由于fseconds使用了浮点数, 上面的代码是能够编译并且运行的, 如果使用默认的seconds作为的参数类型则会编译报错, 因为无论是毫秒还是微秒都不能隐式转换为秒.

而在chrono的实现中, 当我们使用浮点数类型来表示时间时, 所有转换都不会存在truncation error(比如前文提到的3456ms转换为3s), 取而代之的是由于浮点数不能精确表示一个数字时, 会发生rounding error.

而第二个模板参数Period, 在chrono中本质上代表的则是时间的单位, 但对外的表现形式时这个单位和秒的换算关系, 这也是为什么称为ratio的原因. 我们举几个例子:

chrono中所有换算关系都是以秒为换算单位, 但ratio本身就是一个编译器运算的工作类, 没有任何实际含义.

// 1微秒是1/1000000秒
using micro = ratio<1, 1000000>;
using std::chrono::microseconds = std::chrono::duration<int64_t, std::micro>;

// 1分钟则是60/1 = 60秒
using std::chrono::minutes = std::chrono::duration<int64_t, std::ratio<60, 1>>;

// 1小时则是3600/1 = 3600秒
using std::chrono::hours = std::chrono::duration<int64_t, std::ratio<3600, 1>>;

基于这个模版, 我们可以自定义时间类型, 比如一个每秒60帧的游戏中, 每一帧的时长就可以如下定义:

using frames = duration<int32_t, ratio<1, 60>>;

void f(duration<float, milli> d) {
  // ...
}

f(frames{1});         // 16.6667ms
f(45ms + frames{5});  // 128.333ms

到这里, 我们总算能回答一个问题了: chrono是如何实现不同类型之间的运算? 我们就以上面的f(45ms + frames{5})为例, 它们的模版实际模版参数如下, <>中的模板参数都是编译器就确定好的:

// 首先我们计算求和的部分
45 <int64_t, 1/1000> + 5 <int32_t, 1/60>

当我们要计算两个不同单位时间时, 首先要将它们都转换成相同单位(不妨想象成两个单位的最小公倍数), 然后再进行相加:

// 1000和60的最小公倍数是3000, 上面的算式等同于
45 * 3 <int64_t, 1/3000> + 5 * 50 <int64_t, 1/3000>
// 单位相同, 就可以直接相加, 得到
385 <int64_t, 1/3000>
// 然后把上一步结果, 传到f这个参数时, 需要再向duration<float, milli>转换
// 由于(1/3000) / (1/1000) = (1/3), 最终得到
128.333 <float, 1/1000>

所以, chrono就是如此对两个不同类型的时间进行运算的. 给定任意类型, 在编译期就能够确定能否进行相应运算. 如果可以, 代码正常编译, 在运行期也不会产生算术运算之外的额外开销. 如果不能, 在编译期就会报错.

值得提示的另一点是, duration的模版如果没有自定义时间类型的需要, 是完全不需要关注的, 使用默认提供的duration类型即可.

这一篇到这就结束, 下一篇, 我们会基于duration, 介绍剩余的几个概念.

Tags:

Categories:

Updated: