C++进阶:(二)多态的深度解析
目录
前言
一、多态的概念:什么是多态?
1.1 多态的通俗理解
1.2 多态的分类
1.2.1 编译时多态(静态多态)
1.2.2 运行时多态(动态多态)
二、多态的定义及实现:三大核心条件
2.1 核心条件一:继承关系
2.2 核心条件二:虚函数与重写
2.2.1 虚函数的定义
2.2.2 虚函数的重写(覆盖)
2.3 核心条件三:基类的指针或引用调用虚函数
2.4 多态实现的完整示例
三、虚函数重写的进阶细节
3.1 协变:返回值类型不同的特殊重写
3.1.1 协变的定义
协变的注意事项
3.2 析构函数的重写:避免内存泄漏
3.3 override 和 final 关键字:增强重写的安全性
3.3.1 override:验证重写是否正确
3.3.2 final:禁止虚函数被重写
3.4 重载、重写、隐藏的区别(面试高频)
示例:三者的直观对比
四、纯虚函数和抽象类
4.1 纯虚函数的定义
4.2 抽象类的特性
4.3 抽象类的应用场景
五、多态的底层原理:虚函数表与动态绑定
5.1 虚函数表指针(__vfptr)
5.1.1 虚函数表指针的本质
5.1.2 包含虚函数的类的对象大小
5.2 虚函数表(vtable)的结构
5.2.1 基类的虚表
5.2.2 派生类的虚表
5.2.3 虚表的存储位置
5.3 动态绑定与静态绑定
5.3.1 静态绑定(Static Binding)
5.3.2 动态绑定(Dynamic Binding)
5.4 多态的性能开销
六、多态的面试高频题解析
6.1 选择题:虚函数重写与多态判断
6.2 简答题:多态的实现条件
6.3 简答题:虚函数表是什么?它存储在哪里?
6.4 简答题:为什么析构函数要声明为虚函数?
6.5 编程题:利用多态实现计算器
总结
前言
在 C++ 面向对象编程的三大核心特性(封装、继承、多态)中,多态无疑是最具灵活性和扩展性的特性。它允许不同类的对象对同一消息做出不同响应,让代码更具通用性和可维护性,是实现设计模式、框架开发的基础。很多开发者在初学多态时,往往只能掌握表面用法,对其底层原理和进阶细节理解不深。本文将从概念定义出发,逐步深入多态的实现条件、核心机制、底层原理,结合大量实战代码和面试高频考点,全面解析 C++ 多态的方方面面,帮助大家真正吃透这一核心特性。下面就让我们正式开始吧!
一、多态的概念:什么是多态?
1.1 多态的通俗理解
多态(polymorphism),字面意思是 “多种形态”。在编程语境中,指的是同一个行为(函数调用),作用于不同的对象,会产生不同的执行结果。生活中处处可见多态的影子:
- 买票行为:普通人买票全价、学生买票打折、军人买票优先,同样是 “买票” 操作,不同身份的人(不同对象)执行结果不同;
- 动物叫声:猫叫是 “喵”,狗叫是 “汪汪”,同样是 “发声” 行为,不同动物(不同对象)表现形式不同;
- 交通工具行驶:汽车在路上跑,飞机在天上飞,轮船在水里游,同样是 “移动” 行为,不同交通工具(不同对象)实现方式不同。


这种 “一个接口,多种实现” 的思想,正是多态的核心价值 —— 它屏蔽了不同对象之间的差异,让开发者可以通过统一的方式调用不同对象的方法,极大简化了代码逻辑。
1.2 多态的分类
C++ 中的多态分为两大类:编译时多态(静态多态) 和运行时多态(动态多态),二者的核心区别在于 “行为确定的时机” 不同。
1.2.1 编译时多态(静态多态)
编译时多态是指在编译阶段就确定了函数的调用关系,行为结果在编译时已经明确。它的实现方式主要有两种:
- 函数重载:同一作用域内,函数名相同但参数列表(参数类型、个数、顺序)不同的函数,编译器会根据实参类型匹配对应的函数;
- 函数模板:通过模板参数自动适配不同类型,编译时会为每种使用的类型生成对应的函数实例。
示例:函数重载实现静态多态
#include <iostream>
using namespace std;// 函数重载:参数类型不同
int Add(int a, int b) {cout << "int Add: ";return a + b;
}double Add(double a, double b) {cout << "double Add: ";return a + b;
}// 函数重载:参数个数不同
int Add(int a, int b, int c) {cout << "int Add(3 params): ";return a + b + c;
}int main() {cout << Add(1, 2) << endl; // 调用int Add(int, int)cout << Add(1.5, 2.5) << endl; // 调用double Add(double, double)cout << Add(1, 2, 3) << endl; // 调用int Add(int, int, int)return 0;
}
运行结果:
int Add: 3
double Add: 4
int Add(3 params): 6
静态多态的特点是效率高(编译时确定调用地址,无运行时开销),但灵活性差(必须在编译时明确所有可能的行为,无法适应运行时动态变化的场景)。
1.2.2 运行时多态(动态多态)
运行时多态是指在程序运行阶段才确定函数的调用关系,行为结果取决于运行时的对象类型。它是 C++ 多态的核心,也是本文重点讲解的内容。
示例:运行时多态的直观体现
#include <iostream>
using namespace std;// 基类:人
class Person {
public:// 虚函数:买票virtual void BuyTicket() {cout << "普通人买票:全价" << endl;}
};// 派生类:学生(继承自Person)
class Student : public Person {
public:// 重写基类虚函数virtual void BuyTicket() {cout << "学生买票:半价(硬座)/75折(高铁二等座)" << endl;}
};// 派生类:军人(继承自Person)
class Soldier : public Person {
public:// 重写基类虚函数virtual void BuyTicket() {cout << "军人买票:优先购票" << endl;}
};// 统一接口:调用买票行为
void DoBuyTicket(Person& people) {people.BuyTicket(); // 同一调用语句,不同对象表现不同
}int main() {Person p;Student s;Soldier sol;DoBuyTicket(p); // 输出:普通人买票:全价DoBuyTicket(s); // 输出:学生买票:半价(硬座)/75折(高铁二等座)DoBuyTicket(sol); // 输出:军人买票:优先购票return 0;
}
运行结果:
普通人买票:全价
学生买票:半价(硬座)/75折(高铁二等座)
军人买票:优先购票
在这个示例中,DoBuyTicket函数接收Person类型的引用,但传入不同的派生类对象时,会执行对应的BuyTicket方法。这种 “同一接口,多种实现” 的效果,正是运行时多态的核心体现。它的特点是灵活性高(支持动态扩展,新增派生类无需修改原有接口代码),但有轻微运行时开销(需要在运行时查找函数地址)。
二、多态的定义及实现:三大核心条件
想要实现 C++ 运行时多态,必须满足三个核心条件,缺一不可。很多开发者在使用多态时出现问题,本质上都是没有完全满足这三个条件。
2.1 核心条件一:继承关系
多态必须建立在类的继承体系之上,即存在基类(父类)和派生类(子类)的继承关系。派生类通过继承基类,获得基类的接口(虚函数),并可以根据自身需求重写该接口。
需要注意:
- 支持单一继承(一个派生类继承一个基类)和多重继承(一个派生类继承多个基类),但多重继承可能导致虚函数表复杂,需要谨慎使用;
- 派生类必须是公有继承(public inheritance),才能保证基类的指针 / 引用可以访问派生类的虚函数(私有继承或保护继承会限制访问权限)。

2.2 核心条件二:虚函数与重写
2.2.1 虚函数的定义
虚函数是多态的 “开关”,在基类的成员函数前加上virtual关键字,该函数就成为虚函数。
语法格式:
class 基类名 {
public:virtual 返回值类型 函数名(参数列表) {// 函数实现}
};
注意事项:
virtual关键字仅需在基类声明时添加,派生类重写时可加可不加,但建议加上,能够提高代码可读性;- 非成员函数(全局函数)、静态成员函数(
static修饰)、构造函数不能声明为虚函数;- 析构函数可以(且建议)声明为虚函数,这是面试高频考点,后续会详细讲解。
2.2.2 虚函数的重写(覆盖)
虚函数的重写(也叫覆盖)是指:派生类中有一个与基类虚函数完全相同的函数,即满足 “三同” 原则:
- 函数名相同;
- 参数列表(参数类型、个数、顺序)相同;
- 返回值类型相同(协变情况除外,后续讲解)。
示例:虚函数重写的正确实现
#include <iostream>
using namespace std;class Animal {
public:// 基类虚函数virtual void Talk() const {cout << "动物发出声音" << endl;}
};class Cat : public Animal {
public:// 重写基类虚函数:三同原则virtual void Talk() const {cout << "(>^ω^<)喵~" << endl;}
};class Dog : public Animal {
public:// 重写基类虚函数:三同原则(派生类可省略virtual,但不推荐)void Talk() const { // 仍构成重写,因为继承了基类虚函数属性cout << "汪汪汪!" << endl;}
};// 统一接口
void LetHear(const Animal& animal) {animal.Talk();
}int main() {Cat cat;Dog dog;LetHear(cat); // 输出:(>^ω^<)喵~LetHear(dog); // 输出:汪汪汪!return 0;
}
常见错误:不满足三同原则,导致重写失败
// 错误示例1:参数列表不同
class Animal {
public:virtual void Eat(string food) { // 参数为string类型cout << "动物吃" << food << endl;}
};class Rabbit : public Animal {
public:virtual void Eat(const char* food) { // 参数为const char*类型,不满足三同cout << "兔子吃" << food << endl;}
};// 错误示例2:返回值类型不同(非协变)
class Animal {
public:virtual int GetAge() { // 返回int类型return 0;}
};class Bird : public Animal {
public:virtual double GetAge() { // 返回double类型,不满足三同(非协变)return 1.5;}
};
在上述错误的示例中,派生类的函数与基类虚函数不满足 “三同” 原则,无法构成重写,因此也就无法实现多态。
2.3 核心条件三:基类的指针或引用调用虚函数
多态的触发必须通过基类的指针或基类的引用来调用虚函数,直接通过对象本身调用虚函数无法触发多态。
示例:不同调用方式的对比
#include <iostream>
using namespace std;class Person {
public:virtual void BuyTicket() {cout << "普通人买票:全价" << endl;}
};class Student : public Person {
public:virtual void BuyTicket() {cout << "学生买票:半价" << endl;}
};int main() {Person p;Student s;// 1. 直接通过对象调用:无多态p.BuyTicket(); // 输出:普通人买票:全价s.BuyTicket(); // 输出:学生买票:半价(这是直接调用派生类函数,非多态)// 2. 基类指针调用:触发多态Person* p1 = &p;Person* p2 = &s;p1->BuyTicket(); // 输出:普通人买票:全价p2->BuyTicket(); // 输出:学生买票:半价(多态生效)// 3. 基类引用调用:触发多态Person& r1 = p;Person& r2 = s;r1.BuyTicket(); // 输出:普通人买票:全价r2.BuyTicket(); // 输出:学生买票:半价(多态生效)return 0;
}
为什么必须用基类指针 / 引用?
- 基类指针 / 引用具有 “兼容性”:可以指向基类对象,也可以指向派生类对象(派生类对象包含基类部分);
- 直接通过对象调用时,编译器会根据对象的静态类型(编译时确定的类型)调用函数,是无法体现动态多态的;
- 通过基类指针 / 引用调用时,编译器会延迟到运行时,根据指针 / 引用实际指向的对象类型来调用对应的虚函数。
2.4 多态实现的完整示例
结合以上三个条件,下面给出一个完整的多态实现示例,涵盖继承、虚函数重写、基类指针 / 引用调用三个核心要素:
#include <iostream>
#include <string>
using namespace std;// 基类:交通工具
class Vehicle {
public:Vehicle(string name) : _name(name) {}// 虚函数:行驶virtual void Run() {cout << _name << ":正在行驶" << endl;}// 虚函数:鸣笛virtual void Honk() {cout << _name << ":鸣笛警告" << endl;}protected:string _name; // 交通工具名称
};// 派生类:汽车
class Car : public Vehicle {
public:Car(string name) : Vehicle(name) {}// 重写Run函数virtual void Run() {cout << _name << ":在公路上匀速行驶,速度60km/h" << endl;}// 重写Honk函数virtual void Honk() {cout << _name << ":嘀嘀嘀~" << endl;}
};// 派生类:飞机
class Plane : public Vehicle {
public:Plane(string name) : Vehicle(name) {}// 重写Run函数virtual void Run() {cout << _name << ":在蓝天上飞行,高度10000米" << endl;}// 重写Honk函数virtual void Honk() {cout << _name << ":呜呜呜~(航空警报)" << endl;}
};// 派生类:轮船
class Ship : public Vehicle {
public:Ship(string name) : Vehicle(name) {}// 重写Run函数virtual void Run() {cout << _name << ":在海面上航行,航向正东" << endl;}// 重写Honk函数virtual void Honk() {cout << _name << ":嘟嘟嘟~(雾笛)" << endl;}
};// 统一接口:控制交通工具运行和鸣笛
void ControlVehicle(Vehicle* vehicle) {vehicle->Run(); // 多态调用Run函数vehicle->Honk(); // 多态调用Honk函数cout << "------------------------" << endl;
}int main() {// 创建不同交通工具对象Car car("家用轿车");Plane plane("民航客机");Ship ship("远洋货轮");// 通过基类指针调用,触发多态ControlVehicle(&car);ControlVehicle(&plane);ControlVehicle(&ship);return 0;
}
运行结果如下:
家用轿车:在公路上匀速行驶,速度60km/h
家用轿车:嘀嘀嘀~
------------------------
民航客机:在蓝天上飞行,高度10000米
民航客机:呜呜呜~(航空警报)
------------------------
远洋货轮:在海面上航行,航向正东
远洋货轮:嘟嘟嘟~(雾笛)
------------------------
从运行结果可以看出,ControlVehicle函数通过基类指针Vehicle*接收不同的派生类对象,却能正确调用各自的Run和Honk方法,实现了 “一个接口,多种实现” 的多态效果。如果后续需要新增 “高铁”“自行车” 等交通工具,只需新增派生类并重写虚函数,无需修改ControlVehicle函数,这大大提高了代码的可扩展性。
三、虚函数重写的进阶细节
在实际开发和面试中,虚函数重写还有很多容易混淆的进阶细节,比如协变、析构函数重写、override 和 final 关键字等。这些细节既是重点,也是高频考点,大家也需要深入理解。
3.1 协变:返回值类型不同的特殊重写
我在前面提到,虚函数重写需要满足 “三同” 原则,其中返回值类型必须相同。但存在一种特殊情况 ——协变,允许派生类虚函数的返回值类型与基类虚函数不同。
3.1.1 协变的定义
协变是指:基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用(派生类是基类的子类)。这种情况下,即使返回值类型不同,也构成虚函数重写。
示例:协变的实现
#include <iostream>
using namespace std;// 基类:A
class A {};// 派生类:B(继承自A)
class B : public A {};// 基类:Person
class Person {
public:// 基类虚函数:返回A*virtual A* BuyTicket() {cout << "普通人买票:全价" << endl;return new A();}
};// 派生类:Student
class Student : public Person {
public:// 派生类虚函数:返回B*(B是A的子类),构成协变virtual B* BuyTicket() {cout << "学生买票:半价" << endl;return new B();}
};int main() {Person p;Student s;Person* p1 = &p;Person* p2 = &s;delete p1->BuyTicket(); // 输出:普通人买票:全价delete p2->BuyTicket(); // 输出:学生买票:半价(多态生效)return 0;
}
协变的注意事项
- 协变仅支持返回值为 “指针” 或 “引用” 的情况,返回值为普通对象时不支持;
- 派生类虚函数的返回值类型必须是基类虚函数返回值类型的子类(即存在继承关系);
- 协变的实际应用场景较少,主要用于一些特殊的设计模式(如工厂模式),了解即可。
3.2 析构函数的重写:避免内存泄漏
析构函数的重写是面试中最高频的考点之一。很多开发者在使用多态时,容易忽略析构函数的虚函数声明,导致派生类对象的资源无法释放,引发内存泄漏。
下面通过一个问题场景来引入对析构函数重写的介绍——未声明虚析构函数:
#include <iostream>
using namespace std;class Base {
public:Base() {cout << "Base::构造函数" << endl;_p = new int[10]; // 动态分配内存}// 非虚析构函数~Base() {cout << "Base::析构函数" << endl;delete[] _p; // 释放内存}private:int* _p;
};class Derive : public Base {
public:Derive() {cout << "Derive::构造函数" << endl;_q = new char[100]; // 动态分配内存}~Derive() {cout << "Derive::析构函数" << endl;delete[] _q; // 释放内存}private:char* _q;
};int main() {Base* p = new Derive(); // 基类指针指向派生类对象delete p; // 释放对象return 0;
}
运行结果:
Base::构造函数
Derive::构造函数
Base::析构函数
问题分析:
delete p时,由于基类Base的析构函数不是虚函数,编译器根据基类指针的静态类型(Base*)调用基类的析构函数;- 派生类
Derive的析构函数没有被调用,导致_q指向的内存无法释放,引发内存泄漏。
解决方案:声明虚析构函数
将基类的析构函数声明为虚函数,派生类的析构函数会自动构成重写,从而触发多态析构。
#include <iostream>
using namespace std;class Base {
public:Base() {cout << "Base::构造函数" << endl;_p = new int[10];}// 虚析构函数virtual ~Base() {cout << "Base::析构函数" << endl;delete[] _p;}private:int* _p;
};class Derive : public Base {
public:Derive() {cout << "Derive::构造函数" << endl;_q = new char[100];}// 派生类析构函数:自动重写基类虚析构~Derive() {cout << "Derive::析构函数" << endl;delete[] _q;}private:char* _q;
};int main() {Base* p = new Derive();delete p;return 0;
}
运行结果:
Base::构造函数
Derive::构造函数
Derive::析构函数
Base::析构函数
原理说明:
- 编译器会对析构函数的名称做特殊处理,编译后所有析构函数的名称统一为
destructor;- 基类析构函数声明为
virtual后,派生类析构函数会自动重写该虚函数;delete p时,通过基类指针调用虚析构函数,触发多态,先调用派生类析构函数(释放派生类资源),再调用基类析构函数(释放基类资源),避免内存泄漏。
因此,只要类可能被继承,并且可能通过基类指针删除派生类对象,就必须将基类的析构函数声明为虚函数;若类不会被继承,或不会通过基类指针删除派生类对象,可不用声明虚析构函数(因为虚函数会增加对象内存开销)。
3.3 override 和 final 关键字:增强重写的安全性
C++11 引入了override和final两个关键字,用于解决虚函数重写中的 “隐式错误”,增强代码的可读性和安全性。
3.3.1 override:验证重写是否正确
override关键字用于派生类的虚函数,表示该函数意图重写基类的虚函数。编译器会检查是否满足重写条件,若不满足则编译报错。
示例:override 的使用
#include <iostream>
using namespace std;class Car {
public:// 基类虚函数:注意函数名是Drive(拼写正确)virtual void Drive() {cout << "汽车:基本行驶功能" << endl;}
};class Benz : public Car {
public:// 意图重写Drive函数,但拼写错误(Dirve)virtual void Dirve() override { // 编译报错:没有重写任何基类方法cout << "奔驰:舒适行驶" << endl;}
};int main() {Benz benz;return 0;
}
VS 编译器下的编译错误信息:
error C3668: “Benz::Dirve”: 包含重写说明符“override”的方法没有重写任何基类方法
作用:
- 强制编译器验证重写条件(三同原则),避免因拼写错误、参数不匹配等导致的重写失败;
- 明确告知其他开发者该函数是重写基类的虚函数,提高代码可读性。
3.3.2 final:禁止虚函数被重写
final关键字用于基类的虚函数,表示该函数禁止被任何派生类重写;也可用于类,表示该类禁止被继承。
示例 1:禁止虚函数被重写
#include <iostream>
using namespace std;class Car {
public:// final修饰虚函数:禁止派生类重写virtual void Drive() final {cout << "汽车:基本行驶功能" << endl;}
};class BMW : public Car {
public:// 试图重写被final修饰的函数,编译报错virtual void Drive() {cout << "宝马:操控性行驶" << endl;}
};int main() {BMW bmw;return 0;
}
VS 编译器下的编译错误信息:
error C3248: “Car::Drive”: 声明为“final”的函数无法被“BMW::Drive”重写
示例 2:禁止类被继承
#include <iostream>
using namespace std;// final修饰类:禁止被继承
class Car final {
public:virtual void Drive() {cout << "汽车:基本行驶功能" << endl;}
};// 试图继承被final修饰的类,编译报错
class Audi : public Car {
public:virtual void Drive() {cout << "奥迪:科技感行驶" << endl;}
};int main() {Audi audi;return 0;
}
VS 编译器下的编译错误信息:
error C3246: “Audi”: 无法从“Car”继承,因为它已被声明为“final”
作用:
- 限制虚函数的重写或类的继承,避免不必要的扩展,保证代码的稳定性;
- 明确告知其他开发者该函数 / 类不允许被修改,提高代码可维护性。
3.4 重载、重写、隐藏的区别(面试高频)
C++ 中函数的重载、重写、隐藏是三个容易混淆的概念,面试中经常会以选择题或简答题的形式考察。通过下面这张图来详细对比三者的区别:
示例:三者的直观对比
#include <iostream>
using namespace std;class Base {
public:// 1. 重载:同一作用域,参数列表不同void Func(int a) {cout << "Base::Func(int): " << a << endl;}void Func(double a) {cout << "Base::Func(double): " << a << endl;}// 虚函数:用于重写virtual void Show() {cout << "Base::Show()" << endl;}// 普通函数:用于隐藏void Display() {cout << "Base::Display()" << endl;}
};class Derive : public Base {
public:// 2. 重写:基类虚函数,三同原则virtual void Show() {cout << "Derive::Show()" << endl;}// 3. 隐藏:与基类同名,不满足重写条件void Display() {cout << "Derive::Display()" << endl;}// 隐藏:与基类Func同名,但参数列表不同(非重载,因为作用域不同)void Func(const char* str) {cout << "Derive::Func(const char*): " << str << endl;}
};int main() {Derive d;// 测试重载:调用Base类的重载函数d.Func(10); // 输出:Base::Func(int): 10d.Func(3.14); // 输出:Base::Func(double): 3.14// 测试隐藏:调用Derive类的Func(隐藏基类Func)d.Func("hello"); // 输出:Derive::Func(const char*): hello// 测试重写:基类指针指向派生类,多态调用Base* p = &d;p->Show(); // 输出:Derive::Show()(重写,多态)// 测试隐藏:基类指针调用基类Display,派生类对象调用派生类Displayp->Display(); // 输出:Base::Display()(隐藏,静态绑定)d.Display(); // 输出:Derive::Display()(隐藏,静态绑定)return 0;
}
运行结果:
Base::Func(int): 10
Base::Func(double): 3.14
Derive::Func(const char*): hello
Derive::Show()
Base::Display()
Derive::Display()
结论:
- 重载看 “同一作用域 + 参数不同”;
- 重写看 “不同作用域 + 三同 + 虚函数”;
- 隐藏看 “不同作用域 + 同名 + 非重写”;
- 只有重写能触发多态,重载和隐藏都是静态绑定。
四、纯虚函数和抽象类
在实际开发中,有些基类只需要定义接口,不需要实现具体功能,具体功能由派生类实现。这时就需要用到纯虚函数和抽象类。
4.1 纯虚函数的定义
纯虚函数是指在基类中声明的、没有具体实现的虚函数,语法格式为在虚函数声明后加上=0。
语法格式:
class 基类名 {
public:virtual 返回值类型 函数名(参数列表) = 0; // 纯虚函数
};
需要注意以下两点:
- 纯虚函数不需要实现(语法上允许提供实现,但无实际意义);
- 包含纯虚函数的类称为抽象类。
4.2 抽象类的特性
抽象类是一种特殊的类,具有以下核心特性:
- 抽象类不能实例化对象(编译报错);
- 派生类必须重写抽象类中的所有纯虚函数,否则派生类也会成为抽象类,无法实例化;
- 抽象类可以定义普通成员函数和成员变量;
- 抽象类的指针 / 引用可以指向其派生类对象(用于实现多态)。
示例:纯虚函数和抽象类的使用
#include <iostream>
#include <string>
using namespace std;// 抽象类:形状(包含纯虚函数)
class Shape {
public:Shape(string name) : _name(name) {}// 纯虚函数:计算面积(仅声明,无实现)virtual double CalculateArea() = 0;// 纯虚函数:计算周长(仅声明,无实现)virtual double CalculatePerimeter() = 0;// 普通成员函数:显示形状名称void ShowName() {cout << "形状:" << _name << endl;}protected:string _name;
};// 派生类:圆形(重写所有纯虚函数)
class Circle : public Shape {
public:Circle(string name, double radius) : Shape(name), _radius(radius) {}// 重写纯虚函数:计算面积(圆面积=πr²)virtual double CalculateArea() {return 3.14159 * _radius * _radius;}// 重写纯虚函数:计算周长(圆周长=2πr)virtual double CalculatePerimeter() {return 2 * 3.14159 * _radius;}private:double _radius; // 半径
};// 派生类:矩形(重写所有纯虚函数)
class Rectangle : public Shape {
public:Rectangle(string name, double length, double width) : Shape(name), _length(length), _width(width) {}// 重写纯虚函数:计算面积(矩形面积=长×宽)virtual double CalculateArea() {return _length * _width;}// 重写纯虚函数:计算周长(矩形周长=2×(长+宽))virtual double CalculatePerimeter() {return 2 * (_length + _width);}private:double _length; // 长double _width; // 宽
};// 派生类:三角形(未重写所有纯虚函数,仍为抽象类)
class Triangle : public Shape {
public:Triangle(string name, double a, double b, double c) : Shape(name), _a(a), _b(b), _c(c) {}// 仅重写一个纯虚函数,另一个未重写virtual double CalculatePerimeter() {return _a + _b + _c;}private:double _a, _b, _c; // 三边长
};// 统一接口:计算并显示形状的面积和周长
void ShowShapeInfo(Shape* shape) {shape->ShowName();cout << "面积:" << shape->CalculateArea() << endl;cout << "周长:" << shape->CalculatePerimeter() << endl;cout << "------------------------" << endl;
}int main() {// 1. 抽象类不能实例化对象(编译报错)// Shape shape("未知形状"); // 2. 派生类(重写所有纯虚函数)可以实例化Circle circle("圆形", 5.0);Rectangle rect("矩形", 4.0, 6.0);// 3. 未重写所有纯虚函数的派生类不能实例化(编译报错)// Triangle tri("三角形", 3.0, 4.0, 5.0);// 4. 抽象类指针指向派生类对象,实现多态ShowShapeInfo(&circle);ShowShapeInfo(&rect);return 0;
}
运行结果:
形状:圆形
面积:78.5397
周长:31.4159
------------------------
形状:矩形
面积:24
周长:20
------------------------
4.3 抽象类的应用场景
抽象类的核心价值是定义统一接口,强制派生类实现特定功能,常见应用场景包括:
- 框架开发:定义框架的核心接口,具体实现由用户自定义的派生类完成;
- 设计模式:如工厂模式、策略模式等,通过抽象类定义产品或策略的接口;
- 团队协作:统一代码规范,确保不同开发者实现的派生类都包含必要的功能接口。
例如,在图形处理软件中,抽象类Shape定义了所有图形必须具备的 “计算面积” 和 “计算周长” 接口,后续新增 “正方形”“椭圆形” 等图形时,只需继承Shape并重写纯虚函数,即可无缝集成到现有代码中,无需修改原有接口逻辑。
五、多态的底层原理:虚函数表与动态绑定
很多人在使用多态的时候,只知道 “怎么用”,却不知道 “为什么能这样用”。理解多态的底层原理,不仅能帮助我们更灵活地使用多态,也是面试中的核心考点(比如 “虚函数表是什么?”“动态绑定的过程是怎样的?”等)。
5.1 虚函数表指针(__vfptr)
在 C++ 中,当一个类包含虚函数时,编译器会为该类的每个对象添加一个隐藏的成员变量 ——虚函数表指针(virtual function table pointer),简写为__vfptr。
5.1.1 虚函数表指针的本质
__vfptr是一个指针,指向一个存储虚函数地址的数组,这个数组称为虚函数表(virtual function table),简称虚表(vtable);- 一个类的所有对象共用同一张虚表(虚表存储在代码段 / 常量区,而非对象中);
- 虚函数表指针的大小为 4 字节(32 位系统)或 8 字节(64 位系统),独立于类的其他成员变量。
5.1.2 包含虚函数的类的对象大小
下面通过示例验证包含虚函数的类的对象大小(以 32 位系统为例):
#include <iostream>
using namespace std;// 普通类(无虚函数)
class Base1 {
public:void Func() {}
private:int _a = 1;char _ch = 'x';
};// 包含虚函数的类
class Base2 {
public:virtual void Func() {}
private:int _a = 1;char _ch = 'x';
};int main() {cout << "Base1对象大小:" << sizeof(Base1) << endl; // 输出:8(int占4字节,char占1字节,内存对齐为8字节)cout << "Base2对象大小:" << sizeof(Base2) << endl; // 输出:12(4字节__vfptr + 4字节int + 1字节char,内存对齐为12字节)return 0;
}
运行结果(32 位系统):
Base1对象大小:8
Base2对象大小:12
分析:
Base1无虚函数,对象大小由成员变量决定(int 4 字节 + char 1 字节,内存对齐为 8 字节);Base2包含虚函数,对象大小 = 虚函数表指针(4 字节) + 成员变量大小(8 字节),内存对齐后为 12 字节。
这证明了包含虚函数的类的对象中,确实存在一个隐藏的虚函数表指针。
5.2 虚函数表(vtable)的结构
虚函数表是一个存储虚函数地址的数组,其结构取决于类的继承关系和虚函数重写情况。
5.2.1 基类的虚表
基类包含虚函数时,编译器会为其生成一张虚表,虚表中存储基类所有虚函数的地址。
示例:基类虚表结构
#include <iostream>
using namespace std;class Base {
public:virtual void Func1() { cout << "Base::Func1" << endl; }virtual void Func2() { cout << "Base::Func2" << endl; }
private:int _a = 1;
};int main() {Base b;// 通过内存查看虚表结构(32位系统)// b的内存布局:__vfptr(4字节) + _a(4字节)// __vfptr指向虚表,虚表中存储Func1和Func2的地址,末尾可能有nullptr标记(编译器相关)return 0;
}
基类虚表的结构示意如下:
Base的虚表(vtable for Base):
[0] → &Base::Func1
[1] → &Base::Func2
[2] → nullptr(VS编译器标记,g++无此标记)
5.2.2 派生类的虚表
派生类继承基类后,会继承基类的虚表指针和虚表。当派生类重写基类的虚函数时,会将虚表中对应基类虚函数的地址替换为派生类重写函数的地址;同时,派生类新增的虚函数会被添加到虚表的末尾。
示例:派生类虚表结构
#include <iostream>
using namespace std;class Base {
public:virtual void Func1() { cout << "Base::Func1" << endl; }virtual void Func2() { cout << "Base::Func2" << endl; }
private:int _a = 1;
};class Derive : public Base {
public:// 重写基类Func1virtual void Func1() { cout << "Derive::Func1" << endl; }// 新增虚函数Func3virtual void Func3() { cout << "Derive::Func3" << endl; }
private:int _b = 2;
};int main() {Derive d;// d的内存布局:__vfptr(4字节) + 继承的_a(4字节) + 自身的_b(4字节)// __vfptr指向派生类的虚表,虚表中Func1被替换为Derive::Func1,新增Func3return 0;
}
派生类虚表的结构示意如下:
Derive的虚表(vtable for Derive):
[0] → &Derive::Func1(重写,替换基类Func1地址)
[1] → &Base::Func2(未重写,继承基类Func2地址)
[2] → &Derive::Func3(新增,添加到虚表末尾)
[3] → nullptr(VS编译器标记)
5.2.3 虚表的存储位置
虚函数表存储在代码段(常量区),而非堆或栈中。可以通过以下代码验证:
#include <iostream>
using namespace std;class Base {
public:virtual void Func1() {}
};class Derive : public Base {
public:virtual void Func1() {}
};int main() {// 栈:局部变量int i = 0;// 静态区:静态变量static int j = 1;// 堆:动态分配内存int* pHeap = new int;// 常量区:字符串常量const char* pConst = "hello";Base b;Derive d;Base* pBase = &b;Derive* pDerive = &d;// 输出各区域地址cout << "栈地址:" << &i << endl;cout << "静态区地址:" << &j << endl;cout << "堆地址:" << pHeap << endl;cout << "常量区地址:" << (void*)pConst << endl;cout << "Base虚表地址:" << *(int*)pBase << endl; // 虚表指针指向的地址即虚表地址cout << "Derive虚表地址:" << *(int*)pDerive << endl;delete pHeap;return 0;
}
运行结果(32 位系统):
栈地址:0x0019FF3C
静态区地址:0x0041D000
堆地址:0x0042D740
常量区地址:0x0041ABA4
Base虚表地址:0x0041AB44
Derive虚表地址:0x0041AB84
我们也可以通过VS中的监视窗口和内存窗口进行观察:


分析:
- 虚表地址(0x0041AB44、0x0041AB84)与常量区地址(0x0041ABA4)非常接近,说明虚表存储在代码段 / 常量区;
- 栈、堆、静态区的地址与虚表地址差异较大,进一步验证了虚表的存储位置。
5.3 动态绑定与静态绑定
多态的实现本质是动态绑定,而普通函数调用是静态绑定。二者的核心区别在于函数地址的确定时机。
5.3.1 静态绑定(Static Binding)
静态绑定是指在编译阶段就确定函数的调用地址,直接生成函数调用指令。
适用场景:
- 普通函数调用;
- 静态成员函数调用;
- 通过对象直接调用虚函数(非基类指针 / 引用);
- 不满足多态条件的函数调用。
示例:静态绑定的汇编代码分析
#include <iostream>
using namespace std;class Base {
public:void Func() { cout << "Base::Func" << endl; } // 普通函数
};int main() {Base b;b.Func(); // 静态绑定return 0;
}
对应的汇编代码(VS 编译器,32 位)如下:
0041141E lea ecx,[b]
00411421 call Base::Func (04110ACh) // 直接调用Func的地址04110AC
分析:编译时直接确定Func的地址(04110AC),生成call指令调用该地址,无运行时开销。
5.3.2 动态绑定(Dynamic Binding)
动态绑定是指在运行阶段通过虚函数表指针查找虚函数地址,再调用函数。
适用场景:通过基类指针 / 引用调用虚函数(满足多态条件)。
示例:动态绑定的汇编代码分析
#include <iostream>
using namespace std;class Base {
public:virtual void Func() { cout << "Base::Func" << endl; } // 虚函数
};class Derive : public Base {
public:virtual void Func() { cout << "Derive::Func" << endl; } // 重写虚函数
};void CallFunc(Base* p) {p->Func(); // 动态绑定
}int main() {Derive d;CallFunc(&d);return 0;
}
对应的汇编代码(VS 编译器,32 位)如下:
void CallFunc(Base* p) {
00411450 push ebp
00411451 mov ebp,esp
00411453 push esi
00411454 mov esi,dword ptr [p] // esi = p(基类指针)p->Func();
00411457 mov eax,dword ptr [esi] // eax = p->__vfptr(虚表指针)
00411459 mov edx,dword ptr [eax] // edx = 虚表[0](Func的地址)
0041145B mov ecx,dword ptr [p] // ecx = p(this指针)
0041145E call edx // 调用edx中的地址(Derive::Func)
00411460 pop esi
00411461 pop ebp
00411462 ret
}
动态绑定的过程如下:
- 取出基类指针
p指向的对象中的虚表指针__vfptr(eax = *p);- 从虚表中取出对应的虚函数地址(
edx = *eax,即虚表第 0 个元素);- 调用该地址对应的函数(
call edx);- 由于
p指向Derive对象,虚表中存储的是Derive::Func的地址,因此最终调用Derive::Func。
这就是多态的底层实现原理:通过虚函数表指针和虚表,在运行时动态查找函数地址,实现 “同一接口,多种实现”。
5.4 多态的性能开销
多态的灵活性是以轻微的性能开销为代价的,主要体现在两个方面:
- 内存开销:包含虚函数的类的每个对象都会增加一个虚函数表指针(4/8 字节);
- 时间开销:动态绑定需要在运行时查找虚表,比静态绑定多两次指针间接访问(
__vfptr→ 虚表 → 虚函数地址)。
但在大多数场景下,这种开销是可以忽略不计的。只有在对性能要求极高的核心模块(如高频调用的函数),才需要考虑是否使用多态。
六、多态的面试高频题解析
多态是 C++ 面试的重中之重,以下整理了常见的面试题及详细解析,帮助读者应对面试。
6.1 选择题:虚函数重写与多态判断
题目:以下程序的输出结果是什么?( )
#include <iostream>
using namespace std;class A {
public:virtual void func(int val = 1) { cout << "A->" << val << endl; }virtual void test() { func(); }
};class B : public A {
public:void func(int val = 0) { cout << "B->" << val << endl; }
};int main() {B* p = new B;p->test();delete p;return 0;
}
选项:A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确
解析:
A::func是虚函数,B::func与A::func满足三同原则(函数名、参数列表、返回值相同),构成重写;p是B*类型,调用test()函数(继承自A),test()中调用func();func()是虚函数,通过this指针(B*类型)调用,触发多态,调用B::func;- 虚函数的默认参数是由基类函数声明决定的(静态绑定),而非派生类。
A::func的默认参数是 1,因此val的值为 1;- 最终输出
B->1。
因此最终答案为:B
6.2 简答题:多态的实现条件
题目:C++ 中运行时多态的实现需要满足哪些条件?
解析:
- 存在继承关系(基类和派生类,公有继承);
- 基类声明虚函数(
virtual修饰);- 派生类重写基类的虚函数(满足三同原则,协变除外);
- 通过基类的指针或引用调用虚函数。
6.3 简答题:虚函数表是什么?它存储在哪里?
题目:什么是虚函数表?虚函数表存储在内存的哪个区域?
解析:
- 虚函数表(vtable)是一个存储虚函数地址的指针数组,由编译器自动生成;
- 包含虚函数的类的每个对象都会有一个虚函数表指针(
__vfptr),指向该类的虚表;- 虚函数表存储在代码段(常量区),而非堆、栈或静态区;
- 同一类的所有对象共用同一张虚表,不同类(基类和派生类)有各自独立的虚表。
6.4 简答题:为什么析构函数要声明为虚函数?
题目:为什么基类的析构函数建议声明为虚函数?如果不声明会有什么问题?
解析:
- 目的:避免内存泄漏。当通过基类指针删除派生类对象时,确保派生类的析构函数被调用;
- 问题:若基类析构函数不是虚函数,删除基类指针指向的派生类对象时,编译器会根据基类指针的静态类型调用基类析构函数,派生类析构函数不会被调用,导致派生类中动态分配的资源无法释放,引发内存泄漏;
- 原理:基类析构函数声明为虚函数后,派生类析构函数会自动重写该虚函数(编译器统一处理析构函数名称),删除对象时触发多态,先调用派生类析构函数,再调用基类析构函数。
6.5 编程题:利用多态实现计算器
题目:利用 C++ 多态实现一个计算器,支持加法、减法、乘法、除法运算,要求可以灵活扩展新的运算(如取模、平方)。
解析:
- 定义抽象基类
Calculator,声明纯虚函数Calculate(统一接口);- 定义派生类
Add、Subtract、Multiply、Divide,分别重写Calculate函数,实现对应运算;- 新增运算时,只需新增派生类并重写
Calculate,无需修改原有代码。
实现代码:
#include <iostream>
#include <stdexcept>
using namespace std;// 抽象基类:计算器
class Calculator {
public:Calculator(double a, double b) : _a(a), _b(b) {}virtual ~Calculator() {}// 纯虚函数:计算接口virtual double Calculate() const = 0;// 获取操作数double GetA() const { return _a; }double GetB() const { return _b; }protected:double _a; // 操作数1double _b; // 操作数2
};// 派生类:加法
class Add : public Calculator {
public:Add(double a, double b) : Calculator(a, b) {}virtual double Calculate() const {return GetA() + GetB();}
};// 派生类:减法
class Subtract : public Calculator {
public:Subtract(double a, double b) : Calculator(a, b) {}virtual double Calculate() const {return GetA() - GetB();}
};// 派生类:乘法
class Multiply : public Calculator {
public:Multiply(double a, double b) : Calculator(a, b) {}virtual double Calculate() const {return GetA() * GetB();}
};// 派生类:除法
class Divide : public Calculator {
public:Divide(double a, double b) : Calculator(a, b) {if (b == 0) {throw invalid_argument("除数不能为0");}}virtual double Calculate() const {return GetA() / GetB();}
};// 派生类:取模(新增运算,无需修改原有代码)
class Mod : public Calculator {
public:Mod(int a, int b) : Calculator(a, b) {if (b == 0) {throw invalid_argument("模数不能为0");}}virtual double Calculate() const {return static_cast<int>(GetA()) % static_cast<int>(GetB());}
};// 统一接口:执行计算并输出结果
void DoCalculate(const Calculator& calc, const string& opName) {cout << calc.GetA() << " " << opName << " " << calc.GetB() << " = " << calc.Calculate() << endl;
}int main() {try {Add add(10, 5);Subtract sub(10, 5);Multiply mul(10, 5);Divide div(10, 5);Mod mod(10, 3);DoCalculate(add, "+"); // 输出:10 + 5 = 15DoCalculate(sub, "-"); // 输出:10 - 5 = 5DoCalculate(mul, "*"); // 输出:10 * 5 = 50DoCalculate(div, "/"); // 输出:10 / 5 = 2DoCalculate(mod, "%"); // 输出:10 % 3 = 1// 测试除数为0的异常// Divide div2(10, 0);} catch (const exception& e) {cout << "错误:" << e.what() << endl;}return 0;
}
运行结果:
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
10 / 5 = 2
10 % 3 = 1
总结
多态是 C++ 面向对象编程的灵魂,掌握多态的概念、实现和原理,不仅能写出更灵活、可维护的代码,也是成为高级 C++ 开发者的必备技能。建议大家结合本文的示例代码反复练习,深入理解每个细节,尤其是虚函数表和动态绑定的底层逻辑,应对面试时才能游刃有余。我们下期再见!
