首页 / 资讯中心 / 文章详情

C++类继承实战:学生成绩计算题中的虚函数与构造函数避坑指南

C++类继承实战:学生成绩计算题中的虚函数与构造函数避坑指南 ★ FEATURED ARTICLE
PTA刷到“7-3 学生CPP成绩计算”这道题时很多人第一反应是又是算成绩的十几行代码就能搞定。结果真把代码交上去编译错误站起来一大片改来改去最后卡在“派生类构造函数怎么给基类传参”这种最基础的位置。这道15分的题挂在“第5章 类继承”下面确实不复杂但它把C面向对象里最容易漏的几个知识点全部串起来了继承关系怎么设计、protected和private怎么选、虚函数什么时候该加、构造函数调用顺序是什么。这篇文章我结合刷题时踩过的坑把题目的完整拆解、一个能直接过测试的写法和一堆在不同PTA版本上实测下来的问题一起整理出来给正在啃类继承的朋友做个避坑参考。1. 题意拆解题目要的不是“算成绩”是“继承体系”1.1 先把输入输出结构和计算规则剥干净不同学校的PTA习题库对这道题的描述会有细节出入但核心框架高度一致。我这边遇到的版本是这样写的某课程的成绩评定由平时成绩和期末考试组成普通学生总评成绩 平时成绩 × 0.3 期末成绩 × 0.7而参加过竞赛的学生属于另一类有额外的竞赛加分总评 平时成绩 × 0.3 期末成绩 × 0.7 竞赛加分加分后总分不得超过100分。输入格式一般长这样第一行学生人数 n 接下来 n 行每行以 0 或 1 开头 0 学号 姓名 平时成绩 期末成绩 1 学号 姓名 平时成绩 期末成绩 竞赛加分输出要求是逐行输出“学号 姓名 总评成绩”总评成绩保留一位小数。如果你拿到的题目描述是“研究生还有论文成绩论文占20%”本质完全一样只是派生类多了一个成员、总评的计算公式换一下。把握住一点题目想让你用“基类放公共属性、派生类放差异属性”的方式组织代码而不是在同一个类里用一堆if-else硬写。1.2 三个看似基础、实则全部踩坑的考点这道题看起来只是算术题实际上挂了三个关键考点。第一个是继承层次划分什么东西应该放在基类里什么东西应该放在派生类里。学号、姓名、平时成绩、期末成绩是所有学生共有的放进基类竞赛加分只属于竞赛学生放进派生类。凡是你发现“为了某个特例在基类里多加一个意义不大的字段”基本就是层次划分出问题了。第二个考点是虚函数与重写。总评成绩的计算在不同类型学生身上不一样如果基类里已经写好了普通学生的计算逻辑竞赛学生必须重写这个逻辑。但如果基类里的计算函数不是虚函数通过基类指针调用时就只会执行基类版本输出就是错的。这个坑我在新手代码里见过太多次。第三个考点是构造函数链。派生类对象构造时一定先构造基类部分再构造派生类新增部分。竞赛学生类没有自己的学号、姓名初始化逻辑它必须把基类需要的参数通过初始化列表传给基类构造函数。一旦基类没有默认构造函数而派生类构造函数里忘了调用它编译器立刻报错。这个错误信息C初学者经常看不懂后面我会专门讲。1.3 为什么这题必须用继承而不是打个标记位不少同学偷懒写一个类塞一个bool字段isBonus然后在total()函数里if判断一下。提交也能通过毕竟PTA只看输出对不对。但这样写的问题很现实如果后续要增加“研究生”“交换生”“重修生”每个类型一种成绩算法这个类会膨胀成什么样构造函数参数越来越多total()里一长串if-else改一个类型容易影响其他类型。继承恰恰解决了“共性与个性”的问题。基类负责稳定的部分派生类负责变化的部分。从出题人的角度第5章讲的就是这个题目就是要你写出层次关系。我建议刷题时别只求通过多想一想如果这个班型再加三种我的代码结构能不能撑住用继承写新增一种学生只需要加一个类、改main里的输入分支不需要动原有的任何类。这才是类继承真正的价值。2. 类继承设计成员怎么放、权限怎么选、构造函数怎么传参2.1 基类放公共因子派生类只放自己的增量这道题的最佳设计是这样的基类Student持有id、name、daily平时成绩、finalExam期末成绩同时提供计算总评的total()和打印的print()。派生类BonusStudent继承Student的所有公开和受保护成员同时新增一个自己的bonus成员并重写total()。这里有个容易被忽略的点print()也可以重写但通常不需要。因为print()内部调用total()时由于total()是虚函数哪怕是基类print()里写的调用在派生类对象身上也会自动执行派生类的total()版本。这就是多态的好处——打印逻辑写一份不同学生自动打印出不同的总评。很多初学者会把print()也复制粘贴一遍然后在派生类里改一点这就是多余的重复代码。成员函数尽量复用基类的是继承设计的核心原则。派生类里只写“跟别人不一样的那部分”一样的东西永远从基类来。2.2 protected、private、public三者的边界感这道题里id、name、daily、finalExam应该放在基类的protected区而不是private区。很多同学不理解 protected和private的区别总觉得都是外部不可访问差不多。但在继承场景下区别非常大private成员对派生类也是不可见的派生类只能通过基类提供的公有函数间接访问protected成员则对派生类可见派生类可以像操作自己的成员一样直接使用。举个例子如果基类的id放在private里派生类想打印“学号 姓名”时就只能调用基类公开的getId()、getName()接口。这道题里派生类不需要直接操作这几个成员所以理论上id放private也能跑。但放在protected里更符合语义这几个字段是“学生这个大家族的公共资产”不是普通用户能随便改的东西但子类有权直接看。反观bonus是竞赛学生自己的私有财产所以就放在派生类的private里。访问权限的正确顺序是先考虑外部接口需要什么用public再考虑子类需要什么用protected剩下都是private。不要把东西一股脑全塞private也不要把所有成员全都public那是面向对象初学者最常见的两个极端。2.3 构造函数初始化列表派生类必须先把基类喂饱派生类的构造函数语法是新手重灾区。BonusStudent构造时先执行Student的构造函数再给bonus赋值。这个“先”不是自动完成的必须显式在初始化列表里写Student(i, n, d, f)就是向基类构造函数传参。如果你只写BonusStudent(...) : bonus(b) {}编译器会去找Student的无参构造函数找不到就报错。这就是为什么博文开头提到“no default constructor exists for class Student”时一堆人发懵。解决办法就一个在任何派生类构造函数里把基类需要的实参通过初始化列表传过去顺序是基类先、成员变量其次、最后构造函数体。从C的初始化顺序规则看派生类对象的内存布局就是“基类子对象在前新增成员在后”。初始化列表的执行顺序严格按照继承顺序和成员声明顺序而不是你写列表的顺序。理解了这一点以后任何继承题目的构造函数都不太会写出bug。2.4 虚函数、override与虚析构少一个都可能埋雷total()必须声明为virtual否则上面的多态打印就不成立。而C11之后重写时建议加override关键字不是必须但强烈建议。override的作用是让编译器替你检查你写的这个函数到底有没有跟基类某个虚函数签名匹配。如果写错了参数类型比如基类是virtual double total()派生类写成double total(int)没有override时编译器就把它当成一个全新的普通函数你的多态悄悄失效程序还能跑输出却是错的加了override编译器直接给你报错瞬间定位。另外这道题我推荐养成一个习惯基类析构函数写成virtual。因为main里我可能会用基类指针Student*指向new出来的BonusStudent然后delete它。这时如果基类析构函数不是virtualdelete只会调用Student的析构函数BonusStudent里如果有动态资源就会泄漏。虽然这道题里没有资源需要释放但这是一个面向对象的通用习惯从第一道继承题就开始养成后面会少踩很多雷。3. 完整C实现一版可以直接交上去的写法3.1 完整代码展示下面这份代码是我按前面设计写出来的完整实现可以直接编译运行。拿到的题目如果要求“只补充类定义”把类部分抠出来就行如果要求“补全整个程序”这份可以直接提交。#include iostream #include iomanip #include string using namespace std; class Student { protected: string id; string name; double daily; // 平时成绩 double finalExam; // 期末成绩 public: Student(const string i, const string n, double d, double f) : id(i), name(n), daily(d), finalExam(f) {} virtual double total() const { return daily * 0.3 finalExam * 0.7; } virtual void print() const { cout id name fixed setprecision(1) total() endl; } virtual ~Student() {} }; class BonusStudent : public Student { private: double bonus; // 竞赛加分 public: BonusStudent(const string i, const string n, double d, double f, double b) : Student(i, n, d, f), bonus(b) {} double total() const override { double t Student::total() bonus; return t 100.0 ? 100.0 : t; } }; int main() { int n; cin n; for (int i 0; i n; i) { int type; string id, name; double daily, finalExam; cin type id name daily finalExam; Student* stu nullptr; if (type 0) { stu new Student(id, name, daily, finalExam); } else { double bonus; cin bonus; stu new BonusStudent(id, name, daily, finalExam, bonus); } stu-print(); delete stu; } return 0; }这份代码的要点有几个。参数全部用const string避免拷贝也说明这些数据不会被改。total()标记为const因为它只是读成员计算不修改对象状态。print()同样是const这是写这类“查询型”成员函数应该有的规范。total()使用override明确告诉编译器我在重写万一签名写错立刻报错。3.2 为什么这里要用new和基类指针而不是直接定义对象可能有同学会问直接在分支里定义Student s(...)再调用s.print()不也行吗确实行但那就完全失去教学意义了。直接定义派生类对象时编译器知道这个对象的静态类型就是BonusStudent调print()即使不是虚函数也会调派生类版本。真正体现虚函数价值的场景是用基类指针或引用去操作派生类对象。main代码里我定义了一个Student*指针同一套调用stu-print()在循环的每次迭代里这个指针一会儿指向普通学生对象一会儿指向竞赛学生对象。程序运行时根据指针实际指向的对象类型动态决定调用哪个total()。这就是运行时多态。你在考试或者面试时说清楚这一步才算是真正把第5章的继承学透了。如果你担心new出来的内存泄漏我每个分支new完都delete了而基类析构函数声明为virtual所以delete是安全的。如果你个人风格偏向不用动态内存也可以用局部对象配合引用但foreach层次会比较绕。PTA这种简单题用我这种写法最直观也最容易检验多态是否正确触发。3.3 用样例数据完整跑一遍假设输入是4 0 2024001 张三 80 90 1 2024002 李四 70 85 10 1 2024003 王五 95 80 8 0 2024004 赵六 60 55逐行分析张三80×0.3 90×0.7 24 63 87.0输出2024001 张三 87.0李四70×0.3 85×0.7 21 59.5 80.5加10 90.5输出2024002 李四 90.5王五95×0.3 80×0.7 28.5 56 84.5加8 92.5输出2024003 王五 92.5赵六60×0.3 55×0.7 18 38.5 56.5输出2024004 赵六 56.5再测一个加分导致总评超过100的边界样例。假如某竞赛学生平时100、期末100、加分10总评计算出来是110题目要求超过100按100计代码里return t 100.0 ? 100.0 : t所以输出100.0。这个边界如果不处理平时成绩和期末成绩略高一点评测点就直接被干掉。这道题15分里至少有一两个测试点是专门针对这个逻辑的。4. 踩坑记录与调试经验那些能编译却错了的情况4.1 编译错误Top3看一眼就知道错在哪我在实际帮人看这份代码的时候编译错误集中在三个类型。“no default constructor exists for class Student”原因就是派生类构造函数初始化列表里没写Student(...)。解决办法是确认基类确实有默认构造或者像我们这样在派生类初始化列表里显式调用带参构造。这个错误在派生类的构造函数被构造的瞬间触发不是命名问题是调用顺序没有被满足。“‘Student::id’ is a private member”这类访问错误大多是基类成员用了private。解决办法是把基类里需要被派生类直接访问的成员放进protected。注意如果本意是让派生类完全不管这些成员通过基类公有接口操作那private也合理。但本题里派生类的print()是复用的派生类不直接碰这些成员所以确实放private也能过。但我个人建议放protected更符合教学意图也更方便以后扩展。“invalid new-expression of abstract class type”较少见一旦出现多半是基类里有纯虚函数没被派生类全部实现。本题没有纯虚函数但如果有人把total()写成virtual double total() 0又忘记在BonusStudent里实现或者签名写错就会报这个。看到这个错误去对照派生类的override函数签名一个字母一个字母地对。我把几个常见错误整理成了表格方便对照。错误信息特征大概率原因快速修复方向no default constructor exists for class Student派生类未调用基类带参构造函数在派生类初始化列表加 Student(...)is a private member成员放错访问权限改为 protected 或通过公有接口访问invalid new-expression of abstract class type基类纯虚函数未完全重写检查 override 签名是否一致override specifier on non-virtual member function基类对应函数没加 virtual给基类函数补 virtual 关键字4.2 能编译但答案错误往往是边界和数据精度代码能跑、样例也对交上去却错一两个点我总结最常见的原因就两个。第一就是加分上限100没处理这个上文说过了第二是输出精度不对。总分保留一位小数用fixed setprecision(1)是标准做法。但有些同学的编译器里用了C风格的printf(%.1f, ...)如果总评是87printf输出的是87.0看起来也没问题。可一旦成绩涉及浮点运算的累积误差比如平时成绩是0.1的倍数考试又刚好除不尽double的二进制表示会造成第小数点第二位出现微小误差C的流输出和printf的舍入规则在位模式下可能表现不同。PTA用special judge时会允许误差但如果该评测点要求字符串精确匹配这种地方就可能挂。稳妥做法是全程double运算输出统一用cout配合setprecision并且不要自己写round函数去四舍五入让编译器按流默认规则处理。还有一种答案错误更隐蔽定义Student数组后按对象拷贝存储。比如你先定义一个普通学生对象赋值给基类数组元素再把竞赛学生对象也塞进去。这时发生对象切片派生类部分的bonus被丢掉调用total()时全部落在基类版本输出全错。我见过不少同学用Student stu[100]的方式做这道题然后疑难杂症全来了。这也是我在main里坚持用指针的原因对象切片问题是新手最容易忽略的。4.3 我的调试三步法再复杂的继承题也能定位每次写继承相关的题我都会先做三件事。第一用最小样例走通基本流程n1一个普通学生确认基类逻辑没问题。第二加一个竞赛学生故意让平时和期末成绩高到让加分后超过100验证边界处理。第三在print()之前临时打印一行total()的实际值确认虚函数有没有正确触发而不是直接去推断。如果发现总评没按派生类公式算第一反应不是去改公式而是检查total()有没有加virtual派生类有没有加override。我见过无数人对着输出结果调半天加减乘除最后才发现是虚函数没写。编译器不会告诉你“你忘写多态了”只能在第二行加个中间输出看到调用的是哪个版本的total()问题一下就暴露了。这个小习惯看着简单但能省一晚上的排查时间。调试继承代码和调试普通函数最大的不同是程序实际运行的代码路径跟表面书写的调用语句可以不是同一段函数先确认“到底调了谁”再分析“为什么算错”顺序不能反。5. 一点扩展如果题目换个派生类型你也能秒解“学生CPP成绩计算”最常见的变体是“研究生论文成绩计算”也就是派生类从BonusStudent换成GraduateStudent新增成员从bonus换成thesis总评公式从“加论文×20%”替换掉原来的分配比例。代码框架完全不用动只需要修改派生类的构造函数和total()重写部分main里输入处多读一个字段。这说明前面把基类设计好了扩展成本极低。如果题目变成“输入时先给一个总类型数再按类型逐个录入”main里也可以改成先用一个基类指针数组存放全部对象最后统一循环print。这个模式在以后做更复杂的多人成绩系统、图形形状面积计算题时都是通用的。我个人在刷完这道题之后最大的体会是类继承这块代码量从来不是问题问题是分清楚“哪些代码属于基类哪些属于派生类”。每次写之前先在纸上划一条线线上是公共属性线下是扩展属性再动手写比盲目敲代码快得多。最后再说一个实际写题的小习惯不管第几章继承题的main函数代码一般很短反而才是整个题的地基。先把main里创建对象的方式定下来再去设计类你会发现自己对虚函数和构造函数的需求突然就清晰了。希望这篇整理能让你少走一点弯路。
阅读完成 · 觉得有帮助?
咨询建站