C++ 多态完全指南:同一个接口,千变万化的行为
多态是面向对象编程的三大支柱中最具魔力的一环。它让"同一个接口,表现不同的行为"成为可能,是框架设计、插件系统、设计模式的基石。
我们知道,封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类)。它们的目的都是为了代码重用。而多态除了代码的复用性外,还可以解决项目中紧偶合的问题,提高程序的可扩展性。
如果项目耦合度很高的情况下,维护代码时修改一个地方会牵连到很多地方,会无休止的增加开发成本。而降低耦合度,可以保证程序的扩展性。而多态对代码具有很好的可扩充性。增加新的子类不影响已存在类的多态性、继承性,以及其他特性的运行和操作。实际上新加子类更容易获得多态功能。例如,在实现了圆锥、半圆锥以及半球体的多态基础上,很容易增添球体类的多态性。
但多态不仅仅是 virtual 函数。C++ 中有四种多态形式,每一种都值得深入理解。
1. 什么是多态?一个直观的例子
cpp
// 没有多态的世界:针对每种形状写不同的函数
void drawCircle(const Circle& c);
void drawRectangle(const Rectangle& r);
void drawTriangle(const Triangle& t);
// 有多态的世界:一个接口处理所有形状
void draw(const Shape& s); // s.draw() 会调用正确的版本
std::vector<Shape*> shapes = {new Circle(), new Rectangle(), new Triangle()};
for (auto* s : shapes) {
s->draw(); // 同一个调用,不同的行为
s->area(); // 同一个调用,不同的计算结果
}
多态的本质 :通过一个统一的接口(基类指针/引用),调用出不同派生类的行为。
C++支持两种多态性:编译时多态和运行时多态。
编译时多态 :也称为静态多态,我们之前学习过的函数重载、运算符重载就是采用的静态多态,C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数,又称为静态联编。
运行时多态:在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供这么一套称为"动态联编"(dynamic binding)的机制,也叫动态联编。C++通过虚函数来实现动态联编。接下来,我们提到的多态,不做特殊说明,指的就是动态多态。
2. C++ 的四种多态
C++ 支持的多态比多数语言更丰富,可以分为四类:
| 多态类型 | 绑定时机 | 实现机制 | 典型场景 |
|---|---|---|---|
| 函数重载 | 编译时 | 名称修饰 | 同名函数,不同参数 |
| 模板多态 | 编译时 | 模板实例化 | 泛型容器、算法 |
| 强制多态 | 编译时/运行时 | 隐式类型转换 | int→double,派生类→基类 |
| 子类型多态(动态多态) | 运行时 | 虚函数 + 虚表 | 继承体系,多态调用 |
我们通常说的"多态",指的是第四种------动态多态。但了解全部四种才能完整理解 C++ 的多态体系。
2.1 函数重载:静态多态的基础
cpp
void print(int x) { std::cout << "int: " << x << "\n"; }
void print(double x) { std::cout << "double: " << x << "\n"; }
void print(const std::string& s) { std::cout << "string: " << s << "\n"; }
print(42); // int: 42
print(3.14); // double: 3.14
print("hello"); // string: hello
编译时根据参数类型和数量决定调用哪个版本。
2.2 模板多态:编译时泛化
cpp
template<typename T>
T max(T a, T b) {
return a > b ? a : b;
}
max(3, 5); // 生成 int 版本
max(3.14, 2.71); // 生成 double 版本
max("abc", "xyz"); // 生成 const char* 版本
模板是编译时多态 :编译时为每种类型生成一份代码,零运行时开销。也被称为静态多态 或参数化多态。
2.3 强制多态:类型转换
cpp
void func(double x) { std::cout << x << "\n"; }
int a = 42;
func(a); // int 隐式转换为 double
class Base {};
class Derived : public Base {};
Derived d;
Base* p = &d; // 派生类指针隐式转换为基类指针(上行转换)
2.4 子类型多态(动态多态):本篇核心
虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?其实激活条件还是比较严格的,需要满足以下全部要求:
- 基类定义虚函数
- 派生类中要覆盖虚函数 (覆盖的是虚函数表中的地址信息)
- 创建派生类对象
- 基类的指针指向派生类对象(或基类引用绑定派生类对象)
- 通过基类指针(引用)调用虚函数最终的效果:基类指针调用到了派生类实现的虚函数。
分析如下示例代码:
cpp
class Animal {
public:
virtual void speak() const { std::cout << "Animal sound\n"; }
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override { std::cout << "Woof!\n"; }
};
class Cat : public Animal {
public:
void speak() const override { std::cout << "Meow!\n"; }
};
void makeSound(const Animal& a) {
a.speak(); // 运行时决定调用哪个 speak
}
Dog dog;
Cat cat;
makeSound(dog); // Woof!
makeSound(cat); // Meow!
这就是最核心的多态 。a.speak() 这一行代码,具体执行哪个函数由运行时 a 实际引用的对象类型决定。
补充:虚函数------实现多态的核心
虚函数 = 允许子类重写,实现运行时多态
虚函数的定义在一个成员函数的前面加上virtual关键字,该函数就成为虚函数看这样一个例子:基类和派生类中定义了同名的display函数。
代表含义:
- 基类声明虚函数,派生类可以重写 (override) 这个函数
- 调用时看对象真实类型,不看指针 / 引用类型
- 触发动态绑定(晚绑定):程序运行时才确定调用哪个版本
- 类内部自动生成虚函数表 (vtable),存所有虚函数地址
- 有虚函数的类不能直接当成纯数据结构体,内存多一个虚表指针
核心作用
-
父类指针 / 引用 指向子类对象,自动调用子类重写的函数
-
实现多态:一个接口,多种实现
3. 动态多态的实现原理:虚函数表
观察如下代码的输出:
cpp
class Base{
public:
Base(long x): _base(x){}
void display() const{
cout << "Base::display()" << endl;
}
private:
long _base;
};
class Derived: public Base{
public:
Derived(long base,long derived): Base(base)//创建基类子对象
, _derived(derived){}
void display() const{
cout << "Derived::display()" << endl;
}
private:
long _derived;
};
void print(Base * pbase){
pbase->display();
}
void test0(){
Base base(10);
Derived dd(1,2);
print(&base);
cout << endl;//用一个基类指针指向派生类对象
//能够操纵的只有基类部分
print(&dd);
}
cout << "sizeof(Base):" << sizeof(Base) << endl;
cout << "sizeof(Derived):" << sizeof(Derived) << endl;
得到的结果

------给Base中的display函数加上virtual关键字修饰,得到的结果

从运行结果中我们发现,virtual关键字加入后,发生了一件"奇怪"的事情 ------ 用基类指针指向派生类对象后,通过这个基类对象竟然可以调用派生类的成员函数。
而且,基类和派生类对象所占空间的大小都改变了,说明其内存结构发生了变化。
内存结构如下所示:

虚函数的底层是通过虚函数表实现的。当类中定义了虚函数之后,就会在对象的存储开始位置,多一个虚函数指针,该虚函数指针指向一张虚函数表,虚函数表中存储的是虚函数入口地址。
虚函数指针
当Base的display函数加上了virtual关键字,变成了一个虚函数,Base对象的存储布局就改变了。在存储的开始位置会多加一个虚函数指针,该虚函数指针指向一张虚函数表(简称虚表),其中存放的是虚函数的入口地址Derived继承了Base类,那么创建一个Derived对象,依然会创建出一个Base类的基类子对象

在Derived类中又定义了display函数,发生了覆盖的机制(override),覆盖的是虚函数表中虚函数的入口地址

Base* p 去指向Derived对象,依然只能访问到基类的部分。用指针p去调用display函数,发现是一个虚函数,那么会通过vfptr找到虚表,此时虚表中存放的是Derived::display的入口地址,所以调用到Derived的display函数。
问题 :
对虚函数和虚函数表有了基本认知后,我们可以思考这样几个问题(面试常考题)
1、虚表存放在哪里?
编译完成时,虚表应该已经存在;在使用的过程中,虚函数表不应该被修改掉(如果能修改,将会找不到对应的虚函数)------应该存在只读段 ------具体位置不同厂家有不同实现。
2、一个类中虚函数表有几张?
虚函数表(虚表)可以理解为是一个数组,存放的是一个个虚函数的地址一个类可以没有虚函数表(没有虚函数就没有虚函数表);
可以有一张虚函数表(即使这个类有多个虚函数,将这些虚函数的地址都存在虚函数表中);也可以有多张虚函数表(继承多个有虚函数的基类)

内存布局概念图:
Base 对象: Base 虚表:
┌──────────┐ ┌───────────────┐
│ vptr │──→ vtable │ &Base::f │
├──────────┤ ┌───────┐ │ &Base::g │
│ baseData │ │ 槽0: ─│──→│ &Base::~Base │
└──────────┘ │ 槽1: ─│ └───────────────┘
│ 槽2: ─│
└───────┘
Derived 对象: Derived 虚表:
┌──────────┐ ┌───────────────┐
│ vptr │──→ vtable │ &Derived::f │(覆盖了)
├──────────┤ ┌───────┐ │ &Base::g │(继承了)
│ baseData │ │ 槽0: ─│──→│ &Base::~Derived│(覆盖了)
├──────────┤ │ 槽1: ─│ │ &Derived::h │(新增的)
│derivedData│ │ 槽2: ─│ └───────────────┘
└──────────┘ │ 槽3: ─│
└───────┘
虚函数的覆盖
如果一个基类的成员函数定义为虚函数,那么它在所有派生类中也保持为虚函数,即使在派生类中省略了virtual关键字,也仍然是虚函数。虚函数一般用于灵活拓展,所以需要派生类中对此虚函数进行覆盖。覆盖的格式有一定的要求:
- 与基类的虚函数有相同的函数名;
- 与基类的虚函数有相同的参数个数;
- 与基类的虚函数有相同的参数类型;
- 与基类的虚函数有相同的返回类型。
我们在派生类中对虚函数进行覆盖时,很有可能写错函数的形式(函数名、返回类型、参数个数),等到要使用时才发现没有完成覆盖。这种错误很难发现,所以C++提供了关键字override来解决这一问题。
关键字override的作用 :
在虚函数的函数参数列表之后,函数体的大括号之前,加上override关键字,告诉编译器此处定义的函数是要对基类的虚函数进行覆盖。
cpp
void display() const override
{
cout << "Derived::display()" << endl;
}
覆盖 总结:
(1)覆盖是在虚函数之间的概念,需要派生类对象中定义的虚函数与基类中定义的虚函数的形式完全相同;
(2)当基类中定义了虚函数时,派生类去进行覆盖,即使在派生类的同名的成员函数前不加virtual,依然是虚函数;
(3)发生在基类派生类之间,基类与派生类中同时定义相同的虚函数 覆盖的是虚函数表中的入口地址,并不是覆盖函数本身。
概念区分
重载 (overload) : 发生在同一个类中, 当函数名称相同时 ,函数参数类型、顺序、个数任一不同;
隐藏 (oversee) : 发生在基类派生类之间 ,函数名称相同时,就构成隐藏(参数不同也能构成隐藏);
覆盖(override): 发生在基类派生类之间,基类与派生类中同时定义相同的虚函数,覆盖的是虚函数表中的入口地址,并不是覆盖函数本身
虚函数的限制
虚函数机制给C++提供了灵活的用法,但仍然受到了一些约束,以下几种函数不能设为虚
函数:
- 构造函数不能设为虚函数
构造函数的作用是创建对象,完成数据的初始化,而虚函数机制被激活的条件之一就是要先创建对象,有了对象才能表现出动态多态。如果将构造函数设为虚函数,那此时构造未执行完,对象还没创建出来,存在矛盾。- 虚函数依赖虚表指针
对象没构造完成前,虚表指针还没初始化,无法走动态绑定。 - 调用时机矛盾
虚函数是运行时确定调用版本;
构造函数必须编译期确定调用父类构造,顺序固定。
- 虚函数依赖虚表指针
- 静态成员函数不能设为虚函数
虚函数的实际调用: ** this -> vfptr -> vtable -> virtual function **,但是静态成员函数没有this指针,所以无法访问到vfptr - Inline函数不能设为虚函数
因为inline函数在编译期间完成替换,而在编译期间无法展现动态多态机制,所以效果是冲突的如果同时存在,inline失效 - 普通函数不能设为虚函数
虚函数要解决的是对象多态的问题,与普通函数无关
此外,父类析构函数必须要声明为虚函数
当父类指针 / 引用 指向子类对象,用 delete 释放时。
- 普通析构:静态绑定,编译期只看指针类型(父类)
- 虚析构:动态绑定,运行时看真实对象类型(子类)
cpp
class A { ~A(){} }; // 普通析构,非虚
class B : public A { ~B(){} };
A *p = new B;
delete p;
普通析构会导致只调用父类析构,子类析构不执行,子类资源(堆内存、文件、句柄)无法释放,造成内存泄漏
在执行delete p时的步骤:
首先会去调用B的析构函数,但是此时是通过一个A类指针去调用,无法访问到,只能跳过,再去调用Base的析构函数,回收掉存放堆数据的这片空间,最后调用operator delete回收掉堆对象本身所占的整片空间(编译器知道需要回收的是堆上的 Derived对象,会自动计算应该回收多大的空间,与delete语句中指针的类别没有关系 ------ delete p)

为了让基类指针能够调用派生类的析构函数,需要将Base的析构函数也设为虚函数。
Derived类中发生虚函数的覆盖,将Derived的虚函数表中记录的虚函数地址改变了。析构函数尽管不重名,也认为发生了覆盖。

cpp
class A { virtual ~A(){} }; // 虚析构
这样才能 先调子类析构 → 再调父类析构 完整释放父子所有资源。
只要类存在继承关系,父类析构一律写 virtual ,让父类指针删除子类对象时,完整析构子类。不写后果会导致子类析构函数不执行,内存泄漏
虚函数调用的过程
cpp
Base* ptr = new Derived();
ptr->f(); // 这背后发生了什么?
调用步骤:
- 从
ptr指向的对象中取出vptr - 从虚表中取出
f()对应的函数指针(通常是第一个槽位) - 调用该函数指针,传入
ptr(作为this) - 实际执行的是
Derived::f()
整个过程需要两次间接寻址:ptr → vptr → vtable → 函数地址。这就是虚函数调用的轻微性能开销:比普通函数调用多了几次内存访问。不过在现代 CPU 上,这个开销通常可以忽略。
验证虚表的存在
cpp
class Base{
public:
virtual void print() {
cout << "Base::print()" << endl;
}
virtual void display() {
cout << "Base::display()" << endl;
}
virtual void show() {
cout << "Base::show()" << endl;
}
private:
long _base = 10;
};
class Derived
: public Base
{
public:
virtual void print() {
cout << "Derived::print()" << endl;
}
virtual void display() {
cout << "Derived::display()" << endl;
}
virtual void show() {
cout << "Derived::show()" << endl;
}
private:
long _derived = 100;
};
void test0(){
Derived d;
long * pDerived = reinterpret_cast<long*>(&d);
cout << pDerived[0] << endl;
cout << pDerived[1] << endl;
cout << pDerived[2] << endl;
cout << endl;
long * pVtable = reinterpret_cast<long*>(pDerived[0]);
cout << pVtable[0] << endl;
cout << pVtable[1] << endl;
cout << pVtable[2] << endl;}
cout << endl;
typedef void (*Function)();
Function f = (Function)(pVtable[0]);
f();
f = (Function)(pVtable[1]);
f();
f = (Function)(pVtable[2]);
f();
}
创建一个Derived类对象d,这个对象的内存结构是由三个内容构成的,开始位置是虚函数指针,第二个位置是long型数据_base,第三个位置是long型数据_derived.
第一次强转将这个Derived类对象视为了存放三个long型元素的数组,打印这个数组中的三个元素,后两个本身就是long型数据,输出其值,第一个本身是指针(地址),打印出来的结果是编译器以long型数据来看待这个地址的值。这个虚函数指针指向虚表,虚表中存放三个虚函数的入口地址(3 * 8字节),那么再将虚表视为存放三个long型元素的数组,第二次强转,直接输出数组的三个元素,得到的结果是编译器以long型数据来看待这三个函数地址的值。虚表中的三个元素本身是函数指针,那么再将这个三个元素强转成相应类型的函数指针,就可以通过函数指针进行调用了。------验证了虚表中存放虚函数的顺序,是按照声明顺序去存放的。


4. 多态的必要条件
要让动态多态生效,需要满足三个条件:
4.1 基类声明虚函数
cpp
class Base {
public:
virtual void func(); // 必须 virtual
};
4.2 派生类重写(Override)虚函数
cpp
class Derived : public Base {
public:
void func() override; // 签名必须匹配,用 override 保证
};
4.3 通过基类指针或引用调用
cpp
// 指针
Base* p = new Derived();
p->func(); // 多态
// 引用
Derived d;
Base& r = d;
r.func(); // 多态
// 值传递------不是多态!
Base b = Derived(); // 切片!派生类部分被切掉
b.func(); // 调用 Base::func,不是 Derived::func
对象切片(Object Slicing) 是多态的经典陷阱:
cpp
void processByValue(Base b) { // 传值!
b.func(); // 永远是 Base::func
}
Derived d;
processByValue(d); // d 被切片成 Base 对象
5. 纯虚函数与抽象基类
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。纯虚函数的格式如下:
cpp
class Shape { // 抽象类
public:
virtual double area() const = 0; // 纯虚函数 = 接口
virtual double perimeter() const = 0;
virtual void draw() const = 0;
virtual ~Shape() = default;
};
在基类中声明纯虚函数就是在告诉子类的设计者 ------ 你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它。
多个派生类可以对纯虚函数进行多种不同的实现,但是都需要遵循基类给出的接口(纯虚函数的声明)。
定义了纯虚函数的类成为抽象类,抽象类不能实例化对象。
效果:
Shape不能被实例化:Shape s;编译错误- 派生类必须实现所有纯虚函数,否则自身也是抽象类
- 纯虚函数可以有实现(但调用需要显式用类名限定:
Shape::area())
抽象类有两种形式:
1 . 定义了纯虚函数的类,称为抽象类
2 . 只定义了protected型构造函数的类,也称为抽象类
cpp
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
double perimeter() const override { return 2 * 3.14159 * radius; }
void draw() const override { /* 画圆 */ }
};
只定义了protected构造函数的抽象类
如果一个类只定义了protected型的构造函数而没有提供public构造函数,无论是在外部还是在派生类中作为其对象成员都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类。
Derived类定义了protected属性的构造函数,Derived类也是抽象类,无法创建对象,但是可以定义指针指向派生类对象
cpp
class Base {
protected:
Base(int base): _base(base) { cout << "Base()" << endl; }
private:
int _base;
};
class Derived
: public Base {
public:
Derived(int base, int derived)
: Base(base), _derived(derived)
{
cout << "Derived(int,int)" << endl;
}
void print() const
{
cout << "_base:" << _base << ", _derived:" << _derived << endl;
}
private:
int _derived;
};
void test()
{
Base base(1);//error
Derived derived(1, 2);
}
6. override 与 final:现代 C++ 的安全保障
6.1 override:让编译器帮你检查
C++11 及以后:override 不是语法强制必须写,但强烈建议必写
cpp
class Base {
public:
virtual void func(int) const;
virtual void process(double);
};
class Derived : public Base {
public:
void func(int) const override; // OK:正确重写
// void func() override; // 编译错误!签名不匹配(没有参数)
// void func(int) override; // 编译错误!缺少 const
// void process(int) override; // 编译错误!参数类型不匹配
};
永远使用 override 。如果基类后来修改了虚函数签名,派生类会立刻编译报错,而不是默默变成非多态的独立函数。代码可读性极强,一眼看出是重写
不加 override 时 :只要函数名、参数、返回值完全匹配,依然构成重写,虚析构正常生效
A* p = new B; delete p; 依旧先走~B 再走~A
隐患极大:写错签名编译器不报错,悄悄变成隐藏 / 普通函数,丢失重写
6.2 final:阻止进一步重写
cpp
class Base {
public:
virtual void canOverride() {}
virtual void cannotOverride() final {} // 不能被进一步重写
};
class Derived : public Base {
public:
void canOverride() override {} // OK
// void cannotOverride() override {} // 编译错误!
};
class NoInherit final : public Derived { // 不能被继承
};
final 有两个层面的作用:
- 用在虚函数:阻止重写
- 用在类:阻止继承
编译器可以利用 final 进行去虚拟化优化,将虚函数调用直接转为普通函数调用,消除 vtable 查找开销。
cpp
class 动物{
public:
virtual void 呼吸() final{
// 核心生理逻辑,绝对不能改
}
};
class 狗:动物{
// 报错!呼吸不能重写
//道理:呼吸方式是固定的,不能让子类随便改写。
void 呼吸() override{}
};
实际用处
- 保证核心算法 / 流程不变
父类写好底层关键逻辑,标记final。子类只能调用,不能篡改核心实现。避免子类乱重写导致业务出错、逻辑混乱。 - 安全防护
框架、库类常用:关键虚函数禁止重写,防止恶意改写、破坏底层机制。 - 提升程序效率
编译器知道此函数不会再被重写,可以做静态绑定、内联优化,运行更快。 - 统一行为规范
整个继承体系里,这个函数行为必须统一。所有子类必须沿用同一个实现,不能各写各的。 - 终止多态链条
多层继承时:父→子→孙
在子类加 final,孙类就不能再重写,到此为止。
虚拟继承
虚函数 vs 虚拟继承
在虚函数机制(动态多态机制)中
- 虚函数是存在的;(存在)
- 通过间接的方式去访问;(间接)
- 通过基类的指针访问到派生类的函数,基类的指针共享了派生类的方法(共享)
(如果没有虚函数,当通过pbase指针去调用一个普通的成员函数,那么就不会通过虚函数指针和虚表,直接到程序代码区中找到该函数;有了虚函数,去找这个虚函数的方式就成了间接的方式)
虚拟继承同样使用virtual关键字(存在、间接、共享)
- 存在即表示虚继承体系和虚基类确实存在
- 间接性表现在当访问虚基类的成员时同样也必须通过某种间接机制来完成(通过虚基表来完成)
- 共享性表现在虚基类会在虚继承体系中被共享,而不会出现多份拷贝
(虚基类的说法,如果B类虚拟继承了A类,那么说A类是B类虚基类,因为A类还可以以非虚拟的方式派生其他类)
虚拟继承时派生类对象的构造和析构
如下菱形继承的结构中,中间层基类虚拟继承了顶层基类,注意底层派生类的构造函数
cpp
class A
{
public:
A(double a): _a(a){
cout << "A(double)" << endl;
}
~A(){cout << "~A()" << endl;}
private:
double _a = 10;
};
class B
: virtual public A
{
public:B(double a, double b): A(a), _b(b){
cout << "B(double,double)" << endl;
}
~B(){
cout << "~B()" << endl;
}
private:
double _b;
};
class C
: virtual public A
{
public:
C(double a, double c): A(a), _c(c){
cout << "C(double,double)" << endl;
}
~C(){
cout << "~C()" << endl;
}
private:
double _c;
};
class D: public B, public C
{
public:
D(double a,double b,double c,double d): A(a), B(a,b), C(a,c), _d(d){
cout << "D(double * 4)" << endl;
}
~D(){ cout << "~D()" << endl; }
private:
double _d;
};

在虚拟继承的结构中,最底层的派生类不仅需要显式调用中间层基类的构造函数,还要在初始化列表最开始调用顶层基类的构造函数。
------那么A类构造岂不是会调用3次?
并不会,有了A类的构造之后会压抑B、C构造时调用A类构造,A类构造只会调用一次。可以对照菱形继承的内存模型理解,D类对象中只有一份A类对象的内容。

虚基类指针
当类 B 虚继承 A(虚基类),且 A / B 都有虚函数时,B 的虚函数表结构是什么样的?
当 B 虚继承 A,并且 A 是带虚函数的抽象类 / 基类时,分以下几种情况:
- 当子类没有重写父类虚函数,且未定义新的虚函数时:

- 如果虚基类中包含了虚函数,但未定义新的虚函数时:

- 如果派生类中又定义了新的虚函数,会在内存中多出一个属于派生类的虚函数指针,指向一张新的虚表(VS的实现)

一个 B 对象的内存是这样排列的:
cpp
+-----------------------------+ <-- B 对象起始地址
| B 的虚表指针 (vfptr_B) | ------> 指向 B 的虚函数表
+-----------------------------+
| 虚基类偏移指针 (vbptr) | ------> 记录 A 在哪里
+-----------------------------+
| (B 的成员变量) |
+-----------------------------+
| |
| A 的子对象(虚基类部分) |
| +-------------------------+ |
| | A 的虚表指针 (vfptr_A) | | ------> 指向 A 的虚函数表
| +-------------------------+ |
| | A 的成员变量 | |
| +-------------------------+ |
| |
+-----------------------------+
关键点:
-
B 自己有一个虚表指针 vfptr_B
-
虚继承带来一个隐藏的 vbptr(虚基类表指针)
-
A 作为虚基类,放在对象最后面,它自己也有 vfptr_A
-
A 和 B 的虚函数是分开存放的,不会互相覆盖。
B 的虚函数表:
0: &B::funB() → B 自己新增的虚函数
A 的虚函数表:
0: &B::funA() → 因为 B 重写了 funA()
重点
- A 的虚表存放被重写的虚函数
- B 的虚表存放自己新增的虚函数
- 两个表完全独立
效率分析

7. 静态多态 vs 动态多态
| 特性 | 静态多态(模板/重载) | 动态多态(虚函数) |
|---|---|---|
| 绑定时机 | 编译时 | 运行时 |
| 机制 | 模板实例化/函数重载解析 | 虚函数表 |
| 运行时开销 | 零开销 | 轻微(vtable 查找) |
| 灵活性 | 编译时确定,不能运行时改变 | 运行时动态改变行为 |
| 代码膨胀 | 每种类型一份代码 | 只需一份代码 |
| 类型安全 | 编译时报错 | 运行时可检测(dynamic_cast) |
| 典型用法 | STL 容器、泛型算法 | 框架、插件、设计模式 |
CRTP 模式:用模板模拟多态,兼具静态多态的性能和动态多态的接口统一。
cpp
// CRTP:奇异递归模板模式
template<typename Derived>
class Base {
public:
void interface() {
static_cast<Derived*>(this)->implementation();
}
};
class DerivedA : public Base<DerivedA> {
public:
void implementation() { std::cout << "A\n"; }
};
class DerivedB : public Base<DerivedB> {
public:
void implementation() { std::cout << "B\n"; }
};
template<typename T>
void use(Base<T>& obj) {
obj.interface(); // 静态多态,无虚表开销
}
8. 多态中的 RTTI 与 dynamic_cast
cpp
void processShape(Shape* s) {
if (auto* circle = dynamic_cast<Circle*>(s)) {
// 安全地当作 Circle 使用
circle->setRadius(5.0);
} else if (auto* rect = dynamic_cast<Rectangle*>(s)) {
rect->setWidth(10.0);
} else {
std::cout << "Unknown shape\n";
}
}
要点:
dynamic_cast只能用于有虚函数的类(多态类型)- 指针版本失败返回
nullptr,引用版本失败抛出std::bad_cast - 有运行时开销,不要滥用
- 大量使用
dynamic_cast通常意味着设计有缺陷(应该优先用虚函数分派)
9. 常见陷阱
9.1 构造函数中调用虚函数
cpp
class Base {
public:
Base() { init(); } // 构造函数中调用虚函数!
virtual void init() { std::cout << "Base::init\n"; }
};
class Derived : public Base {
public:
void init() override { std::cout << "Derived::init\n"; }
};
Derived d; // 输出:Base::init(不是 Derived::init!)
原因 :构造派生类对象时,先构造基类部分。在基类构造函数执行期间,对象的虚表还是基类的虚表,派生类还没构造完毕。这个规则也适用于析构函数。
9.2 析构函数非虚
cpp
class Base {
public:
~Base() {} // 非虚析构!
};
class Derived : public Base {
std::vector<int> data;
public:
Derived() : data(1000000) {}
};
Base* p = new Derived();
delete p; // 未定义行为!data 可能泄漏
9.3 对象切片
前面讲过,传值会切片。传引用或指针才能保持多态。
9.4 默认参数的静态绑定
cpp
class Base {
public:
virtual void func(int x = 10) {
std::cout << "Base: " << x << "\n";
}
};
class Derived : public Base {
public:
void func(int x = 20) override {
std::cout << "Derived: " << x << "\n";
}
};
Derived d;
Base* p = &d;
p->func(); // 输出:Derived: 10(函数是派生类的,参数是基类的!)
虚函数的默认参数是静态绑定的 (编译时看指针/引用类型决定),而函数体是动态绑定的。这是一大坑,避免在虚函数中使用默认参数。
10. 面试常考清单
10.1 什么是多态?C++ 如何实现多态?
答案要点 :多态是同一接口表现不同行为的能力。C++ 主要通过虚函数实现动态多态:基类声明虚函数,派生类重写,通过基类指针/引用调用时,运行时根据虚函数表找到正确的函数地址。
10.2 虚函数表是什么?虚函数调用如何实现?
答案要点:每个有虚函数的类有一张虚函数表,存储虚函数地址。每个对象有一个虚指针指向所属类的虚表。调用虚函数时,运行时通过虚指针→虚表→函数地址的两次间接寻址找到正确函数。
10.3 虚函数的开销有哪些?
答案要点:
- 空间:每个对象多一个 vptr(通常 8 字节),每个类多一张虚表
- 时间:虚函数调用多两次指针间接寻址(相比普通函数调用)
- 不能被内联(除非编译器去虚拟化优化)
- 通常这些开销可以忽略,不应成为不用的理由
10.4 构造函数和析构函数可以是虚的吗?
答案要点:
- 构造函数不能是虚的(构造时对象类型还不完整,虚表尚未正确设置)
- 析构函数应该是虚的(如果类可能被继承),确保通过基类指针删除派生类对象时正确调用派生类析构
10.5 什么条件下多态会失效?
答案要点:
- 值传递(对象切片)
- 构造函数和析构函数中调用虚函数(虚表尚未设置或已被重置)
- 没有通过指针或引用调用
- 没有声明为虚函数
10.6 纯虚函数和虚函数的区别?
答案要点:
- 虚函数有实现,派生类可选重写
- 纯虚函数(
= 0)没有强制实现(但可以有默认实现),派生类必须重写 - 有纯虚函数的类是抽象类,不能实例化
10.7 override 和 final 的作用?
答案要点:
override:显式声明重写,编译器检查签名是否匹配final:阻止虚函数被进一步重写,或阻止类被继承;编译器可利用它做去虚拟化优化
10.8 静态多态和动态多态的区别和应用场景?
答案要点:
- 静态多态(模板):编译时确定,零运行时开销,适合性能敏感的泛型代码
- 动态多态(虚函数):运行时确定,需要继承体系,适合需要运行时灵活切换行为的框架和设计模式
- CRTP 结合两者优势,用模板实现类似多态的接口
10.9 虚函数中默认参数的行为?
答案要点 :默认参数是静态绑定 的(编译时根据指针/引用类型决定),函数体是动态绑定的。导致基类指针调用派生类虚函数时,函数体执行派生类版本,但默认参数来自基类。建议不要在虚函数中使用默认参数。
11. 最佳实践总结
- 基类析构必须虚 :
virtual ~Base() = default; - 重写必须加
override:编译器帮你把关 - 不重写的加
final:语义清晰,利于优化 - 用引用和指针传对象:避免切片,保持多态
- 不在构造/析构中调用虚函数:行为不符合预期
- 不在虚函数中使用默认参数:静态绑定惹的祸
- 优先纯虚函数定义接口:清晰的契约
- 性能敏感用 CRTP 或模板:需要多态接口但不想付虚函数开销时
- 少用 dynamic_cast:大量出现说明设计有问题
多态是 C++ 面向对象编程的灵魂。理解虚函数表让你知道它怎么工作,遵循最佳实践让你避免它的陷阱,善用它让你的代码获得运行时灵活性和优雅的扩展性。多态的本质不是 virtual 关键字,而是"依赖抽象而非具体"的设计思维。