| 目录 |

38 日期与时间功能

“时间是一种幻觉。午餐时间则加倍如此。”
——福特·普里弗克特,《银河系漫游指南》

这部分并不太复杂,但一开始可能有点吓人:既有各种不同的可用类型,又有我们在它们之间转换的方式。

再把 GMT(UTC)和本地时间掺进来,处理时间和日期时的全套惯常乐趣™就齐了。

当然,永远不要忘记日期和时间的黄金法则:绝不要试图自己编写日期和时间功能。只使用库提供给你的东西。

时间太复杂了,凡俗程序员根本处理不对。说真的,我们都欠每一位参与过日期时间库的人一份情,所以请把这份成本列入预算。

38.1 术语与信息速览

先简单说几个术语,以防你还没记牢。

一般规则是:如果你描述的是只发生一次的事件,比如一条日志、一次火箭发射,或者你终于弄懂指针的那一刻,就用 UTC。

另一方面,如果它是那种在每个时区都发生在同一时刻的事情,比如除夕或晚餐时间,就用本地时间。

由于许多语言只擅长在 UTC 和本地时间之间转换,你要是选择用错误的形式存储日期,就会给自己带来很多痛苦。(别问我怎么知道的。)

38.2 日期类型

在 C 中处理日期时,主要有两个192类型:time_t 和 struct tm。

规范其实并没怎么规定它们:

在很多系统上,time_t 表示自纪元193以来的秒数。从计算机的角度看,纪元在某种意义上就是时间的起点,通常为 1970 年 1 月 1 日 UTC。time_t 可以为负,用来表示纪元之前的时间。就我所能判断的,Windows 的行为与 Unix 相同。

那么 struct tm 里有什么?有下面这些字段:

struct tm {
    int tm_sec;    // 分钟之后的秒数——[0, 60]
    int tm_min;    // 小时之后的分钟数——[0, 59]
    int tm_hour;   // 自午夜起的小时数——[0, 23]
    int tm_mday;   // 当月的日期——[1, 31]
    int tm_mon;    // 自一月起的月份数——[0, 11]
    int tm_year;   // 自 1900 年起的年数
    int tm_wday;   // 自星期日起的天数——[0, 6]
    int tm_yday;   // 自 1 月 1 日起的天数——[0, 365]
    int tm_isdst;  // 夏令时标志
};

注意,除月中的日之外,所有字段都从零开始计数。

有一点很重要:你可以往这些类型里放任何你想要的值。有一些函数可以帮你取得当前时间,但这些类型保存的是某个时间,而不是那个时间。

于是问题就变成:“这些类型的数据怎么初始化,又怎么在它们之间转换?”

38.3 初始化与类型间的转换

首先,你可以用 time() 函数取得当前时间,并把它存入一个 time_t。

time_t now;  // 用于保存当前时间的变量

now = time(NULL);  // 你可以这样获取……

time(&now);        // ……或者这样。与上一行相同。

很好!你现在有了一个能取到当前时间的变量。

有趣的是,要打印 time_t 里的内容,只有一种可移植的办法,就是用那个很少被使用的 ctime() 函数,它按本地时间打印该值:

now = time(NULL);
printf("%s", ctime(&now));

它返回一个格式非常固定的字符串,末尾还带一个换行符:

Sun Feb 28 18:47:25 2021

所以说它有点不够灵活。如果你想要更多控制权,就应该把这个 time_t 转换成 struct tm。

38.3.1 把 time_t 转换为 struct tm

有两种很妙的方式可以完成这个转换:

下面用 asctime() 函数打印出一个 struct tm,看看现在是什么时间:

printf("Local: %s", asctime(localtime(&now)));
printf("  UTC: %s", asctime(gmtime(&now)));

输出(我处于太平洋标准时区):

Local: Sun Feb 28 20:15:27 2021
  UTC: Mon Mar  1 04:15:27 2021

一旦你把手里的 time_t 变成了 struct tm,各种大门就都打开了。你可以用多种方式打印时间、算出某个日期是星期几,等等。也可以再把它转换回 time_t。

这些内容很快就会讲到!

38.3.2 把 struct tm 转换为 time_t

如果你想走另一个方向,可以用 mktime() 来做这件事。

mktime() 会替你设置 tm_wday 和 tm_yday 的值,所以不用费心去填它们,反正会被覆盖掉。

另外,你可以把 tm_isdst 设为 -1,让它替你判断。也可以手动把它设为 true 或 false。

// 别忍不住给这些数字加前导零(除非你
// 就是想让它按八进制解释)!

struct tm some_time = {
    .tm_year=82,   // 自 1900 年起的年数
    .tm_mon=3,     // 自一月起的月份数——[0, 11]
    .tm_mday=12,   // 当月的日期——[1, 31]
    .tm_hour=12,   // 自午夜起的小时数——[0, 23]
    .tm_min=0,     // 小时之后的分钟数——[0, 59]
    .tm_sec=4,     // 分钟之后的秒数——[0, 60]
    .tm_isdst=-1,  // 夏令时标志
};

time_t some_time_epoch;

some_time_epoch = mktime(&some_time);

printf("%s", ctime(&some_time_epoch));
printf("Is DST: %d\n", some_time.tm_isdst);

输出:

Mon Apr 12 12:00:04 1982
Is DST: 0

像那样手动填充 struct tm 时,其中的时间应当是本地时间。mktime() 会把该本地时间转换成 time_t 形式的日历时间。

不过奇怪的是,标准并没有提供办法把 UTC 时间装进 struct tm 再转换成 time_t。如果你想在类 Unix 系统上这么做,可以试试非标准的 timegm()。在 Windows 上则用 _mkgmtime()。

38.4 格式化日期输出

我们已经见过几种把格式化后的日期输出到屏幕的方法。对于 time_t,可以用 ctime();对于 struct tm,可以用 asctime()。

time_t now = time(NULL);
struct tm *local = localtime(&now);
struct tm *utc = gmtime(&now);

printf("Local time: %s", ctime(&now));     // 用 time_t 表示的本地时间
printf("Local time: %s", asctime(local));  // 用 struct tm 表示的本地时间
printf("UTC       : %s", asctime(utc));    // 用 struct tm 表示的 UTC 时间

但是,亲爱的读者,如果我告诉你,有一种办法能让你对日期的打印方式拥有大得多的控制权呢?

当然,我们也可以从 struct tm 里把一个个字段单独掏出来,但有一个很棒的函数叫 strftime(),能替你完成大量繁琐的工作。它就像是针对日期的 printf()!

来看几个示例。在每一个示例中,我们都会传入一个目标缓冲区、一个最多可写入的字符数,然后是一个格式字符串(写法类似 printf(),但并不相同),它告诉 strftime() 要打印 struct tm 的哪些组成部分,以及如何打印。

你还可以在格式字符串里加入其他固定字符,让它们也出现在输出中,就像使用 printf() 时一样。

本例中我们的 struct tm 来自 localtime(),不过任何来源都可以。

#include <stdio.h>
#include <time.h>

int main(void)
{
    char s[128];
    time_t now = time(NULL);

    // %c:按当前区域设置打印日期
    strftime(s, sizeof s, "%c", localtime(&now));
    puts(s);   // 例如:Sun Feb 28 22:29:00 2021

    // %A:星期几的全称
    // %B:月份的全称
    // %d:当月的日期
    strftime(s, sizeof s, "%A, %B %d", localtime(&now));
    puts(s);   // 例如:Sunday, February 28

    // %I:小时(12 小时制)
    // %M:分钟
    // %S:秒
    // %p:AM 或 PM
    strftime(s, sizeof s, "It's %I:%M:%S %p", localtime(&now));
    puts(s);   // 现在是 10:29:00 PM

    // %F:ISO 8601 的 yyyy-mm-dd
    // %T:ISO 8601 的 hh:mm:ss
    // %z:ISO 8601 时区偏移
    strftime(s, sizeof s, "ISO 8601: %FT%T%z", localtime(&now));
    puts(s);   // ISO 8601:2021-02-28T22:29:00-0800
}

strftime() 的日期打印格式说明符多得是,所以一定要去 strftime() 参考页194看看。

38.5 用 timespec_get() 获得更高精度

用 timespec_get() 可以获取自 Epoch 起的秒数和纳秒数。

也许吧。

各实现未必有纳秒级精度(纳秒就是十亿分之一秒),所以你能拿到几位有效数字谁也说不准,不过不妨试试看。

timespec_get() 接受两个实参。一个是指向 struct timespec 的指针,用来保存时间信息;另一个是 base,规范允许你把它设为 TIME_UTC,表示你关心的是自纪元(Epoch)起的秒数。(其他实现可能会给 base 提供更多选项。)

而这个结构体本身有两个字段:

struct timespec {
    time_t tv_sec;   // 秒
    long   tv_nsec;  // 纳秒(十亿分之一秒)
};

下面这个示例获取时间,并分别以整数值和浮点值的形式打印出来:

struct timespec ts;

timespec_get(&ts, TIME_UTC);

printf("%ld s, %ld ns\n", ts.tv_sec, ts.tv_nsec);

double float_time = ts.tv_sec + ts.tv_nsec/1000000000.0;
printf("%f seconds since epoch\n", float_time);

示例输出:

1614581530 s, 806325800 ns
1614581530.806326 seconds since epoch

struct timespec 还出现在若干多线程函数里,这些函数需要能够以这样的精度来指定时间。

38.6 时间之间的差值

关于求两个 time_t 之间的差值,有一点要提醒:规范并未规定该类型如何表示时间,所以你未必能直接把两个 time_t 相减就得到有意义的结果195。

好在你可以用 difftime() 计算两个日期之间相差的秒数。

在下面的示例中,有两个相隔一段时间发生的事件,我们用 difftime() 计算它们的差值。

#include <stdio.h>
#include <time.h>

int main(void)
{
    struct tm time_a = {
        .tm_year=82,   // 自 1900 年起的年数
        .tm_mon=3,     // 自一月起的月份数——[0, 11]
        .tm_mday=12,   // 当月的日期——[1, 31]
        .tm_hour=4,    // 自午夜起的小时数——[0, 23]
        .tm_min=00,    // 小时之后的分钟数——[0, 59]
        .tm_sec=04,    // 分钟之后的秒数——[0, 60]
        .tm_isdst=-1,  // 夏令时标志
    };

    struct tm time_b = {
        .tm_year=120,  // 自 1900 年起的年数
        .tm_mon=10,    // 自一月起的月份数——[0, 11]
        .tm_mday=15,   // 当月的日期——[1, 31]
        .tm_hour=16,   // 自午夜起的小时数——[0, 23]
        .tm_min=27,    // 小时之后的分钟数——[0, 59]
        .tm_sec=00,    // 分钟之后的秒数——[0, 60]
        .tm_isdst=-1,  // 夏令时标志
    };

    time_t cal_a = mktime(&time_a);
    time_t cal_b = mktime(&time_b);

    double diff = difftime(cal_b, cal_a);

    double years = diff / 60 / 60 / 24 / 365.2425;  // 差不多就行

    printf("%f seconds (%f years) between events\n", diff, years);
}

输出:

1217996816.000000 seconds (38.596783 years) between events

就是这样!记住要用 difftime() 来求时间差。虽说在 POSIX 系统上直接相减也行,但不妨保持可移植性。


| 目录 |