☰
C++继承与多态:第五六讲作业参考答案与虚函数核心解析
2026/10/4 6:31:57 网站建设 项目流程

如果你也在刷同济大学开的这门C++MOOC,到了第五、六讲突然觉得作业无从下手,那这篇正是为你准备的。第五讲继承与派生、第六讲多态与虚函数,是整个C++课程里真正拉开差距的分水岭:前面的变量、循环、函数、数组,说白了是“用C++写C语言”,到了类和对象之后才开始进入面向对象编程的核心。这篇会把第五、六讲的典型题目答案拆开讲清楚,并且告诉你为什么这样写、有哪些坑、考试和作业里会怎么考。适合学完前四讲但作业卡壳的同学,也适合期末复习或者准备复试面试的人拿来梳理知识体系。

先说一个原则:参考答案不等于抄作业。我会把每道题的考点、代码思路、常见错误一起讲,你最好是先自己写一遍,卡住超过十五分钟再来看解答,看完之后合上屏幕重新敲一遍,这才是有用的学习闭环。下面直接进入正题。

1. 先搞清楚第五、六讲到底卡在哪

1.1 第五讲核心:继承与派生

继承的基本语法其实不复杂,class Derived : public Base这一行就能定义一个派生类,难的是背后这一连串问题:三种继承方式对成员访问权限的影响是什么样的?基类和派生类的构造函数析构函数到底按什么顺序执行?同名成员出现时是覆盖还是隐藏?基类指针能不能指向派生类对象?能的话有什么限制?

如果你能把这几个问题全部说清楚,第五讲就算真正吃透了。很多同学卡住是因为只记住了语法,却没有理解继承的意义。举个例子,你写了一个Animal类,有eat()、sleep()方法,现在想再写一个Dog类,最直接的想法是复制粘贴再改改,但继承让你可以写class Dog : public Animal,从而自动拥有基类的所有公开接口,然后只补充狗独有的行为。这就是代码复用的威力。

1.2 第六讲核心:多态与虚函数

第六讲进入多态与虚函数,难度一下子上升一个台阶。多态要解决的核心问题是:一份调用代码,怎么根据对象的实际类型表现出不同的行为?

C++实现多态的机制是虚函数。基类把某个函数声明为virtual,派生类重写这个函数,然后通过基类指针或引用调用它,程序在运行时根据对象的真实类型去决定调用哪个版本。这个“运行时决定”叫做动态绑定。光看概念很抽象,等你写出一个形状面积计算器,把圆形、矩形放在同一个Shape*指针数组里统一调用area(),就能直观感受到多态的价值:调用方不需要关心具体是什么形状,只关心它能算面积,新增一个三角形类型也不影响原有代码。这就是面向对象设计里的“开闭原则”。

1.3 参考答案的正确打开方式

在展示答案之前,必须把使用心态摆正。我自己见过太多同学把参考答案当“提词器”,看一遍觉得懂了,考试全懵。参考答案的正确用法是当“错题诊断器”:你写完代码跑不通,拿答案逐行对比,看差异在哪里;或者你写完能跑,但不确定是不是最优解,拿答案对照设计思路。

另外,官方MOOC平台的作业有截止日期,也有查重机制,直接抄代码提交不仅学不到东西,还可能惹上学术诚信麻烦。真正聪明的做法是:用参考答案理解思路,然后关掉它,用自己的话重写一遍,甚至尝试改成另一种实现。比如实现多态面积计算器时用switch写一遍,再用虚函数写一遍,你才能体会到为什么多态更好。下面的内容我尽量按“题目要求—参考答案—考点解析—易错点”的结构来写,你既可以拿来对照,也可以当作复习提纲。

2. 第五讲参考答案:继承与派生的四道高频题

2.1 构造与析构顺序:先有爸爸,再有儿子

这是一道出现率极高的题目,作业里会让你写出程序输出,或者让你解释为什么是这个顺序。直接看代码:

#include <iostream> using namespace std; class Base { public: Base() { cout << "Base constructor" << endl; } virtual ~Base() { cout << "Base destructor" << endl; } }; class Derived : public Base { public: Derived() { cout << "Derived constructor" << endl; } ~Derived() override { cout << "Derived destructor" << endl; } }; int main() { Derived d; return 0; }

输出结果是:

Base constructor Derived constructor Derived destructor Base destructor

为什么是这个顺序?因为派生类对象在内存中的布局是“先基类部分,再派生类新增部分”。构造时先初始化基类部分,等基类部分齐全了,派生类新增的成员才能放心使用;析构时反过来,先释放派生类自己申请的资源,再调用基类析构函数,避免派生类析构函数访问到已经销毁的基类成员。

这里要特别注意一个隐藏考点:如果基类析构函数没有加virtual,而你是用Base* p = new Derived(); delete p;来删除对象,那么只会有Base destructor被调用,Derived析构函数直接跳过,可能导致资源泄漏。这就是第六讲会专门讲的“虚析构函数”问题,第五讲阶段至少要知道析构顺序是正确的,后面再深入原理。

还有一个容易被忽略的细节:如果派生类有成员对象,那么成员对象的构造顺序不是按初始化列表的书写顺序,而是按它们在类中声明的顺序。这个冷门知识点在期末考试也经常出现,建议你现在就写代码验证一遍,印象会深刻得多。

2.2 三种继承方式与访问权限对照

第五讲作业最喜欢考的就是:“public继承、protected继承、private继承后,基类成员在派生类里分别变成什么访问级别?”直接背结论容易混,我建议你看这张表:

继承方式基类public成员基类protected成员基类private成员派生类外部能否访问基类public
public继承publicprotected不可直接访问能
protected继承protectedprotected不可直接访问不能
private继承privateprivate不可直接访问不能

记忆方法很简单:继承方式相当于对基类成员的“降级规则”。public继承保持原样,protected继承把public降为protected,private继承把所有能访问的成员都降为private。基类的private成员在任何继承方式下都不能被派生类直接访问,只能通过基类提供的public或protected接口来间接访问。

有个题目经常拿来考:class B : private A {};,然后在外部定义B b; b.func();,其中func()是A的public成员函数。因为private继承把A的public降成了B的private,所以外面根本调不到,编译直接报错。还有更进阶一点的考法:在派生类中使用using Base::func;把被降级的成员重新暴露为public,这个是加分项,作业里一般不会强制要求,但你能写出来会显得理解更深。

2.3 类型兼容规则:指针、引用和切片

公有继承下有一个非常重要的性质,叫“类型兼容规则”。简单说,派生类对象可以当作基类对象来使用。它包含三条具体规则:

  • 派生类对象可以赋值给基类对象;
  • 派生类对象可以初始化基类引用;
  • 派生类对象的地址可以赋给基类指针。

看起来很简单,但里面藏着一个大坑:对象赋值会发生“切片”。比如你有Dog类和Animal类,执行Animal a = dog;时,编译器只把dog对象中属于Animal的部分复制过去,Dog独有的成员全部丢失。所以如果你要保存一堆不同派生类对象,不要用vector<Animal>,那样存进去的每一个元素都被切掉了派生类部分,等你想调虚函数时就傻眼了。正确做法是存指针,比如vector<Shape*>。

另外有一个非常重要的伏笔:因为虚函数是动态绑定,只有通过指针或引用调用才能触发多态。如果直接用对象调用虚函数,编译器仍然按静态类型处理。这个点我在下一节还会详细展开,你现在先记住这句话:多态需要指针或引用,对象不行。

2.4 作业里常出现的链表与结构体类化

不少MOOC作业会把C语言的结构体链表改写成C++类,用来考察结构体和类的区别,以及构造函数、析构函数的使用。参考答案大致是这个样子:

struct Node { int data; Node* next; Node(int d, Node* n = nullptr) : data(d), next(n) {} }; class LinkedList { private: Node* head; public: LinkedList() : head(nullptr) {} void push_front(int v) { head = new Node(v, head); } ~LinkedList() { Node* p = head; while (p) { Node* t = p; p = p->next; delete t; } } };

这里考了三个核心点:第一,struct和class在C++里几乎一样,唯一区别是默认访问权限,struct默认public,class默认private。第二,构造函数用初始化列表head(nullptr)来初始化成员,比在函数体里赋值更高效。第三,析构函数必须遍历链表把所有new出来的节点delete掉,否则会内存泄漏。

有些题目还会要求链表排序,这时候你会用到冒泡排序或者插入排序。C++写冒泡排序的思路和C语言一致,用两层循环比较相邻节点,但写的时候要特别小心指针重连。如果在链表上排序觉得麻烦,先取出到数组排序再重建链表也是可行的,作业没有规定只能用一种方法。

3. 第六讲参考答案:多态与虚函数的三个典型练习

3.1 用虚函数实现“形状面积计算器”

第六章最经典的作业就是做一个形状面积计算器,要求可以计算圆形、矩形等不同形状的面积。初学者最容易写出的版本是用一个枚举加switch区分类型,但那样每新增一种形状就要改switch,非常不优雅。正确解法是用虚函数实现多态,参考答案如下:

#include <iostream> #include <vector> using namespace std; class Shape { public: virtual double area() const { return 0; } virtual ~Shape() {} }; class Circle : public Shape { private: double radius; public: Circle(double r) : radius(r) {} double area() const override { return 3.14159 * radius * radius; } }; class Rectangle : public Shape { private: double width, height; public: Rectangle(double w, double h) : width(w), height(h) {} double area() const override { return width * height; } }; int main() { vector<Shape*> shapes; shapes.push_back(new Circle(2.0)); shapes.push_back(new Rectangle(3.0, 4.0)); for (Shape* s : shapes) { cout << "Area = " << s->area() << endl; } for (Shape* s : shapes) { delete s; } return 0; }

这段代码为什么能实现多态?关键在于area()是虚函数,所以Shape*指针调用area()时,不是看指针类型,而是看它实际指向的是Circle还是Rectangle。这就是运行时多态。

这里有几个细节必须提一下。override关键字是C++11引入的,用来告诉编译器“我这是重写基类的虚函数”,如果你函数签名写错了,比如漏掉const,编译器会直接报错,而不是让你在运行时才发现问题。vector里存的是Shape*而不是Shape,是为了避免切片,同时支持不同派生类共存。最后不要忘记delete释放堆内存,写完这段代码可以顺手用内存检测工具跑一跑,养成好习惯。

3.2 纯虚函数与抽象类:设计基类别忘“契约”

如果你觉得上面的Shape类里area()返回0有点不合理,那就说明你已经摸到抽象类的门槛了。实际上,一个形状连面积都没有定义,它就不该被实例化。更严谨的设计是把Shape写成抽象类,把area()声明为纯虚函数:

class Shape { public: virtual double area() const = 0; // 纯虚函数 virtual ~Shape() {} };

带= 0的虚函数就叫纯虚函数,包含纯虚函数的类叫抽象类。抽象类不能创建对象,只能作为基类使用。它的作用是定义一份“接口合同”:任何继承自Shape的类,都必须实现area(),否则它自己仍然是抽象类。

可以把这个理解成签合同:抽象类规定“你必须有算面积的函数”,派生类负责具体实现。生活中也有类似场景,比如公司招聘一个“会写方案的人”,岗位要求是必须能输出方案,但具体写什么方案由不同岗位自己定,大家共用同一个招聘入口,这就是面向接口编程的思想。

第六讲作业里经常会让你设计一个Employee雇员类体系:基类提供一个virtual double salary() const = 0;,经理和实习生分别实现不同的工资计算方式,然后统一放到Employee*容器里计算总工资。这类题目只要你理解了纯虚函数,写起来就很顺畅。注意一个常见的坑:派生类必须把基类所有的纯虚函数都实现了,哪怕某个函数对当前类没有意义,也得写一个空实现,否则无法实例化。

3.3 虚析构函数:为什么基类析构必须加virtual

第六讲另一个必考考点是虚析构函数,而且它经常和内存泄漏结合出题。先看一段有问题的代码:

class Base { public: ~Base() { cout << "~Base()" << endl; } }; class Derived : public Base { private: int* arr; public: Derived() { arr = new int[10]; } ~Derived() { cout << "~Derived()" << endl; delete[] arr; } }; Base* p = new Derived(); delete p; // 危险:只调用 ~Base(),arr 泄漏

这段代码的问题很隐蔽。delete p时,p的静态类型是Base*,而编译器看到Base的析构函数不是虚函数,就只会调用Base::~Base(),Derived的析构函数被跳过,arr指向的那块堆内存永远不会释放,造成内存泄漏。

解决办法很简单,把基类析构函数声明为virtual:

class Base { public: virtual ~Base() { cout << "~Base()" << endl; } };

这样delete p就会先调用Derived::~Derived(),再调用Base::~Base(),资源得到正确释放。记住一个经验法则:只要类里至少有一个虚函数,就一定要把析构函数写成虚函数,而且最好是virtual ~Base() = default;。

有人在面试里被问到“构造函数能不能是虚函数”,答案是不能。原因也很直接:对象在构造过程中,虚函数表指针还没初始化好,虚函数机制无从谈起;而且从语义上讲,构造函数是用来创建具体类型对象的,调用哪个构造函数必须在编译期就确定,不需要动态绑定。

4. 你在作业和考试中一定会踩的六个坑

4.1 覆盖、隐藏、重载:三兄弟别搞混

这三个概念是C++选择题和填空题的重灾区。我见过太多人把“覆盖”说成“重载”,或者把“隐藏”和“覆盖”混为一谈。先看表格:

概念出现位置判定条件调用规则
重载 overload同一个作用域(同一个类内)同名、参数列表不同,virtual可有可无编译器根据实参列表在编译期选择
覆盖 override派生类重写基类虚函数同名、同参数、同const,基类必须为virtual运行时根据对象动态类型选择
隐藏 hide派生类定义同名函数,不管参数是否相同基类版本非virtual,或参数列表不同通过派生类对象调用时,基类版本不可见,需Base::func()显式调用

一个典型的例子:

class Base { public: virtual void f(int) {} void g(int) {} }; class Derived : public Base { public: void f(int) override {} // 覆盖 void g(int) {} // 隐藏 void f() {} // 隐藏,因为参数列表不同 };

这里Derived::g(int)和Derived::f()都是隐藏。尤其注意f(),它虽然与基类虚函数f(int)同名,但参数不同,所以不是覆盖,而是隐藏。如果你用Derived d; d.f();,它调用的是派生类自己的f();如果想调用基类的f(int),必须写d.Base::f(5),否则编译器会报错说找不到匹配的函数。

4.2 抽象类报错:cannot instantiate abstract class

第六讲作业里最常见的编译报错就是抽象类实例化错误。不同编译器提示不一样,Visual Studio会报“cannot instantiate abstract class”,g++会报“cannot declare variable ‘xxx’ to be of abstract type”。原因基本只有一个:某个类还有未实现的纯虚函数。

具体来说有三个高发场景:

  • 派生类漏掉了override关键字,导致函数签名不匹配,编译器不认为它实现了基类的纯虚函数;
  • 基类纯虚函数带const,派生类实现时忘记写const,签名不一致;
  • 试图直接创建抽象类对象,比如Shape s;,这在语法上就不允许。

排查方法其实很简单:先看基类里所有的纯虚函数,逐个检查派生类里有没有完全一致的实现。特别提醒,const修饰符是函数签名的一部分,double area() const和double area()是两个不同的函数,这个细节坑过无数人。

4.3 虚函数表与多态性能:别被八股吓倒

很多同学在学到虚函数时都好奇过:C++多态底层到底怎么实现的?简单说,每个含虚函数的类在编译期会生成一张虚函数表(vtable),里面存着这个类所有虚函数的地址。每个对象内部会多一个隐藏指针(vptr),指向自己所属类的虚函数表。当通过基类指针调用虚函数时,程序会两步走:先通过对象的vptr找到虚函数表,再通过表里的函数地址跳转到实际函数。这就是动态绑定。

因为这个机制,虚函数调用比普通函数调用多一次间接寻址,性能上确实有一点开销,但通常可以忽略不计。虚函数还有一个限制:不能内联。因为内联是编译期展开,而虚函数要运行时才知道调用谁。如果某个函数又短又被频繁调用,而且不需要多态,就不要加virtual,也算是给编译器一个优化空间。

面试如果问“讲讲C++多态”,不要只背“虚函数表”四个字。还是那句老话:八股文不是背出来的,先动手写代码,再看原理,自然就理解了。

4.4 编译器环境:Dev-C++、VSCode、Visual C++那些事

学C++过程中,环境问题往往比语法问题更让人崩溃。我根据常见搜索关键词整理一下高频问题:

  • 很多同学的Windows电脑上跑pip安装Python包时会遇到error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools",这个报错本质上是缺少C++编译工具链,和MOOC作业关系不大,但很吓人。解决办法是安装Visual Studio Build Tools,安装时勾选“使用C++的桌面开发”工作负载,装完重启终端就好了。
  • 如果只是做课程作业,最省心的方案是用Visual Studio Community,新建控制台项目直接F5运行,调试器体验很好。
  • 喜欢轻量方案的同学用VSCode,需要自己配置C/C++插件和编译器(MinGW-w64),配好tasks.json和launch.json之后也能做到自动编译调试。第一次配置确实有点麻烦,但胜在跨平台,以后写项目也用得上。
  • 千万不要抱着Visual C++ 6.0不放。那是一个1998年的老古董,对C++11以后的新特性支持非常差,连nullptr、override都不认识。如果你课程要求必须用它,也建议用Dev-C++ 5.11或VSCode把作业另跑一遍,确保代码在新标准下也能编译通过。

4.5 C++风格I/O与字符串初始化

MOOC作业经常要求从键盘输入数据,很多同学在这里栽跟头。区分清楚C风格和C++风格的输入输出,能省掉很多调试时间。

#include <iostream> #include <string> using namespace std; int main() { char s1[20] = "hello"; // C风格字符串,长度要留出'\0'的位置 string s2 = "hello"; // C++标准库string string arr[3] = {"a", "b", "c"}; // string数组初始化 int n; cin >> n; // 读入一个整数,停在换行符前 cin.ignore(); // 吃掉换行符 string line; getline(cin, line); // 读一整行 cout << line << endl; return 0; }

这里最容易踩的坑就是cin >> n之后,输入缓冲区里还留着一个换行符,直接调用getline(cin, line)会得到一个空字符串。解决办法是在中间加cin.ignore()。还有字符串数组初始化时,char s[3] = "abc";会越界,因为字符串末尾的'\0'也需要一个字节。这些都是C++入门阶段特别典型的低级错误,但每次线上OJ报错都能让新手怀疑人生,所以专门列出来提醒你。

4.6 判断质数、快速幂等练习的优化思路

第五、六讲之后,很多综合性大作业会把之前学过的算法结合起来考。如果你在搜索“判断质数c++优化”、“快速幂算法c++”、“二分查找 c++”,说明你已经开始做这类综合题了。这里简单给几个常用的C++实现思路。

判断质数,最基础的是从2到n-1逐个试除,复杂度O(n);稍微优化一点是只检查到sqrt(n),因为如果n能被某个大于sqrt(n)的数整除,那它必然也能被一个小于等于sqrt(n)的数整除。再进一步是跳过偶数,2单独处理,从3开始步长2。代码写出来大概是这样:

bool isPrime(int n) { if (n < 2) return false; if (n == 2) return true; if (n % 2 == 0) return false; for (int i = 3; i * i <= n; i += 2) { if (n % i == 0) return false; } return true; }

二分查找的C++实现要注意两个点:一是区间开闭要统一,二是求中间位置用mid = left + (right - left) / 2;而不是(left + right) / 2,后者在left和right都很大的时候可能溢出。快速幂则是一个典型的把指数拆成二进制来降低复杂度的算法,核心循环是:

long long fastPow(long long a, long long n) { long long res = 1; while (n) { if (n & 1) res *= a; a *= a; n >>= 1; } return res; }

这些算法严格来说不是第五、六讲的内容,但大作业和考试都喜欢把它们作为继成多态的应用场景,所以提前了解一下不吃亏。

5. 参考答案用得好,胜过刷十遍

5.1 看完答案后,你能独立做对这五件事

每次看完一份参考答案,不要急着合上就说“我会了”。建议你拿这五件事当自测清单,逐条检验:

  1. 不看代码,能画出继承体系下对象的内存布局,说出基类部分和派生类部分的位置关系;
  2. 能手写出构造与析构函数的输出顺序,并且解释为什么;
  3. 能分辨覆盖、隐藏、重载,并说明在具体代码里会调用哪个函数;
  4. 能手写一个形状面积计算器,使用纯虚函数和override,并且确保内存不泄漏;
  5. 能解释为什么基类析构要加virtual,能写出一段演示虚析构必要性的代码。

这五件事如果能全部完成,第五、六讲的核心考点你已经掌握得差不多了。完成不了也不要慌,重新回到对应的章节再看一遍,再敲一遍代码,通常第二遍会通顺很多。

5.2 从MOOC到面试:继承多态怎么讲才能加分

如果你学完这门课之后准备找实习或者参加复试面试,继承和多态几乎是必问题目。怎么回答才能加分?我的建议是不要只背概念,按“实现原理—代码示例—设计意义”三步走。

先讲实现原理:多态依赖虚函数表,对象里有一个vptr指向虚函数表,调用虚函数时通过vptr查表完成动态绑定。再给一个简洁的示例,比如Shape和Circle的area()函数。最后讲设计意义:多态让调用方稳定、实现方能扩展,符合开闭原则,这也是很多框架采用接口设计的原因。

如果你能在回答结束时说一句“我在MOOC作业里用多态实现过一个形状面积计算器,后来添加新的形状类时完全没改原调用代码”,那这个回答就非常加分。面试官要的不是完美的八股文,而是你真正用过、踩过坑之后的那种真实感。

最后再分享一个我自己的经验。当年学第五、六讲的时候,我每次看完参考答案都会做一件事:合上答案,把自己当成老师,把代码一行一行讲给自己听。讲不清楚的地方,就是还没吃透的地方。尤其是虚析构那个坑,我讲了好多遍才真正理解为什么基类析构必须加virtual。参考答案从来都不是终点,它只是一面镜子,帮你确认自己的思路对不对。你按上面的案例练一遍,卡住了就来对答案,然后关掉屏幕重写,用这个方法啃下继承和多态,绝对比你对着屏幕抄十遍印象深刻得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询