一篇文章吃透动态多态------概念、重写规则、抽象类,以及底层的虚函数表(vtable)机制。所有示例代码整理自学习笔记,可直接编译运行。
cpp
void func(Person* p)
{
p->BuyTicket();
}
什么是多态
多态(Polymorphism)是面向对象三大特性(封装、继承、多态)中最难、也最核心的一个。用一句话概括:同一个调用接口,在不同类型的对象上表现出不同的行为。更直白一点------同样是 p->BuyTicket(),当 p 指向不同的对象时,执行的是完全不同的函数。
多态通常分为两类:
- 静态多态(编译期绑定):函数重载、运算符重载、模板。编译阶段就已经确定调用的是哪个函数。
- 动态多态(运行期绑定):继承 + 虚函数。运行到调用点的那一刻,根据对象的真实类型决定执行哪个版本。
| 维度 | 静态多态 | 动态多态 |
|---|---|---|
| 实现手段 | 函数重载 / 运算符重载 / 模板 | 继承 + 虚函数重写 |
| 绑定时机 | 编译期 | 运行期(查虚函数表) |
| 典型场景 | 同一个函数名处理不同类型的参数 | 基类指针 / 引用调用派生类实现 |
本文讲的动态多态:虚函数如何重写、抽象类如何使用,以及这一切背后的虚函数表(vtable)原理。
虚函数与动态多态
第一个例子:买票
先看一个最经典的场景------不同身份的人买票,票价不同:
C++复制
cpp
6<iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket()
{
cout <<"买票 - 全价"<< endl;
}
};
class Student : public Person
{
public:
//派生类可以不写virtual
void BuyTicket()
{
cout << "买票 - 半价"<< endl;
}
};
void func(Person* p)
{
p->BuyTicket();
}
int main()
{
Person p;
Student s;
func(&p);
func(&s);
return 0;
}
关键在于 func 只接收一个 Person*,函数体里也只有一句 p->BuyTicket()。可传入 Person 对象和传入 Student 对象,输出完全不同------函数调用在运行时"看对象下菜",这就是动态多态。
派生类可以不写 virtual。基类中 BuyTicket 已是虚函数,派生类重写后它依然是虚函数------"虚"会沿着继承链一直传下去。
由此可以总结出多态的两个必要条件:
- 派生类重写了基类的虚函数;
- 通过基类的指针或引用调用该虚函数。
传值不行(对象切片)。如果把 Student 对象按值传给形参 Person p,派生类特有的部分会被"切掉",调用到的仍然是 Person::BuyTicket。指针和引用传递的是地址,对象真实类型的信息才能保留下来。
引用同样触发多态:动物叫声
多态不只限于指针,基类引用同样可以:
cpp
class Animal
{
public:
virtual void talk() const {}
};
class Cat : public Animal
{
public:
virtual void talk() const
{
cout << "喵喵" << endl;
}
};
class Dog : public Animal
{
public:
virtual void talk() const
{
cout << "汪汪" << endl;
}
};
void letsHear(const Animal& animal)
{
animal.talk();
}
int main()
{
Cat cat;
Dog dog;
letsHear(cat);
letsHear(dog);
return 0;
}
引用在底层实现上就是指针的"别名"(传的同样是地址),所以 const Animal& 一样能触发动态多态。
重写时函数签名必须完全一致,包括 const。基类是 talk() const,派生类也必须写 talk() const;否则不构成重写,只会变成"隐藏"。
应用场景:一套接口,处理多种身份
多态最大的价值在于扩展性。把例子扩展成三种身份:
cpp
class Person
{
public:
virtual void BuyTicket() { cout << "买票 - 全价" << endl; }
protected:
string _name;
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票 - 半价" << endl; }
protected:
string _id;
};
class Soldier : public Person
{
public:
virtual void BuyTicket() { cout << "买票 - 优先" << endl; }
protected:
string _codename;
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person p1;
Student s1;
Soldier sl1;
Func(&p1);
Func(&s1);
Func(&sl1);
return 0;
}
以后要新增一种身份,只需要继承 Person 并重写 BuyTicket,Func 一行代码都不用改。这就是面向对象设计里常说的**"对扩展开放"**------多态让"统一的调用代码"和"各自的具体实现"彻底解耦。
重写(override)的规则与细节
重写、重载、隐藏,别搞混
"重写"是理解多态的关键词。它要求派生类中的函数与基类虚函数满足**"三同"** :函数名相同、参数列表相同、返回值相同(返回值有一个例外叫协变,见 3.3)。满足三同、且基类函数是虚函数,才构成重写。
与之容易混淆的是"重载"和"隐藏":
| 概念 | 作用域 | 参数要求 | 与 virtual 的关系 |
|---|---|---|---|
| 重载 overload | 同一个作用域 | 参数列表必须不同 | 无关,普通函数即可 |
| 重写 override | 基类 ↔ 派生类 | 参数列表必须相同 | 基类函数必须是虚函数 |
| 隐藏 hiding | 基类 ↔ 派生类 | 同名但参数不同(或不满足重写条件) | 是否虚函数均可 |
隐藏是最容易踩的坑:派生类写了一个与基类同名、但参数不同的函数,编译器不会报错,只是把基类版本"藏"了起来,多态自然也不会触发。
经典坑:虚函数的缺省参数是静态绑定的
虚函数的函数体 是动态绑定的(运行期决定),但它的缺省参数是静态绑定的(编译期决定)!看这个经典例子:
cpp
#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();
p->func();
return 0;
}
cpp
p->test():test() 是 A::test,函数体里的 func() 通过 this 动态绑定,调到 B::func;
但缺省参数在编译期就已确定------调用点写在一个 A 的作用域里,
编译器看到的是 A::func 的声明,所以默认参数取 A 的 1,输出 B->1。
p->func():p 的静态类型是 B*,编译器直接按 B::func 的声明取缺省参数 0,输出 B->0。
函数体看"对象是谁"(动态),缺省参数看"指针类型是谁"(静态)。重写虚函数时,请保持与基类一致的缺省参数值,避免写出"看似多态、实则错乱"的代码。
协变:返回值不同的重写特例
一般情况下重写要求返回值相同,但有一个特例叫协变(covariance) :基类虚函数返回基类对象的指针或引用 ,派生类重写时可以返回派生类对象的指针或引用,此时仍然构成重写。
cpp
class Person
{
public:
virtual Person* BuyTicket()
{
cout << "买票 - 全价" << endl;
return nullptr;
}
};
class Student : public Person
{
public:
virtual Student* BuyTicket() override
{
cout << "买票 - 半价" << endl;
return nullptr;
}
};
void Func(Person* p)
{
p->BuyTicket();
}
int main()
{
Person p;
Student s;
Func(&p);
Func(&s);
return 0;
}
协变只允许指针或引用 类型的返回值。只要两个类构成继承关系即可(例如 A* ↔ B*,A 是 B 的基类),不限于本例的 Person* ↔ Student*。
析构函数的重写:基类析构必须写成虚函数
析构函数也可以重写------而且几乎必须重写。看这个资源管理例子:
cpp
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A
{
public:
~B()
{
cout << "~B()" << endl;
delete[] _arr;
}
protected:
int* _arr = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
因为 ~A() 是虚函数,delete p2 会按对象的真实类型先调用 ~B(),B 的资源被正确释放,随后自动向上调用 ~A()。
如果基类析构不是 虚函数,delete p2 只会调用 ~A(),B 中 _arr 指向的堆内存不会被释放------内存泄漏。结论:只要一个类被设计成可以被继承,它的析构函数就应该写成虚函数。
override:让编译器帮你检查重写
C++11 提供了 override 关键字,显式声明"我要重写基类虚函数"。如果签名写错,编译器直接报错,而不是悄悄变成隐藏:
cpp
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
virtual void Drive() override
{
cout << "舒适" << endl;
}
};
如果把 Drive() 误写成 Drive(int) 之类,编译器会报错:error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法 。没有 override 时,同样的错误会静默变成"隐藏",非常难排查。
final:禁止重写
与 override 相反,如果不想让派生类重写某个虚函数,用 final 修饰:
cpp
class Car
{
public:
virtual void Drive() final {}
};
// error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
class Benz : public Car
{
virtual void Drive()override
{
cout << "舒适" << endl;
}
};
final 也可以直接修饰类(class Benz final : public Car),表示该类不允许被继承。
纯虚函数与抽象类
在虚函数的声明后面写上 = 0,这个函数就变成了纯虚函数 。纯虚函数只需要声明、不需要定义实现(语法上允许实现,但没什么意义------它注定要被派生类重写)。包含纯虚函数的类叫做抽象类。
cpp
class Car
{
public:
virtual void Drive() = 0;
};
class Benz : public Car
{
public:
virtual void Drive() override
{
cout << "舒适" << endl;
}
};
class BMW : public Car
{
public:
virtual void Drive() override
{
cout << "操作" << endl;
}
};
int main()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
抽象类有两个关键约束:
- 不能实例化 :
Car car;或new Car都会编译报错(error C2259)。- 派生类必须重写所有纯虚函数才能实例化;只要有一个纯虚函数没重写,派生类自己也还是抽象类。
抽象类的指针 / 引用可以指向派生类对象,并照常触发多态------这正是上面 Car* pBenz 的用法。抽象类最常见的用途是定义"接口规范":基类只声明接口,强制每个派生类实现自己的行为(比如每辆车都必须能"驾驶",但开法各不相同)。
多态的原理:虚函数表
虚函数表与调用链路
动态多态能"看对象下菜",秘密在于虚函数表(virtual table,简称 vtable):
- 每个包含虚函数的类,编译器都会生成一张虚函数表------本质是一个数组,存放该类所有虚函数的地址。
- 每个对象的内存中会多出一个指针:虚表指针 vptr,指向它所属类的虚函数表。
- 调用虚函数时,程序先通过对象的 vptr 找到虚表,再从虚表里取出函数地址执行。所以"实际调用哪个函数",是在运行时由对象的真实类型决定的。

Student s;Person* p = &s;p->BuyTicket();
① 传地址,指针指向派生类对象Student 对象vptr成员区:_name / _id
② 经 vptr 找到虚表虚函数表(vtable)0: Student::BuyTicket()1: (nullptr)
③ 取出函数地址
④ 跳转执行Student::BuyTicket(){ cout << "买票 - 半价" << endl; }
虚函数调用 = vptr → vtable → 函数地址 → 跳转执行;对象切片之所以失效,是因为按值拷贝出的"Person 对象"其 vptr 指向的是 Person 的虚表。
虚函数地址 vs 普通函数地址
虚函数和普通函数的代码都存放在代码段(只读区) ,地址本身没有本质区别;区别在调用方式 :普通函数直接 call 固定地址,虚函数要先查虚表、再间接调用。用一段代码验证(沿用 5.3 的 Base / Derive 类定义):
cpp
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
上述 *(int*) 取虚表指针是32 位环境下的探索写法 。64 位下指针占 8 字节,请改用 (uintptr_t*) 或 (void**) 强转;&Base::func1 取虚函数地址同样依赖编译器扩展,不同编译器表现略有差异。
单继承下的虚函数表
用"打印虚表"的方式,把单继承下虚表的内容逐个拿出来看:
cpp
class Base
{
public:
virtual void func1() {}
virtual void func2() {}
private:
int a;
};
class Derive : public Base
{
public:
virtual void func1() {}
virtual void func3() {}
virtual void func4() {}
void func5() {}
private:
int b;
};
typedef void(*VFPTR)();
void printTable(VFPTR* vTable)
{
cout << "虚函数表的地址" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf("第%d个虚函数的地址:0x%p,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
cout << endl;
}
}
int main()
{
Base b;
Derive d;
VFPTR* ptr1 = (VFPTR*)(*((int*)&b));
printTable(ptr1);
VFPTR* ptr2 = (VFPTR*)(*((int*)&d));
printTable(ptr2);
return 0;
}
Base 的虚函数表:
0: Base::func1()
1: Base::func2()
Derive 的虚函数表:
0: Derive::func1() ← 重写版本,顶掉了 Base::func1
1: Base::func2() ← 未重写,沿用基类实现
2: Derive::func3() ← 新增
3: Derive::func4() ← 新增
(实际打印的地址值因运行环境而异,这里展示的是逻辑结构)
由此归纳出单继承下虚表的四条规律:
- 重写的虚函数 :派生类版本覆盖 虚表中原来的槽位(
Derive::func1顶掉了Base::func1的地址);- 未重写的虚函数 :沿用 基类实现,地址不变(
Base::func2);- 新增虚函数 :按声明顺序追加 到虚表末尾(
func3、func4);- 普通函数 :不进虚表 (
func5),直接按地址调用。

覆盖继承新增Base 对象vptr成员:
int aBase 虚函数表0: Base::func1()1: Base::func2()Derive 对象vptr成员:int b + 继承的 aDerive 虚函数表0: Derive::func1() ← 覆盖1: Base::func2() ← 继承2: Derive::func3() ← 新增3: Derive::func4() ← 新增
普通函数 func5 不进入虚表,通过固定地址直接调用。
多继承下的虚函数表
多继承的情况复杂一些------有几个含虚函数的基类,就有几张虚函数表:
cpp
class Base1
{
public:
virtual void func1() {}
virtual void func2() {}
private:
int a;
};
class Base2
{
public:
virtual void func1() {}
virtual void func3() {}
private:
int a;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1() {}
virtual void func3() {}
virtual void func4() {}
void func5() {}
private:
int b;
};
typedef void(*VFPTR)();
void printTable(VFPTR* vTable)
{
cout << "虚函数表的地址" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf("第%d个虚函数的地址:0x%p,->", i, vTable[i]);
}
}
int main()
{
Base1 b1;
Derive d;
VFPTR* ptr1 = (VFPTR*)(*((int*)&b1));
printTable(ptr1);
VFPTR* ptr2 = (VFPTR*)(*((int*)&d));
printTable(ptr2);
Base2* Bptr = &d;
VFPTR* ptr3 = (VFPTR*)(*((int*)Bptr));
printTable(ptr3);
return 0;
}
观察这份代码,可以得出多继承虚表的几个要点:
- 对象内存中有多个 vptr,分别指向各基类对应的虚表;
- 第一张虚表 对应第一个基类(Base1),Derive 新增的虚函数(
func4)追加在这里;- 第二张虚表 对应第二个基类(Base2)。通过
Base2*调用虚函数时,this 必须先从 Base2 子对象位置调整回整个对象的起始位置,所以第二张虚表里存的往往不是函数本体,而是一小段 thunk ------汇编层面表现为sub ecx, 偏移量; jmp Derive::func1():先修正 this,再跳转到真正的函数;- 因此两个虚表里的 func1 地址不一样,以第一张表(Base1)里的 func1 地址为准------第一张表存的是函数本体,第二张表存的是 thunk;
- 这就是为什么
printTable里注释掉了f():直接以函数指针调用 thunk 时没有正确的 this,行为未定义。

第一张虚表(本体)thunk(this 调整后跳转)新增Derive 对象vptr₁└ Base1 子对象vptr₂└ Base2 子对象成员:b + 继承的两个 a指向指向第一张虚表(Base1 部分)0: Derive::func1() 本体1: Base1::func2()2: Derive::func4() 新增第二张虚表(Base2 部分)0: thunk:sub ecx, 偏移量jmp Derive::func1()1: thunk:sub ecx, 偏移量jmp Derive::func3()注意:两张表里的 func1 地址不同,以第一张表里的地址为准
thunk 的本质:先把 this 从 Base2 子对象位置调整回对象起始位置,再跳转到真正的 Derive 函数。
单继承 vs 多继承虚表
| 维度 | 单继承 | 多继承 |
|---|---|---|
| 虚表数量 | 1 张 | 每个含虚函数的基类各 1 张 |
| vptr 数量 | 1 个 | 多个 |
| 重写虚函数 | 覆盖虚表中的原槽位 | 在每个基类虚表里各自覆盖 |
| 新增虚函数 | 追加到表尾 | 追加到第一张虚表 |
| 第二张虚表 | --- | 通常存 thunk(this 调整后跳转) |
06
补充:虚继承与菱形继承
当两个派生类 A、B 都继承自同一个基类 Base,而 C 又同时继承 A 和 B,就形成了菱形继承(也叫钻石继承):
cpp
class Base
{
public:
void func() {}
int b;
};
class A : public Base
{
protected:
int _a = 1;
};
class B : public Base
{
protected:
int _b = 2;
};
class C : public A, public B
{
protected:
int _c = 3;
};
虚继承 就是用来解决这个问题的:让 A、B 用 virtual public Base 继承,这样无论经过几条继承路径,Base 子对象在 C 中都只保留一份:
cpp
class Base
{
public:
void func() {}
int b;
};
class A : virtual public Base
{
public:
void func() {}
protected:
int _a = 1;
};
class B : virtual public Base
{
public:
void func() {}
protected:
int _b = 2;
};
class C : public A, public B
{
public:
void func() {}
protected:
int _c = 3;
};
int main()
{
C c;
c.func();
cout << sizeof(c) << endl;
return 0;
}
虚继承会影响对象的内存布局(额外引入虚基类指针来定位共享的基类子对象),这和虚函数表是两回事 :虚继承解决菱形继承的数据冗余与二义性,本身并不产生多态。日常开发中建议优先使用组合和单继承,避免复杂的菱形结构。
总结
把整篇的结论收拢成一张速查表:
| 主题 | 结论 |
|---|---|
| 多态的本质 | 同一个调用接口,运行期根据对象的实际类型选择实现 |
| 两个必要条件 | 虚函数重写 + 通过基类指针 / 引用调用(按值传参会切片,无效) |
| 重写的"三同" | 函数名、参数列表、返回值相同(协变除外) |
| 缺省参数 | 静态绑定!函数体是动态的、缺省参数是静态的,重写时保持与基类一致 |
| 协变 | 返回值允许 基类指针 / 引用 ↔ 派生类指针 / 引用 |
| 析构函数 | 基类析构必须写成虚函数,否则 delete 基类指针会泄漏派生类资源 |
| override / final | C++11:显式声明重写,让编译器替你检查 / 禁止重写 |
| 抽象类 | 含纯虚函数(= 0),不能实例化,用于定义接口规范 |
| 虚函数表 | 存放虚函数地址的表:单继承"覆盖 + 追加";多继承每个基类一张表,第二张表通常存 thunk |
| 虚继承 | 解决菱形继承的数据冗余与二义性,与多态无直接关系 |
多态是面向对象设计的灵魂:它让"统一的调用代码"不必关心"具体的实现是谁",从而支撑起框架与插件、策略与接口等设计模式。