前言
"算两个日期差多少天""某个日期往后推 N 天是哪天""这天是星期几"——这三件事看起来简单到不需要写库,但真正动手写就会发现坑很多:闰年规则不是"能被 4 整除"、月份天数不固定、星期几没有闭式公式(其实有一个很好用的)、时区和夏令时会在你完全没料到的地方咬你一口。
最常见的几个误解是:
- "闰年就是能被 4 整除。"不完整。完整规则是"能被 4 整除但不能被 100 整除,或者能被 400 整除"。所以 1900 年不是闰年,2000 年是。
- "用
time_t相减再除以 86400 就是天数。"在本地时区下会被夏令时(DST)搅乱,某一天可能是 23 小时或 25 小时,除出来会差一天。 - "
std::tm的tm_year就是年份。"它是年份减 1900;tm_mon还是从 0 开始计数的。
本文先用 C++17 手写一个不依赖任何平台的Date类(核心是把日期映射成整数"序数日"),再对照 C++20 的std::chrono日历类型。所有代码都以 C++17 为基准,在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译;用到 C++20 的地方会明确标注。
一、先把"参照系"定下来:序数日
日期运算之所以容易写错,是因为"年-月-日"这个三元组本身不适合做加减。正确的做法是先把它转换成一个整数序数日(serial day):规定 1970-01-01 为第 0 天,之后的每一天加 1,之前的每一天减 1。这样:
- 两个日期相减 = 两个序数日相减,直接得到天数;
- 日期加 N 天 = 序数日加 N,再转回"年-月-日";
- 星期几 = 序数日对 7 取模(需要一个偏移量)。
序数日只跟"哪一天"有关,和时区、夏令时完全无关,这是它最大的好处。下面这张表里的数值都是可以用后文代码直接验证的:
| 日期 | 序数日(1970-01-01 = 0) | 星期 |
|---|---|---|
| 1900-01-01 | -25567 | 周一 |
| 1970-01-01 | 0 | 周四 |
| 2000-01-01 | 10957 | 周六 |
| 2024-01-01 | 19723 | 周一 |
| 2024-02-29 | 19782 | 周四 |
因为有 1970-01-01 是周四这个已知事实,星期几的公式就是:把序数日对 7 取余(注意负数),加上 4,再对 7 取余,结果的 0 表示周日、1 表示周一……
关于闰年(leap year)规则,标准的判据是:
constexpr bool is_leap(int y) noexcept { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; }这里y % 4 == 0 && y % 100 != 0覆盖了普通闰年,y % 400 == 0覆盖了世纪闰年。注意运算符优先级:&&优先级低于||,所以写成上面这样是对的;如果写成(y % 4 == 0) && (y % 100 != 0 || y % 400 == 0),1900 就会被误判成闰年。
二、C++17:手写一个可移植的 Date 类
下面这个类只依赖标准库,没有任何平台 API。核心是 Howard Hinnant 提出的days_from_civil/civil_from_days算法,它把"以 3 月为一年起点"的技巧用到了极致:把 1 月和 2 月挪到上一年的末尾,闰日就永远落在"一年的最后一天",不用再对 2 月做特殊处理。
// date.cpp —— g++ -std=c++17 -Wall -Wextra date.cpp -o date #include <cstdint> #include <cstdio> #include <iostream> #include <stdexcept> #include <string> class Date { public: Date(int y, int m, int d) : y_(y), m_(m), d_(d) { if (!is_valid(y, m, d)) { throw std::invalid_argument("invalid date"); } } int year() const noexcept { return y_; } int month() const noexcept { return m_; } int day() const noexcept { return d_; } // 1970-01-01 记为 0,之前的日期为负数 std::int64_t serial() const noexcept { return days_from_civil(y_, m_, d_); } static Date from_serial(std::int64_t z) { int y = 0, m = 0, d = 0; civil_from_days(z, y, m, d); return Date(y, m, d); } Date add_days(std::int64_t n) const { return from_serial(serial() + n); } // 两个日期相差的天数((*this) - other) std::int64_t operator-(const Date& other) const noexcept { return serial() - other.serial(); } // 0 = 周日, 1 = 周一, ... 6 = 周六 int weekday() const noexcept { const std::int64_t s = serial(); return static_cast<int>((s % 7 + 7 + 4) % 7); } const char* weekday_name() const noexcept { static const char* const names[7] = { "周日", "周一", "周二", "周三", "周四", "周五", "周六" }; return names[weekday()]; } std::string to_string() const { char buf[16] = {0}; std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", y_, m_, d_); return std::string(buf); } static bool is_leap(int y) noexcept { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; } static int days_in_month(int y, int m) noexcept { static const int table[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (m == 2 && is_leap(y)) return 29; return table[m - 1]; } static bool is_valid(int y, int m, int d) noexcept { if (m < 1 || m > 12) return false; return d >= 1 && d <= days_in_month(y, m); } private: // 把"年-月-日"转成序数日;m 取 1..12 static std::int64_t days_from_civil(int y, unsigned m, unsigned d) noexcept { y -= (m <= 2) ? 1 : 0; // 3 月当作一年的开始 const std::int64_t era = (y >= 0 ? y : y - 399) / 400; const unsigned yoe = static_cast<unsigned>(y - era * 400); // [0, 399] const unsigned mp = (m + 9) % 12; // 3 月 -> 0, 1 月 -> 10 const unsigned doy = (153 * mp + 2) / 5 + d - 1; // [0, 365] const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] return era * 146097 + static_cast<std::int64_t>(doe) - 719468; } // 序数日转回"年-月-日" static void civil_from_days(std::int64_t z, int& y, int& m, int& d) noexcept { z += 719468; const std::int64_t era = (z >= 0 ? z : z - 146096) / 146097; const unsigned doe = static_cast<unsigned>(z - era * 146097); // [0, 146096] const unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] const int yy = static_cast<int>(yoe) + static_cast<int>(era) * 400; const unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] const int mp = static_cast<int>((5 * doy + 2) / 153); // [0, 11] d = static_cast<int>(doy - (153u * static_cast<unsigned>(mp) + 2u) / 5u + 1u); m = mp + (mp < 10 ? 3 : -9); // [1, 12] y = yy + ((m <= 2) ? 1 : 0); } int y_, m_, d_; }; int main() { const Date a(2000, 1, 1); const Date b(2024, 2, 29); std::cout << a.to_string() << ' ' << a.weekday_name() << '\n'; // 2000-01-01 周六 std::cout << b.to_string() << ' ' << b.weekday_name() << '\n'; // 2024-02-29 周四 std::cout << "相差 " << (b - a) << " 天\n"; // 相差 8825 天 const Date c = a.add_days(1000); std::cout << "2000-01-01 之后 1000 天:" << c.to_string() << '\n'; // 2002-09-27 std::cout << "序数日 19723 对应 " << Date::from_serial(19723).to_string() << '\n'; // 2024-01-01 try { const Date bad(2023, 2, 29); // 2023 不是闰年 } catch (const std::invalid_argument& e) { std::cout << "构造失败:" << e.what() << '\n'; } }逐行核对几个容易出问题的点:
days_from_civil的第三个参数是unsigned,而d_是int。因为构造函数已经保证d_ >= 1,这里的隐式转换是安全的。mp = (m + 9) % 12的作用是把"以 3 月为起点"的月份编号算出来:3 月得 0、12 月得 9、1 月得 10、2 月得 11。doy的公式(153 * mp + 2) / 5是这套月长序列的整数近似,读者可以直接代入mp = 0, d = 1验证得到 0。yoe * 365 + yoe / 4 - yoe / 100里没有- yoe / 400,是因为yoe的取值范围被限制在[0, 399],400 年一个"纪元(era)"的重置已经在外层的era * 146097里处理了。weekday()里的(s % 7 + 7 + 4) % 7:C++ 的%对负数返回负余数,+ 7是为了把它掰回非负区间,再+ 4是因为序数日 0 对应周四。std::snprintf在<cstdio>里;std::int64_t在<cstdint>里;两者都要显式包含,不要靠别的头文件间接引入。Date的构造函数会抛std::invalid_argument,所以它不是noexcept的,也不能作为字面类型用在常量表达式里(除非把校验挪到工厂函数里)。
三、C++20:直接用std::chrono的日历类型
C++20 把上面这一整套东西标准化了。std::chrono::year_month_day就是"年-月-日",std::chrono::sys_days就是序数日(准确说,是自 1970-01-01 起计的天数时间点)。本节代码需要 C++20 及以上,C++17 请用上一节的Date类。
// chrono_date.cpp —— g++ -std=c++20 -Wall -Wextra chrono_date.cpp -o chrono_date #include <chrono> #include <iostream> int main() { using namespace std::chrono; const year_month_day a{year{2000}, month{1}, day{1}}; const year_month_day b{year{2024}, month{2}, day{29}}; std::cout << a << '\n'; // 2000-01-01 std::cout << b << '\n'; // 2024-02-29 std::cout << "b.ok() = " << b.ok() << '\n'; // 1 const sys_days da = a; // 隐式转换成序数日时间点 const sys_days db = b; std::cout << "相差 " << (db - da).count() << " 天\n"; // 8825 const weekday wd{db}; // 由 sys_days 得到星期 std::cout << "c_encoding = " << wd.c_encoding() << '\n'; // 4(周日为 0) const year_month_day next{sys_days{db + days{1000}}}; std::cout << next << '\n'; // 2026-11-25 const year_month_day bad{year{2023}, month{2}, day{29}}; std::cout << "bad.ok() = " << bad.ok() << '\n'; // 0 }对照上一节,几处签名要记清楚:
operator sys_days()是隐式的,所以const sys_days da = a;可以直接写。year_month_day(const sys_days&)是显式的(explicit),所以从序数日回转必须用直接初始化year_month_day next{...},写成拷贝初始化year_month_day next = ...会编译失败。weekday从sys_days构造也是explicit的;c_encoding()返回unsigned,取值 0..6 且0 表示周日,和常见的"周一为一周之始"不一样。year{2023}/2/29这种写法不会报错,它能构造出一个ok() == false的对象。对ok() == false的对象做转换,标准没有规定结果,所以用之前一定要if (x.ok())。
常见坑点
| # | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 1 | return y % 4 == 0;判断闰年(1900 会被判成闰年) | `return (y % 4 == 0 && y % 100 != 0) \ |
| 2 | (t2 - t1) / 86400用time_t秒数算天数 | 先转成序数日或std::chrono::sys_days再相减,避免 DST 导致某天只有 23 小时 |
| 3 | tm.tm_year = 2024; tm.tm_mon = 2; tm.tm_mday = 29; | tm_year = 2024 - 1900;、tm_mon = 2 - 1;,tm_mday才是真实的日 |
| 4 | 用std::mktime之前不设tm.tm_isdst | 显式写tm.tm_isdst = -1;(让库自己判断)或0;不初始化是 UB |
| 5 | 让构造函数"自动进位":Date(2024, 2, 30)静默变成 2024-03-01 | 构造时就校验并抛std::invalid_argument,别让错误日期悄悄溜进业务逻辑 |
| 6 | std::chrono::year_month_day不检查ok()就直接转sys_days | 先if (ymd.ok());ok() == false时转换结果标准没有规定 |
| 7 | 用int承接time()的返回值 | std::time_t和std::chrono::system_clock::time_point都用各自的原生类型,别退化成 32 位 |
第 4 条展开说一下。std::tm是一个聚合体,最省事的初始化方式是:
#include <ctime> #include <iostream> int main() { std::tm tm{}; tm.tm_year = 2024 - 1900; // 年份是"距 1900 年" tm.tm_mon = 2 - 1; // 月份从 0 开始 tm.tm_mday = 29; // 日从 1 开始 tm.tm_hour = 12; // 用 12 点,避开夏令时切换的边界 tm.tm_min = 0; tm.tm_sec = 0; tm.tm_isdst = -1; // 让 mktime 自己判断是否夏令时 const std::time_t t = std::mktime(&tm); // 注意:这是本地时间 if (t == static_cast<std::time_t>(-1)) { std::cerr << "mktime 失败\n"; return 1; } std::cout << std::asctime(&tm); // 输出用的是本地时区的星期与月名 }三个必须记住的点:std::mktime把std::tm当成本地时间解释(要 UTC 语义得用平台扩展,POSIX 上是timegm,MSVC 上是_mkgmtime,两者都不是标准 C++);std::mktime会把越界字段静默规范化(2024-02-30 会变成 2024-03-01,且返回值不告诉你);std::tm{}的零初始化把tm_isdst设成 0("确定不是夏令时"),在夏令时期间会算错一个小时。
另外,std::mktime失败时返回-1,但-1也是一个合法的时间值(1969-12-31 23:59:59 UTC),所以必须用errno或更可靠的判据来区分。这就是为什么跨平台的日期计算更推荐用第二节那种纯算术实现,或者 C++20 的std::chrono。
总结
| 需求 | C++17 写法 | C++20 写法 |
|---|---|---|
| 表示某一天 | 自定义Date(y, m, d) | std::chrono::year_month_day |
| 表示序数日 | 自定义serial()返回整数 | std::chrono::sys_days |
| 两个日期相差天数 | b - a返回std::int64_t | (db - da).count() |
| 日期加 N 天 | a.add_days(N) | sys_days{d} + std::chrono::days{N} |
| 星期几 | 序数日对 7 取模再偏移 | std::chrono::weekday{sys_days}的c_encoding() |
| 日期合法性校验 | 构造函数里is_valid并抛异常 | ymd.ok() |
| 依赖 | 仅标准库,可移植 | 需要 C++20(GCC 10+ / Clang 13+ / MSVC 19.29+) |
日期计算的核心思路只有一句话:先把"年-月-日"转成整数序数日,所有加减和差值都在整数上做,最后再转回来。闰年、月份天数、星期几这三件事就都不会互相干扰了。只要能避免用std::tm加std::mktime去做"差值"运算(它们是为日历到时间戳的单向转换设计的,不是为日期算术设计的),这个日期计算器就不会有意外。