Skykey's Home

Skykey的私人博客ᕕ( ᐛ )ᕗ

C++日期与时间编程(C++11-C++17)

C++日期与时间编程(C++11-C++17)

0x00 导言

日期和时间是在编程中常常接触到的东西,而C++也为程序员提供了强(复)大(杂)的日期和时间编程的相关支持。在C++20标准中,C++的chrono库继C++11标准后也迎来了空前绝后大更新,但本文讨论的重点主要集中在C++11至C++17标准中日期和时间相关的内容,C++20标准中的chrono新东西完全可以另开一个坑。

在翻了N篇cppreference标准、chrome爽吃我800Mb的内存之后,我果断决定把这次的学习心得记下来,以防以后全部忘光光(实在太多了.jpg)。本片学习心得纯新手向,求大佬轻喷。

0x01 概论

先给两个例子:

  1. 获取当前时间
1
2
3
4
5
6
7
8
9
10
11
/** 实现1 **/
#include <iostream>
#include <ctime>

using namespace std;

int main()
{
time_t now = time(nullptr);
cout << "Now is: " << ctime(&now);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 实现2 **/
#include <iostream>
#include <chrono>
#include <ctime>

using namespace std;

int main(){
auto now = chrono::system_clock::now();
time_t time = chrono::system_clock::to_time_t(now);
cout << "Now is: " << ctime(&time) << endl;
return 0;

}
  1. 计算时间差
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** 实现1 **/
#include <iostream>
#include <ctime>

using namespace std;

int main()
{
time_t time1 = time(nullptr);
double sum = 0;
for(int i = 0; i < 1000000000; i++) {
sum += sqrt(i);
}
time_t time2 = time(nullptr);

double time_diff = difftime(time2, time1);
cout << "time1: " << time1 << endl;
cout << "time2: " << time2 << endl;
cout << "time_diff: " << time_diff << "s" << endl;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/** 实现2 **/
#include <iostream>
#include <chrono>

using namespace std;

int main()
{
auto start = chrono::steady_clock::now();
double sum = 0;
for(int i = 0; i < 100000000; i++) {
sum += sqrt(i);
}
auto end = chrono::steady_clock::now();

auto time_diff = end - start;
auto duration = chrono::duration_cast<chrono::seconds>(time_diff);
cout << "Operation cost : " << duration.count() << "s" << endl;
}

上文的两个要求都分别给出了两种实现方法,那接下来要说的事情已经显而易见了:

C++给出了两类日期时间API,分别是:

  • C-Style日期时间库,主要位于<ctime>头文件(原C语言<time.h>头文件的C++版本)中。
  • chrono库:C++11新增的API。

在C++11引入chrono库之前,C++程序员只能使用C-Style的日期时间库。然而,C-Style日期时间库有着鲜明的缺点:精度只有秒级别(当时),这对于对时间有着高精度要求的程序来说是完全不够用的。而C++11引入的chrono库解决了这个问题,它极大地扩展了对精度的支持。当然,针对习惯C-Style日期时间库的程序员来说,C++17也进行了精度的扩充,详情请见下文。

本文也会分为C-Style和chrono两个板块进行讲解。

0x02 必备的知识

在正式进入日期时间库的讨论之前,我们应该先复习一下与日期时间相关的必要知识。

UTC时间

协调世界时Coordinated Universial Time,简称UTC)是最主要的时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林威治标准时间。

协调世界时是世界上调节时钟和时间的主要时间标准,它与0度经线的平太阳时相差不超过1秒。因此UTC时间+8即可获得北京标准时间(UTC+8)。

本地时间

本地时间与当地的时区相关,例如中国当地时间采用了北京标准时间(UTC+8).

纪元时间

纪元时间(Epoch time)又被称为Unix时间(常用Linux的小伙伴可能会比较熟悉)。它表示1970年1月1日00:00UTC以来所经历的秒数(不考虑闰秒)。

例如北京时间2021年5月18日晚上9点07分32秒的纪元时间为:1621372052

作为一个敏感的CPP程序员,你应该很快就意识到这个大整数在储存会产生很多问题,例如溢出。然而事实正是如此,在一些历史机器上,使用了32位的有符号整数来储存这个时间戳,因此产生在结果就是:在2038-01-19 03:14:07这一刻,该值会溢出。

另外,如果你对为什么选1970-1-1日零点做位纪元时间起点,可以看一下这个帖子:

Why is 1/1/1970 the “epoch time”?

0x03 C-Style 日期时间库

C-Style日期时间库主要位于<ctime>头文件中,下面给出头文件中包含的常用的类型函数

常用类型与函数

类型

类型名 说明
clock_t 进程运行时间
size_t sizeof运算符返回的无符号整数类型
time_t 从纪元起的时间类型
tm 日历时间类型
timespec*(C++17)* 以秒和纳秒表示的时间(C++17)

函数

函数名 说明
std::clock_t clock() 返回自程序启动时起的原始处理器时钟时间
std::time_t time(std::time_t* arg) 返回自纪元起计的系统当前时间
double difftime(std::time_t time_end, std::time_t time_beg) 计算时间之间的差
int timespec_get(std::timespec* ts, int base)(C++17) 返回基于给定时间基底的日历时间(C++17)
char* ctime(const std::time_t* time) 转换 time_t 对象为文本表示
char* asctime(const std::tm* time_ptr) 转换 tm 对象为文本表示
std::size_t strftime(char* str, std::size_t count, const char* format, const std::tm* time) 转换 tm 对象到自定义的文本表示
std::size_t wcsftime( wchar_t* str, std::size_t count, const wchar_t* format, const std::tm* time) 转换 tm 对象为定制的宽字符串文本表示
std::tm* gmtime(const std::time_t* time) 将time_t转换成UTC表示的时间
std::tm* localtime(const std::time_t *time) 将time_t转换成本地时间
std::time_t mktime(std::tm* time) 将tm格式的时间转换成time_t表示的时间

函数和数据类型又多又杂有木有,刚开始接触的时候很容易弄混且不太容易记住,这里借鉴一下大佬的一张图来理解记忆:

这幅图中,以数据类型为中心,带方向的实心箭头表示该函数返回相应类型的结果。

  • clock函数比较特别,它返回进程运行的时间,因而是相对独立的。
  • time_t描述纪元时间(精确到秒),使用time函数获得。
  • timespec类型在time_t的基础上增加了纳秒的精度,需要通过timespec_get函数获取。该类型与函数为C++17新增内容。
  • tm是日历类型,包含了年月日等信息。可以通过gmtime,localtime和mktime函数进行time_t和tm类型的相互转化。
  • gmtime和localtime两个函数存在时区差异相关问题。
  • time_t和tm结构都可以用字符串格式输出。ctime和asctime输出的格式是固定的,如果需要自定义格式,需要使用strftime或者wcsftime函数。

计算进程运行的时间

clock函数会返回从关联到进程开始执行的实现定义时期的起,进程所用的粗略处理器时间。将此值除以CLOCKS_PER_SEC常量可转换为秒。

这里给出cppreference上clock函数示例的简化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <threads.h> // POSIX 中的 pthread.h

// 函数 f() 做一些耗时的工作
int f(void* thr_data) // POSIX 中返回 void*
{
volatile double d = 0;
for (int n=0; n<10000; ++n)
for (int m=0; m<10000; ++m)
d += d*n*m;
return 0;
}

int main(void)
{
clock_t t1 = clock();

thrd_t thr1, thr2; // C11 ;POSIX 中用 pthread_t
thrd_create(&thr1, f, NULL); // C11 ; POSIX 中用 pthread_create
thrd_create(&thr2, f, NULL);
thrd_join(thr1, NULL); // C11 ; POSIX 中用 pthread_join
thrd_join(thr2, NULL);

clock_t t2 = clock();

double dur = 1000.0*(t2-t1)/CLOCKS_PER_SEC;

printf("CPU time used (per clock(): %.2f ms\n", dur);
}

可能的输出:

1
CPU time used (per clock(): 1580.00 ms

我们应当注意,clock时间或许会快于或慢于挂钟时间,这取决于操作系统给予程序的执行资源。在单处理器的情况下,若CPU为其他进程所共享,clock可能慢于挂钟,若当前进程为多线程,而有更多资源可用,clock时间可能会快于挂钟。在多处理器情况下,若进程使用了多线程,那么clock时间可能要慢于挂钟。

获取纪元时间

使用time函数可以获取储存于time_t类型返回值里的纪元时间。

示例:

1
2
time_t epoch_time = time(nullptr);
cout << "Epoch time: " << epoch_time <<endl;

在北京时间2021年5月18日晚上9点07分32秒输出如下:

1
Epoch time: 1621372052

计算时间差

在一些情况下我们需要计算操作所花费的时间长度。可以看出,time_t结构中储存的是时间点,而通过常识我们得知:

1
时间差(时间长度) = 时间点 - 时间点

在C-Style日期时间库中,我们可以通过difftime函数来计算两个时间点的差。

下面给出cppreference的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <ctime>

int main()
{
std::time_t start = std::time(NULL);
volatile double d;

// 一些耗时操作
for (int n=0; n<10000; ++n) {
for (int m=0; m<100000; ++m) {
d += d*n*m;
}
}

std::cout << "Wall time passed: "
<< std::difftime(std::time(NULL), start) << " s.\n";
}

可能的输出如下:

1
Wall time passed: 7 s.

UTC时间与本地时间

在C-Style日期时间库中,我们可以使用gmtime将std::time_t的纪元时间转换为UTC时间,使用localtime将纪元时间转换为本地时区所代表的日历时间。

gmtime与localtime返回值的类型为tm结构,即日历时间的结构描述,其结构如下:

1
2
3
4
5
6
7
8
9
10
11
struct tm {
int tm_sec;
int tm_min
int tm_hour;
int tm_mday;
int tm_mon;
int tm_year;
int tm_wday;
int tm_yday;
int tm_isdst;
};

有两点我们需要注意:

  • tm_mon表示的范围为[0, 11]。转换成日常使用的月份表示需要+1。
  • tm_year表示的是自1900年之后所过的年份数。转换成日常使用的年份表示需要+1900。

下面给出示例:

1
2
3
4
5
6
7
time_t now = time(nullptr);

tm* gm_time = gmtime(&now);
tm* local_time = localtime(&now);

cout << "gmtime: " << asctime(gm_time);
cout << "local_time: " << asctime(local_time);

输出如下:

1
2
gmtime: Tue May 18 21:36:48 2021
local_time: Tue May 18 21:36:48 2021

输出时间和日期

获取了时间,我们自然想要将时间以字符串的形式打印出来。此时可以使用ctime函数。

应注意的是,ctime函数打印的格式是固定的:

1
Www Mmm dd hh:mm:ss yyyy\n

示例:

1
2
time_t now = time(nullptr);
cout << "Now is: " << ctime(&now);

输出:

1
Now is: Tue May 18 21:39:46 2021

而tm储存的日期时间结构,我们也可以使用asctime函数将其转换为字符串格式。

然而,ctime和asctime函数其输出的格式都是固定的,在有格式要求的情况下,我们通常有两种做法:

  1. 拆分tm结构体的字段:

    1
    2
    3
    4
    5
    time_t now = time(nullptr);
    tm* t = localtime(&now);

    cout << "Now is: " << t->tm_year + 1900 << "/" << t->tm_mon + 1<< "/" << t->tm_mday << " ";
    cout << t->tm_hour << ":" << t->tm_min << ":" << t->tm_sec << endl;
  2. 使用strftime或者wcsftime函数来指定格式输出。函数格式可以参考以下文档:

    https://zh.cppreference.com/w/cpp/chrono/c/strftime

    示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <ctime>
    #include <iostream>
    #include <locale>

    int main()
    {
    std::locale::global(std::locale("ja_JP.utf8"));
    std::time_t t = std::time(NULL);
    char mbstr[100];
    if (std::strftime(mbstr, sizeof(mbstr), "%A %c", std::localtime(&t))) {
    std::cout << mbstr << '\n';
    }
    }

    示例输出:

    1
    火曜日 2011年12月27日 17時39分03秒

C++17新内容timespec,迈向纳秒精度

纪元时间的精度只有秒,这在很多时候是不够用的。为了解决这个问题,C++17增加timespec类型,提供了纳秒级别的精度。

一些熟悉C语言的朋友可能会立即满头问号:timespec不是在C11(注意看,是C11,不是CPP11)就已经引入了么?

但事实的确如此,严格的C++的C-Style日期时间库,在C++17标准才正式引入timespec类型。

timespec类型结构如下:

1
2
3
4
struct timespec {
std::time_t tv_sec;
long tv_nsec;
};

其中tv_nsec成员保存了纳秒数。

示例:

1
2
3
4
5
timespec ts;
timespec_get(&ts, TIME_UTC);
char buff[100];
strftime(buff, sizeof buff, "%D %T", std::gmtime(&ts.tv_sec));
printf("Current time: %s.%09ld UTC\n", buff, ts.tv_nsec);

输出:

1
Current time: 05/18/21 13:50:58.667861100 UTC

C-Style日期时间库至此已基本结束,接下来讲解C++11起新增的chrono库。


0x04 chrono库

chrono是以各种精度跟踪时间的类型的灵活汇集。chrono库定义三种主要类型以及工具函数和常用的typedef:

  • 时钟
  • 时长
  • 时间点

时钟

C++11的chrono库主要包含了三种类型的时钟:

名称 说明
system_clock 来自系统范畴实时时钟的挂钟时间
steady_clock 决不会调整的单调时钟
high_resolution_clock 拥有可用的最短嘀嗒周期的时钟

system_clock来源是系统时钟。然而在大多数系统上,系统时间是可以在任何时候被调节的。所以如果用来计算两个时间点的时间差,这并不是一个好的选择。

steady_clock是一个单调时钟。此时钟的时间点无法减少,因为物理十几件向前移动。因而steady_clock是度量间隔的最适宜的选择。

high_resolution_clock表示实现提供的拥有最小计次周期的时钟。它可以是system_clock或steady_clock的别名,或者第三个独立时钟。

对于high_resolution_clock,cppreference给出了以下需要我们注意的地方:

https://zh.cppreference.com/w/cpp/chrono/high_resolution_clock

high_resolution_clock 在不同标准库实现之间实现不一致,而应该避免使用它。通常它只是 std::chrono::steady_clockstd::chrono::system_clock 的别名,但实际是哪个取决于库或配置。它是 system_clock 时不是单调的(即时间能后退)。例如对于 gcc 的 libstdc++ 它是 system_clock ,对于 MSVC 它是 steady_clock ,而对于 clang 的 libc++ 它取决于配置。

通常用户应该直接使用 std::chrono::steady_clockstd::chrono::system_clock 代替 std::chrono::high_resolution_clock :对时长度量使用 steady_clock ,对壁钟时间使用 system_clock

对于这三个时钟类,有着以下共同的成员:

名称 说明
now() 静态成员函数,返回当前时间,类型为clock::time_point
time_point 成员类型,当前时钟的时间点类型。
duration 成员类型,时钟的时长类型。
rep 成员类型,时钟的tick类型,等同于clock::duration::rep
period 成员类型,时钟的单位,等同于clock::duration::period
is_steady 静态成员类型:是否是稳定时钟,对于steady_clock来说该值一定是true

每个时钟类都有着一个静态成员函数new()来获取当前时间。该函数的返回类型则是由该时钟类的time_point描述,例如std::chrono::time_point<std::chrono::system_clock>或者std::chrono::time_point<std::chrono::steady_clock>。我们可以使用auto关键字来简写(auto是个好文明)。

阅读文档,我们不难发现system_clock有着与另外两个clock所不具有的特性:它是唯一有能力映射其时间点到C-Style时间的C++时钟。system_clock提供了两个静态成员函数来与std::time_t进行互相转换:

名称 说明
to_time_t 转换系统时钟时间点为 std::time_t
from_time_t 转换 std::time_t 到系统时钟时间点

为了方便理解和记忆,我们也用一副图来描述几种时间类型的转换:

时长

人类对精度的要求永无止境。C-Style日期时间库为了提供对纳秒的精度,增加了timespec类型及相关的函数。那如果以后对更高精度的需求越来越高,C++标准库还要不断增加更多的类型和配套函数吗?这明显是一个不合理的设计。因而C++标准提出了一个新的解决思路,而这个思路涉及到了C++11引入的一个新的头文件和类型:ratio。

ratio

std::ratio定义在<ratio>文件中,提供了编译期的比例计算功能。

std::ratio的定义如下:

1
2
3
4
template<
std::intmax_t Num,
std::intmax_t Denom = 1
> class ratio;

类成员Num即为分子,类成员Denom即为分母。我们可以直接通过调用类成员来获取相关值。

<ratio>头文件还包含了:ratio_add,ratio_subtract,ratio_multiply,ratio_divide来完成分数的加减乘除四则运算。

例如,想要计算5/7 + 59/1023,我们可以这样写:

1
2
3
ratio_add<ratio<5, 7>, ratio<59, 1023>> result;
double value = ((double) result.num) / result.den;
cout << result.num << "/" << result.den << " = " << value << endl;

对于编译期有理数算数的相关内容,可以在这篇文档中找到更多信息:

https://zh.cppreference.com/w/cpp/numeric/ratio

有了ratio之后,结合std::chrono::duration,我们便可以表示任意精度的值了。

例如,相对于秒来说,毫秒是1/1,000,微秒是1/1,000,000,纳秒是1/1,000,000,000。通过ratio就可以这样表达:

1
2
3
std::ratio<1, 1000>       milliseconds;
std::ratio<1, 1000000> microseconds;
std::ratio<1, 1000000000> nanoseconds;

时长类型

类模板 std::chrono::duration 表示时间间隔。有了ratio之后,表达时长就很方便了,下面是chrono库中提供的很常用的几个时长单位:

类型 定义
std::chrono::nanoseconds duration</*至少 64 位的有符号整数类型*/, std::nano>
std::chrono::microseconds duration</*至少 55 位的有符号整数类型*/, std::micro>
std::chrono::milliseconds duration</*至少 45 位的有符号整数类型*/, std::milli>
std::chrono::seconds duration</*至少 35 位的有符号整数类型*/>
std::chrono::minutes duration</*至少 29 位的有符号整数类型*/, std::ratio<60»
std::chrono::hours duration</*至少 23 位的有符号整数类型*/, std::ratio<3600»

我们可以调用duration类的count()成员函数来获取具体数值。

时长运算

时长运算可以直接使用“+”或“-”相加相减。chrono库也提供了几个常用的函数:

函数 说明
duration_cast 进行时长的转换
floor(C++17) 以向下取整的方式,将一个时长转换为另一个时长
ceil(C++17) 以向上取整的方式,将一个时长转换为另一个时长
round(C++17) 转换时长到另一个时长,就近取整,偶数优先
abs(C++17) 获取时长的绝对值

例如:想要知道2个小时零5分钟一共是多少秒,可以这样写:

1
2
3
4
5
6
chrono::hours two_hours(2);
chrono::minutes five_minutes(5);

auto duration = two_hours + five_minutes;
auto seconds = chrono::duration_cast<chrono::seconds>(duration);
cout << "02:05 is " << seconds.count() << " seconds" << endl;

我们可以得到:

1
02:05 is 7500 seconds

从C++14开始,你甚至可以用字面值来描述常见的时长。这包括:

  • h表示小时
  • min表示分钟
  • s表示秒
  • ms表示毫秒
  • us表示微妙
  • ns表示纳秒

这些字面值位于std::chrono_literals命名空间下。于是,可以这样表达2个小时以及5分钟:

1
2
3
using namespace std::chrono_literals;
auto two_hours = 2h;
auto five_minutes = 5min;

时间点

时钟的now函数返回的值就是一个时间点,时间点包含了时钟和时长两个信息。

类模板std::chrono::time_point表示时间中的一个点,定义如下:

1
2
3
4
template<
class Clock,
class Duration = typename Clock::duration
> class time_point;

与我们的常识一致,时间点具有加法和减法操作:

1
2
时间点 + 时长 = 时间点
时间点 - 时间点 = 时长

因而我们可以通过两个时间点相减来计算一个时间间隔,示例如下:

1
2
3
4
5
6
7
8
9
10
auto start = chrono::steady_clock::now();
double sum = 0;
for(int i = 0; i < 100000000; i++) {
sum += sqrt(i);
}
auto end = chrono::steady_clock::now();

auto time_diff = end - start;
auto duration = chrono::duration_cast<chrono::milliseconds>(time_diff);
cout << "Operation cost : " << duration.count() << "ms" << endl;

两个时间点也存在着比较操作,用于判断一个时间点在另外一个时间点之前还是之后,std::chrono::time_point重载了==!=<<=>>=操作符来实现比较操作。