你有没有遇到过这样的需求:一个程序需要记录自己被编译的时间戳?当然,首先排除硬编码的方式,那蠢爆了。
“这题我会!我们有 __DATE__
和 __TIME__
宏!”
说的道理。
确实,__DATE__
给你一个 "Apr 1 2024"
,__TIME__
给你一个 "03:34:29"
。然后我们进行一些神必的字符串解析操作,就能得到一个时间戳了。但是,这一切真的值得吗?
不是,哥们,我们思考一下这到底意味着什么。这意味着在编译好的二进制文件里其实只有这两个 const char []
,你甚至可以用 strings
直接把这两个字符串给 dump 出来。而解析是在运行时进行的。当然,it works,但你要在运行时花额外的时间( CPU 周期 ) 来解析这两个字符串。作为追求极致性能( 整天被隔壁 Rust 用户骑脸输出 ) 的 C++ 批,这是不能容忍的。
OK,说到这你大概已经能猜到我们今天又要用到哪个神奇妙妙工具了。对,就是你,constexpr
,所以这意味着你需要一个能支持 C++11 的编译器。(你不会没有吧?不会吧?)我们会实现一个编译时 struct tm
常量。作为 Bonus,我们还会有一个 time_t
常量。
不想看过程的可以直接跳到最后。
…不好意思,还是先暂时忘了 C++11 吧。 当然,不是 C++11 写不起,而是 C++14 更有性价比。C++11 中的 constexpr
函数太弱小了,函数体内只能有单行 return
。C++14 以后就没有这个限制了。所以我们先按 C++14 的标准来写。C++11 的版本会在最后给出,单行 return
限制的存在意味着我们注定需要一些额外的操作,最后的实现也没有 C++14 版本易读,当然效果上是一样的。-std=c++14
开!
(C++14 前)
函数体必须被弃置或预置,或只含有下列内容:空语句(仅分号) static_assert
声明不定义类或枚举的 typedef
声明及别名声明 using
声明using
指令当函数不是构造函数时,有恰好一条 return
语句 (C++14 起)(C++23 前)
函数体必须不含 :goto
语句带有除 case
和 default
之外的标号的语句(C++20 前)
非字面类型的变量定义 静态或线程存储期变量的定义 (是 =default;
或 =delete;
的函数体均不含任何上述内容。) (抄自 constexpr
说明符 - cppreference.com )
去码头整点轮子 思考一下我们目前应该怎么下手。切入点肯定是 __DATE__
和 __TIME__
。
__DATE__
This macro expands to a string constant that describes the date on which the preprocessor is being run. The string constant contains eleven characters and looks like "Feb 12 1996"
. If the day of the month is less than 10, it is padded with a space on the left.
If GCC cannot determine the current date, it will emit a warning message (once per compilation) and __DATE__
will expand to "??? ?? ????"
__TIME__
This macro expands to a string constant that describes the time at which the preprocessor is being run. The string constant contains eight characters and looks like "23:59:01"
.
If GCC cannot determine the current time, it will emit a warning message (once per compilation) and __TIME__
will expand to "??:??:??"
.
——That’s what GCC says .
再想一下如果在运行时解析我们要用到什么——也就是说,大概需要一个 constexpr
版本的 atoi
。这应该是最简单的一部分。
CompileTime.cpp 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 constexpr int atoi_constexpr (const char *s) { int result = 0 ; while (*s == ' ' ) { s++; } while ('0' <= *s && *s <= '9' ) { result *= 10 ; result += *s - '0' ; s++; } return result; }
非常好。atoi_constexpr
在手,我们现在可以解析的数据有:
CompileTime.cpp 32 33 34 35 36 constexpr int mday = atoi_constexpr (&__DATE__[4 ]);constexpr int year = atoi_constexpr (&__DATE__[7 ]);constexpr int hour = atoi_constexpr (__TIME__);constexpr int min = atoi_constexpr (&__TIME__[3 ]);constexpr int sec = atoi_constexpr (&__TIME__[6 ]);
可以,那现在我们来解析月份——稍微有点麻烦的部分,因为月份是以英文缩写的形式给出的。聪明的小朋友们可能会想实现一个 constexpr
版本的 strcmp
或者 strncmp
——但其实不用。我们有最暴力的方法:
CompileTime.cpp 37 38 39 40 41 42 43 44 45 46 constexpr int mon = __DATE__[0 ] == 'J' ? __DATE__[1 ] == 'a' ? 1 : __DATE__[2 ] == 'n' ? 6 : 7 : __DATE__[0 ] == 'F' ? 2 : __DATE__[0 ] == 'M' ? __DATE__[2 ] == 'r' ? 3 : 5 : __DATE__[0 ] == 'A' ? __DATE__[1 ] == 'p' ? 4 : 8 : __DATE__[0 ] == 'S' ? 9 : __DATE__[0 ] == 'O' ? 10 : __DATE__[0 ] == 'N' ? 11 : 12 ;
…OK,必须承认这一堆 ?:
丑爆了。
在急着投产之前 在这个点,有必要回顾一下 tm
到底是什么。
std:: tm
保有拆分到各组分的日历日期和时间的结构体。
成员对象
int tm_sec
- 分后之秒 - [0
, 60
][注 1] 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 日起之日 - [0
, 365
]int tm_isdst
- 夏令时标志。值若夏令时有效则为正,若无效则为零,若无可用信息则为负标准只强制前述成员按任一顺序存在。实现通常对此结构体添加额外的数据成员。
范围允许正闰秒。不允许同一分钟内有两个闰秒(C89 中错误地引入范围 [0
, 61
],而在 C99 中更正) …实际上,现在所有必要的 信息都已经有了。“必要的 ”的意思是:
std:: mktime
转换本地日历时间为从纪元起的时间,作为 std::time_t
对象。忽略 time->tm_wday
与 time->tm_yday
。容许 time
中的值在其正常范围外。
time->tm_isdst
的负值会导致 mktime
尝试确定在指定时间夏时令是否有效。
若转换成功,则 time
对象会被修改。更新 time
的所有字段为符合其正确范围的值。用其他字段的可用信息重新计算 time->tm_wday
与 time->tm_yday
。
mktime
和我们朴素的认知都告诉我们,要计算一个 UNIX 时间戳,只需要年月日时分秒。理论上来说这个时候我们直接摆烂也就可以了。…就像你的人生糊弄糊弄不就完了吗。
虽然我很想把 wday
和 yday
的实现留作课后习题,但有时候在编译期知道一年的第几天和星期几也是有用的。
我们继续。
constexpr
everything其实到这已经有点索然无味了。计算 yday
是很简单的。
CompileTime.cpp 54 55 56 57 constexpr int days[] = {0 , 31 , 59 , 90 , 120 , 151 , 181 , 212 , 243 , 273 , 304 , 334 };constexpr bool is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0 );constexpr int yday = (mday - 1 ) + days[mon - 1 ] + (mon > 2 && is_leap);
这里除了 days
数组有点抽象之外都很 trivial。其实 days
数组也很 trivial,单纯只是天数的部分和。那 wday
咋办?
这要用到我们小学二年级就学过的蔡勒公式 :
w = ( y + ⌊ y 4 ⌋ + ⌊ c 4 ⌋ − 2 c + ⌊ 26 ( m + 1 ) 10 ⌋ + d − 1 ) m o d 7 \displaystyle w = \left(y + \left\lfloor \frac y4 \right\rfloor + \left\lfloor \frac c4 \right\rfloor - 2c + \left\lfloor \frac{26(m+1)}{10} \right\rfloor + d - 1\right) \bmod 7 w = ( y + ⌊ 4 y ⌋ + ⌊ 4 c ⌋ − 2 c + ⌊ 10 26 ( m + 1 ) ⌋ + d − 1 ) mod 7 其中 c c c 是年份前两位,y y y 是年份后两位,m m m 是月份,但一月和二月要看作上一年的十三月和十四月。同时我们还需要考虑到可能出现的负数结果。w w w 就是星期。上面给出的公式是经过调整的蔡勒公式,使得星期日是 0——与 tm_wday
的定义一致。
没什么好说的,看公式说话就完事了。记得注意可能出现的负数结果。
CompileTime.cpp 48 49 50 51 52 constexpr int m = mon < 3 ? mon + 12 : mon;constexpr int y = (year - (mon < 3 )) % 100 ;constexpr int c = (year - (mon < 3 )) / 100 ;constexpr int wday = ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1 ) / 10 + mday - 1 ) % 7 + 7 ) % 7 ;
至此,已成艺术。
CompileTime.cpp 59 60 61 62 63 64 65 66 67 68 69 return tm { .tm_sec = sec, .tm_min = min, .tm_hour = hour, .tm_mday = mday, .tm_mon = mon - 1 , .tm_year = year - 1900 , .tm_wday = wday, .tm_yday = yday, .tm_isdst = -1 , };
在这里 DST 是唯一的我们没有任何手段能在编译时确定的东西。甚至没有任何办法能让你在编译时自动确定你的时区。但无所谓,我们冲国人不用 DST。(不…不吗?见后面 )
现在,时间戳 现在就是更索然无味的 OI 向问题了。给定年月日时分秒,同时还给了 yday
降低难度,求 UNIX 时间戳。
在这里我们还需要考虑的一点是 Epoch。对于 UTC+8 的我们来说,UNIX 时间 0 是 1970 年 1 月 1 日 08:00:00。所以 1970 年 1 月 1 日 00:00:00 的时间戳是 -28800。
CompileTime.cpp 72 73 74 75 76 77 constexpr time_t get_timestamp (const tm &t, int epoch) { uint32_t leap_days = (t.tm_year - 69 ) / 4 - (t.tm_year - 101 ) / 100 + (t.tm_year - 101 ) / 400 ; uint32_t day = t.tm_yday + 365 * (t.tm_year - 70 ) + leap_days; return epoch + 86400LL * day + 3600LL * t.tm_hour + 60 * t.tm_min + t.tm_sec; }
思考一下 leap_days
那一行是怎么算的。因为已经有 yday
了,所以其实我们需要的是从 1970 到 year - 1
年的闰年数。
结束了。和无法获取 DST 信息一个道理,我们也无法在编译时确定 epoch。这是唯一需要写死的地方。
CompileTime.cpp 149 150 constexpr tm COMPILE_TIME = get_compile_time ();constexpr time_t COMPILE_TIMESTAMP = get_timestamp (COMPILE_TIME, -28800 );
测试! 热知识:constexpr
并不是只能在编译时使用。所以来点高强度随机测试。
CompileTime.cpp 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 #include <random> #include <chrono> #include <iomanip> #include <iostream> int main () { tm t {}; std::cerr << __cplusplus << std::endl; std::mt19937 gen (std::random_device {}()) ; std::uniform_int_distribution<int > dist (0 , 500 ) ; for (int i = 0 ; i < 1000000 ; i++) { t.tm_sec = dist (gen); t.tm_min = dist (gen); t.tm_hour = dist (gen); t.tm_mday = dist (gen); t.tm_mon = dist (gen); t.tm_year = 70 + dist (gen); time_t gold = timegm (&t); time_t calc = get_timestamp (t, 0 ); if (gold != calc) { std::cerr << "Test failed: " << std::put_time (&t, "%F %T" ) << std::endl; std::cerr << "Expected: " << gold << std::endl; std::cerr << "Got: " << calc << std::endl; std::cerr << "Diff: " << gold - calc << std::endl; return 1 ; } } }
记得开 -std=c++14
。
一些测试时可能会遇到的神必问题 注意到我们在上面用了非标准的 timegm
。这是因为 mktime
会按本地时间来处理,而 timegm
是按 UTC 时间来处理。你可以把 timegm
换成 mktime
,然后给 get_timestamp
的 epoch 换成 -28800。俺寻思能行,直到遇到这种东西:
1 2 3 4 5 6 $ ./a.out 201103 Test failed: 1986-05-29 07:02:06 Expected: 517701726 Got: 517705326 Diff: -3600
…实际上是因为我国在历史上曾有一段时间推行过夏令时。mktime
甚至能处理这种情况。所以如果没有 timegm
用的话,你也许需要正常用 mktime
并把 epoch 换成 0,然后在运行的时候:
一些索然无味的分析 我们现在来关心一点别的问题。我们定义了两个辅助函数,get_compile_time
和 get_timestamp
。时间复杂度这一块我们已经拿捏死了,但是空间复杂度呢?
当然,其实不该说“空间复杂度”——其实我只是想表达,这些辅助函数会被带进最终的二进制文件吗?我的意思是,现在的 CompileTime.cpp
正确的使用方法,应该是有另一个单独的 main.cpp
,里面有大概这样的东西:
main.cpp 1 2 3 4 5 6 7 8 9 10 #include <ctime> #include <cassert> extern const tm COMPILE_TIME;extern const time_t COMPILE_TIMESTAMP;int main () { tm t {COMPILE_TIME}; assert (COMPILE_TIMESTAMP == std::mktime (&t)); }
然后我们这样编译:
1 $ clang++ -O0 -std=c++14 -o main main.cpp CompileTime.cpp
注意那个 -O0
,这是我们待会儿会用到的神奇妙妙参数。如果你读过动态链接、系统调用与 C 的 inline 这篇,会知道有个妙妙工具叫 nm
。
1 2 3 4 5 6 7 8 $ nm main 0000000100003f68 S _COMPILE_TIME 0000000100003fa0 S _COMPILE_TIMESTAMP U ___assert_rtn 0000000100000000 T __mh_execute_header 0000000100003e88 T _main U _memcpy U _mktime
我们发现这里只有 COMPILE_TIME
和 COMPILE_TIMESTAMP
,没有别的东西。这就是我们 constexpr
函数——运行时用不到的东西不会被带进来。-O0
说明即使不开启任何优化也是如此。
如果 nm
仍然说服不了你:
注意左侧的 _COMPILE_TIME
和 _COMPILE_TIMESTAMP
是以 __const
数据形式存在的。再看看右侧反编译出来的代码。
这就是我们 constexpr
啊,你们有没有这样的 constexpr
啊,真是常常又量量啊。
最后的最大最恶危机 实际上这里还有最后的一点不足——但实际上是一个很大的问题——如果 tm
结构体的成员声明顺序不是按我们所写的顺序怎么办?毕竟,标准里(这里指 ISO/IEC 9899:2011)确实这么说了(你可以在 N1570 中找到这段话):
§ 7.27.1 Components of time
… The tm
structure shall contain at least the following members, in any order . The semantics of the members and their normal ranges are expressed in the comments.1 2 3 4 5 6 7 8 9 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;
当然,ISO/IEC 9899 是 C 标准,而 C 中结构体成员初始化的顺序是无关紧要的。但 C++ 不一样——指派初始化器 不允许乱序。
(好吧,其实这里还有另一个问题——指派初始化器直至 C++20 才被标准化。所以在那之前,严格上来说,我们其实都是在依赖编译器的扩展。)
思考一下这意味着什么。tm
是 C++ 从 C 继承来的,因此也没有特别对其成员顺序做规定。这意味着可能有的实现会更改其成员顺序。而一旦更改了,我们现在的实现必会报错。
CompileTime.cpp 59 60 61 62 63 64 65 66 67 68 69 return tm { .tm_sec = sec, .tm_min = min, .tm_hour = hour, .tm_mday = mday, .tm_mon = mon - 1 , .tm_year = year - 1900 , .tm_wday = wday, .tm_yday = yday, .tm_isdst = -1 , };
很难绷,因为,实际上,这是一个完全无解的问题。是的,没有任何手段,直到 C++20 允许我们在一个 constexpr
函数中这样写:
1 2 3 4 5 6 7 8 9 10 11 12 13 tm t; t.tm_sec = sec; t.tm_min = min; t.tm_hour = hour; t.tm_mday = mday; t.tm_mon = mon - 1 ; t.tm_year = year - 1900 ; t.tm_wday = wday; t.tm_yday = yday; t.tm_isdst = -1 ;return t;
所以如果你的编译器报错 designator order for field 'tm_sec' does not match declaration order in 'tm'
,唯一的办法是去观察你的平台上 tm
的定义,然后手动调整这些成员的顺序。
实际上也不是那么无解 …前提是我们完全抛弃 C++11。对它来说确实是无解的,因为单行 return
限制。
(即使是用上一些别的肮脏手段,比如 GCC 的 Statements and Declarations in Expressions 也摆脱不了这个限制。实际上,C++11 模式下的 g++-13
甚至会对 constexpr
函数内的 statement expression 的一些写法产生 internal compiler error。 而 clang++
就是正常的。赞美 clang
!)
对 C++14 和 C++17,它们不支持:
不进行初始化的变量定义
这是什么意思呢?这意味着只要我们给 tm
一个初始值就可以了。空初始化足矣。
1 2 tm t {}; t.tm_sec = sec;
一些课后习题 把它改造成一个头文件!(一般我们管这种东西叫 single-header library)更优雅一点,也许你需要思考一下怎么把那一堆辅助函数给隐藏起来…? 但其实如果真的这样做了,也许会产生一些潜在的其他问题…?Hint:思考一个有巨多文件的项目和/或一台巨慢无比的机器。 试着自己把它改写成 C++11 版本吧!那个 days
数组可能比较难以下手,所以我们在这里给出 yday
的一种实现。CompileTime.cpp 118 119 120 121 constexpr int __yday(int mday, int mon, int year) { return (mday - 1 ) + (const int []) {0 , 31 , 59 , 90 , 120 , 151 , 181 , 212 , 243 , 273 , 304 , 334 }[mon - 1 ] + (mon > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0 ))); }
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 #include <cstdint> #include <ctime> #if __cplusplus >= 201402L static constexpr int __a2i(const char *s) { int result = 0 ; while (*s == ' ' ) { s++; } while ('0' <= *s && *s <= '9' ) { result *= 10 ; result += *s - '0' ; s++; } return result; }static constexpr tm __get_compile_time() { constexpr int mday = __a2i(&__DATE__[4 ]); constexpr int year = __a2i(&__DATE__[7 ]); constexpr int hour = __a2i(__TIME__); constexpr int min = __a2i(&__TIME__[3 ]); constexpr int sec = __a2i(&__TIME__[6 ]); constexpr int mon = __DATE__[0 ] == 'J' ? __DATE__[1 ] == 'a' ? 1 : __DATE__[2 ] == 'n' ? 6 : 7 : __DATE__[0 ] == 'F' ? 2 : __DATE__[0 ] == 'M' ? __DATE__[2 ] == 'r' ? 3 : 5 : __DATE__[0 ] == 'A' ? __DATE__[1 ] == 'p' ? 4 : 8 : __DATE__[0 ] == 'S' ? 9 : __DATE__[0 ] == 'O' ? 10 : __DATE__[0 ] == 'N' ? 11 : 12 ; constexpr int m = mon < 3 ? mon + 12 : mon; constexpr int y = (year - (mon < 3 )) % 100 ; constexpr int c = (year - (mon < 3 )) / 100 ; constexpr int wday = ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1 ) / 10 + mday - 1 ) % 7 + 7 ) % 7 ; constexpr int days[] = {0 , 31 , 59 , 90 , 120 , 151 , 181 , 212 , 243 , 273 , 304 , 334 }; constexpr bool is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0 ); constexpr int yday = (mday - 1 ) + days[mon - 1 ] + (mon > 2 && is_leap); tm t {}; t.tm_sec = sec; t.tm_min = min; t.tm_hour = hour; t.tm_mday = mday; t.tm_mon = mon - 1 ; t.tm_year = year - 1900 ; t.tm_wday = wday; t.tm_yday = yday; t.tm_isdst = -1 ; return t; }static constexpr time_t __get_timestamp(const tm &t, int epoch) { uint32_t leap_days = (t.tm_year - 69 ) / 4 - (t.tm_year - 101 ) / 100 + (t.tm_year - 101 ) / 400 ; uint32_t day = t.tm_yday + 365 * (t.tm_year - 70 ) + leap_days; return epoch + 86400LL * day + 3600LL * t.tm_hour + 60 * t.tm_min + t.tm_sec; }#else static constexpr const char *__space_sled(const char *s) { return *s == ' ' ? __space_sled(s + 1 ) : s; }static constexpr int __a2i(const char *s, int c) { return *s >= '0' && *s <= '9' ? __a2i(s + 1 , c * 10 + *s - '0' ) : c; }static constexpr int __a2i(const char *s) { return __a2i(__space_sled(s), 0 ); }static constexpr int __mon() { return __DATE__[0 ] == 'J' ? __DATE__[1 ] == 'a' ? 1 : __DATE__[2 ] == 'n' ? 6 : 7 : __DATE__[0 ] == 'F' ? 2 : __DATE__[0 ] == 'M' ? __DATE__[2 ] == 'r' ? 3 : 5 : __DATE__[0 ] == 'A' ? __DATE__[1 ] == 'p' ? 4 : 8 : __DATE__[0 ] == 'S' ? 9 : __DATE__[0 ] == 'O' ? 10 : __DATE__[0 ] == 'N' ? 11 : 12 ; }static constexpr int __year() { return __a2i(&__DATE__[7 ]); }static constexpr int __mday() { return __a2i(&__DATE__[4 ]); }static constexpr int __wday(int y, int c, int m, int mday) { return ((y + y / 4 + c / 4 - 2 * c + 26 * (m + 1 ) / 10 + mday - 1 ) % 7 + 7 ) % 7 ; }static constexpr int __yday(int mday, int mon, int year) { return (mday - 1 ) + (const int []) {0 , 31 , 59 , 90 , 120 , 151 , 181 , 212 , 243 , 273 , 304 , 334 }[mon - 1 ] + (mon > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0 ))); }static constexpr tm __get_compile_time() { return tm { .tm_sec = __a2i(&__TIME__[6 ]), .tm_min = __a2i(&__TIME__[3 ]), .tm_hour = __a2i(__TIME__), .tm_mday = __mday(), .tm_mon = __mon() - 1 , .tm_year = __year() - 1900 , .tm_wday = __wday((__year() - (__mon() < 3 )) % 100 , (__year() - (__mon() < 3 )) / 100 , __mon() < 3 ? __mon() + 12 : __mon(), __mday()), .tm_yday = __yday(__mday(), __mon(), __year()), .tm_isdst = -1 , }; }static constexpr int __days_since_epoch(int yday, int year) { return yday + 365 * year + ((year + 1 ) / 4 - (year - 31 ) / 100 + (year - 31 ) / 400 ); }static constexpr time_t __get_timestamp(const tm &t, int epoch) { return epoch + 86400LL * __days_since_epoch(t.tm_yday, t.tm_year - 70 ) + 3600LL * t.tm_hour + 60 * t.tm_min + t.tm_sec; }#endif extern constexpr tm COMPILE_TIME = __get_compile_time();extern constexpr time_t COMPILE_TIMESTAMP = __get_timestamp(COMPILE_TIME, -28800 );