某在线实训平台的“7-4 日程安排(多重继承+重载)”,20分,出在C++课程的类继承章节。很多同学第一眼看到题名,觉得“多重继承”和“重载”是两个新知识点叠在一起,还没动手就先虚了一半。其实这类题考的内容非常固定:你会不会用两个现成的类去拼出一个新类,会不会让新类的对象支持运算符比较,能不能用 cout 把一个自定义对象直接打出来,再按时间顺序排好输出。这篇文章我把这一类题的完整思路、核心语法、能直接改着用的代码模板,以及我在改作业和刷题时见过的高频报错全部整理出来。适合正在学第5章类继承、或者准备期末机考的同学参考,看完可以直接照抄思路去解你自己平台上的那道题。
1. 拿到题目先别急着写:拆解“日程安排”到底要你做什么
1.1 题目本质:把日期、时间和事件拼成一个新类
这类题目通常会先给你一个日期相关的类和一个时间相关的类,可能是完整的,也可能只给了成员变量声明让你自己补全。日程项这个概念本质上就是“日期 + 时间 + 事件描述”的组合,比如“2025-03-02 14:30 开组会”。
如果你学过组合,第一反应可能是设计一个 ScheduleItem,里面放一个 Date 对象、一个 Time 对象、一个 string 描述。这种设计在真实项目里完全没问题,甚至更常见。但题目明确写了“多重继承”,那就不是让你用组合,而是让你用继承来表达“日程项既是一个日期,也是一个时间”的 is-a 关系。
从语法训练角度来看,多重继承要考察的细节比组合多得多:基类构造函数怎么传参、基类的保护成员能否直接访问、构造函数执行顺序、析构顺序、可能出现的二义性、是否要虚继承等。判题系统一般会通过你提交的代码里是否真的出现了class ScheduleItem : public Date, public Time这样的继承结构来判定你对知识点的掌握。所以题目怎么说,你就怎么写,不要在考场上突然开始设计模式。
1.2 运算符重载到底重载哪几个
题目名字里“重载”两个字也很关键。结合“日程安排”这个场景,最自然的运算符就是三类:
operator<:比较两个日程的先后,给排序用。operator<<:把一个日程直接输出到屏幕,方便检查结果。operator==:判断两个日程是不是同一时刻,方便做查找、去重之类的操作。
有同学觉得重载嘛,一定是很复杂的运算符,比如+、++、[]。其实在日程安排这个题里,operator+可以用来表示“日程往后推 N 分钟”,operator++可以表示“跳到下一条日程”,这些只是扩展玩法,不是常规考点。判题用例大概率会构造一个 vector,里面放若干日程对象,然后排序输出。所以operator<和operator<<是生存技能,operator==属于顺手加起来就行的保险操作。
1.3 20 分的时间分配建议
这类题写在纸上可能就一小段需求,但真要直接开写,很容易漏掉格式问题。我建议按这个节奏来:前 5 分钟不写代码,只做两件事,一是把题面的输入输出样例抄下来,二是画一个极简类图:ScheduleItem 左侧连 Date、右侧连 Time,下面挂运算符。接下来 10 分钟写三个类,15 分钟写重载和排序,最后 10 到 15 分钟调试格式和边界。
为什么要先抄样例?因为判题题的输出格式极其严格,空格、换行、补零一个都不能差。你把样例抄在注释里,每写一段就对着看,能省掉一次提交才发现格式错的尴尬。
2. 动手前必须搞懂的两套语法:多重继承和运算符重载
2.1 多重继承的构造顺序,比你想的更死板
先看一个最典型的类结构:
class Date { protected: int year, month, day; public: Date(int y, int m, int d) : year(y), month(m), day(d) {} }; class Time { protected: int hour, minute; public: Time(int h, int min) : hour(h), minute(min) {} }; class ScheduleItem : public Date, public Time { private: std::string description; public: ScheduleItem(int y, int m, int d, int h, int min, const std::string& desc) : Date(y, m, d), Time(h, min), description(desc) {} };注意一个反直觉的点:基类构造函数的执行顺序并不是看你初始化列表里谁写在前面,而是看你继承列表里的声明顺序。class ScheduleItem : public Date, public Time这一行写的是 Date 在前 Time 在后,那么实际构造时一定是先执行 Date 的构造函数,再执行 Time 的构造函数,然后才执行 ScheduleItem 自己成员变量的初始化。
可能有人会有疑问:我把初始化列表写成Time(h, min), Date(y, m, d)能不能让 Time 先构造?答案是:不能。编译器会按继承声明顺序忽略初始化列表书写顺序。析构顺序则完全相反,先析构派生类自己的成员,再析构 Time,最后析构 Date。
这个顺序想明白之后,写代码不会出错,但更重要的是理解“为什么题目会选择多重继承”。因为从内存布局看,一个 ScheduleItem 对象里面往往会包含一个完整的 Date 子对象和一个完整的 Time 子对象,它们按照声明顺序排在一起。你把一个日程对象传给排序函数,本质上就是在比较两个这样的“复合体”里面日期部分和时间部分的大小。
2.2 虚继承:本题不需要,但必须知道什么时候需要
“多重继承”四个字一出来,很多教科书就会立刻搬出“菱形继承”和“虚基类”来吓人。比如 A 是基类,B 和 C 都继承 A,D 又同时继承 B 和 C,这时候 D 里面会有两份 A 的子对象,访问 A 的成员就会产生二义性。
但本题目给的是 Date 和 Time 两个平行的类,它们没有共同的祖先,所以 ScheduleItem 里面只会有两个基类子对象,不存在重复的问题。你不需要写 virtual,也不需要担心菱形继承。只要把基类成员设计成 protected,在派生类里直接访问 year、month、day、hour、minute 都是合法且清晰的。
如果哪天你把 Date 和 Time 都改成继承自同一个 ClockBase,那就变成菱形了。到时候必须在继承列表里写成public virtual Date才能保证只有一个 ClockBase 子对象。这是后话,本题先不用管。
2.3 运算符重载放哪里:成员函数、全局函数还是友元
这是这类题失分的重灾区。先说结论:
operator<写成成员函数,加 const。operator==写成成员函数,加 const。operator<<必须写成全局函数,通常声明为友元。
原因很简单。成员函数形式要求左操作数必须是一个自定义类对象,所以a < b里 a 是 ScheduleItem,可以。但std::cout << item里左操作数是 ostream 对象,不是你的类,你不可能去修改标准库里的 ostream,让它认得 ScheduleItem。所以只能把operator<<写成全局函数,第一个参数是std::ostream&,第二个参数是const ScheduleItem&。
operator<的完整签名建议这样写:
bool operator<(const ScheduleItem& other) const;两个细节别省:参数用 const 引用,避免拷贝构造;函数本身加 const,承诺这个比较过程不会修改当前对象。后面用 std::sort 排序的时候,sort 内部会拿 const 对象来比较,如果你的成员函数没有加 const,编译器会直接报错,错误提示往往还很抽象。
2.4 排序必须提供“严格弱序”
std::sort 排序依赖operator<,而且要求它满足严格的弱序关系。通俗说,你的比较规则必须自洽,不能出现“a < b、b < c、但 c < a”这种循环。
比较两个日程的先后,最稳的写法是一层一层比下去:先比年,年相同比月,月相同比日,日相同比小时,小时相同比分钟。这跟比较两个字符串的字典序是一模一样的思路。
有同学图省事,想把日期时间换算成分钟数再比。比如return year * 525600 + month * 43800 + day * 1440 + hour * 60 + minute < ...。这样做在本题范围内可能也能跑通,但需要小心月份天数不一样,直接乘个固定系数会出错,跨月、跨年时可能产生错误顺序。与其琢磨这种花活,不如老老实实写逐层比较,简单直观,不容易错。
3. 完整实现:一个能直接提交的日程安排程序
3.1 先写日期类和时间类
虽然有些题面会给你完整的 Date 和 Time,但自己动手写一遍印象才深。日期类三个整数分别存年月日,时间类两个整数存小时和分钟,年份用 int,时间用 int,都不要用 char 数组,那是在给自己挖坑。
#include <iostream> #include <iomanip> #include <string> #include <vector> #include <algorithm> class Date { protected: int year; int month; int day; public: Date(int y, int m, int d) : year(y), month(m), day(d) {} int getYear() const { return year; } int getMonth() const { return month; } int getDay() const { return day; } }; class Time { protected: int hour; int minute; public: Time(int h, int min) : hour(h), minute(min) {} int getHour() const { return hour; } int getMinute() const { return minute; } int toMinutes() const { return hour * 60 + minute; } };如果你拿到题目时别人已经定义好了这两个类,别忘了看成员是 private 还是 protected。如果是 private,派生类里不能直接访问,就必须调用getYear()之类的公有接口。这也是这题的一个隐藏考察点:继承后的访问权限控制。
3.2 派生类 ScheduleItem:多重继承 + 运算符重载
接下来是核心类。它同时继承 Date 和 Time,再增加一个事件描述字段。构造函数把年月日时分和描述全部收进来,然后用初始化列表转发给两个基类。
class ScheduleItem : public Date, public Time { private: std::string description; public: ScheduleItem(int y, int m, int d, int h, int min, const std::string& desc) : Date(y, m, d), Time(h, min), description(desc) {} const std::string& getDescription() const { return description; } bool operator<(const ScheduleItem& other) const { if (year != other.year) return year < other.year; if (month != other.month) return month < other.month; if (day != other.day) return day < other.day; if (hour != other.hour) return hour < other.hour; if (minute != other.minute) return minute < other.minute; return description < other.description; } bool operator==(const ScheduleItem& other) const { return year == other.year && month == other.month && day == other.day && hour == other.hour && minute == other.minute; } friend std::ostream& operator<<(std::ostream& os, const ScheduleItem& item); }; std::ostream& operator<<(std::ostream& os, const ScheduleItem& item) { os << std::setw(4) << std::setfill('0') << item.year << '-' << std::setw(2) << std::setfill('0') << item.month << '-' << std::setw(2) << std::setfill('0') << item.day << ' ' << std::setw(2) << std::setfill('0') << item.hour << ':' << std::setw(2) << std::setfill('0') << item.minute << " " << item.description; return os; }这段代码里有几个地方值得停下来看一眼。
operator<里面直接写了year,而不是this->year或Date::year。因为 ScheduleItem 从 Date 公开继承,受保护的基类成员在派生类成员函数里可以直接当自己的成员用。同理other.year也是跨对象访问另一个 ScheduleItem 对象中基类部分的数据,同样合法。平时写代码可以省略 this,但心里要知道你访问的其实是基类子对象的数据。
operator==判断的是“同一时刻”,也就是年月日时分全部相等。如果你还需要判断“同一天”,可以再加一个函数或在主函数里手动比较年月日三项。请根据题目要求灵活处理。
operator<<用了 friend。因为 year 是 Date 的 protected 成员,不是 ScheduleItem 的私有成员,全局函数本来无法直接访问 item.year。通过友元声明,全局函数拿到了访问 ScheduleItem 任意成员的权限。如果你不想用友元,就得在类里写一堆 getter,然后在全局函数里调用,代码会变长,而且 getter 满天飞。
3.3 输出格式:setw 和 setfill 的坑
从上面的重载函数里可以看到,我反复写了std::setw(4)、std::setw(2)、std::setfill('0')。这些来自<iomanip>头文件。
setfill 的作用是设置填充字符,一旦设置会一直生效;setw 只对紧接着的下一次输出有效。所以每输出一个数字段之前,都必须重新写一次 setw。年份是 4 位,月份、日期、小时、分钟都是 2 位。如果你漏掉一次 setw,比如只写了os << std::setfill('0') << item.year << '-' << item.month,那么月份单数时就不会补零,输出会变成类似2025-3-02,格式分直接丢掉。
还有个容易忽略的细节:setfill 设置成 '0' 之后,如果你后面还要输出其他非数字内容,填充字符仍然是 0。本题里面后面就是空格和描述文本,空格不受填充影响,因此问题不大。但如果你在一个更大的程序里复用了这段输出,记得在不需要补零的地方用std::setfill(' ')改回来。
3.4 主函数:判题版和本地自测版
如果判题输入是“先给一个数字 N,然后 N 行每行包含日期时间和事件描述”,主函数可以写成这样:
int main() { int n; std::cin >> n; std::vector<ScheduleItem> items; for (int i = 0; i < n; ++i) { int y, m, d, h, min; std::string desc; std::cin >> y >> m >> d >> h >> min; std::cin >> desc; items.emplace_back(y, m, d, h, min, desc); } std::sort(items.begin(), items.end()); for (const auto& item : items) { std::cout << item << std::endl; } return 0; }这里要注意,std::cin >> desc只能读入一个连续的字符串,中间不能有空格。如果题面的事件描述是“go to bed”这种带空格的英文短语,用这个写法读不完整,只能读到 go。这时候你需要改用std::getline(std::cin, desc),但在 getline 之前,先把上一行输入后的残留换行符用std::cin.ignore()吃干净。
如果是本地自测,不想手动敲 N 条数据,也可以把数据直接写在 vector 里:
int main() { std::vector<ScheduleItem> items = { {2025, 3, 2, 14, 30, "MathReview"}, {2025, 1, 18, 9, 0, "LabReport"}, {2025, 1, 18, 8, 0, "GroupMeeting"}, {2024, 12, 31, 23, 59, "NewYear"} }; std::sort(items.begin(), items.end()); for (const auto& item : items) { std::cout << item << std::endl; } return 0; }因为构造函数带 6 个参数,C++11 以后可以直接用花括号初始化来构造临时对象。排序后控制台输出顺序应该是 2024 年那条在最前,接着是 2025 年 1 月 18 日 8 点那条,然后是 9 点那条,最后才是 3 月 2 日下午那条。这说明逐层比较的思路在跨年场景下也能给出正确结果。
4. 写这类题目最容易踩的坑:判题平台版速查
4.1 编译错误速查表
我改代码的时候,见过最多的编译错误就是下面这几个。做成表格方便你对号入座。
| 报错信息(常见节选) | 大概率原因 | 解决思路 |
|---|---|---|
| no match for operator< | 没有重载<,或者参数类型不匹配 | 在类里补bool operator<(const ScheduleItem&) const |
| passing const ScheduleItem as this discards qualifiers | operator<没加 const,sort 里拿到的是 const 引用 | 成员函数末尾加 const |
| invalid operands to binary expression | 直接cout << item但没重载<< | 提供全局operator<<(std::ostream&, const ScheduleItem&) |
| base type ‘Date’ is ambiguous | 出现菱形继承,或者两个基类里有同名成员 | 看继承关系是否出现虚继承需求,用Date::消歧义 |
| use of deleted function / no matching constructor | 派生类构造函数初始化列表没有正确转发给基类 | 检查: Date(...), Time(...)是否写全 |
这些错误提示在不同的编译器版本下措辞略有差异,但排查思路是一致的:先确认运算符重载函数是否存在、参数对不对、有没有 const、是不是全局函数。
4.2 逻辑正确但格式全错的排查
最让人崩溃的不是编译不过,而是本地看着好好的,提交到判题平台全是格式错误或者答案错误。有几个高频原因:
排序方向反了。题面可能要求“按时间从早到晚”,也可能要求“从晚到早”。如果只改了输出循环没改比较函数,你就会得到顺序反了但自己看不出来的结果。拿到题先圈出题面里“先后”“早晚”“日期顺序”这些关键词。
只比了时分没比日期。如果把operator<写成直接比较toMinutes(),那么 2024-12-31 23:59 会被认为比 2025-01-01 00:00 晚,因为 23:59 的分钟数大。这种错误在单纯比较“同一天内的时分”时不会暴露,一旦跨日就翻车。所以逐层比较年月日时分最安全。
输出没补零。月份 3 输出成 3,时间 8:0 输出成 8:0,看起来只是不好看,但在判题系统眼里这就是错误。务必用 setw 控制宽度。
描述里带不可见字符。读入用 cin 遇到空格会中断,事件描述后半段被当成后续输入,导致错位。如果你拿不准,优先看题面格式说明,是每行一个词还是每行一个短语。
4.3 关于析构函数和内存安全
这个题里没有堆上分配的对象,没有多态删除,所以析构函数写不写虚函数都不会触发内存问题。但我的习惯是,只要写继承关系,就在基类析构函数前面加上 virtual。原因很简单:以后如果题目改成用基类指针管理派生类对象,delete基类指针时如果没有虚析构,派生类的析构函数不会被执行,就会产生未定义行为。提前写上,成本为零,收益是少踩一个深坑。
virtual ~Date() {} virtual ~Time() {}不要小看这两行。很多课程设计项目后来会演化出多态场景,基类指针指向不同派生类对象,虚析构就成了刚需。
4.4 考场上的自查顺序
写完之后不要急着提交,按这个顺序自查:
- 先对着题目抄的样例跑一遍,确认输出字符一个不差。
- 再跑一遍边界数据:跨年、跨月、同一天多个事件、00:00、23:59。
- 检查事件描述是不是完整读入,后面有没有混进换行符。
- 检查排序结果的第一条和最后一条是否符合题面说的顺序。
- 最后看一次你的代码里是否真的出现了“多重继承”和“运算符重载”这两个考点,别刚写完就改回组合写法。
如果提交了两三次都不过,不要反复盲目改输出,先把operator<<单独拿出来打印一个已知对象,确认类本身没问题,再怀疑排序和主函数。
5. 一点个人经验和后续扩展
我帮人调试这类题的时候,发现大部分问题其实不出在“多重继承”这个知识点本身,而出在“重载”相关的细节:const 忘写、friend 漏声明、setfill 没恢复。你只要肯静下心把这段几十行的代码完整敲一遍,胜过抄十遍答案。
最后分享一个能大幅提高调试效率的小技巧:先把operator<<重载写好,然后从第一天开始就用std::cout << item << std::endl;来观察每个对象内容。这样你在排查排序错误、读入错误时,每一步都能直接看到对象的完整状态,不需要再去写一堆临时 getter。我见过很多同学先把输出重载放一边,等到最后才补,结果中途想打印某个日程对象只能打印地址,调试体验非常痛苦。
如果这个题你写完还有余力,可以顺手做两个延伸:一是给日程类重载一个operator+,让“某个日程加 N 分钟”能直接返回一个新日程对象,这会涉及“返回新对象还是修改自身”的设计决策;二是把 Date 和 Time 再抽象出一个共同的基类,比如 ClockBase,然后故意让 ScheduleItem 同时继承 Date 和 Time,看看菱形继承和二义性到底是什么报错。把这两个延伸做完,这一章的内容才算真正吃透。