C++ 的时间库之一:C 语言传统
在 C++ 11 标准之前,C++ 的代码在处理跟时间有关的内容时,沿用的是 C 语言的库,主要是定义在
time.h
头文件中,当然,C++ 的代码应该用ctime
这个头文件。C++ 11 开始支持用 C++ 的风格处理时间,但是 chrono
整个内容像是个半成品,很多情况下要进行时间的处理还不得不借助于传统的 C 库,虽然这并不冲突,但是当你的代码在 C++ 风格和 C
风格之间突兀地来回转换时,可能会导致“精神分裂”吧?不过话说回来,到现在还在用 C++ 的人,多半也都不是正常人。好在经过 C++ 17 和
C++ 20 的补充,现在的 chrono 库已经完善。但是在介绍 C++ 的 chrono 库之前,了解一下 C
语言的时间处理方法是十分必要的,毕竟很多项目不可能立刻升级到 C++ 20,各种程度的“精神分裂”还需要持续一段时间。
1 系统时间和本地时间
1.1 系统时间和本地时间的来历
我们常说的“系统时间”,指的是 UTC 时间(国际协调时),传统的 GMT 时间是指 0 度经线经过的格林威治天文台所在地的时间,也被称为 UTC + 0 时区的时间。我们常说的国际时间(UT)是以天体运行的规律相一致的时间,过去这个时间靠观测天体的运行来确定,现在则是用相关的轨道公式计算出来。我们知道,地球是个不规则的球体,自转的时候还扭来扭去的晃动,最关键的是它的自转一直在变慢,这就导致与人类感觉相一致的 UT 时间与极度冷酷的铯原子钟代表的线性的 UTC 时间出现偏差。人们接受这两个时间的偏差在 0.9 秒之内,如果超过就要进行闰秒修正。所以,当我们说“系统时间” 的时候,你将其理解为 UTC 还是 UT 都可以,因为无论是 UT 还是 UTC 时间,所谓的系统时间其实就是这一刻发生时,UTC + 0 时区用时间。这也全球统一时间,任何跨时区的活动组织,都以系统时间为准。但是因为地球自转导致的昼夜不同,全球都使用系统时间过日子是不现实的,毕竟没有人愿意在中午十二点出来看月亮,所以就有了“时区”,有了“本地时间”。大多数操作系统内部使用的都是 UTC 时间,也被称为系统时间,但是展示给用户的时间是经过时区修正后的“本地时间”,因为本地时间更适应人们的生活习惯。虽然有点麻烦,但是往好处想,同一地区的时间转换关系是固定的,所以程序在处理时间的时候,不需要同时记录两个时间,只要记录系统时间就可以了。
为什么建议记录系统时间而不是直接记录本地时间?讲一个我“道听途说”来的故事。若干年前,有一个跨国项目,老外项目组总是向中方项目组抱怨,每次中方项目组人员提交代码,都会导致整个项目的代码重新编译,延长了测试部署的时间。中方的一个机灵小伙经过一番研究,终于发现原因是系统中自动生成编译脚本的程序,将生成的文件设置为本地时间了,所以每次中方项目组的成员编译完代码,提交的时候会导致库中相关文件被修改问中方的本地时间。当老外们编译的时候,由于文件时间被修改,导致所有相关的依赖都被重新编译。事实上,每次老外入库,也会导致中方这边的编译问题,只是善良的中国人没有抱怨而已。事实查明是老外的编译脚本有 BUG,但是导致中方背了很长时间的锅。
这个故事告诉我们,使用系统时间的重要性。事实上,操作系统在生成或修改文件的时候,记录的文件时间就是系统时间。在不同地区的人通过操作系统界面看到的本地时间虽然不一致,但是大家对这个绝对时间点的理解是一样的。我曾遇到一个加密文件的程序,在加密文件之后总是“执着地”将文件的最后修改时间设置成本地时间,虽然在国内用没有问题,但是当我们把加密文件传给在不同时区的外国人的时候,他们可能会惊讶,你们中国人怎么能穿越到过去加密这个文件,或者穿越到未来加密这个文件?
1.2 time_t 和 struct tm
在介绍系统时间和本地时间转换之前,先介绍两个 C 标准库的数据类型,一个是 time_t
类型 ,另一个是struct tm
类型。 time_t
类型本质上是一个无符号整数,在不同的系统上长度不一样,可能是 32 位,也可能是 64 位。正如你理解的那样,这是一个计数器,在 C++ 语言中,这个计数器表示的是从格林威治标准(UTC)时间 1970 年 1 月 1 日 0 时 0 分 0 秒开始到某个时间点之间经过了多少秒,类似这样的整数型的时间计数,也常被称为“时间戳”。所以,它不是我们一般理解的年、月、日计时的时间,但是它可以转换成年、月、日计时的时间。一般用struct tm
类型表示经过转换后的年、月、日计时的时间,struct tm
则是一个比较容易理解的数据结构:
struct tm
{
int tm_sec; // seconds after the minute - [0, 60] including leap second
int tm_min; // minutes after the hour - [0, 59]
int tm_hour; // hours since midnight - [0, 23]
int tm_mday; // day of the month - [1, 31]
int tm_mon; // months since January - [0, 11]
int tm_year; // years since 1900
int tm_wday; // days since Sunday - [0, 6]
int tm_yday; // days since January 1 - [0, 365]
int tm_isdst; // daylight savings time flag
};
大部分情况下,转换成这样类型的时间,就是为了用户界面的展示,所以用这个数据结构存的一般是本地时间。如果确定用这个数据结构存格林威治地区时间,也就是 UTC 时间,一定要做到心里有数。在这里说一下我个人的习惯,我总是用time_t
类型存储通过系统得到的 UTC 时间,用struct tm
类型存储经过时区转换的本地时间,保持这个原则,可以帮助我在做时间的各种计算时,即便是各种复杂转换也不会出错。
1.3 系统时间和本地时间转换
言归正传,C++ 提供了将系统时间转换成本地时间的方法:
struct tm* localtime(const time_t *timer);
参数 timer 是time_t
类型的 UTC 时间,返回值是struct tm
表示的用年、月、日表示的本地时间。这个函数并不智能,它只是依据当前系统的时区设置对 timer 进行转换,如果 timer 存储的不是 UTC 时间,将会得到一个错误的结果。这里要强调一下,这是个旧的 C 语言库函数,它是线程不安全的,它返回的struct tm
指针并不总是有效的,特别注意不要在线程之间传递这个指针。2011 年发布的 C11 标准提供了一个线程安全的版本:
struct tm *localtime_s( const time_t *timer, struct tm *buf );
但是,槽点来了,不同编译器的localtime_s()
函数长的还不一样。因为 C11 标准来的太晚了,很多迫不及待的编译器厂商已经使用了这个名字,比如那个总喜欢给库函数加上_s
的微软,他家的localtime_s()
函数长这样:
errno_t localtime_s(struct tm* _Tm, const time_t *timer);
返回值类型就不一样,更要命的是,两个参数居然是反的,这还怎么玩?标准委员会也注意到这个问题了,他们在规划中的 C23 标准中又重新定了一个库函数:
struct tm *localtime_r(const time_t *timep, struct tm *result);
虽然 C23 的标准还在制定中,但是一些编译器厂商很早就已经支持这个函数了,如果你的编译器支持localtime_r()
,请尽量使用localtime_r()
,否则在用localtime_s()
的时候,就需要知道它在不同编译器上的差异。
吐槽完这个,又一个槽点来了。既然 localtime()
是将 time_t
类型的系统时间转成struct tm
类型的本地时间,那么反过来转换的函数名字应该是systemtime()
,或者类似的名字吧?结果不是,反向转换的函数是这个:
time_t mktime (struct tm *timeptr);
这个函数名字看起来就是做个数据类型转换嘛,然而,函数不能望名生意,它其实在内部做了从本地时间向系统时间的逆向转换(曾经因为这个命名误会的人来举个手)。标准库对这个函数的解释是“将 timeptr 所指向的结构转换为自 1970 年 1 月 1 日以来持续的秒数”,显然,这个结果如果不是 UTC 时间就没有任何意义了。往好处想,这个不图名利的幕后转换也是符合了我对 time_t
类型和struct tm
类型的使用原则,即:用 time_t
类型表示系统时间,用struct tm
类型表示本地时间。
假设我们勉强接受了mktime()
函数。但是这货又是什么东西?难道不是逆向时间转换的函数吗?
struct tm *gmtime(const time_t *timer);
struct tm *gmtime_s( const time_t *restrict timer, struct tm *restrict buf ); // C11
struct tm *gmtime_r( const time_t *timer, struct tm *buf ); // C23
不是,真不是。这三个函数虽然名字里有 “gmt”,但它们不做时区转换的事情,它们只是将 time_t
类型的时间转换成struct tm
类型的时间,不做任何时区计算。如果你想在做数据类型的转换的同时完成时区转换,还得用 localtime 系列函数。同样需要注意,gmtime()
也是线程不安全的,C11 同样提供了线程安全版本 gmtime_s()
,但是微软的 CRT 库又与众不同了,他家的gmtime_s()
函数长这样:
errno_t gmtime_s(struct tm *_Tm, const time_t *timer);
如果编译器带的标准库支持已经提前支持了gmtime_r()
,请尽量用gmtime_r()
,如果用gmtime_s()
,需要注意上述差异。
2 获取当前时间
第一节先介绍系统时间和本地时间的转换,是为了让大家更好地理解时间的概念,现在来看看获取当前时间的方法。这个库函数就是简单的time()
函数:
time_t time (time_t *timer);
它得到的是一个从格林威治标准时间 1970 年 1 月 1 日 0 时 0 分 0 秒到现在历时的秒数,可以通过函数的返回值获得,也可以通过函数的指针参数获得,如果你两个都用了,你会惊奇地发现它们的值是一样的。一般人们都是这么用的:
time_t now = time(nullptr);
理论上说,当取时间失败的时候,这个函数会返回 -1,但是我从未遇到过。
另一个获取时间戳的函数是:
clock_t clock (void);
它返回的是你的程序从启动到现在经历的 CPU 时钟,单位是 ticks,计数是从 0 开始的。一个 tick 具体是多长时间,依系统而定,一般建议用这个 CLOCKS_PER_SEC 常量转换成以秒为单位的计数:
std::clock_t t1 = std::clock();
// 做点处理
std::clock_t t2 = std::clock();
double duration = (double)(t2 - t1) / CLOCKS_PER_SEC;//转换浮点型
对 clock() 函数的使用需要注意,因为它获得的是 CPU 的处理时间,所以当你使用多线程或使用 sleep() 之类的系统函数时,都会影响它的值,比如 sleep 的时间是不计算在 CPU 处理时间的。还有一点需要注意的就是这个函数的返回值是有时间精度的,可能是 55ms,也可能是 10 ms,具体要看你使用的是什么系统。所以,对于即使精度有要求的场合,应避免使用这个函数。
对于某些编译器,还可以使用gettimeofday()
这个函数获取当前时间,但它不是标准库的函数,不是所有的编译器都支持。
int gettimeofday(struct timeval*tv,struct timezone *tz )
3 时间字符串
3.1 struct tm 类型转字符串
将struct tm
类型表示的时间转换成人类能读的时间字符串,可以用:
char* asctime(const struct tm * timeptr);
errno_t asctime_s( char* buf, rsize_t bufsz, const struct tm* time_ptr ); //C11
char *asctime_r( const struct tm* time_ptr, char* buf ); //C23
转换后的时间字符串的格式由系统决定,也就是你的操作系统中设定的时区、单位进制和时间表达方式。前面做本地时间和系统时间转换的函数,也是使用的系统设置,你可以使用 locale 设置当前环境的本地化。asctime()
这个函数是线程不安全的,尽量避免使用,请用asctime_r()
或asctime_s()
。对这个函数不再提微软的 CRT 库,是因为微软的库的实现和 C11 终于一样了。
除了用系统默认的格式输出时间字符串,还可以用更灵活的strftime()
函数自定义时间字符串的格式,C95 还提供了支持宽字符集的函数wcsftime()
:
size_t strftime(char *str, size_t count, const char *format, const struct tm *time );
size_t wcsftime( wchar_t* str, size_t count, const wchar_t* format, tm* time );
format
的格式有一张长长的表,读者可以通过 C 语言的帮助文档了解这个格式。但是关于时间格式,仍有需要说明的地方。比如“A%”表示输出星期的名字,中文和英文应该是不一样的输出。“c%” 是得到完整的年月日时分秒时间字符串,同样不同的语言得到的结果也不相同。除了系统设置的默认格式,还可以使用 setlocale()
函数设置本地化信息,以此影响strftime()
函数的输出结果。关于 C++ 的地域化设置,可参考这一篇《C++ 的地域化 (locale) 设置》。
另外,本节介绍的 asctime
和 strftime
函数都不做时区的计算,也就是说,如果想得到本地时间的字符串,需要确保 tm 中存的时间是本地时间。
3.2 time_t 类型转字符串
将time_t
类型时间戳转字符串的函数是这几个:
char* ctime ( const time_t* timer );
errno_t ctime_s( char *buf, rsize_t bufsz, const time_t* timer ); //C11
char* ctime_r( const time_t* timer, char* buf ); //C23
需要注意的是,这个函数会做时区转换,将 timer 表示的 UTC 秒数转成本地时间的可读字符串,同样的建议是不要再用第一个函数,因为线程不安全。请尽量使用后两个函数,如果有可能,优先使用ctime_r()
。
4 时间差
time_t
类型的时间,可以直接求差计算两个时间点之间的时间差(时间跨度),尽管如此,C 的标准库还是提供了difftime()
函数,用在一些不支持长整型数据计算的系统中:
double difftime( time_t time_end, time_t time_beg );
这个函数返回以秒为单位的时间差,除此之外,没有更多需要说明的。
5 timespec 和 timespec_get
使用 time()
获取的时间精确到秒,对很多更高要求的场合,这个函数有点不堪重用,于是在 C 11 标准中新增了timespec_get()
方法,这个方法的参数是一个 timespec 类型的数据结构:
struct timespec
{
time_t tv_sec; // Seconds - >= 0
long tv_nsec; // Nanoseconds - [0, 999999999]
};
tv_sec 是当前的秒数,与time()
得到的结果一致,tv_nsec 是不足一秒部分的时间,单位是纳秒。
struct timespec ts;
timespec_get(&ts, TIME_UTC);
char buff[100];
strftime(buff, sizeof buff, "%D %T", gmtime(&ts.tv_sec));
printf("Current time: %s.%09ld UTC\n", buff, ts.tv_nsec);
C++ 17 标准也根据 C 11 补充了这部分内容(注意两个标准之间的差异,C 99 对 C 语言的扩充,C++ 的标准库中 C 的部分一直到 C++ 11 的时候才部分支持。不过今后这种差距将慢慢弥合,从 C 23 和 C++ 23 开始,两个标准委员会似乎有意统一二者的版本更新脚步)。
6 get_time 和 put_time
事实上,get_time() 和 put_time() 不是 C 标准库的内容,这两个家伙是 C++ 11 引入的,主要是处理时间的格式化输入和输出。看名字像是两个函数,但是它们是定义在 <iomanip>
头文件中的两个流操纵器(manipulator)。get_time() 从一个输入流中解析时间,时间的格式可由参数控制,解析的时间存入一个 struct tm 数据结构中。put_time() 则正好相反,将一个 struct tm 数据类型的时间按照指定的格式输出到一个输出流中。这两个操纵器不仅支持标准输入、输出流,还支持字符流和文件流,下面是这两个函数的使用例子:
std::tm t = {};
std::istringstream ss("2011-Februar-18 23:12:34"); //德语,从远方弄来的例子
ss.imbue(std::locale("de_DE.utf-8"));
ss >> std::get_time(&t, "%Y-%b-%d %H:%M:%S");
if (ss.fail()) {
std::cout << "Parse failed\n";
} else
{
std::cout << std::put_time(&t, "%c") << '\n';
}
//输出 Sun Feb 18 23:12:34 2011
顺便说一下,get_time() 和 put_time() 操纵器的输入和输出格式虽然可由相应的格式化字符串控制,但是也受到地域化(locale)设置的影响。以上述例子来说,虽然输出时指定了格式 “%c”,但是星期到底是输出 Sun 还是“星期三”,则是由 std::time_get 和 std::time_put 这两个 facet 来决定。关于这方面的具体内容,请参考《C++ 的地域化 (locale) 设置》一篇的内容。
参考资料
[1] https://en.cppreference.com/w/cpp/chrono/c/clock
[2] Jacek Galowicz. C++17 STL Cookbook. Packtpub. 2017
[3] https://en.cppreference.com/w/cpp/io/manip/get_time
[4] https://en.cppreference.com/w/cpp/io/manip/put_time
关注作者的算法专栏
https://blog.csdn.net/orbit/category_10400723.html
关注作者的出版物《算法的乐趣(第二版)》
https://www.ituring.com.cn/book/3180