ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于C++实现日期计算器的详细教程

基于C++实现日期计算器的详细教程 前言算两个日期差多少天某个日期往后推 N 天是哪天这天是星期几——这三件事看起来简单到不需要写库但真正动手写就会发现坑很多闰年规则不是能被 4 整除、月份天数不固定、星期几没有闭式公式其实有一个很好用的、时区和夏令时会在你完全没料到的地方咬你一口。最常见的几个误解是闰年就是能被 4 整除。不完整。完整规则是能被 4 整除但不能被 100 整除或者能被 400 整除。所以 1900 年不是闰年2000 年是。用time_t相减再除以 86400 就是天数。在本地时区下会被夏令时DST搅乱某一天可能是 23 小时或 25 小时除出来会差一天。std::tm的tm_year就是年份。它是年份减 1900tm_mon还是从 0 开始计数的。本文先用 C17 手写一个不依赖任何平台的Date类核心是把日期映射成整数序数日再对照 C20 的std::chrono日历类型。所有代码都以 C17 为基准在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译用到 C20 的地方会明确标注。一、先把参照系定下来序数日日期运算之所以容易写错是因为年-月-日这个三元组本身不适合做加减。正确的做法是先把它转换成一个整数序数日serial day规定 1970-01-01 为第 0 天之后的每一天加 1之前的每一天减 1。这样两个日期相减 两个序数日相减直接得到天数日期加 N 天 序数日加 N再转回年-月-日星期几 序数日对 7 取模需要一个偏移量。序数日只跟哪一天有关和时区、夏令时完全无关这是它最大的好处。下面这张表里的数值都是可以用后文代码直接验证的日期序数日1970-01-01 0星期1900-01-01-25567周一1970-01-010周四2000-01-0110957周六2024-01-0119723周一2024-02-2919782周四因为有 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 就会被误判成闰年。二、C17手写一个可移植的 Date 类下面这个类只依赖标准库没有任何平台 API。核心是 Howard Hinnant 提出的days_from_civil/civil_from_days算法它把以 3 月为一年起点的技巧用到了极致把 1 月和 2 月挪到上一年的末尾闰日就永远落在一年的最后一天不用再对 2 月做特殊处理。// date.cpp —— g -stdc17 -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_castint((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_castunsigned(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_caststd::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_castunsigned(z - era * 146097); // [0, 146096] const unsigned yoe (doe - doe / 1460 doe / 36524 - doe / 146096) / 365; // [0, 399] const int yy static_castint(yoe) static_castint(era) * 400; const unsigned doy doe - (365 * yoe yoe / 4 - yoe / 100); // [0, 365] const int mp static_castint((5 * doy 2) / 153); // [0, 11] d static_castint(doy - (153u * static_castunsigned(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) % 7C 的%对负数返回负余数 7是为了把它掰回非负区间再 4是因为序数日 0 对应周四。std::snprintf在cstdio里std::int64_t在cstdint里两者都要显式包含不要靠别的头文件间接引入。Date的构造函数会抛std::invalid_argument所以它不是noexcept的也不能作为字面类型用在常量表达式里除非把校验挪到工厂函数里。三、C20直接用std::chrono的日历类型C20 把上面这一整套东西标准化了。std::chrono::year_month_day就是年-月-日std::chrono::sys_days就是序数日准确说是自 1970-01-01 起计的天数时间点。本节代码需要 C20 及以上C17 请用上一节的Date类。// chrono_date.cpp —— g -stdc20 -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())。常见坑点#❌ 错误写法✅ 正确写法1return y % 4 0;判断闰年1900 会被判成闰年return (y % 4 0 y % 100 ! 0) \2(t2 - t1) / 86400用time_t秒数算天数先转成序数日或std::chrono::sys_days再相减避免 DST 导致某天只有 23 小时3tm.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不初始化是 UB5让构造函数自动进位Date(2024, 2, 30)静默变成 2024-03-01构造时就校验并抛std::invalid_argument别让错误日期悄悄溜进业务逻辑6std::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_caststd::time_t(-1)) { std::cerr mktime 失败\n; return 1; } std::cout std::asctime(tm); // 输出用的是本地时区的星期与月名 }三个必须记住的点std::mktime把std::tm当成本地时间解释要 UTC 语义得用平台扩展POSIX 上是timegmMSVC 上是_mkgmtime两者都不是标准 Cstd::mktime会把越界字段静默规范化2024-02-30 会变成 2024-03-01且返回值不告诉你std::tm{}的零初始化把tm_isdst设成 0确定不是夏令时在夏令时期间会算错一个小时。另外std::mktime失败时返回-1但-1也是一个合法的时间值1969-12-31 23:59:59 UTC所以必须用errno或更可靠的判据来区分。这就是为什么跨平台的日期计算更推荐用第二节那种纯算术实现或者 C20 的std::chrono。总结需求C17 写法C20 写法表示某一天自定义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()依赖仅标准库可移植需要 C20GCC 10 / Clang 13 / MSVC 19.29日期计算的核心思路只有一句话先把年-月-日转成整数序数日所有加减和差值都在整数上做最后再转回来。闰年、月份天数、星期几这三件事就都不会互相干扰了。只要能避免用std::tm加std::mktime去做差值运算它们是为日历到时间戳的单向转换设计的不是为日期算术设计的这个日期计算器就不会有意外。
返回列表