C++ 多态(Polymorphism)课堂笔记
一、什么是多态
- 多态 = 多种形态
- 分为两类:
- 静态多态(编译时多态):编译阶段就确定行为的多态
- 动态多态(运行时多态):运行时才确定行为的多态 —— 本节课重点
1.1 静态多态
- 主要指函数重载和函数模板
- 原理:例如
cout << int、cout << double,编译器根据函数模板实例化出两个不同函数,本质上是函数重载,编译时就确定调用哪个函数 - 表面看起来是同一个函数,实际上调用了不同函数,行为不同 —— 这就是多态,只是发生在编译时
1.2 动态多态
- 定义:完成某个行为时,不同的对象去做这个行为,结果不一样
- 运行的时候传入不同的对象,行为不同
- 生活中的例子:
- 买票:普通人全价、学生打折、军人优先。同样"买票"这个行为,传不同对象效果不同
- 动物叫:传猫→"喵喵",传狗→"汪汪",传鸭子→"嘎嘎"
二、虚函数(virtual)与重写
2.1 虚函数
- 用关键字
virtual声明的成员函数 - 回顾:之前在**菱形继承(虚继承)**解决数据冗余和二义性时也用过
virtual,同一关键字两种用途
2.2 重写(覆盖)
- 定义:派生类中有一个与基类完全相同的虚函数,就称派生类的虚函数重写了基类的虚函数
- "三同"条件:返回类型、函数名、参数列表完全相同
- 唯一可以不同:函数体(实现)
- 注意区分:
- 重写:语法层面的概念
- 覆盖:底层(虚表)层面的概念
三、多态的两个条件
- 必须通过基类的指针或引用调用虚函数
- 派生类必须重写基类的虚函数(且满足三同)
逻辑链:多态的两个条件之一 → 重写虚函数 → 重写又必须满足三同
如果少了条件(例如没完成虚函数重写),即使做了切片,也达不到多态效果
四、例题:带虚函数的类 sizeof(经典坑题)
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;
}
选项:A. 编译报错 B. 运行报错 C. 8 D. 12
答案:D. 12
计算过程(默认 32 位下,未说明也按 32 位算):
- 类中有虚函数 → 对象多一个虚函数表指针 vfptr,占 4 字节
int _b:4 字节char _ch:1 字节- 内存对齐:
4(vfptr) + 4(int) + 1(char) = 9,再补 3 字节对齐 → 12 字节
要点回顾:
- 计算类大小只考虑成员变量,成员函数不存进对象里
- 要考虑内存对齐
- 这题必须懂多态原理(虚函数表指针)才能做对,是易错点
五、虚函数表(vftable)
5.1 基本概念
- 类中有虚函数 → 对象增加一个指针:虚函数表指针(vfptr)
- 全称:
vftable指针(virtual function table),简写为 vfptr - vfptr 指向一张虚函数表(虚表)
- 虚表本质:一个函数指针数组 —— 数组里存的是函数指针,一层层包裹:数组 → 存指针 → 存函数指针
5.2 虚表里放什么
- 存放该类所有虚函数的地址,有几个放几个
- 非虚函数(普通函数)不放
- 可通过调试器监视窗口 / 内存窗口观察
代码示例:
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::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
说明:func1、func2、func3 进虚表;func4、func5 是普通函数,不进虚表。
调试小技巧:监视窗口里虚表内容看起来不对时,先清理项目重新生成——有时只改了部分代码没重新编译,编译器会显示错乱。
六、多态的原理:指向谁,调用谁
买票示例(多态也发生在多个派生类之间):
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
string _id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
string _codename;
};
void Func(Person* ptr)
{ // 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
// 但是跟ptr没关系,而是由ptr指向的对象决定的。
ptr->BuyTicket();
}
int main(){ // 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
// 多态也会发生在多个派生类之间。
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
输出:买票-全价 → 买票-打折 → 买票-优先
6.1 对象模型
- 每个对象有自己的 vfptr,指向自己的虚表
- Person:虚表里只有
Person::BuyTicket,成员_name - Student:继承
_name,虚表里是Student::BuyTicket,成员_id - Soldier:继承
_name,虚表里是Soldier::BuyTicket,成员_codename(代号)
6.2 底层原理
- 指针视角:父类指针无论指向 Person / Student / Soldier,从内存看都是一个 Person 对象(因为切片),但通过 vfptr 找到的虚表不同 → 找到的虚函数地址不同
- 调用流程(汇编层面):
- 取指针(ptr 指向的对象地址)
- 从对象头部取 vfptr(对象第一个成员)
- 通过 vfptr 找到虚表
- 从虚表中取出对应虚函数的地址
call该地址
汇编演示(动态绑定,满足多态):
ptr->BuyTicket(); // ptr是指针+BuyTicket是虚函数满足多态条件
// 这就是动态绑定,编译在运行时到ptr指向对象的虚函数表中确定调用函数地址
00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax
- 核心理解:无论指向父类还是子类,执行的汇编指令完全一样;只是因为传的对象不同 → 对象虚表里存的地址不同 → 调用到不同的虚函数
- 这也正是"重写"的意义:子类对象虚表里对应位置放的是子类的虚函数地址
七、静态绑定 vs 动态绑定
| 静态绑定 | 动态绑定 | |
|---|---|---|
| 发生时机 | 编译时 | 运行时 |
| 条件 | 不满足多态 | 满足多态 |
| 地址确定 | 编译时根据 ptr 的类型确定 | 运行时通过 ptr 指向对象的虚表确定 |
| 效率 | 更高(指令少、不跳转) | 较低(多几条取虚表指令) |
- 静态绑定:编译阶段直接确定函数地址并 call
ptr->BuyTicket(); // BuyTicket不是虚函数,不满足多态条件
// 这就是静态绑定,编译器直接确定调用函数地址
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student (0EA153Ch)
- 静态 / 动态 = 编译时 / 运行时,记住这个对应关系即可
八、同类型对象共享虚表
- 不同类型对象:虚表各自独立(父类虚表放父类虚函数地址,子类虚表放子类虚函数地址)
- 同类型对象:虚表是同一个,共享(如 b1、b2 两个同类型对象虚表地址相同)
- 为什么不把虚函数地址直接放对象里,而只放一个指针?
- 同类型对象共享一张表,只需一个指针,节省空间
- 例:3 个虚函数各 4 字节 = 12 字节,若每个对象都存一份就浪费;共享一张表,无论多少对象都只存 12 字节
- 结论:同类型对象,虚表通用;不同类型对象,虚表各自独立
九、派生类虚表的构成(三部分)
- 派生类对象成员由两部分构成:继承基类的成员 + 自己的成员
- 虚函数表指针是继承基类的(基类有虚函数,对象里已有 vfptr),派生类不会额外生成一个;但派生类和基类的 vfptr 不是同一个(各自指向自己的虚表,互相独立,就像同名成员变量各自独立一样)
- 派生类虚表的生成逻辑:
- 先把基类的虚表拷贝过来(继承得来的虚函数地址)
- 若重写了某个虚函数,用重写的地址覆盖基类对应位置
- 自己新增的虚函数追加到表尾
- 普通函数(func4、func5)不进虚表
- 覆盖的意义:父类指针指向父类 → 虚表对应位置是父类函数;指向子类 → 对应位置被覆盖成子类函数 → 完成多态
十、虚函数与虚表的存储位置(易错点)
- 虚函数本身(函数代码):存在代码段,和普通函数一样(编译好的代码)
- 虚函数表:是一个数组(指针数组),存的是虚函数地址;存放在只读数据段,不可修改
- 易错点:虚表存的是"虚函数地址"而非函数代码本身,函数代码在代码段,两者别混淆
- 补充(编译器实现差异):VS 在虚表末尾放一个
0000作"哨兵"(每次运行地址会变);GCC 没有这个约定,了解即可

672

被折叠的 条评论
为什么被折叠?



