看到这个题目,我第一反应是:经典。只要是学 C++ 运算符重载,日期类几乎是绕不开的标配练习,课程作业里有它,笔试面试的机考题里也常有它的变体。A同学前几天把题目拍给我看,要求就是标题里那几句:设计一个 Date 类,年、月、日作为私有数据成员,实现一日期加上天数、一日期减去天数、两日期相差的天数等基本运算。题目正文就两三行,但想写出一个真正能扛住边界情况的实现,要处理的东西远比表面多得多。
这篇东西适合三类人:正在写课程作业、看到“运算符重载”四个字还发懵的学生;刷题准备笔试、想快速掌握日期运算套路的老手;以及日常工作里偶尔要处理日期逻辑、想一次性把坑填平的开发者。我会把从需求拆解、类设计、算法选型、运算符实现到边界测试的完整过程讲一遍,代码可以直接抄,原理也尽量讲透。
1. 拿到题目先别急着写码:先拆需求
1.1 这道题到底在考什么
题目字面要求就三件事:Date 类、私有数据成员、基本日期运算。但如果你只按这三件事去写,大概率只能拿个及格分。它真正在考的是四个点:
- 封装:年、月、日必须是私有成员,外部只能通过接口访问。这是 C++ 类设计的底线,漏了直接扣分。
- 运算符重载:不让你写 addDays()、minusDays() 之类的成员函数,而是要求用 operator+、operator- 把日期运算变得像内置类型一样自然。
- 函数重载与签名区分:同样是 operator-,Date - int 和 Date - Date 是两回事,参数类型不同,编译器会分开匹配。这是很多人第一次接触“同名函数多个重载”的场景。
- 日期算法的严谨性:闰年、月末、跨年、借位,这些边界情况才是真正的分水岭。能写出 2024-02-29 + 1 = 2024-03-01 的人,和写出 2024-02-30 的人,差距就在这。
一句话总结:题目考的是“用运算符重载把日期运算做得像原生类型一样丝滑”,同时“不能在里面埋下日期错误的定时炸弹”。
1.2 运算符选型清单:一眼看清要重载什么
先把需求翻译成运算符清单。我建议至少实现下面这些,注意每一项的签名和返回值都不一样:
| 需求 | 运算符 | 签名 | 返回值 |
|---|---|---|---|
| 日期加天数 | operator+ | Date operator+(int days) const | Date |
| 日期减天数 | operator- | Date operator-(int days) const | Date |
| 两日期相差天数 | operator- | int operator-(const Date& other) const | int |
| 复合加法 | operator+= | Date& operator+=(int days) | Date& |
| 复合减法 | operator-= | Date& operator-=(int days) | Date& |
| 自增自减 | operator++ / operator-- | 前后缀各一组 | Date& / Date |
| 相等/不等 | operator== / operator!= | bool ... | bool |
| 比较排序 | operator< / <= / > / >= | bool ... | bool |
| 流输出 | operator<< | 非成员函数 | ostream& |
为什么建议做这么多?因为运算符之间是配套的。有了 +=,++ 会变得很简单;有了 == 和 <,其他四个比较运算符都能转发出来。教学作业虽然只要求前三行,但把配套的做完,你的代码才会像一个“真正的类型”,而不是一个只用来交差的壳。
有一个设计问题要提前想清楚:operator+ 到底做成员函数还是非成员函数?如果做成成员函数,你只能写 d + 7,写 7 + d 会编译失败。C++ 内置类型里 7 + d 和 d + 7 都是合法的,想模拟这种对称性,就得额外提供一个非成员函数。这个坑我在第 4 节专门讲,这里先记住结论:复合赋值用成员函数,普通运算符优先用非成员函数。
2. 数据模型与合法性校验:先让日期“不可能错”
2.1 私有成员与构造函数:第一道合法性闸门
Date 类的私有成员没什么好纠结的,就是三个 int:year_、month_、day_。为什么不用 time_t 或者序列日数做内部存储?因为那不是“日期”,而是“时间点”,直接暴露给用户既不直观,也会让构造函数变得很尴尬。内部存年月日,运算时做转换,这是可读性和效率之间最平衡的选择。
构造函数是整个类的第一道闸门。如果你允许构造出 2024-02-31,后面所有运算都建立在脏数据上,再怎么调也白搭。我的写法是:
Date::Date(int year, int month, int day) : year_(year), month_(month), day_(day) { if (month < 1 || month > 12) throw std::invalid_argument("月份必须在 1~12 之间"); if (day < 1 || day > daysInMonth(year, month)) throw std::invalid_argument("日期超出当月天数"); }这里有两个值得说透的点。第一,用 std::invalid_argument 抛异常而不是直接 return 或者置零。置零会把非法输入悄悄“吞掉”,调用方完全不知道数据已经坏了,之后排查问题会非常痛苦。抛异常是显式的,调用方要么自己 catch,要么程序立刻暴露问题。第二,验证依赖 daysInMonth,而 daysInMonth 依赖闰年判断,这三层逻辑搭好了,数据安全就有保障了。
2.2 闰年与每月天数:别在这两个函数上翻车
闰年规则是很多人写错的重灾区。公历闰年的完整规则是:能被 4 整除,但不能被 100 整除;如果能被 400 整除,也算闰年。翻译成代码:
bool Date::isLeapYear(int year) const { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } int Date::daysInMonth(int year, int month) const { static const int days[] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && isLeapYear(year)) return 29; return days[month]; }为什么闰年规则这么绕?因为地球绕太阳一周大约是 365.2422 天,光用“能被 4 整除”会每 100 年多出将近一天,所以要把整百年里不被 400 整除的年份去掉;但这样又少补了一点,所以再让 400 的倍数恢复闰年。2000 年是闰年,1900 年不是,2100 年也不是。这三个边界年份是考试最爱出的,记住一句口诀:整百的年份,只有能被 400 整除才算闰。
这里有个小坑要提醒:数组下标我故意留了第 0 个元素,让月份可以直接当下标用,days[1] 到 days[12] 对应 1 月到 12 月。很多人图省事写成 0 到 11,然后靠 month - 1 索引,也不是不行,但容易在边界处出错,尤其后面做进位借位时,月份加减会让下标变成负数。用 1 到 12 直接索引,省心很多。
2.3 const 语义与访问器:容易被忽略的隐形考点
私有成员决定了外部读不到年、月、日,所以至少要有 year()、month()、day() 三个访问器。注意一点,这几个方法要标成 const,因为读取不改对象状态。同样,operator+、operator-(Date - Date)、比较运算符这些“只读不写”的接口,全部要加 const。否则你声明一个 const Date 变量,后面寸步难行。
C++ 里 const 成员函数的规则说白了就一句话:加了 const 的函数承诺不改动成员变量,编译器会帮你监督。凡是概念上不改对象的操作,一律加 const。这是这门课的隐形考点,很多人功能全对,遇到 const Date 参数直接编译不过,栽在这里太亏了。
3. 核心算法选择:序列日数转换为何是最优解
3.1 为什么不推荐逐位进位法
拿到“日期加天数”,新手最自然的想法是 day += n,然后借着 daysInMonth 判断 day 是不是超了,超了就进位到月份,月份超了进位到年份。这个思路本身没错,但有几个问题。
首先是性能。操作次数和 n 成正比,加 36500 天就可能要循环几百次,每次还要反复判断闰年。如果工作里要对几十万条日期做批量偏移计算,这个性能没法看。其次是边界逻辑容易写漏。day 减到 0 或负数时,需要先从月份借位,再算出上个月的正确天数;而“上个月”可能跨年。这些分支叠在一起,代码会变得又长又脆,每一条分支都要单独测试。
我强烈推荐第二种思路:先定义一个“序列日数”(serial day number),把日期转换成与某个固定纪元的间隔总天数,日期运算就变成纯粹的整数加减,算完再把整数转回日期。加 100 万天也只是两次整数运算加一次转换,O(1) 完成,逻辑还极难出错。
3.2 日期转序列日数:civil 转换的正向实现
日期和序列日数互相转换,有一套在开源社区广泛流传的实现,很多 C++ 日期库的底层都用了类似思路。核心是把 3 月当作一年的起点来做推导,这样闰日 2 月 29 日总是“当前年”的最后一天,闰年的判断就统一了,不用拆成两半。我自己一直使用这套换算,不过十几行代码:
// 基准:1970-01-01 对应序列日数 0 int Date::toSerial() const { int y = year_; int m = month_; int d = day_; y -= (m <= 2); // 1、2月视为上一年的13、14月 int era = (y >= 0 ? y : y - 399) / 400; unsigned yoe = y - era * 400; // 年份在400年周期内的偏移 [0, 399] unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; return era * 146097 + static_cast<int>(doe) - 719468; }这个函数第一次看容易懵,我拆开讲。第一步 y -= (m <= 2) 是“把三月当作年初”。为什么?因为 2 月 29 日如果按正常年份看出现在年初,会让“今年是否闰年”在不同日期的处理上出现分裂;把 3 月设为一年起点后,闰日天然变成年末,年份和闰日的关系就统一了。
第二步 era、yoe、doe 是在算“我处于 400 年大周期中的哪一年、哪一天”。400 年是公历的完整循环,400 年里有 146097 天,这个数恰好能被 7 整除,所以星期几也会跟着 400 年循环。doe 表示该日期在 400 年周期内已经过的天数,等于 yoe 年乘上 365 天,再加上这个周期内的闰年数。闰年数为什么是 yoe/4 - yoe/100?因为每 4 年一闰,但要砍掉整百年,而 400 的倍数又补回来。
doy 那一行负责把“第 m 月第 d 日”映射成“年内第几天”。常数 153 来自每个月平均 30.6 天的近似,配合取整可以让 3 月到 2 月的月长序列变成规则的 31、30、31、30……第一次接触时不用硬记,把它理解成“月日到年内天数”的查表公式的代数版本即可。最后减掉的 719468,是为了把基准日从算法内部的“0000-03-01”校准到 1970-01-01。
3.3 序列日数转日期:反向转换与负数取整
反向转换逻辑上是正变换的逆过程,但有一个 C++ 特有的坑:整数除法对负数是向零取整,而不是向下取整。如果直接套用正变换的逆公式,1970 年以前的日期就会算错。所以 era 的取整必须先判断正负,再偏移一次:
Date Date::fromSerial(int z) { z += 719468; // 还原到以0000-03-01为0的计数 int era = (z >= 0 ? z : z - 146096) / 146097; unsigned doe = static_cast<unsigned>(z - era * 146097); unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; int y = static_cast<int>(yoe) + era * 400; unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); unsigned mp = (5 * doy + 2) / 153; unsigned d = doy - (153 * mp + 2) / 5 + 1; unsigned m = mp < 10 ? mp + 3 : mp - 9; return Date(y + (m <= 2), m, d); }yoe 那行看着最吓人,其实是在解一个一元不等式:已知“400 年周期内的天数”,反推出“这是该周期内的第几年”。分子上三个除法是对闰日的一种补偿,让结果稳定落在 0 到 399 之间。这里最需要注意的是负数处理,era 的取整方向要是写错,公元前的日期偏移会差出整整一年还多。我之前拿公元 1 年附近的日期做测试时撞到过这个问题,排查到最后发现就是负数取整方向不对。
3.4 用参照实现做交叉验证
写完转换函数,第一件事不是写运算符,而是先验证 toSerial 和 fromSerial 是否满足“互逆”:对任意合法日期,fromSerial(toSerial(d)) 都应该等于原日期;对任意合理整数,toSerial(fromSerial(n)) 也应该等于原 n。
你可以用 Python 的 datetime 或者系统自带的 date 命令做参照,比如:
date -d "2024-03-01 -1 day" +%F # 输出 2024-02-29我在开发时习惯把序列日数打出来,和 Python 的 date.toordinal() 对比。两边基准日不同,差的是一个固定常量 719162 天,所以只要随机生成几千个日期,两边互减之后全部等于这个常量,转换就基本可信了。这一步花不了五分钟,却能把算法里最隐蔽的错误提前拦住。
4. 运算符重载完整实现:从原语搭出可用类型
4.1 三个核心重载:直接写成员版
日期运算的核心原语就三个:加天数、减天数、日期差。有了转换函数,基础版实现非常直白:
Date Date::operator+(int days) const { return fromSerial(toSerial() + days); } Date Date::operator-(int days) const { return fromSerial(toSerial() - days); } int Date::operator-(const Date& other) const { return toSerial() - other.toSerial(); }注意 operator- 这里出现了两个同名重载,参数分别是 int 和 const Date&,编译器靠参数类型区分,互不干扰。这是 C++ 里很典型的“同名函数重载”用法,也是这道题想让你掌握的技能。写的时候要小心一个经典错误:Date - Date 返回的是 int,有人脑洞大开写成 Date,然后两个日期相减得到一个没意义的“日期”,编译可能过了,结果完全没法用。
接下来是复合赋值运算符。它和普通运算符不同的是要就地修改对象并返回引用,这样 d += 7 之后 d 本身变了,返回值还可以继续链式调用:
Date& Date::operator+=(int days) { *this = *this + days; return *this; } Date& Date::operator-=(int days) { *this = *this - days; return *this; }这里用“已有 operator+ 实现 operator+=”,也可以反过来用 += 实现 +,两种都能跑。工程上更推荐把 += 作为原语,普通运算符复制一份临时对象再调 +=,这样天然保持 const 语义,不容易改坏原始对象。上面这两行也是对的,因为每次赋值后返回引用,行为完全正确。
4.2 前后缀自增自减:纤毫之差
日期自增表示“下一天”,自减表示“前一天”。实现一定要区分前缀和后缀,C++ 的约定是用一个 int 哑元参数区分,后缀版本带一个不使用的 int 参数:
Date& Date::operator++() { // 前缀:++d *this += 1; return *this; } Date Date::operator++(int) { // 后缀:d++ Date temp = *this; *this += 1; return temp; } Date& Date::operator--() { // 前缀:--d *this -= 1; return *this; } Date Date::operator--(int) { // 后缀:d-- Date temp = *this; *this -= 1; return temp; }核心区别就一句话:前缀返回新值(引用),后缀返回旧值(拷贝)。所以后缀实现必须先保存一份 temp,再修改自身,最后返回 temp。很多人第一次写会把顺序搞反,结果测试 d++ 输出的是下一天而不是当天,一查一个准。
4.3 对称性问题:7 + d 为什么必须用自由函数
上面的 operator+ 是成员函数,意味着 d + 7 合法,7 + d 不行。内置类型里 int 加 Date 产生这种不对称感,会让调用代码的人很别扭。解决方法是在类外面提供两个自由版本:
Date operator+(Date d, int days) { d += days; return d; } Date operator+(int days, Date d) { d += days; return d; }这里第一个参数按值传递,本质是复制一份原日期,在副本上做 +=,然后返回副本。原对象根本没被动过,所以不需要 friend,也不需要访问私有成员,封装一点都没被破坏。
但有个陷阱必须讲清楚:如果你保留了上一节的成员版 operator+(int),同时再加这两个自由版本,那么 d + 7 调用时,编译器面对成员函数 operator+(int) 和自由函数 operator+(Date, int) 两个候选,会直接报二义性错误。正解是:普通 operator+ 只保留自由版本,成员版去掉;成员函数里只留 += 作为原语。面试时能说出这套设计逻辑,比只会背代码的印象分高不少。
operator- 不太一样。Date - int 和 Date - Date 继续保持成员版没问题,因为你不会写 int - Date 这种没意义的式子,也就不存在二义性场景。
4.4 比较运算符与流输出
日期比较有个简单可靠的方案:既然 toSerial 已经把日期映射成整数,直接比整数即可。
bool Date::operator==(const Date& other) const { return toSerial() == other.toSerial(); } bool Date::operator<(const Date& other) const { return toSerial() < other.toSerial(); } bool Date::operator!=(const Date& other) const { return !(*this == other); } bool Date::operator>(const Date& other) const { return other < *this; } bool Date::operator<=(const Date& other) const { return !(other < *this); } bool Date::operator>=(const Date& other) const { return !(*this < other); }只要实现 == 和 <,其余四个比较都能转发出来。
流输出 operator<< 必须是非成员函数,因为第一个参数必须是 ostream&,不可能做成 Date 的成员。为了让输出格式变成标准的 yyyy-mm-dd,需要一个小的格式化技巧:
std::ostream& operator<<(std::ostream& os, const Date& d) { char fill = os.fill('0'); os << d.year() << '-' << std::setw(2) << d.month() << '-' << std::setw(2) << d.day(); os.fill(fill); return os; }setw(2) 会在月、日不足两位时补 0。这里有两个细节:setw 只对紧跟在后面的一个输出项生效,月份后面那个 setw 要重新写;另外别忘了恢复 fill 字符,否则会污染同一条流上后续的输出。这个细节我在写日志模块时踩过,某天发现日志里的数字被莫名补成 0007,排查半天才发现就是 fill 没有复位。
5. 测试与踩坑实录:边界用例、编译错误、加分变体
5.1 十个必测边界用例
写运算符容易,证明它对难。我把日期类和主流程跑通后,第一件事就是建一张测试表,把日历上最容易出错的时间点全部过一遍:
| 用例 | 表达式 | 期望结果 | 覆盖点 |
|---|---|---|---|
| 零偏移 | Date(2024,1,1) + 0 | 2024-01-01 | 原样返回 |
| 月末进位 | Date(2024,1,31) + 1 | 2024-02-01 | 小月月末 |
| 闰日 | Date(2024,2,29) + 1 | 2024-03-01 | 闰年二月 |
| 平年二月 | Date(2023,2,28) + 1 | 2023-03-01 | 平年二月 |
| 跨年 | Date(2024,12,31) + 1 | 2025-01-01 | 年份进位 |
| 跨年借位 | Date(2024,1,1) - 1 | 2023-12-31 | 年份借位 |
| 世纪平年 | Date(2100,2,28) + 1 | 2100-03-01 | 2100不闰 |
| 世纪闰年 | Date(2000,3,1) - 1 | 2000-02-29 | 2000闰 |
| 闰日间隔 | Date(2024,3,1) - Date(2024,2,29) | 1 | 闰日差值 |
| 整年间隔 | Date(2024,1,1) - Date(2023,1,1) | 365 | 平年整年 |
第五、六行是跨年测试,也是最容易露馅的地方。第三、七、八行专门打闰年的三个分支:闰二月、非闰整百、闰整百。第十行说明一下,2023 年是平年,所以从 2023-01-01 到 2024-01-01 正好 365 天;如果换成 2025-01-01 减 2024-01-01,就是 366 天,因为 2024 年是闰年。这组用例全绿,日期运算的正确性就有底了。
配合 assert 写自动化测试会更省心:
#include <cassert> #include <sstream> void testDate() { Date d(2024, 2, 29); assert(d + 1 == Date(2024, 3, 1)); assert(Date(2024, 1, 1) - 1 == Date(2023, 12, 31)); assert(Date(2024, 3, 1) - Date(2024, 2, 29) == 1); std::ostringstream oss; oss << Date(2024, 3, 1); assert(oss.str() == "2024-03-01"); }5.2 编译期最容易翻车的五个姿势
- 常量对象调用非 const 成员。参数写成 const Date&,然后你试图在它上面调用一个没加 const 的方法,编译直接报错。所有“只读”接口务必加 const,访问器、==、<、operator+ 全都不例外。
- operator+ 返回局部对象却写成了引用。
Date& operator+(int days)返回一个函数结束就销毁的临时对象,悬垂引用,行为未定义。记住黄金法则:普通运算符返回新值,复合赋值才返回引用。 - 后缀 ++ 返回了修改后的值。测试自增时发现输出是下一天而不是当天,说明后缀实现里没保存 temp。对照 4.2 的写法检查。
- 构造函数不校验,直接存下 2023-02-30。这种情况最容易出现在从字符串解析日期或接收外部脏数据时。没有异常抛出,后面所有打印都带着一个不存在的日期,越调越乱。
- 在序列日数上加一个很大的 int 时溢出。toSerial 本身对公元 0 到 9999 年不会超过约 365 万,但如果你写 toSerial() + n,n 又是个极端值,总和可能超过 int 上限。工作里处理大跨度日期时,我习惯把中间量提升到 long long,再在 fromSerial 内部安全收窄。
5.3 考试和面试里的加分变体
这道题太经典,出题人经常主动加难度。提前给自己打几个补丁,遇到变体不慌:
- 要求 int + date 也能运算。就是 4.3 的对称自由函数,写完直接说“我已经考虑了对称性”,非常加分。
- 要求支持格式化字符串,比如输出“2024年03月01日”或从“2024-02-29”解析回来。这基本是在考 operator<< 和字符串解析,可以顺手在类里加静态 fromString 和 toString 方法。
- 要求支持“返回下个月的同一天”这种偏移。本质是在运算符之外扩展 addMonths 方法,但月份偏移比天数偏移复杂得多:1 月 31 日加 1 个月应该是什么?是 2 月 28/29 日还是 3 月 2 日?各家实现定义不同,别在基础版本里轻易上。
- 问“哪些运算符不能重载”。这是概念题,记住五个高频答案即可:
.成员访问、.*成员指针访问、::作用域、?:三目、sizeof。
再多说一句:不要为了炫技重载语义不清晰的运算符,比如用 operator[] 取日期某个字段,或者用 operator* 做日期缩放。运算符重载的第一原则是“保持直觉”,别人看到 d + 7 能秒懂,看到 d * 2 就会满脑子问号。可读性永远是最高优先级。
这段代码我在本地跑过很多轮,最后一次用随机生成的十万对日期和标准库交叉比对,全部一致。实际工程里,日期运算往往藏在排期、对账、数据分析这些重要逻辑里,一个 2 月 30 日的脏数据可能带来的是整条业务线上的连环错误。所以我自己有个习惯:无论多小的日期工具类,边界测试表一定常驻,每次改代码后全量回归一遍,成本极低,收益极高。
最后分享一个小技巧:调试日期类时,把中间过程全部打出来。我在排查负数日期问题时,就是把 toSerial 每一步的中间量都打印出来和参照值对比,才发现是 era 对负数取整方向的问题。这种问题光盯着最终结果看是永远看不出来的,拆分打印是最快的定位路径。希望你写出的日期类,也能在各种边界下站得住。