
Ciallo(∠・ω< )⌒☆
| 系列专栏 | 直达链接 |
|---|---|
| 🔤 C 语言 | 进入专栏 → |
| 🧱 数据结构 | 进入专栏 → |
| 🔧 C++ | 进入专栏 → |
| 🐍 Python | 进入专栏 → |
| 🔌 STM32 | 进入专栏 → |
| 🐧 Linux | 进入专栏 → |
🏠 博客主页:暖焰核心
文章目录
- 引言
-
- 一、多态的概念与构成条件
-
- [1.1 编译时多态与运行时多态](#1.1 编译时多态与运行时多态)
- [1.2 构成多态的两个条件](#1.2 构成多态的两个条件)
- [1.3 虚函数与重写](#1.3 虚函数与重写)
- 二、重写里的四处细节
-
- [2.1 题目一:缺省参数与虚函数的搭配](#2.1 题目一:缺省参数与虚函数的搭配)
- [2.2 协变](#2.2 协变)
- [2.3 析构函数的重写](#2.3 析构函数的重写)
- [2.4 override 与 final](#2.4 override 与 final)
- 三、重载、重写与隐藏的对比
- 四、纯虚函数与抽象类
- 五、多态的原理
-
- [5.1 题目二:sizeof 与虚函数表指针](#5.1 题目二:sizeof 与虚函数表指针)
- [5.2 虚表的查找过程](#5.2 虚表的查找过程)
- [5.3 动态绑定与静态绑定](#5.3 动态绑定与静态绑定)
- [5.4 虚表的内容与存放位置](#5.4 虚表的内容与存放位置)
引言
学完继承,代码复用的问题解决了,但有个很别扭的地方,同一句调用,为什么不能让它做的事随对象变?
买票就是这样。普通人全价,学生打折,军人优先,都是"买票"这一个行为,不同的人做,结果不同。假设有下面两个类,Student 继承 Person,各自有一个 BuyTicket:
cpp
Person ps;
Student st;
ps.BuyTicket(); // 全价
st.BuyTicket(); // 打折
一旦 Student 对象交给一个收 Person& 的函数,就出了岔子。函数声明是 void Func(Person& ptr),函数体里只有一句 ptr.BuyTicket():传 ps 进去调 Person::BuyTicket,传 st 进去还是调 Person::BuyTicket。ptr 的静态类型是 Person&,编译器按这个类型去找函数,找到 Person::BuyTicket 就定下来了
多态要的效果是:Func(st) 也调 Student::BuyTicket。函数地址不能编译时就定死,得等到运行时看 ptr 到底指着谁
一、多态的概念与构成条件
1.1 编译时多态与运行时多态
多态(polymorphism)的字面意思就是多种形态。C++ 里的多态分两类,分界线是"什么时候决定调哪个函数"
一类在编译期就定好了。函数重载和函数模板都是:
cpp
void swap(int& a, int& b);
void swap(double& a, double& b);
两个 swap 名字一样,编译器按实参类型挑一个,匹配在编译时完成。这种叫编译时多态,也叫静态多态
另一类要等到运行时才定,就是上面买票那回事。调哪个函数不看函数名和参数,看指针(或引用)指向的到底是哪个对象。这叫运行时多态,或者动态多态
本篇讲的是后一种
1.2 构成多态的两个条件
第一,必须由基类的指针或者引用去调用
只有基类的指针或引用能同时指向基类对象和派生类对象。Person* 既能接 Person 对象的地址,也能接 Student 对象的地址,因为派生类对象里本来就有一份基类部分;反过来 Student* 接不了 Person 对象的地址
第二,被调用的函数必须是虚函数,并且派生类完成了重写
两条缺一条都不构成多态。缺第一条,编译器按静态类型直接定函数地址;缺第二条,派生类里那个同名函数只是把基类的隐藏掉,跟多态没关系
代码如下:
cpp
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
void Func(Person* ptr)
{
// 都是 Person 指针在调 BuyTicket
// 但调哪个跟 ptr 的类型没关系,由 ptr 指向的对象决定
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps); // 买票-全价
Func(&st); // 买票-打折
return 0;
}
ptr 两次都是 Person*,一次指 Person 对象、一次指 Student 对象,输出就分开了
多态不要求一端非得是基类对象。同一个基类下的多个派生类,各自重写虚函数之后,交给同一段代码也都能各调各的那一份
1.3 虚函数与重写
虚函数:类成员函数前面加 virtual。非成员函数不能加
重写(也叫覆盖):派生类里有一个跟基类完全相同的虚函数,返回值类型、函数名、参数列表全部相同,就叫派生类重写了基类的虚函数
这里有个爱考的点:派生类重写时不加 virtual 也能构成重写。基类的虚函数被继承下来之后,在派生类里仍然保持虚函数属性。写法不规范,但不影响构成多态,面试里经常拿这个设坑
反过来,"看起来像"不等于构成重写。函数名拼错一个字母、参数类型里的 const 对不上(比如 const int& 写成 int&),都会让重写不成立,而编译器不会报错,它只是悄悄当成两个不相干的函数。返回值不同是另一回事:同名同参、只改返回值又构不成协变,编译器会直接报错。真正难查的是前两种,等运行时结果不对再回头找就费劲了。解决方法后文 2.4 节的 override 有解释
二、重写里的四处细节
2.1 题目一:缺省参数与虚函数的搭配
先看一道题,代码如下:
cpp
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(int argc, char* argv[])
{
B* p = new B;
p->test();
p->func();
return 0;
}
选项:
A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确
答案是 B,输出 B->1
很多人就会顺着"多态调派生类那份实现"的思维,看到 B::func 里写的是 val = 0,就选了 B->0。这道题卡人的地方在于:缺省参数和函数地址不是同一个时刻定下来的
先看 p->test()。B 没有重写 test,所以调的是从 A 继承来的 A::test。进到函数体里,那句 func() 等价于 this->func(),此时 this 的类型是 A*
func 是虚函数,B::func 也构成对它的重写,多态条件满足,于是函数地址走运行期:this 指向的是 B 对象,虚表里那一项是 B::func,调的是它。到这里都没问题
问题出在参数上。缺省参数是编译期 按静态类型填的:编译器在 A::test 里看到 func(),按 this 的静态类型 A* 找到 A::func,于是把 A::func 声明里的缺省值 1 填进去,这一句在编译后等价于 func(1)。运行时虚表把函数换成了 B::func,可实参 1 早就填好了
两个时刻一分开,结果就出来了:
| 环节 | 什么时候定 | 取谁的 |
|---|---|---|
缺省参数 val |
编译期 | 静态类型 A 里的 1 |
| 函数地址 | 运行期 | 虚表里查到的 B::func |
所以第一句输出 B->1
第二句 p->func() 不一样。p 是 B*,名字查找优先在 B 里找,找到 B::func,缺省值用 B 自己声明的 0,输出 B->0
输出分别是 B->1 和 B->0
结论落到实践上:重写虚函数时最好把缺省值保持一致。既然缺省值跟着静态类型走,声明里写两套值,调用方换一个指针类型就换一个结果,看代码的人很难顺着看出来
2.2 协变
派生类重写基类虚函数时,返回值类型可以不同,但这种"不同"有限定:基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用
代码如下:
cpp
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
A* 和 B* 是不同的类型,这也算重写,叫协变。A 和 B 必须有继承关系,否则不成立。实际意义不大,了解即可
2.3 析构函数的重写
基类的析构函数声明成虚函数时,派生类的析构函数只要定义出来,不管加不加 virtual,都构成对基类析构的重写
按字面看这不符合重写规则:基类叫 ~A,派生类叫 ~B,名字都不一样。原因是编译器对析构函数的名称做了特殊处理,编译后统一处理成 destructor
为什么基类的析构要设计成虚函数?看下面这段:
cpp
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
运行结果:
~A()
~B()->delete:0x22c5c48ea20
~A()
delete p2 的时候,p2 的类型是 A*,指向的却是 B 对象。析构函数是虚函数,构成多态,所以先调 B 的析构把那 10 个 int 释放掉,再自动调基类的 ~A()
把 ~A() 前面的 virtual 去掉,析构就不构成多态了。delete p2 只调 ~A(),~B() 里那句 delete _p 永远执行不到,_p 指的 10 个 int 就漏了
注意:这题面试问得很多。光答"基类析构要加 virtual"不够,得能讲出少调这一次析构漏掉的是什么
2.4 override 与 final
重写的要求严格,函数名、参数、返回值有一个对不上就不成立。名字或者参数对不上时编译器不会报错,只是悄悄变成另一个函数。C++11 给了 override 来治这个:在派生类函数后面写上它,编译器会检查这个函数是不是真的重写了基类的虚函数,不是就报错
代码如下(基类函数名故意拼成 Dirve):
cpp
class Car {
public:
virtual void Dirve()
{}
};
class Benz :public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
编译报 error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法。拼错一个字母,编译器当场就拦下来了
反过来,不想让派生类重写某个虚函数,用 final 修饰基类的那个函数:
cpp
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
报 error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写
三、重载、重写与隐藏的对比
这三个概念名字像,条件和作用域都不一样,放一起最容易记混
先把判据列全:
| 概念 | 作用域 | 函数名 | 参数 | 返回值 | 虚函数 |
|---|---|---|---|---|---|
| 重载 | 同一作用域 | 相同 | 类型或个数不同 | 可同可不同 | 无要求 |
| 重写 | 父类与子类,不同作用域 | 相同 | 相同 | 相同(协变例外) | 两个都必须是虚函数 |
| 隐藏 | 父类与子类,不同作用域 | 相同 | 不要求 | 不要求 | 无要求 |
三条可以压成一句话记:同一作用域看参数,不同作用域看虚函数
同一作用域里两个同名函数,参数不同就是重载,参数也相同就是重复定义,编译直接报错。不同作用域里两个同名函数,构成重写的条件全满足就是重写,只要有一条不满足就是隐藏
隐藏最容易出错的地方是它不挑参数 :派生类里只要出现一个和基类同名的函数,基类里所有同名函数不管参数全部被隐藏。想调基类那份,得写 b.A::fun() 显式指定作用域
代码如下:
cpp
class A
{
public:
void fun() { cout << "fun()" << endl; }
};
class B : public A
{
public:
void fun(int i) { cout << "fun(int i):" << i << endl; }
};
int main()
{
B b;
b.fun(10); // 正常
b.fun(); // 编译报错
b.A::fun(); // 显式指定作用域才能调
return 0;
}
b.fun() 报错的原因是名字查找只认名字:从 B 的作用域开始找 fun,找到 fun(int) 就停了,参数对不上直接报错,不会回退到 A 里去找那个无参版本
四、纯虚函数与抽象类
在虚函数后面写上 = 0,这个函数就是纯虚函数:
cpp
class Car
{
public:
// 纯虚函数
virtual void Drive() = 0;
};
纯虚函数只要声明,通常不需要写定义,它本来就等着派生类去重写(语法上允许给定义,只是一般用不到)
包含纯虚函数的类叫抽象类。抽象类不能实例化对象:
cpp
Car car; // 编译报错 error C2259: "Car": 无法实例化抽象类
派生类继承之后如果不重写纯虚函数,派生类也是抽象类,一样实例化不了。纯虚函数某种程度上强制了派生类必须重写
代码如下:
cpp
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
int main()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
抽象类不能实例化,但可以定义指针和引用,Car* 照样能指向各个派生类对象。这也是多态最常见的用法:接口用抽象类定下来,具体实现交给派生类,调用方只认基类指针
五、多态的原理
5.1 题目二:sizeof 与虚函数表指针
再看一道题,代码如下:
cpp
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
编译成 32 位程序,运行结果是什么?
A. 编译报错 B. 运行报错 C. 8 D. 12
一个 int 加一个 char 是 5 字节,按边界对齐到 8 字节,正好对应选项 C。但答案是 D,12 字节
多出来的 4 字节不是一个成员变量,是一个指针:虚函数表指针。Base 里有虚函数 Func1,编译器就在对象里塞了一个指向虚函数表的指针,Func1 的地址存在那张表里。这个指针叫虚函数表指针(v 代表 virtual,f 代表 function),一般放在对象最前面:
32 位下 Base 对象的内存布局(共 12 字节)
+--------------------------------+
| _vfptr 虚函数表指针 4B | --> 指向虚表,Func1 的地址存在表里
+--------------------------------+
| _b 4B |
+--------------------------------+
| _ch 1B |
+--------------------------------+
| 填充对齐 3B | 按 4 字节边界对齐
+--------------------------------+
注意:虚表指针一般放在对象最前面,C++ 标准没有规定它的位置,主流实现都放在开头。只要类里有虚函数,对象里就至少有一个虚表指针,因为一个类所有虚函数的地址都要放进这个类的虚表里
VS 里可以直接看到它。b 展开后是 {_b=1 _ch=120 'x'},_vfptr 单独占一项:

_ch 的值显示成 120 'x',是因为 char 在监视窗口里会同时给出 ASCII 码值和字符
同一段代码用 MinGW g++ 编译成 64 位跑,输出是 16 。64 位下指针占 8 字节,加上 int 的 4 字节、char 的 1 字节和 3 字节填充,正好 16。数值跟 32 位不一样,但多出来的那份是同一个东西
5.2 虚表的查找过程
回到买票那段。Func 里只有一句 ptr->BuyTicket(),ptr 是 Person*,它怎么知道该调哪个?
不满足多态条件时,这件事很简单:编译器按静态类型就能确定要调哪个函数,把地址直接写进指令。满足多态条件后这条路走不通了,编译器手里只有 ptr 的类型,不知道它运行时指向谁,于是改成运行时去查
查的过程分三步:先从对象里取出虚表指针,用它在虚表里定位到对应那一项,取出函数地址再调用。ptr 指向 Person 对象,取到的就是 Person 对象的虚表,BuyTicket 那一项是 Person::BuyTicket;指向 Student 对象,取到 Student 对象的虚表,那一项是 Student::BuyTicket

两句 Func 编译出来的是同一段查表指令,差别只在 ptr 运行时指向谁。指针指到哪个对象,就查到哪个对象的虚表,就调哪个函数
图里还能看出另一件事:Student 对象里那份 _vfptr 是从继承下来的基类部分带过来的,它指向的是 Student 自己的虚表,不是 Person 对象那张。虚表跟着对象的实际类型走,跟指针的静态类型没关系
5.3 动态绑定与静态绑定
多态的这层机制落到编译结果上,就是两种绑定方式
不满足多态条件(基类指针或引用 + 虚函数)的调用在编译时绑定,编译器直接确定函数地址写进指令,这叫静态绑定。满足多态条件的调用在运行时绑定,运行时到指向对象的虚表里取函数地址,这叫动态绑定
反汇编里两种写法对比很清楚:
// 满足多态:ptr 是指针,BuyTicket 是虚函数
// 运行时到 ptr 指向对象的虚表里取地址
00EF2001 mov eax,dword ptr [ptr] ; 取虚表指针
00EF2004 mov edx,dword ptr [eax] ; 取虚表里 BuyTicket 那一项
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr] ; this
00EF200B mov eax,dword ptr [edx] ; 函数地址
00EF200D call eax ; 间接调用,地址运行时才有
// 不满足多态:BuyTicket 不是虚函数
// 编译期就把地址写死
00EA2C91 mov ecx,dword ptr [ptr] ; this
00EA2C94 call 0EA153Ch ; 编译器写死的地址,不查表
差异全在最后那句 call:动态绑定是 call eax,地址来自寄存器,运行时才有值;静态绑定是直接 call 一个写死的地址,编译完就定了
5.4 虚表的内容与存放位置
这一节换一组更完整的例子来看,代码如下:
cpp
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的 func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
Base 有两个虚函数 func1、func2 和一个普通函数 func5。Derive 重写了 func1,又添了一个虚函数 func3,另外有个普通函数 func4。func4 和 func5 不是虚函数,都进不了虚表
先把对象和虚表的关系摆清楚
同类对象共用一张虚表,基类和派生类各有各的表
一个类的所有对象,虚表指针都指向同一张表。Person 的对象共用 Person 的虚表,Student 的对象共用 Student 的虚表,两张表互相独立
派生类对象里的虚表指针是从基类那部分继承下来的,不新生成
派生类对象由继承下来的基类部分和派生类自己的成员两部分构成。基类部分里本来就带着虚表指针,派生类不会再生成一个。要分清的是,这个指针指向的虚表和基类对象那张虚表不是同一张,就像派生类对象里的基类成员和基类对象自己的成员是两份一样
派生类重写的虚函数,会覆盖虚表里对应的那一项
Student 重写了 BuyTicket,Student 虚表里 BuyTicket 那一项就从 Person::BuyTicket 的地址换成了 Student::BuyTicket 的地址。没重写的那项保持基类函数的地址不动
所以派生类的虚表分三部分:基类的虚函数地址、被重写项覆盖后的地址、派生类自己的虚函数地址
VS 监视窗口里能看到前两项:

b 的 _vfptr 展开是 [0] Base::func1、[1] Base::func2;d 展开后 [0] 换成了 Derive::func1,[1] 还是 Base::func2,地址没变。第三项 Derive::func3 在这张窗口里看不到
看不到 func3 是因为监视窗口按类型信息展开,它只认得出基类那几项。换内存窗口把原始字节摊开就能看全:

按小端读一下 Derive 那一组:
Derive 对象 0x0053FB28 虚表 0x000A9B74
+----------------------+ +----------------------------+
| 74 9b 0a 00 |--------->| 30 12 0a 00 func1 |
+----------------------+ +----------------------------+
| 01 00 00 00 a = 1 | | 13 11 0a 00 func2 |
+----------------------+ +----------------------------+
| 02 00 00 00 b = 2 | | 21 12 0a 00 func3 |
+----------------------+ +----------------------------+
| 00 00 00 00 end |
+----------------------------+
对象开头的 74 9b 0a 00 是虚表指针,按小端读出来就是虚表的地址 0x000A9B74。虚表里三行依次是 Derive::func1、Base::func2、Derive::func3 的地址,最后那个 00 00 00 00 是结束标记
Base::func2 那一项在两张虚表里地址相同,都是 0x000A1113。它没被重写,派生类虚表直接沿用了基类函数的地址
末尾的 00 00 00 00 是 VS 系列编译器在虚表后面放的结束标记。这一条 C++ 标准没有规定,各编译器自己定,g++ 就不放
虚函数放在哪、虚表放在哪,是两个问题
虚函数编译好后就是一段指令,跟普通函数一样放在代码段,只是它的地址又被存了一份到虚表里
虚表放在哪,C++ 标准同样没有规定。写一段程序把各个区域和虚表地址一起打出来对比:
cpp
int main()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Base虚表地址:%p\n", *(int*)p3); // 64 位下写成 *(void**)p3
printf("Derive虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
*(int*)p3 是把对象开头 4 个字节当成 int 取出来,取到的就是虚表指针的值。VS 下编译成 32 位,输出是:
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Base虚表地址:0071AB44
Derive虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF
虚表地址落在常量区那一带。也就是说,VS 下虚表放在只读数据段(平时说的常量区),跟函数指令所在的代码段不是同一处
同样的程序我用 g++ 编译成 64 位跑,位置关系一致:
栈 :0000000c151ffe34
静态区 :00007ff63a6f8000
堆 :0000016a82c0eb70
常量区 :00007ff63a6f9040
Base 虚表地址 :00007ff63a6f9a50
Derive 虚表地址 :00007ff63a6f9a70
虚函数地址 &Base::func1:00007ff63a6f7d90
普通函数地址 &Base::func5:00007ff63a6f7e10
虚表地址与常量区在同一段,虚函数和普通函数落在更低的另一条地址带上
把 Base 虚表的第一项取出来是 00007ff63a6f7d90,和 &Base::func1 完全一样,虚表里存的就是虚函数的地址,这一点能直接对上:
Base 虚表内容:
[0] = 00007ff63a6f7d90 Base::func1
[1] = 00007ff63a6f7dd0 Base::func2
[2] = 0000000000000000
再取 Derive 的虚表:
Derive 虚表内容:
[0] = 00007ff63a6f7e50 Derive::func1
[1] = 00007ff63a6f7dd0 Base::func2
[2] = 00007ff63a6f7e90 Derive::func3
[3] = 0000000000000000
[0] 换了地址,[1] 与 Base 虚表里那一项完全相同,[2] 是派生类追加的虚函数地址