C++ 的时间库之六:日历和时区
前面提到,C++ 11 的时间库提供了各种时钟、时间点以及时间间隔的计算与表达,但是却没有提供日期相关的类型,也没有提供与时区有关的本地时间转换等支持组件,所以用起来不是很顺手。直到 C++ 20 终于补齐了这块短板,时间库具备是时间、日期和时区的完整支持,终于可以替代不安全的 C 函数接口。本篇主要就是介绍 C++ 20 新增的日历和时区相关内容。
1 Time of day
设想一个与时间显式有关的场景,我们拿到了一个当前系统时间的时间点,希望在 UI 界面上显式这个时间。C++ 11 的方法就是先用时间点的 to_time_t() 方法将其转成一个 time_t 类型的秒的计数,然后再用 C 标准库提供的 localtime() 或 gmtime() 系列函数转成离散的时间数据结构 tm,这样就得到以本地时间表示的具体的时、分、秒以及日期信息,可用于界面的显示。这个过程存在两个问题,首先是与 C 的接口互转显得很突兀,其次是将高精度的时间点转成以秒为单位的 time_t 类型计数器会丢失时间精度。当然,我们也可以硬刚 chrono 库,借助时间点和时间间隔的运算符重载拆出具体的时间信息,比如:
auto sys_now_dura = system_clock::now().time_since_epoch();
auto rest_of_day = sys_now_dura % 24h;
auto hour = rest_of_day / 60min;
auto rest_of_hours = rest_of_day % 60min;
auto minute = rest_of_hours / 60s;
auto rest_of_minutes = rest_of_hours % 60s;
auto second = duration_cast<seconds>(rest_of_minutes).count();
//输出 hour:minute:second
C++ 20 提供的hh_mm_ss
能更简单地将一个时间间隔分解成时、分、秒的形式,形如其名,定义如下:
template< class Duration >
class hh_mm_ss;
hh_mm_ss
提供了几个成员函数,可以很方便地获取分离出来的时、分、秒信息。尽管它有一个 duration 类型的模板参数,但是在使用的时候,编译器可以根据构造函数的参数类型推导出这个模板参数,所以可以省略,比如:
hh_mm_ss hms(5h+43min+12s); //自动推导的 duration 类型是 seconds
assert(hms.hours().count() == 5);
assert(hms.minutes().count() == 43);
assert(hms.seconds().count() == 12);
利用hh_mm_ss
的这个特点,获得当前时间的时、分、秒的代码就非常简单了:
hh_mm_ss hms(system_clock::now().time_since_epoch() % 24h);
auto hour = hms.hours().count(); //整数类型的小时数
auto minute = hms.minutes().count(); //整数类型的分钟数
auto second = hms.seconds().count(); //整数类型的秒数
除了hh_mm_ss
类,C++ 20 还提供了判断时间是上午时间还是下午时间的函数,以及转换 12 小时制和 24 小时制的函数。使用也是非常简单:
hh_mm_ss hms(11h+43min+12s);
assert(is_am(hms.hours()));
assert(is_pm(hours(16)));
auto half_h = make12(hours(16));
assert(half_h.hours().count() == 4);
auto full_h = make24(hours(6), true);
assert(full_h.count() == 18);
hh_mm_ss
类的构造函数和主要的成员函数都是 constexpr 类型,这意味着可以声明 constexpr 类型的hh_mm_ss
对象,同时也可以在其他常量表达式或常量函数中使用hh_mm_ss
。
2 日历
2.1 基本日历单位
2.1.1 year、month 和 day
既然时间有hh_mm_ss
,那么日期应该有year_month_day
吧?没错,不仅有year_month_day
,还有year_month
、month_day
、year_month_weekday
等等。day、month 和 year 提供了单独的年、月、日基本日历单位的表达,这些对象本身并没有复杂的功能,只是提供了基本的构造、加减运算和比较运算。比如 day,表示一个月内的日期,正常情况下,其值的范围应该是 [1-31] 区间,但是可以用 0 -255 范围内的值初始化一个 day 对象,它不抱怨并不表示它是个有效的日期,可以用成员函数 ok() 来判断一个 day 对象是否是一个有效的日期:
day md{ 15 };
assert(md.ok());
md -= 4d; //前四天是几号?
assert(md == 11d);
md += 21d; //加上 21 天,变成 32
assert(!md.ok()); // 不是合法的日子
注意 std::chrono::day
和 std::chrono::days
的区别,前者是日历单位,后者是一个以天为计数单位的 duration。day 还支持 <<
输出运算符,以及 from_stream() 函数,使用起来也很简单:
day td{21};
std::cout << td << std::endl; //21
std::istringstream is{ "22" };
std::chrono::from_stream(is, "%d", td);
assert(td == 22d);
至于 month,一年 12 个月是固定的,chrono 库也定义了 12 个 month 类型的常量,分别表示 12 个月,可以在代码中直接使用它们:
constexpr month January{1};
constexpr month February{2};
constexpr month March{3};
constexpr month April{4};
constexpr month May{5};
constexpr month June{6};
constexpr month July{7};
constexpr month August{8};
constexpr month September{9};
constexpr month October{10};
constexpr month November{11};
constexpr month December{12};
需要注意,month 的 ++ 和 – 运算符重载是带回绕的,十二月的下一个月是一月,这很容易理解,但是写出这样的代码就会循环到地老天荒:
for(month m = January; m <= December; m++) { ... }
year 也是作为占位符类型配合 year_month_day
,year_month_weekday
等类型使用,比如:
year_month ym2{ year{2019}, month{5} };
//更推荐这种写法:year_month ym{ 2019y/May };
当然,year 有一个判断闰年的成员函数,还是比较贴心的:
year last_year{ 2023 };
assert(!last_year.is_leap());
2.1.2 weekday
weekday 和 weekday_indexed 表示星期,其中 weekday_indexed 表示第几个星期“几”。这里需要记得几个关于星期的常量以及它们的值,在代码中使用它们会展示更好的可读性:
constexpr weekday Sunday{0};
constexpr weekday Monday{1};
constexpr weekday Tuesday{2};
constexpr weekday Wednesday{3};
constexpr weekday Thursday{4};
constexpr weekday Friday{5};
constexpr weekday Saturday{6};
weekday 支持 <<
运算符,默认输出格式是星期的英文缩写,当然,这和 locale 的设置有关,设置中文 locale,也可以让它显式中文星期,比如:
weekday wi = Monday;
std::cout << wik << '\n'; // 输出 Mon
std::locale zh_loc("zh_CN");
std::cout.imbue(zh_loc);
std::cout << wik << '\n'; // 输出 周一
from_stream() 函数可以从输入流中初始化一个 weekday 对象,比如下面的代码展示了从字符流中初始化 weekday 对象:
weekday wi;
std::istringstream is{ "Friday" };
std::chrono::from_stream(is, "%a", wi);
assert(wi == Friday);
weekday 还重载了operator []
,返回一个 weekday_indexed 对象或 weekday_last 对象。如果调用时指定的参数是表示 index 的整数值,则返回值类型是 weekday_indexed,表示某个月的第几个星期“几”。如果调用参数是 last 标志(tag),则返回值类型是 weekday_last,表示某个月的最后一个星期“几”。weekday_indexed 和 weekday_last 主要是配合 year_month_weekday 或 month_weekday 这些日历类型,组合成某个月的第几个星期“几” 这样的效果。比如下面这两个例子:
weekday wik = Monday;
wik += 2d; //wik = Tuesday
//wix 表示第2个星期三(Tuesday)
weekday_indexed wix = wik[2];
year_month_weekday tmw{ 2024y/October/wix }; //2024年10月的第2个星期三
auto ltp = local_days(tmw); //转成以天为时间间隔单位的本地时间点
std::cout << ltp << std::endl; //2024-10-09
//wdl 表示最后一个星期三(Tuesday)
weekday_last wdl = wik[last];
year_month_weekday tmw2{ 2024y/October/wdl }; //2024年10月的最后一个星期三
auto ltp2 = local_days(tmw2);
std::cout << ltp2 << std::endl; //2024-10-30
2.2 日期
2.2.1 month_day 和 year_month_day
表示几月几日形式的日期,可用 month_day,如果带上年份,可以用 year_month_day 或 year_month,使用起来可以自由组合,十分灵活。可以这么理解,month_day 类型表示一个相对时间,year_month 也不具体到日期,只有 year_month_day 才代表一个具体的日期。构造或初始化 year_month_day 需要指定具体的日期,比如:
year_month_day ymd{ October/01/2021 };
//或者
year_month_day ymd{ 2021y, October, 1 };
也可以使用 month_day 或 year_month “拼接”一个具体的日期,比如:
year_month ym{ 2019y/May };
year_month_day ymd2{ ym/12 }; //2019年5月12日
month_day md{ February/12 };
year_month_day ymd6{ 2012y/md }; //2012年2月12日
//或者:year_month_day ymd6 = 2012y/md;
当然,也可以从以天为计算单位的时间点构造 year_month_day,比如:
year_month_day ymd{ floor<days>(system_clock::now()) };
assert(ymd.year() == 2021y);
assert(ymd.month() == month(12));
assert(ymd.day() == 27d);
上一篇介绍过 floor()
函数,floor<days>
除了数学上的 floor 的意义,还做了转型操作,其结果是得到不超过这个时间的最大整数天数,因为 year_month_day 的构造函数只接受 duration 类型是 days 的时间点。
month_day、year_month_day、year_month 这些类型都支持 <<
输出,也支持 from_stream() 从一个输入流中初始化对象:
month_day mday{ month{4}, day{8} };
std::cout << mday << std::endl;// Apr/08
std::istringstream is{ "03-27" };
std::chrono::from_stream(is, "%m-%d", mday);
assert(mday.day() == 27d);
不同的 locale 设置可能会影响输出的样式,比如月份信息,默认中性环境或英文环境,月份信息一般是月份的英文缩写,但是如果设置 locale 是中文,怎会显式中文月份名字,比如“三月”、“五月”。
2.2.2 operator / 操作符
上一节的代码能正常工作还有一个原因是 chrono 库重载了 operator /
操作符,这个操作符重载不仅适用于 year_month_day,也适用于其他形式的日期,比如:
year_month ym{ 2019y/May }; //2019-05
month_day md{ February/12 }; //02-12
除了初始化日历元素,也可以在代码中直接使用由/
组装的日期,比如:
auto nowdays = time_point_cast<days>(system_clock::now());//类似 floor<days>
if (nowdays == October/01/2021) {
// 开始放假
}
2.3 某一天是哪一天
2.3.1 last_spec 与 last
前面已经用过 last 标签(tag)了,这里具体介绍一下 last_spec 和 last。last_spec 是一个空的 class 或 struct,它是一个 tag,是个标记,需要和其他日历类型配合使用,指示序列中的最后一个东西。根据类型上下文,它可以是一个月的最后一天,或者是一个月的最后一个星期几。而 last 则是一个 last_spec 类型的常量,这类似于 true 和 bool 之间的关系,一个是值,一个是类型:
constexpr last_spec last{};
last 通常作为占位符使用,它的意义与它出现的位置有关,当处在“天”的位置的时候,它就代表最后一天,比如:
year_month_day ymd{ February/last/2021 }; //ymd 表示 2021年2月的最后一天
assert(ymd.day() == 28d); // 平年2月的最后一天是28
//换个闰年试试
year_month_day ymd4{ February/last/2020 };
assert(ymd4.year().is_leap());
assert(ymd4.day() == 29d);
换个位置,它就表示另一个意思,比如作为星期的下标参数:
//tmw 代表2021年10月的最后一个星期日
year_month_weekday tmw{ 2021y/October/Sunday[last] };
//输出 5,表示那个月有 5 个星期日
std::cout << tmw.weekday_indexed().index() << std::endl;
2.3.2 weekday_last 和 weekday_indexed
前面已经提到过,weekday_indexed 表示某个月的第几个星期“几”,构造和初始化 weekday_indexed 需要指定星期“几”和第几个,比如构造一个表示第 2 个星期四的 weekday_indexed 对象,可以这样做:
weekday_indexed wdi{ Thursday, 2 };
//效果等价于
weekday_indexed wdi = Thursday[2];
借助于 weekday 的[]
运算符,可以更方便地表示 weekday_indexed,比如 Thursday[2]。
weekday_last 表示某个月的最后一个星期“几”,至于“几”是什么,则通过 weekday_last 类的构造函数指定。下面的代码构造了一个 weekday_last,代表最后一个星期一,当然,也可以用 weekday 的[]
运算符配合 last 标签达到同样的效果:
weekday_last wdl{ Monday };
//效果等价于
weekday_last wdl = Monday[last];
前面介绍过,weekday_indexed 一般配合 month_weekday 和 year_month_weekday 使用,weekday_last 则配合 month_weekday_last 和 year_month_weekday_last 使用,后面介绍这些类型的时候还会给出具体的例子代码。
2.3.3 month_weekday 和 month_weekday_last
month_weekday 和 month_weekday_last 都是一个相对时间的概念,一般要配合 year 才能具体确定是哪一天。month_weekday 表示某个月的第几个星期“几”,构造和初始化 month_weekday 必须使用 weekday_indexed,不能使用 weekday,比如:
month_weekday mwd{ October/Monday[1] }; //mwd 表示10月的第1个星期一
month_weekday mwd2{ October/Monday }; //错误,不能使用 weekday
month_weekday_last 表示某个月的最后一个星期“几”,构造和初始化 month_weekday_last 需要用到 weekday_last,比如构造一个表示4月的最后一个星期一的对象,可以这么做:
weekday_last wdl{ Monday };//最后一个星期一
month_weekday_last mwd{ April/wdl }; //用 “/” 组合一下
//或者直接一点
month_weekday_last mwd{ April/Monday[last] };
chrono 定义了表示月份和星期的常量,建议在代码中直接使用这些常量,有助于代码可读性的提高。对于某个月的第几个星期“几”这种时间表达形式,结合这些常量和下标运算符,可以非常灵活地表达时间。比如这段代码判断当前日期是否是三个常见的美国节日:
auto thisYear = 2024y;
year_month_day ymd{ floor<days>(system_clock::now()) };
//母亲节、父亲节和感恩节
for (auto&& mw : { May/Sunday[2], June/Sunday[3], November/Thursday[4] }) {
//结合年份后再转成时间点
auto cncDay = sys_days(thisYear/mw);
if (ymd == cncDay) {
//...
}
}
2.3.4 year_month_weekday 和 year_month_weekday_last
year_month_weekday 表示某个某年某月的第几个星期“几”,可以用 “year + month + weekday_indexed” 或 “year + month + weekday_last” 构造和初始化 year_month_weekday 对象,比如:
year_month_weekday ymw{ 2021y/October/Sunday[last] };
year_month_weekday ymw2{ 2018y/April/Monday[2] };
也可以借助 “year + month_weekday” 间接构造,比如:
month_weekday mwd{ October/Monday[1] };
year_month_weekday ymw3{ 2021y/mwd };
year_month_weekday_last 表示某个某年某月的最后一个星期“几”,只能用 “year + month + weekday_last” 方式或“year + month_weekday_last” 方式构造和初始化 year_month_weekday_last 对象,比如:
year_month_weekday_last ymwl{ 2021y/October/Sunday[last] };
//或
month_weekday_last mwd{ April/Monday[last] };
year_month_weekday_last ymwl2{ 2018y/mwd };
year_month_weekday 和 year_month_weekday_last 已经可以具体表示到哪个日期了,这两个类都提供了 operator sys_days
或operator local_days
类型转换操作符,这两个操作符可以将它们转换成一个 time_point(前面的例子代码已经用到了)。这个 time_point 的 duration 类型是 std::chrono::days
。 operator sys_days
得到的时钟类型是 system_clock,表示系统时间或 UTC 时间,operator local_days
得到的是 local_t 时钟,local_t 也是一个占位符,表示本地时间的时钟。比如上面的代码构造的 ymwl 表示 2021年10月的最后一个星期日,如果想知道具体是10月几号,可以使用 local_days 将其转换成一个 time_point<local_t, days>
类型的时间点,然后再转换成 year_month_day :
//根据时间点转成year_month_day
year_month_day ymd{ local_days(ymwl) };
//输出10月31日,可知 2021年10月的最后一个星期日是10月31日
std::cout << ymd.day() << std::endl;
2.3.5 month_day_last 和 year_month_day_last
month_day_last 表示某个月的最后一天,也是个相对的日期:
month_day_last mdl{ February/last };
year_month_day_last 表示具体某年某月的最后一天,这是个确定的日期,可以直接指定年份和月份,比如:
year_month_day_last ymdl{ February/last/2020y }; //2020年2月的最后一天
也可以用 “year_month + last” 或“year + month_day_last” 拼接日期,比如:
year_month ym{ 2019y/May };
year_month_day_last ymdl1{ ym/last }; //2019年5月的最后一天
month_day_last mdl{ April/last };
year_month_day_last ymdl2{ 2022y/mdl }; //2022年4月的最后一天
year_month_day_last 也是个具体日期了,所以它也提供了 operator sys_days
或operator local_days
类型转换操作符,可以将这个时间转换成一个具体的时间点。这两个类型转换操作符的使用和 year_month_weekday_last、year_month_weekday 一样,比如获取 2020年2月的最后一天的具体日期,可以这样操作:
year_month_day_last ymdl{ February/last/2020 };
year_month_day ymd{ local_days(ymdl) }; //根据时间点转成year_month_day
std::cout << ymd.day() << std::endl; //2月29日,2020 年是闰年
当然,找一年中某个月的最后一天也可以直接用 year_month_day,因为这个类有一个将 year_month_day_last 转换成 year_month_day 的构造函数:
constexpr year_month_day(const year_month_day_last& _Ymdl) noexcept;
借助这个构造函数,可以直接用一个 year_month_day_last 类型的变量初始化 year_month_day 对象,从而直接得到这个日期,不需要 local_days 或 sys_days 做一次转换。
3 时区
时区是 C++ 20 对 chrono 库的重要补充,很多人抱怨 C++ 11 的 chrono 库居然不提供系统时间转成本地时间的方法,以至于大家不得不借助于 C 的 localtime() 系列函数做这个转换。实际上,它们只是没赶上 C++ 11 这趟火车, C++ 20 补上时区这个东西后,chrono 的功能才算完整。借助于时区的概念进行时间转换,比呆板的 localtime() 函数更灵活。
3.1 时区数据库
IANA (Internet Assigned Numbers Authority)维护着一份包含地球上所有地区的时区数据库,这个数据库会定期更新。可以通过 get_tzdb_list()
函数获得标准库中的数据库列表,一般最新的版本排在列表的第一位。提供这个函数的目的是为了兼容性,如果当前时区数据库因种种原因(领土争端)无法使用,可以从列表中找到其他版本的数据库。get_tzdb()
函数可以直接获取当前数据库,如果不出意外,他应该就是数据库列表中的第一个。
tzdb 是 chrono 定义的一个类型,它代表了 IANA 数据库中的信息。这个数据结构中有些信息比较有意思,其中之一就是所有时区的列表,其实它的类型就是 std::vector<std::chrono::time_zone>
,通过遍历这个列表,可以得到当前数据库中所有的时区信息。下面的代码打印所有的时区名字:
const tzdb& zdb = get_tzdb();
for (const auto& z : zdb.zones)
std::cout << z.name() << std::endl;
tzdb 还包含了从 1972 年到现在所有闰秒的信息,下面的演示代码输出迄今为止所有闰秒发生的时间:
for (const auto& ls : dbz.leap_seconds)
std::cout << ls.date() << std::endl;
3.2 time_zone
chrono 定义的 time_zone 是作为一个抽象类型使用的,所以不支持直接构造 time_zone,但是可以通过 chrono 库提供的 locate_zone() 函数或 current_zone() 函数获得一个 const 类型的 time_zone 指针。比如下面的代码例子:
const time_zone* tzShanghai = current_zone();
const time_zone *tzParis = locate_zone("Europe/Paris");
//等效于
const tzdb& dbz = get_tzdb();
const time_zone* tzShanghai = dbz.current_zone();
const time_zone *tzParis = dbz.locate_zone("Europe/Paris");
时区的名字不是随便起的,只能使用 IANA 数据库中支持的名字,上一节已经介绍过从 tzdb 中获取所有时区名字的方法了。
chrono 库初始化的时候就创建了所有 time_zone,所以你通过上述方法得到的 time_zone 指针不需要做释放处理。获取 time_zone 的主要目的有两个,一个是做系统时间(UTC)和本地时间的转换,另一个是构造 zoned_time。我们将在下一节介绍 zoned_time,本节先介绍如果做时间转换。time_zone 提供了两个成员函数用于系统时间和本地时间的转换。to_sys() 函数可以将一个本地时间转成系统时间,to_local() 函数可以将系统时间转换成本地时间,转换的依据就是 time_zone 自己所代表的时区信息。
使用 time_zone 做时间转换,不会有时间精度丢失的问题,下面的代码演示了将系统时间转成本地时间的方法:
const time_zone* tzCurrent = current_zone();
std::cout << tzCurrent->to_local(system_clock::now()) << std::endl;//输出本地时间
当然,也可以将系统时间转成其他时区的时间,比如巴黎时间。下面的代码例子演示了如何将北京时间转换成巴黎时间:
//构造一个秒级的北京时间
local_time<seconds> beijingTp = {local_days{2024y/October/12d} + 14h + 18min + 22s};
auto sysTp = current_zone()->to_sys(locTp);
auto parisTp = locate_zone("Europe/Paris")->to_local(sysTp);
3.3 zoned_time 和 local_t
处理时间相关的代码,最困扰的问题就是拿到一个 time_point,不知道是按照本地时间处理还是按照系统时间处理。在《C++ 的时间库之四:Clock》介绍时钟类型的时候,我们提到过 local_t,它不是真正的时钟类型,chrono 库用它作为本地时钟类型的占位符(或 tag)使用。有了这个类型占位符,一些显而易见类型错误就可以让编译器帮你检查:
local_time<seconds> localTp = ...;
//错误,to_local() 要求参数是 system_clock 类型的时间点
auto beijingTp = current_zone()->to_local(localTp); //编译错误
但是很多运行时状况是编译器无能为力的,我们也不希望在每个使用时间的地方都做类型检查。zoned_time 就是为了解决这个问题而存在的,这是 zoned_time 的定义,它实际上是将一个时间间隔是 Duration 类型的时间点和一个时区绑定在一起:
template<class Duration, class TimeZonePtr = const time_zone*>
class zoned_time ;
zoned_time 只限定了时间点的 Duration 类型,没有限定 Clock 类型,它通过构造函数自适应 Clock 类型,所以 local_t 类型占位符可以帮助 zoned_time 识别系统时间和本地时间。下面的代码演示了用系统时间构造一个当前时区的 zoned_time,并检查通过这个 zoned_time 获取本地时间和系统时间是否正确。
auto sys_now = system_clock::now();
zoned_time zonedtime{"Asia/Shanghai", sys_now}; //使用系统时间构造
//等效于:zoned_time zonedtime{locate_zone("Asia/Shanghai"), sys_now};
assert(zonedtime.get_sys_time() == sys_now);//true
//利用 time_zone 提供的方法获得本地时间
auto local_now = locate_zone("Asia/Shanghai")->to_local(sys_now);
assert(zonedtime.get_local_time() == local_now);//true
使用 zoned_time 的好处就是不需要人肉记住每个时间点是系统时间还是本地时间,在使用系统时间或本地时间的地方相应地调用 get_sys_time() 或 get_local_time() 即可。
4 格式化支持
本次修订将 format 格式支持部分放在独立的《C++ 的时间库之八:format 与格式化》一篇介绍,具体的例子请参考这一篇了解,本节就作为占位符放在这里了。
参考资料
[1] Marc Gregoire, Professional C++ (Fifth Edition), John Wiley & Sons, Inc., 2021
[2] Nicolai M. Josuttis, C++20 - The Complete Guide, http://leanpub.com/cpp20’
[3] P1636R2: Formatters for librarytypes
[4] D0355R4: Extending to Calendars and Time Zones
[5] https://en.cppreference.com/w/cpp/chrono
[6] https://en.cppreference.com/w/cpp/chrono/system_clock/formatter#Format_specification
关注作者的算法专栏
https://blog.csdn.net/orbit/category_10400723.html
关注作者的出版物《算法的乐趣(第二版)》
https://www.ituring.com.cn/book/3180