跳转到内容

日期与时间工具

本章讨论的是 C++ 标准库提供的时间相关功能,统称为 chrono 库。它是一组用于处理时间与日期的类和函数,主要由以下几个组成部分构成:

  • duration
  • clock
  • time_point
  • 日期
  • 时区

所有内容都定义在 <chrono> 中的 std::chrono 命名空间里。不过,在正式讨论这些 chrono 库组件之前,我们需要先稍微绕个小弯,看看 C++ 提供的编译期有理数支持,因为 chrono 库对它有大量依赖。

Ratio 库允许你精确表示任何能在编译期使用的有限有理数。所有内容都定义在 <ratio> 中,并位于 std 命名空间里。有理数的分子和分母以 std::intmax_t 类型的编译期常量表示;std::intmax_t 是编译器所支持的位宽最大的有符号整数类型。由于这些有理数是编译期实体,它们的使用方式看起来可能和你平常熟悉的对象不太一样。你不能像定义普通对象那样去定义一个 ratio 对象,也不能直接对它调用成员函数。ratio 实际上是一个类模板,而 ratio 类模板的某个具体实例化,就表示一个具体的有理数。为了给这种具体实例化命名,你可以使用类型别名。比如,下面定义了一个表示分数 1/60 的编译期有理数:

using r1 = ratio<1, 60>;

r1 这个有理数的分子(num)和分母(den)都是编译期常量,可以像下面这样访问:

intmax_t num { r1::num };
intmax_t den { r1::den };

请记住,ratio 表示的是一个 编译期有理数,这意味着分子和分母都必须在编译期已知。下面这段代码会产生编译错误:

intmax_t n { 1 }; // 分子
intmax_t d { 60 }; // 分母
using r1 = ratio<n, d>; // 错误

nd 变成常量之后就没有问题了:

const intmax_t n { 1 }; // 分子
const intmax_t d { 60 }; // 分母
using r1 = ratio<n, d>; // 正确

有理数总是会被规范化。对于一个有理数 ratio<n, d>,首先会计算它的最大公约数,也就是 gcd,然后分子 num 与分母 den 会按如下方式定义:

  • num = sign(n)*sign(d)*abs(n)/gcd
  • den = abs(d)/gcd

这个库支持对有理数做加、减、乘、除。不过,你不能使用普通的算术运算符,因为这些运算依然不是发生在对象上,而是发生在类型上,也就是 ratio 类模板的实例化上,并且是在编译期完成的。取而代之的是一组专门的算术 ratio 类模板。可用的模板包括:ratio_addratio_subtractratio_multiplyratio_divide,它们分别执行加法、减法、乘法和除法。这些模板会把结果计算成一个新的 ratio 类型,而这个类型可以通过其内部名为 type 的类型别名取出。举例来说,下面的代码先定义了两个 ratio:一个表示 1/60,另一个表示 1/30。ratio_add 会把它们相加,得到 result 这个有理数;规范化之后,它的值是 1/20:

using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using result = ratio_add<r1, r2>::type;

标准还定义了一系列 ratio 比较类模板:ratio_equalratio_not_equalratio_lessratio_less_equalratio_greaterratio_greater_equal。和算术 ratio 类模板一样,这些比较也是在编译期完成的,作用对象仍然不是对象,而是 ratio 类型。这些比较模板会定义一个新类型,它是一个 std::bool_constant,表示比较结果。bool_constantstd::integral_constant 的一种;std::integral_constant 是一个 struct 模板,用来保存一个类型以及一个编译期常量值。例如,integral_constant<int, 15> 保存的是一个值为 15 的整数。bool_constant 则是类型为 boolintegral_constant。例如,bool_constant<true> 就是 integral_constant<bool, true>,它保存的是值为 true 的布尔量。ratio 比较模板的结果不是 bool_constant<true>,就是 bool_constant<false>bool_constantintegral_constant 中保存的值可以通过 value 数据成员访问。下面这个例子演示了 ratio_less 的用法:

using r1 = ratio<1, 60>;
using r2 = ratio<1, 30>;
using res = ratio_less<r2, r1>;
println("{}", res::value); // 假

下面这段代码把刚刚介绍的内容串到了一起。由于 ratio 不是对象,而是类型,因此你不能直接写 println("{}", r1) 这种代码;你必须先把分子和分母取出来,再分别打印。

// 定义一个编译期有理数。
using r1 = ratio<1, 60>;
// 获取分子与分母。
intmax_t num { r1::num };
intmax_t den { r1::den };
println("1) r1 = {}/{}", num, den);
// 将两个有理数相加。
using r2 = ratio<1, 30>;
println("2) r2 = {}/{}", r2::num, r2::den);
using result = ratio_add<r1, r2>::type;
println("3) sum = {}/{}", result::num, result::den);
// 比较两个有理数。
using res = ratio_less<r2, r1>;
println("4) r2 < r1: {}", res::value);

输出如下:

1) r1 = 1/60
2) r2 = 1/30
3) sum = 1/20
4) r2 < r1: false

为了方便使用,这个库还提供了一组 SI(Système International)类型别名,如下所示:

using yocto = ratio<1, 1'000'000'000'000'000'000'000'000>; // *
using zepto = ratio<1, 1'000'000'000'000'000'000'000>; // *
using atto = ratio<1, 1'000'000'000'000'000'000>;
using femto = ratio<1, 1'000'000'000'000'000>;
using pico = ratio<1, 1'000'000'000'000>;
using nano = ratio<1, 1'000'000'000>;
using micro = ratio<1, 1'000'000>;
using milli = ratio<1, 1'000>;
using centi = ratio<1, 100>;
using deci = ratio<1, 10>;
using deca = ratio<10, 1>;
using hecto = ratio<100, 1>;
using kilo = ratio<1'000, 1>;
using mega = ratio<1'000'000, 1>;
using giga = ratio<1'000'000'000, 1>;
using tera = ratio<1'000'000'000'000, 1>;
using peta = ratio<1'000'000'000'000'000, 1>;
using exa = ratio<1'000'000'000'000'000'000, 1>;
using zetta = ratio<1'000'000'000'000'000'000'000, 1>; // *
using yotta = ratio<1'000'000'000'000'000'000'000'000, 1>; // *

末尾带星号的 SI 单位,只有在编译器能把相应类型别名中的常量分子与分母表示为 intmax_t 时才会定义。在下一节讨论 duration 时,你会看到这些预定义 SI 单位的一个使用示例。

duration 表示两个时间点之间的区间。它由 duration 类模板表示,内部保存一个 tick 数量和一个 tick period。tick period 表示两个 tick 之间间隔多少秒,它本身由一个编译期 ratio 常量来表示,因此可以是 1 秒的某个分数。Ratio 的内容已经在上一节讨论过。duration 模板接收两个模板类型参数,定义如下:

template <class Rep, class Period = ratio<1>> class duration {}

第一个模板参数 Rep 是保存 tick 数量的变量类型,应当是算术类型,例如 longdouble 等。第二个模板参数 Period 是表示单个 tick 周期的有理常量。如果你没有指定 tick period,就会使用默认值 ratio<1>,它表示每个 tick 的周期是 1 秒。

duration 提供三个 constructor:默认 constructor;一个接收单个值(tick 数量)的 constructor;以及一个接收另一个 duration 的 constructor。最后这个 constructor 可用于把一种 duration 转成另一种 duration,例如从分钟转换成秒。本节稍后会给出例子。

duration 支持 +-*/%++--+=-=*=/=%= 等算术操作,也支持比较运算符 ==<=>。此外,这个类还包含下表所列的成员函数:

成员函数说明
Rep count() const返回 duration 的 tick 数量。返回类型就是 duration 模板第一个模板类型参数指定的类型。
static duration zero()返回一个值等于 0 的 duration
static duration min() / static duration max()返回当前 duration 模板第一个模板类型参数所能表示的最小/最大 duration 值。

这个库还支持对 duration 使用 floor()ceil()round()abs(),其行为与处理数值数据时相同。

下面来看看如何定义 duration。一个“每个 tick 代表 1 秒”的 duration 可以写成:

duration<long> d1;

由于 ratio<1> 是默认 tick period,这和下面的写法等价:

duration<long, ratio<1>> d1;

下面这条语句定义了一个按分钟计的 duration(tick period = 60 秒):

duration<long, ratio<60>> d2;

下面则是一个“每个 tick 等于 1/60 秒”的 duration

duration<double, ratio<1, 60>> d3;

正如本章前面提到的,<ratio> 定义了一组 SI 有理常量。这些预定义常量在定义 tick period 时非常方便。例如,下面这条语句定义了一个每个 tick 周期为 1 毫秒的 duration

duration<long long, milli> d4;

下面来看看 duration 的实际用法。下面这个例子演示了 duration 的多个方面:如何定义它,如何进行算术运算,如何把它打印到屏幕上,以及如何把一种 tick period 的 duration 转换成另一种 tick period 的 duration

// 定义一个每个 tick 等于 60 秒的 duration。
duration<long, ratio<60>> d1 { 123 };
println("{} ({})", d1, d1.count());
// 定义一个以 double 表示、每个 tick 等于 1 秒的 duration,
// 并把它初始化为可表示的最大 duration。
auto d2 { duration<double>::max() };
println("{}", d2);
// 定义两个 duration:
// 第一个 duration 的每个 tick 是 1 分钟。
// 第二个 duration 的每个 tick 是 1 秒。
duration<long, ratio<60>> d3 { 10 }; // = 10 分钟
duration<long, ratio<1>> d4 { 14 }; // = 14 秒
// 比较这两个 duration。
if (d3 > d4) { println("d3 > d4"); }
else { println("d3 <= d4"); }
// 将 d4 递增 1,结果变为 15 秒。
++d4;
// 将 d4 乘以 2,结果变为 30 秒。
d4 *= 2;
// 把两个 duration 相加,并以分钟保存结果。
duration<double, ratio<60>> d5 { d3 + d4 };
// 把两个 duration 相加,并以秒保存结果。
duration<long, ratio<1>> d6 { d3 + d4 };
println("{} + {} = {} or {}", d3, d4, d5, d6);
// 创建一个表示 30 秒的 duration。
duration<long> d7 { 30 };
// 把 d7 的秒数转换成分钟。
duration<double, ratio<60>> d8 { d7 };
println("{} = {}", d7, d8);
println("{} seconds = {} minutes", d7.count(), d8.count());

输出如下:

123min (123)
1.79769e+308s
d3 > d4
10min + 30s = 10.5min or 630s
30s = 0.5min
30 seconds = 0.5 minutes

请特别留意下面两行代码:

duration<double, ratio<60>> d5 { d3 + d4 };
duration<long, ratio<1>> d6 { d3 + d4 };

它们都在计算 d3 + d4,其中 d3 以分钟表示,d4 以秒表示。但第一条语句把结果存成“以分钟为单位的浮点数”,而第二条语句把结果存成“以秒为单位的整数值”。从分钟到秒,或从秒到分钟的转换,都会自动发生。

示例中的下面两行则演示了如何在不同时间单位之间进行显式含义上的转换:

duration<long> d7 { 30 }; // 秒
duration<double, ratio<60>> d8 { d7 }; // 分钟

第一条语句定义了一个表示 30 秒的 duration。第二条语句把这 30 秒转换成分钟,结果是 0.5 分钟。朝这个方向转换时,结果可能不是整数,因此你必须使用底层表示为浮点类型的 duration;否则就会得到一些令人费解的编译错误。例如,下面这些语句之所以不能编译,就是因为 d8 使用的是 long,而不是浮点类型:

duration<long> d7 { 30 }; // 秒
duration<long, ratio<60>> d8 { d7 }; // 分钟 // 错误!

不过,你仍然可以使用 duration_cast() 强制完成这种转换:

duration<long> d7 { 30 }; // 秒
auto d8 { duration_cast<duration<long, ratio<60>>>(d7) }; // 分钟

在这个例子里,d8 的值会是 0 分钟,因为把 30 秒转换成分钟时使用的是整数除法。

朝相反方向转换时,如果源是整数类型,就不需要浮点类型,因为只要起点是整数值,结果就总会是整数值。例如,下面这些语句把 10 分钟转换成秒,并且两边都使用整数类型 long

duration<long, ratio<60>> d9 { 10 }; // 分钟
duration<long> d10 { d9 }; // 秒

这个库在 std::chrono 命名空间中提供了如下标准 duration 类型:

using nanoseconds = duration<X 64 bits, nano>;
using microseconds = duration<X 55 bits, micro>;
using milliseconds = duration<X 45 bits, milli>;
using seconds = duration<X 35 bits>;
using minutes = duration<X 29 bits, ratio<60>>;
using hours = duration<X 23 bits, ratio<3'600>>;
using days = duration<X 25 bits, ratio_multiply<ratio<24>, hours::period>>;
using weeks = duration<X 22 bits, ratio_multiply<ratio<7>, days::period>>;
using years = duration<X 17 bits,
ratio_multiply<ratio<146'097, 400>, days::period>>;
using months = duration<X 20 bits, ratio_divide<years::period, ratio<12>>>;

这里的 X 的精确类型由编译器决定,但 C++ 标准要求它必须是至少具有指定大小的有符号整数类型。前面的这些类型别名用到了本章前面介绍的预定义 SI ratio 类型别名。有了这些预定义类型之后,原本像下面这样写:

duration<long, ratio<60>> d9 { 10 }; // 分钟

你就可以简化成这样:

minutes d9 { 10 }; // 分钟

下面这段代码是另一个使用这些预定义 duration 的示例。代码首先定义变量 t,它表示 1 小时 + 23 分钟 + 45 秒的结果。这里使用 auto,让编译器自动推导 t 的精确类型。第二条语句则借助预定义 seconds duration 的构造函数,把 t 转换成秒并输出到控制台:

auto t { hours { 1 } + minutes { 23 } + seconds { 45 } };
println("{}", seconds { t });

由于标准要求这些预定义 duration 使用整数类型,因此只要某种转换 有可能 产生非整数值,就可能触发编译错误。虽然普通整数除法通常只是直接截断,但在 duration 这里,因为它是用 ratio 类型实现的,编译器会把任何 可能 产生非零余数的计算都判定为编译期错误。例如,下面这段代码不能编译,因为把 90 秒转换成分钟会得到 1.5 分钟:

seconds s { 90 };
minutes m { s };

不过,即便下面这段代码中 60 秒恰好等于 1 分钟,它同样不能编译。原因是:从秒转换成分钟这一操作 有可能 产生非整数值,因此仍然会被标记为编译期错误:

seconds s { 60 };
minutes m { s };

朝相反方向转换则完全没有问题,因为 minutes 使用的是整数类型,把它转换成 seconds 总是会得到整数值:

minutes m { 2 };
seconds s { m };

你可以使用标准字面量 hminsmsusns 来创建 duration。从技术上说,它们定义在 std::literals::chrono_literals 命名空间中;不过,就像第 2 章“使用字符串与字符串视图”中介绍的标准字符串字面量一样,chrono_literals 是一个内联命名空间。因此,你可以用下面任意一种 using 指令把这些 chrono 字面量引入作用域:

using namespace std;
using namespace std::literals;
using namespace std::chrono_literals;
using namespace std::literals::chrono_literals;

另外,这些字面量也会在 std::chrono 命名空间中可用。示例如下:

using namespace std::chrono;
// …
auto myDuration { 42min }; // 42 minutes

chrono 库提供了 hh_mm_ss 类模板。它接收一个 Duration,并把给定的 duration 拆分成小时、分钟、秒以及秒以下部分。它提供 hours()minutes()seconds()subseconds() 这几个访问函数来获取对应数据,并且总是返回非负值。is_negative() 成员函数则在 duration 为负时返回 true,否则返回 false。你会在本章最后的一道练习里用到 hh_mm_ss 类模板。

clock 是一个由 time_pointduration 组成的类。time_point 类型会在下一节详细讨论,不过理解 clock 的工作方式并不需要先掌握 time_point 的全部细节。另一方面,time_point 本身又依赖 clock,所以先弄清 clock 是很重要的。

标准定义了多个 clock,如下表所示。clock 的 epoch 指的是它开始计时的那个时刻。

时钟说明纪元
system_clock表示系统范围的实时时钟给出的 UTC 挂钟时间。1970-01-01 00:00:00
steady_clock保证其 time_point 永远不会倒退,而 system_clock 并不保证这一点,因为系统时钟可能随时被调整。事实上,这个时钟甚至不要求和挂钟时间有直接关系;例如,它可以表示“从操作系统启动以来经过的时间”。未指定
high_resolution_clock拥有尽可能短的 tick period。取决于编译器,它可能只是 steady_clocksystem_clock 的别名。未指定
utc_clock表示协调世界时(UTC)的挂钟时间。1970-01-01 00:00:00
tai_clock表示 International Atomic Time(TAI),它基于多个原子钟的加权平均。1958-01-01 00:00:00
gps_clock表示 Global Position System(GPS)时间,也就是 GPS 卫星原子钟维护的时间。1980-01-06 00:00:00
file_clock表示文件时间。它是 std::filesystem::file_time_type 的别名。未指定,但通常 Unix 上是 1970-01-01,Windows 上是 1601-01-01

utc_clock 是唯一会追踪闰秒的 clock。所谓闰秒,是为了修正 UTC 时间与真实太阳时之间的偏差,而偶尔插入或删去的一秒。其他 clock 都不跟踪闰秒;而对 file_clock 来说,这一点则是未指定的。

每个 clock 都有一个静态 now() 成员函数,用来获取当前时间并返回一个 time_point;同时还有一个 is_steady() 成员函数,如果该 clock 是 steady 的、也就是绝不会倒退,就返回 true,否则返回 false

system_clock 还定义了两个静态辅助成员函数,用于在 time_point 与 C 风格时间表示 time_t 之间进行转换。第一个叫 to_time_t(),它把给定的 time_point 转换成 time_t;第二个叫 from_time_t(),则执行相反的转换。time_t 类型定义在 <ctime> 中。

下面这个例子演示了如何获取当前 UTC 时间,并以人类可读的格式输出到控制台:

// 将全局 locale 设为用户自己的本地区域设置(见第 21 章)。
locale::global(locale { "" });
// 打印当前 UTC 时间。
println("UTC: {:L}", system_clock::now());
println("UTC: {:L%c}", system_clock::now());

这段代码首先把全局 locale 设置为用户自己的 locale;详见第 21 章“字符串本地化与正则表达式”。这样可以确保所有内容都按照用户偏好的方式输出。println() 语句使用 L 格式说明符,根据当前配置的全局 locale 来格式化日期与时间。示例里也展示了 %c 格式说明符的效果。除此之外,还有很多其他可用的格式说明符。要了解更多,请查阅标准库参考资料。下面是前述代码在我的系统上的一份示例输出:

UTC: 2023-07-19 11:38:44,5521944
UTC: 2023-07-19 11:38:44

如果你想测量一段代码执行了多长时间,就应该使用一个保证不会倒退的 clock。因此,应使用 steady_clock。下面的代码片段给出了一个例子。变量 startend 的实际类型都是 steady_clock::time_point,而 diff 的实际类型则是一个 duration

// 获取起始时间。
auto start { steady_clock::now() };
// 执行你要计时的代码。
const int numberOfIterations { 10'000'000 };
double d { 0 };
for (int i { 0 }; i < numberOfIterations; ++i) { d += sqrt(abs(sin(i) * cos(i))); }
// 获取结束时间并计算差值。
auto end { steady_clock::now() };
auto diff { end - start };
// 使用计算结果,否则编译器可能会
// 把整个循环优化掉!
println("d = {}", d);
// 将差值转换为毫秒并输出到控制台。
println("Total: {}", duration<double, milli> { diff });
// 如果不需要带小数的毫秒值,可以使用 duration_cast()。
println("Total: {}", duration_cast<milliseconds>(diff));
// 以纳秒为单位打印每次迭代耗时。
println("{} per iteration", duration<double, nano> { diff / numberOfIterations });

在我的测试系统上,输出如下:

d = 5393526.082683575
Total: 78.7931ms
Total: 78ms
7ns per iteration

这个例子里的循环会执行 sqrt()abs()sin()cos() 等算术运算,以确保循环不会结束得太快。如果你在自己的系统上得到的毫秒差值非常小,那么这些结果往往并不准确,此时你应增加循环迭代次数,让它运行更久一些。计时值过小时之所以不准确,是因为虽然定时器往往以毫秒为分辨率,但在大多数操作系统里,这个定时器实际更新得并不频繁,例如每 10ms 或 15ms 才更新一次。这会带来一种叫 gating error(门控误差)的现象:任何发生在一个定时器 tick 之内的事件,看起来都会花费 0 个时间单位;而任何发生在一个到两个定时器 tick 之间的事件,看起来则会花费 1 个时间单位。例如,在一个定时器每 15ms 更新一次的系统上,一个真实耗时 44ms 的循环,看起来只会像耗时 30ms。使用这类定时器测量计算时间时,务必确保整个计算跨越了足够多的基本定时器 tick 单位,以尽可能降低这些误差。

一个时间点由 time_point 类表示,它内部以相对于某个 epochduration 来存储,而 epoch 表示“时间的起点”。每个 time_point 总是关联着某个特定的 clock,而这个关联 clock 的 epoch 就是该 time_point 的原点。例如,经典 Unix/Linux 时间的 epoch 是 1970 年 1 月 1 日,duration 以秒计量;Windows 的 epoch 则是 1601 年 1 月 1 日,duration 以 100 纳秒为单位。其他操作系统也各有不同的 epoch 日期和 duration 单位。

time_point 类有一个叫做 time_since_epoch() 的函数,它返回一个 duration,表示关联 clock 的 epoch 与当前存储时间点之间的间隔。

凡是有意义的 time_pointduration 算术操作都受支持。下表列出了这些操作,其中 tp 是一个 time_pointd 是一个 duration

tp + d = tptp - d = tp
d + tp = tptp - tp = d
tp += dtp -= d

一个不受支持的例子是 tp + tp

标准支持使用 ==<=> 来比较两个 time_point,并提供两个静态成员函数:min()max(),分别返回最小和最大的可能时间点。

time_point 类提供三个构造函数:

  • time_point(): 构造一个以 duration::zero() 初始化的 time_point。结果表示的是关联 clock 的 epoch。
  • time_point(const duration& d): 构造一个以给定 duration 初始化的 time_point。结果表示 epoch + d
  • template<class Duration2> time_point(const time_point<clock, Duration2>& t): 构造一个以 t.time_since_epoch() 初始化的 time_point

每个 time_point 都关联着某个 clock。创建 time_point 时,你需要把 clock 作为模板参数写出来:

time_point<steady_clock> tp1;

每个 clock 自己也知道对应的 time_point 类型,因此你也可以这样写:

steady_clock::time_point tp1;

下面这段代码演示了一些 time_point 操作:

// 创建一个表示关联 steady clock 的 epoch 的 time_point。
time_point<steady_clock> tp1;
// 给该 time_point 加上 10 分钟。
tp1 += minutes { 10 };
// 保存 epoch 与该 time_point 之间的 duration。
auto d1 { tp1.time_since_epoch() };
// 将 duration 转换为秒并输出到控制台。
duration<double> d2 { d1 };
println("{}", d2);

输出如下:

600s

duration 的转换类似,time_point 的转换也可以是隐式的,也可以是显式的。下面是一个隐式转换的例子。输出结果是 42000ms

time_point<steady_clock, seconds> tpSeconds { 42s };
// 将秒隐式转换为毫秒。
time_point<steady_clock, milliseconds> tpMilliseconds { tpSeconds };
println("{}", tpMilliseconds.time_since_epoch());

如果隐式转换可能导致数据丢失,那么你就需要使用 time_point_cast() 做显式转换,这与前面使用 duration_cast() 进行显式 duration 转换的方式完全类似。下面这个例子会输出 42000ms,尽管起点其实是 42,424ms:

time_point<steady_clock, milliseconds> tpMilliseconds { 42'424ms };
// 将毫秒显式转换为秒。
time_point<steady_clock, seconds> tpSeconds {
time_point_cast<seconds>(tpMilliseconds) };
// 或者:
// auto tpSeconds { time_point_cast<seconds>(tpMilliseconds) };
// 再把秒转换回毫秒,并输出结果。
milliseconds ms { tpSeconds.time_since_epoch() };
println("{}", ms);

这个库还支持对 time_point 使用 floor()ceil()round(),其行为与处理数值数据时相同。

标准库支持处理日历日期。目前只支持公历;不过如果需要,你也完全可以实现自己的日历,并让它与 <chrono> 的其他功能协同工作,例如科普特历和儒略历。

标准库提供了相当多的类和函数来处理日期(以及稍后将讨论的时区)。本节只介绍其中最重要的一部分。若想获得完整总览,请查阅标准库参考资料(见附录 B“注释书目”)。

下面这些日历类都可用于创建日期,且都定义在 std::chrono 中:

说明
year表示区间 [-32767, 32767] 内的某一年。year 有一个名为 is_leap() 的成员函数,用来判断给定年份是否是闰年;若是则返回 true,否则返回 false。静态成员函数 min()max() 分别返回最小与最大年份。
month表示区间 [1, 12] 内的某个月份。此外,还为 12 个月提供了 12 个具名常量,例如 std::chrono::January
day表示区间 [1, 31] 内的某一天。
weekday表示区间 [0, 6] 内的一周某一天,其中 0 表示 Sunday。此外,还为七个星期几提供了七个具名常量,例如 std::chrono::Sunday
weekday_indexed表示某个月中的第 1、2、3、4 或第 5 个星期几。它可以很方便地由 weekday 构造,例如 Monday[2] 表示某个月的第二个星期一。
weekday_last表示某个月的最后一个星期几。
month_day表示一个具体的月和日。
month_day_last表示某个具体月份的最后一天。
month_weekday表示某个具体月份中的第 n 个星期几。
month_weekday_last表示某个具体月份中的最后一个星期几。
year_month表示一个具体的年和月。
year_month_day表示一个具体的年、月、日。
year_month_day_last表示某个具体年与月中的最后一天。
year_month_weekday表示某个具体年与月中的第 n 个星期几。
year_month_weekday_last表示某个具体年与月中的最后一个星期几。

所有这些类都有一个名为 ok() 的成员函数:如果对象处于有效范围内,则返回 true,否则返回 falsestd::literals::chrono_literals 还额外提供了两个标准字面量:y 用来创建 yeard 用来创建 day。完整日期可以通过 operator/ 组合出 yearmonthday,并支持三种顺序:Y/M/D、M/D/Y 和 D/M/Y。下面是一些创建日期的示例:

year y1 { 2020 };
auto y2 { 2020y };
month m1 { 6 };
auto m2 { June };
day d1 { 22 };
auto d2 { 22d };
// 创建一个表示 2020-06-22 的日期。
year_month_day fulldate1 { 2020y, June, 22d };
auto fulldate2 { 2020y / June / 22d };
auto fulldate3 { 22d / June / 2020y };
// 创建一个表示 2020 年 6 月第 3 个星期一的日期。
year_month_day fulldate4 { Monday[3] / June / 2020 };
// 创建一个年份未指定、表示 6 月 22 日的 month_day。
auto june22 { June / 22d };
// 创建一个表示 2020-06-22 的 year_month_day。
auto june22_2020 { 2020y / june22 };
// 创建一个年份未指定、表示某年 6 月最后一天的 month_day_last。
auto lastDayOfAJune { June / last };
// 创建一个表示 2020 年 6 月最后一天的 year_month_day_last。
auto lastDayOfJune2020 { 2020y / lastDayOfAJune };
// 创建一个表示 2020 年 6 月最后一个星期一的 year_month_weekday_last。
auto lastMondayOfJune2020 { 2020y / June / Monday[last] };

sys_time 是一个类型别名,表示“某个特定 duration 精度的 system_clock time_point”。它定义如下:

template <typename Duration>
using sys_time = std::chrono::time_point<std::chrono::system_clock, Duration>;

基于 sys_time,标准还定义了两个额外的类型别名:一个表示精度为秒的 sys_time,另一个表示精度为天的 sys_time

using sys_seconds = sys_time<std::chrono::seconds>;
using sys_days = sys_time<std::chrono::days>;

例如,sys_days 表示自 system_clock epoch 以来的天数,因此它属于一种 基于序列的类型;也就是说,它只保存一个单独数字(自 epoch 以来经过的天数)。相比之下,例如 year_month_day 则属于 基于字段的类型,它会把 yearmonthday 分别保存在独立字段中。如果你要对日期做大量算术运算,那么基于序列的类型通常会比基于字段的类型更高效。

类似的类型别名也存在于本地时间相关场景中:local_timelocal_secondslocal_days。这些内容会在稍后的时区一节中演示。

你可以像下面这样创建一个表示“今天”的 sys_days。这里使用 floor() 将一个 time_point 截断到天精度:

auto today { floor<days>(system_clock::now()) };

sys_days 还可以用来把 year_month_day 转换成 time_point,例如:

system_clock::time_point t1 { sys_days { 2020y / June / 22d } };

反过来,把 time_point 转换成 year_month_day,则可以通过 year_month_day 的 constructor 完成。下面这段代码给出了两个例子:

year_month_day yearmonthday { floor<days>(t1) };
year_month_day today2 { floor<days>(system_clock::now()) };

包含具体时间的完整日期也可以构造出来。示例如下:

// 带时间的完整日期:2020-06-22 09:35:10 UTC。
auto t2 { sys_days { 2020y / June / 22d } + 9h + 35min + 10s };

日期可以使用熟悉的插入运算符写入流:

cout << yearmonthday << endl;

同时也支持日期的打印与格式化。L 格式说明符会根据当前全局 locale 来格式化输出。

println("{:L}", yearmonthday);

需要注意的是,输出结果有时可能并不是你直觉上想要的。例如,前面把 lastMondayOfJune2020 定义为:

// 创建一个表示 2020 年 6 月最后一个星期一的 year_month_weekday_last。
auto lastMondayOfJune2020 { 2020y / June / Monday[last] };

直接打印它时,输出会是 2020/Jun/Mon[last]

println("{:L}", lastMondayOfJune2020); // 2020/Jun/Mon[last]

如果你想输出的是确切日期,也就是 2020-06-29,那就需要先把 year_month_weekday_last 转换成 year_month_day,再输出结果:

year_month_day lastMondayOfJune2020YMD { sys_days { lastMondayOfJune2020 } };
println("{:L}", lastMondayOfJune2020YMD); // 2020-06-29

如果日期本身无效,打印时会插入错误信息。例如,对一个无效的 year_month_day,输出结果中会附加字符串 is not a valid date

使用 L 格式说明符时,星期名和月份名也会按照当前全局 locale 正确本地化。例如,下面的代码片段先把全局 locale 设成荷兰语 nl-NL,然后使用 L 格式说明符以荷兰语打印 Monday%A 格式说明符会输出完整名称,而不是缩写形式。关于全部受支持日期格式说明符的完整列表,请查阅你手边喜欢的标准库参考资料。

locale::global(locale { "nl-NL" });
println("Monday in Dutch is {:L%A}", Monday);

输出如下:

Monday in Dutch is maandag

你可以对日期执行算术操作。示例如下:

// 带时间的完整日期:2020-06-22 09:35:10 UTC。
auto t2 { sys_days { 2020y / June / 22d } + 9h + 35min + 10s };
auto t3 { t2 + days { 5 } }; // 给 t2 加 5 天。
auto t4 { t3 + years { 1 } }; // 给 t3 加 1 年。

不过需要小心,结果并不总会如你预期。比如:

auto t5 { sys_days { 2020y / June / 22d } + 9h + 35min + 10s };
auto t6 { t5 + years { 1 } }; // 给 t5 加 1 年
println("t5 = {:L}", t5);
println("t6 = {:L}", t6);

结果如下:

t5 = 2020-06-22 09:35:10
t6 = 2021-06-22 15:24:22

从结果中你可以看到,年份的确更新了,但时间也跟着发生了变化。问题在于,这里我们处理的是一种基于序列的类型:sys_days 是一个 time_point,而 time_point 本身就是基于序列的类型。给这种基于序列的类型加 1 年,并不是简单地加上 86,400 * 365 = 31,536,000 秒。相反,标准要求“加 1 年”必须加上 1 个 平均年,以便把闰年因素考虑进去,因此它实际加的是 86,400 * ((365 * 400) + 97) / 400 = 31,556,952 秒。

如果你需要精确地“加 1 年”,那么最好改用基于字段的类型,例如:

// 将 t5 拆分为天数和剩余秒数。
sys_days t5_days { time_point_cast<days>(t5) };
seconds t5_seconds { t5 - t5_days };
// 将 t5_days 这个基于序列的类型转换成基于字段的类型。
year_month_day t5_ymd { t5_days };
// 加 1 年。
year_month_day t7_ymd { t5_ymd + years { 1 } };
// 再转换回基于序列的类型。
auto t7 { sys_days { t7_ymd } + t5_seconds };
println("t7 = {:L}", t7);

这将得到:

t7 = 2021-06-22 09:35:10

为了方便处理时区,C++ 标准库内置了一份互联网号码分配局(IANA)时区数据库副本(www.iana.org/time-zones)。你可以通过调用 std::chrono::get_tzdb() 来访问这个数据库;它会返回对唯一现有 std::chrono::tzdb 实例的 const 引用。这个数据库通过一个公开的 vector 成员 zones 提供对所有已知时区的访问。这个 vector 中的每一项都是一个 time_zone;它有一个名字,可通过 name() 访问,并且提供 to_sys()to_local() 成员函数,用于在 local_timesys_time 之间互相转换。由于夏令时的存在,把 local_time 转换成 sys_time 时,可能会遇到“有歧义”或“根本不存在”的时间点。在这种情况下,转换会分别抛出 ambiguous_local_timenonexistent_local_time 类型的异常。

下面这段代码会列出所有可用时区:

const auto& database { get_tzdb() };
for (const auto& timezone : database.zones) {
println("{}", timezone.name());
}

std::chrono::locate_zone() 函数可以根据名称取回一个 time_zone;如果请求的时区在数据库中找不到,它会抛出 runtime_error 异常。current_zone() 函数则可用于获取当前时区。例如:

auto* brussels { locate_zone("Europe/Brussels") };
auto* gmt { locate_zone("GMT") };
auto* current { current_zone() };

time_zone 实例可用于在不同时区之间转换时间:

// 将当前时间(UTC)转换为 Brussels 时间和当前时区时间。
auto nowUTC { system_clock::now() }; // UTC 时间。
auto nowInBrussels { brussels->to_local(nowUTC) }; // Brussels 时区时间。
auto nowInCurrentZone { current->to_local(nowUTC) }; // 当前时区时间。
println("Now UTC: {:L%c}", nowUTC);
println("Now Brussels: {:L%c}", nowInBrussels);
println("Now in current: {:L%c}", nowInCurrentZone);
// 构造一个 UTC 时间。(2020-06-22 09:35:10 UTC)
auto t { sys_days { 2020y / June / 22d } + 9h + 35min + 10s };
// 将 UTC 时间转换为 Brussels 本地时间。
auto converted { brussels->to_local(t) };
println("Converted: {:L}", converted);

zoned_time 类用于表示处在某个特定 time_zone 中的 time_point。下面这段代码先构造 Brussels 时区中的一个具体时间,再把它转换成 New York 时间:

// 在 Brussels 时区中构造一个本地时间。
zoned_time<hours> brusselsTime{ brussels, local_days { 2020y / June / 22d } + 9h };
// 转换为 New York 时间。
zoned_time<hours> newYorkTime { "America/New_York", brusselsTime };
println("Brussels: {:L}", brusselsTime.get_local_time());
println("New York: {:L}", newYorkTime.get_local_time());

本章讨论了如何使用 ratio 类模板来定义并处理编译期有理数。你还学习了如何使用 C++ 标准库通过 chrono 库提供的 durationclocktime_point、日期和时区功能。

下一章将聚焦于标准库提供的随机数生成功能。

通过完成下面这些练习,你可以动手巩固本章讨论过的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先重读本章相关部分,尽量自己找到答案,再去查看网站上的解法。

  1. 练习 22-1: 来稍微玩一玩 duration。创建一个名为 d1duration,精度为秒,并将其初始化为 42 秒。再创建第二个 duration,名为 d2,精度为分钟,并将其初始化为 1.5 分钟。计算 d1d2 的和。然后把结果输出到标准输出:一次以秒表示,一次以分钟表示。
  2. 练习 22-2: 让用户以 yyyy-mm-dd 的形式输入一个日期,例如 2020-06-22。使用正则表达式(见第 21 章)提取其中的 yearmonthday 组成部分,最后再用 year_month_day 来验证这个日期是否有效。
  3. 练习 22-3: 编写一个 getNumberOfDaysBetweenDates() 函数,用来计算两个给定日期之间相隔多少天。在你的 main() 函数中测试它的实现。
  4. 练习 22-4: 编写一个程序,输出 2020 年 6 月 22 日是星期几。
  5. 练习 22-5: 构造一个 UTC 时间。把这个时间转换成日本东京的本地时间;再把结果转换成 New York 时间;最后再把结果转换成 GMT。验证最初的 UTC 时间与最后得到的 GMT 时间是否相等。提示:Tokyo 的时区标识符是 Asia/Tokyo,New York 的是 America/New_York,GMT 的是 GMT
  6. 练习 22-6: 编写一个 getDurationSinceMidnight() 函数,返回从午夜到当前本地时间之间的 duration(以秒为单位)。使用这个函数把“自午夜以来已经过去了多少秒”输出到标准输出控制台。最后,再使用 hh_mm_ss 类把该函数返回的 duration 转换成小时、分钟与秒,并把结果输出到标准输出。