最近收到不少正在复习《C++语言程序设计(下)》的同学来问单元测验7-3的事,问得最多的就是“虚函数、抽象类这几个点到底要掌握到什么程度”。正好我在某高校代过这门课的习题课,也整理过一套针对这个单元测验的复习思路,今天就把里面的关键考点、典型错题和我在批改作业时看到的高频翻车点一次性聊透。这篇文章不是把课件复述一遍,更像是我站在出题人的角度,拆一拆这套测验背后真正想考察的能力。
本文适合这几类人看:正在准备单元测验7-3的在读学生、自学C++想检验多态掌握程度的初学者,以及需要带学生复习的助教或讲师。内容围绕虚函数的运行机制、抽象类的设计意图、析构函数为什么必须加virtual、还有那些“编译能过但行为不对”的隐蔽坑展开。通篇以测验7-3的实际考察风格为主线,尽量用大白话把抽象概念讲清楚,也会给出一眼就能落地的自查方法,帮助你在考场上少丢冤枉分。
1. 单元测验7-3到底在考什么:多态与虚函数的完整知识骨架
1.1 第七章为什么总被看作C++课程的分水岭
先说个背景。C++课程一般分成两半:前半部分讲基本语法、函数、数组、指针,以及类和对象的基本封装,后半部分才真正进入继承、多态、模板这些“重武器”。单元测验7-3刚好卡在第七章的位置,这一章在多数教材里对应的是继承与多态的综合应用,考察重点非常明确:你有没有建立起“面向对象”的思维方式,而不是只会写几个带class的C语言程序。
我自己批改作业的体感是,前六章学得再扎实的人,到第七章也容易懵。原因是多态这个特性跟前面的语法不太一样,它强调的不是“我写了什么”,而是“程序运行时会发生什么”。你可以把继承理解成“父子关系的确立”,把多态理解成“同一个动作在不同子类里呈现出不同行为”。没有多态,你只能用基类指针调用基类自己的函数;有了多态,你才能让基类指针指向不同的派生类对象,并在这个指针上调用出“属于那个派生类”的函数版本。测验7-3里的题目,基本就是围绕这个能力展开的。
1.2 测验7-3核心覆盖点与常见考察方式
根据我对这套单元测验的了解,7-3的知识点分布可以总结成五块:虚函数的基本声明与调用规则、纯虚函数与抽象类、动态联编与静态联编、虚析构函数,以及override与隐藏的辨析。为了让复习更有针对性,我整理了一张考点地图,对应到常见出题形式:
| 考点 | 说明 | 常见考察方式 |
|---|---|---|
| 虚函数声明 | virtual关键字的位置、虚函数能否是静态成员函数 | 选择题判断正误 |
| 动态联编 | 通过基类指针/引用调用虚函数时的实际行为 | 阅读程序写输出 |
| 纯虚函数 | = 0的含义、抽象类不能实例化 | 代码改错、选择 |
| 抽象类 | 有纯虚函数不能用对象、但可以用指针和引用 | 判断能否实例化 |
| 虚析构函数 | 基类析构函数加virtual才能正确释放派生类资源 | 程序行为判断 |
| 覆盖vs隐藏 | 函数签名是否一致、有无virtual | 综合辨析题 |
这套测验里最有区分度的题目,基本集中在“输出结果”和“能否通过编译”两类上。读程序写输出的题,考察的是你对动态联编的理解;判断能否通过编译的题,考察的是你对虚函数规则的边界条件是否清楚。比如静态成员函数能不能是虚函数、抽象类能不能实例化——这种判断题看似简单,但特别能拉开分差,因为很多同学是“背过规则但没真正理解”。
2. 高频错误1:virtual关键字不是“性能优化”,而是“运行时路由开关”
2.1 初学者最常见的理解偏差
我在助教答疑时最常听到的一句话是:“老师,我加了virtual,程序怎么变慢了?”这反映出很多同学把virtual理解成了类似inline的“优化手段”。实际上恰恰相反,虚机制带来的是运行时的间接跳转成本。你需要的不是“快”,而是“正确”。
用生活场景打个比方。你去车站买车票,如果直接买“今天下午3点去某城的票”,这是静态确定好的;但如果买的是“某趟车次”,你到候车室才知道是哪辆车——这有点像虚函数。编译器在看到基类指针调用虚函数时,并不确定这个指针到底指向哪个派生类对象,所以它把这个决定推迟到运行时,由对象自身的“类型信息”来决定跳转到哪个函数实现。这个机制的专业说法叫动态联编,也叫运行时多态。
我见过不少同学手写代码时把基类函数加virtual,但派生类的同名函数没有加virtual。其实C++里派生类重写基类虚函数时,可以省略virtual关键字,因为虚属性会“继承”下去。不过从代码可读性和防错的角度,我还是建议在派生类里也写上,或者直接在函数后面加override,让编译器帮你检查签名是否匹配。这是测验7-3选择题和写程序题经常会考察的细节。
2.2 动态联编与静态联编的对比
静态联编发生在编译期,函数调用在编译时就能找到固定地址;动态联编发生在运行期,通过虚函数表来确定最终调用目标。这里我不想把虚函数表的细节讲得太深,但你需要知道它的存在:每个含有虚函数的类,在编译时会生成一张虚函数表,表中记录了该类所有虚函数的地址;每个对象内部会藏一个虚函数表指针,指向自己所属类的虚函数表。当基类指针指向派生类对象时,它拿到的那个虚函数表指针指向的是派生类的表,所以虚函数调用会进入派生类的实现。
| 对比项 | 静态联编 | 动态联编 |
|---|---|---|
| 发生时间 | 编译期 | 运行期 |
| 依据 | 指针/引用的静态类型 | 对象实际类型 |
| 性能 | 更快,无间接跳转 | 有一次间接寻址开销 |
| 典型语法 | 普通函数、非虚成员函数 | 通过基类指针/引用调用虚函数 |
一道非常经典的测验题:定义了Animal基类和Dog派生类,speak()在基类中是虚函数,派生类重写后,通过Animal* p = new Dog(); p->speak();调用,输出结果就是Dog的版本;如果把virtual去掉,结果就会变成基类的Animal版本。这里的关键不是“指针类型”,而是“对象是谁”——动态联编认的是对象,不是指针。写这类题的判断,我建议先问自己一句:调用这个函数时,实际对象是基类还是派生类?如果是派生类,并且函数是虚函数,那就是派生类版本。
class Animal { public: virtual void speak() { std::cout << "Animal speaks" << std::endl; } }; class Dog : public Animal { public: void speak() { std::cout << "Dog barks" << std::endl; } }; int main() { Animal* ptr = new Dog(); ptr->speak(); // 输出 Dog barks,因为 speak 是虚函数 delete ptr; // 注意:此处基类析构函数需要是虚析构函数 return 0; }它考察的就是“有没有virtual,输出拐不拐弯”的区别。如果你把speak()的virtual去掉,输出就会变成Animal speaks,这也就是很多同学把输出题做错的原因——代码看起来一样,但少了virtual,行为天差地别。
3. 高频错误2:抽象类“不能实例化”不是缺陷,而是设计约束
3.1 纯虚函数与抽象类的语法细节
纯虚函数的写法是在函数声明后面加= 0,比如virtual void draw() = 0;。这里的= 0并不是函数返回0,而是一个语法标记,告诉编译器“这个函数我在当前类里不提供实现,你给我留个接口位,让派生类来填”。含有纯虚函数的类就是抽象类,它不能用来创建对象。
很多同学看到“抽象类不能实例化”就直接死记硬背,其实这个约束背后有很实际的原因。抽象类扮演的是“模板”或者“契约”的角色,它定义了一组接口,但具体的实现细节往往依赖于子类。假如你允许Shape s;这样创建一个抽象类对象,那么s.draw()该执行什么?抽象类自己都不知道。所以C++干脆规定:抽象类只能作为基类,只能通过指针或引用去操作它的派生类对象。
3.2 派生类实现纯虚函数的判定
一个派生类如果继承了抽象类,它必须把基类所有纯虚函数都实现出来,否则这个派生类自己仍然是一个抽象类,依旧不能实例化。这里容易出错的点集中在“部分实现”和“签名不匹配”上。比如基类有virtual void show(int) = 0;,派生类写的是void show(double),看起来像是实现了,其实函数签名不同,这属于隐藏而不属于覆盖,基类纯虚函数仍然没有被实现,派生类就仍然是抽象类。
还有一种情况:派生类实现了全部纯虚函数,但基类也提供了某个纯虚函数的内联实现。C++允许给纯虚函数写函数体,但对象依然不能实例化,派生类依然必须重写。这个点比较冷门,但单元测验7-3的判断题偶尔会考,属于“看书不仔细就丢分”的题目。我的建议是:看到纯虚函数,第一反应不要想“它有没有函数体”,而要想“这个类还能不能创建对象”。
3.3 抽象类也能写出能跑的代码
虽然抽象类不能实例化,但抽象类的指针和引用非常有用。最常见的写法是:用抽象类指针指向派生类对象,然后调用虚函数,实现统一接口管理。比如有一个Shape抽象基类,派生出Circle和Rectangle,你定义一个Shape* shapes[2]数组,分别指向两个派生类对象,再遍历数组调用draw(),就能得到各自不同的绘制结果。这其实是多态最核心的用法,也是单元测验7-3后面编程题或综合题的雏形。
class Shape { public: virtual void draw() = 0; virtual ~Shape() {} }; class Circle : public Shape { public: void draw() override { std::cout << "Draw Circle" << std::endl; } }; class Rectangle : public Shape { public: void draw() override { std::cout << "Draw Rectangle" << std::endl; } };要学会从设计角度理解抽象类:它存在的意义不是为了“让自己能用”,而是为了“让所有子类长得像一家人”。在答题时,凡是看到选项里出现“抽象类可以实例化”或者“纯虚函数可以有函数体”,都要多留个心眼,前者必错,后者则要结合上下文判断。
4. 最容易翻车的三道典型题:从错误选项倒推考点
4.1 典型题1:构造函数里调用虚函数,为什么“多态失效”了
这是测验7-3里出错率最高的选择题之一。题目往往给出一个基类和派生类,二者各有同名的虚函数,然后在基类构造函数里调用这个虚函数,问输出是什么。很多同学想当然地认为“既然是虚函数,就应该调用派生类版本”,结果答案是调用基类版本,直接选错。
原因在于对象的构造顺序。创建派生类对象时,先执行基类构造函数,再执行派生类构造函数。在基类构造函数执行期间,派生类部分还没有被初始化,对象在C++看来还处于“基类构造中”的状态,此时虚函数调用不会走动态联编,而是直接调用当前正在构造的这个类里的版本。你可以这样记:构造函数运行时,对象还没有“完整成型”,所以虚函数表指针指向的仍然是基类的虚函数表。等到派生类构造函数执行完,对象才真正变成派生类对象。
这种题考的不是你会不会写代码,而是你有没有真正理解对象的生命周期。我建议在做这类题时别急着选答案,先画一条对象构造时间线:基类构造 → 派生类成员构造 → 派生类构造。凡是在“基类构造”这个时间点调用虚函数,答案就是基类版本。
4.2 典型题2:基类析构函数要不要加virtual,程序崩溃就在一念之间
另一道高频题问的是析构函数。给定一个基类指针指向new出来的派生类对象,然后delete这个指针,问程序会调用哪些析构函数。如果基类析构函数没有virtual,delete时只会调用基类的析构函数,而不会调用派生类的析构函数,结果就是派生类自己申请的资源(比如堆内存、文件句柄)没有被释放,造成内存泄漏。只有基类析构函数是虚函数时,delete基类指针才会先调用派生类析构函数,再自动调用基类析构函数,跟构造顺序刚好相反。
这个知识点我每次上课都会强调:只要一个类打算作为基类使用,并且通过基类指针操作派生类对象,就一定要给析构函数加virtual。注意,析构函数加virtual和普通虚函数有一点不同——它是唯一一个在“函数签名不同但依然构成覆盖”的特殊场景,因为派生类和基类的析构函数名字不同,但C++仍把它们看作覆盖关系。测验7-3里如果考这个概念,通常会给你四种组合:基类加/不加virtual、派生类加/不加virtual,然后问delete时的调用序列。记住:真正决定能否完整析构的是基类析构函数有没有virtual。
4.3 典型题3:static成员函数不能是虚函数,原因是它没有this
还有一道判断正误题也很经典:静态成员函数能否用virtual修饰?答案是否定的。因为虚函数机制依赖于对象自身的类型信息,调用虚函数时需要有this指针去找到虚函数表;而静态成员函数不属于任何对象,没有this指针,自然也就没办法完成动态联编。你可以把静态成员函数理解成“类级别的普通函数”,它跟对象没有绑定关系,跟多态完全是两条路线。
我把这个知识点整理成几个“铁律”,做选择题时可以快速排除:
- 虚函数必须是成员函数,不能是全局函数。
- 虚函数不能是静态成员函数。
- 构造函数不能是虚函数(析构函数可以)。
- 内联函数可以是虚函数,但虚函数的动态联编特性会让内联变得几乎没有意义。
这几条几乎覆盖了测验7-3所有关于virtual修饰范围的判断题。做题时看到选项里有static、friend(友元函数不是成员函数,也不能是虚函数)这类词,直接判断“不能是虚函数”,属于稳妥的拿分项。
5. 编译能过但多态失效:函数签名不同导致的隐藏才是大坑
5.1 const成员函数与虚函数:一个容易被忽略的签名差异
比“加了virtual没效果”更隐蔽的坑,是函数签名不同导致的隐藏。我见过不少同学写代码时自信满满地以为自己在“重写虚函数”,运行结果却还是基类版本,改了半天发现是少了const或者参数类型不匹配。
举个例子:
class Base { public: virtual void print() const { std::cout << "Base const print" << std::endl; } }; class Derived : public Base { public: void print() { // 少了 const,签名不同 std::cout << "Derived print" << std::endl; } }; int main() { Base* p = new Derived(); p->print(); // 输出 Base const print return 0; }这段代码能编译通过,但运行结果是基类的版本。因为Derived里的void print()和Base里的void print() const不是同一个签名,前者没有覆盖后者,而是把基类的print“隐藏”了。由于这个隐藏是通过基类指针看不见的,所以动态联编根本不会发生。const是函数签名的一部分这种细节,平时写作业不觉得,一旦到单元测验7-3的选择题里,就成了区分高手和普通人的题目。
5.2 覆盖、隐藏、重载三者之间的边界
为了让这类题目不再失分,我总结了一张对比表,覆盖(override)、隐藏(redeclare/hide)、重载(overload)三者的区别一目了然:
| 关键词 | 是否同名 | 是否在同一作用域 | 函数签名要求 | virtual要求 |
|---|---|---|---|---|
| 重载 overload | 是 | 必须在同一作用域 | 参数列表不同 | 无所谓 |
| 覆盖 override | 是 | 不同类(基类/派生类) | 参数列表与const完全一致 | 基类必须有virtual |
| 隐藏 hide | 是 | 不同类(基类/派生类) | 不同于覆盖的任意情况 | 基类有无virtual都可以 |
做题时只要按三步走就能判断:先看是否同名,再看作用域是否跨类,最后比对函数签名。如果不同类、同名、签名完全一致且基类函数是虚函数,那就是覆盖,会发生多态;如果不同类、同名但签名不一致,那就是隐藏,不会发生多态。测验7-3的最后一道综合题往往就是给出一个继承层次,里面既有重载、又有隐藏、还有覆盖,让你写出各个调用的输出结果。这种题想要拿满分,你必须先学会用表格里的条件去“定性”,再逐行推导输出。
我特别建议你在编译器里实际做一次这种实验:注释掉粘的override,故意在派生类少写一个const,看看编译器会不会报错。实际上,C++11的override关键字非常有用,它能强制让编译器检查“这个函数是否真的覆盖了基类的虚函数”,如果签名不一致,编译直接报错。很多同学担心写override会麻烦,但恰恰是它能在考前帮你自动发现签名不匹配的问题。测验7-3里如果遇到代码补全或改错类大题,手动检查签名一致性,再加override验证,基本不会丢分。
6. 考前的自查清单与最后的冲刺建议
6.1 两分钟快速判断题目的五个问题
复习到这个阶段,最重要的是形成条件反射式的判断逻辑。我在课上带学生讲过一套“五连问”自查法,做任何一道多态相关的选择题时,按顺序过一遍,答案自然出来:
- 被调用的函数是不是成员函数?不是 → 不考虑多态。
- 这个函数在基类中是否声明为virtual?不是 → 不会发生动态联编。
- 调用时通过什么方式发起?通过基类指针或基类引用 → 才可能动态联编;通过对象直接调用 → 仍然是静态联编。
- 派生类中的同名函数签名是否与基类完全一致?包括参数类型、const限定符 → 一致才是覆盖。
- 派生类对象是否已经构造完成?如果当前还在基类构造函数中 → 虚调用仍走基类版本。
这套方法对付单元测验7-3的选择题和改错题足够了。比如看到“通过基类对象s = 派生类对象复制赋值,然后调用虚函数”,第一反应就是不产生多态,因为对象切片后,虚函数表指针指向的已经是基类虚函数表了。这种题很容易骗到只记住“指针/引用触发多态”的人。
6.2 我一直推荐的复习与实战方法
最后说说我自己的复习习惯,也是我一直推荐给学生的。第一,把每一章的核心概念浓缩成一句不超过20个字的话,写在卡片上。比如“虚函数=运行时路由开关”“抽象类=只给接口不给实现”。这句话你得能讲给自己听,讲不清楚就是没理解透。第二,动手把书本上的小例子自己敲一遍,然后把virtual删掉、给析构函数去掉virtual、把派生类函数加个const,分别观察运行结果和编译报错。这样做的效果比背十道题都来得快,因为你会亲眼看到行为差异。
单元测验7-3这套题,难并不是因为语法复杂,而是因为它要求你同时理解编译器的静态视角和运行时的动态视角。对我来说,带这门课最深的体会是:真正把多态学明白的同学,写出来的代码都有一个共同特征——不依赖记忆去堆规则,而是能从对象的实际类型出发推演行为。你如果能做到这一点,应对测验7-3会非常轻松,更关键的是后面学模板、STL时也会顺畅很多。考试前,把这篇文章里的代码和表格过一遍,再动手运行两遍,比闷头刷题有效得多。