多态(虚表,动态/静态绑定)
C++ 多态完全指南:从原理到面试
目录
- 什么是多态
- 为什么要有多态
- 怎么用多态
- 静态多态 — 重载 + 模板
- 静态绑定
- 动态多态 — 继承 + 虚函数
- 动态绑定
- 虚函数
- 虚函数的表指针
- 虚函数表(虚表)
- 面试题(静态 / 动态绑定)
- 静态多态 — 重载 + 模板
- override 和 final 关键字
- override
- final
- 重载,重写/覆盖,隐藏
什么是多态
同一行为(接口/方法),作用于不同对象时,表现的不同实现结果
为什么要有多态
多态存在的唯一目的,就是让上层代码(调用者)无需关心底层具体类型,从而在不修改已有代码的前提下,通过新增子类无限扩展新功能。
怎么用多态
多态有两种形态一个时运行时多态(动态多态),一个是编译时多态(静态多态)
静态多态-重载 + 模板
核心特征:编译器在编译阶段就确定了调用哪个函数的具体地址,生成对应的机器码。
1.重载
#include <iostream> using namespace std; // 编译时:编译器记录了两个不同名字修饰(Name Mangling)的函数 void print(int x) { cout << "打印整数: " << x << endl; } void print(double x) { cout << "打印浮点数: " << x << endl; } int main() { int a = 10; double b = 3.14; // 编译时:编译器看到 a 是 int,直接在这行生成 call _Z5printi(固定地址) print(a); // 编译时:编译器看到 b 是 double,直接在这行生成 call _Z5printd(固定地址) print(b); return 0; }2.模板
#include <iostream> using namespace std; // 模板:编译时,编译器会根据调用类型生成两份独立函数 // 对于 int,生成 int maxValue_int(int a, int b) // 对于 double,生成 double maxValue_double(double a, double b) template <typename T> T maxValue(T a, T b) { return (a > b) ? a : b; } int main() { // 编译时:生成 int 版本的机器码,并直接 call 这个地址 cout << maxValue(3, 5) << endl; // 编译时:生成 double 版本的机器码,并直接 call 这个地址 cout << maxValue(3.14, 2.71) << endl; return 0; }静态绑定
是什么
编译期决定“调用哪个函数地址”或“取哪个值”。编译器看着代码的静态类型(声明时的类型)直接写死地址或数值,运行时不再改变。
编译期直接写死值,不改变。
什么情况会出现
| 情况 | 说明 | 举例 |
|---|---|---|
| ① 调用非虚函数 | 编译器直接写死call A::eat | p->eat()(eat非虚),永远调A的版本。 |
| ② 函数重载 | 编译期根据参数类型匹配 | print(1)调print(int),print(1.0)调print(double)。 |
| ③ 模板实例化 | 编译期生成具体类型的代码 | max<int>(1,2)生成整型版本。 |
| ④ 通过对象(而非指针/引用)调用虚函数 | 编译器明确知道类型,直接静态绑定,甚至内联展开。 | B b; b.func();(即使func是虚函数,也直接写死调B::func)。 |
| ⑤ 默认参数的取值 | 大陷阱!即使函数体是动态查的,默认参数的值在编译期就定死了。 | 下面那道题,val=1就是在编译期静态绑定的。 |
动态多态-继承+虚函数
核心特征:编译时只检查语法(父类有没有这个方法),运行期才根据实际对象去虚表(vtable)里查找。
实现它必须满足两个要求:1.必须是基类的指针或者引⽤调⽤虚函数 2.被调⽤的函数必须是虚函数,并且完成了虚函数重写/覆盖。
#include <iostream> using namespace std; class Animal { public: // 虚函数:编译时,编译器知道要生成虚表(vtable) // 此时会在对象中预留一个隐藏指针(vptr) virtual void speak() { cout << "动物发出某种声音" << endl; } virtual ~Animal() {} // 虚析构保证正确释放 }; // 子类1:重写 speak class Dog : public Animal { public: void speak() override { // override 是 C++11 关键字,提高可读性 cout << "旺财: 汪汪汪!" << endl; } }; // 子类2:重写 speak class Cat : public Animal { public: void speak() override { cout << "咪咪: 喵喵喵~" << endl; } }; // 一个全局函数,接受父类引用(多态的经典用法) 引用调用 void makeSound(Animal& animal) { // 问题来了:这行代码编译时,编译器只知道 animal 是 Animal& // 但运行时,传进来的可能是 Dog,也可能是 Cat animal.speak(); } //如果是 makeSound(Animal animal)的话,它无法分辨因为传入变量不再是之前那个变量, int main() { Dog dog; Cat cat; Animal* animal = &dog; animal->speak();//基类的指针,执行Dog::speak() // 编译时:编译器检查 makeSound 接受 Animal&,dog 是 Dog 类,可以隐式转换,通过编译。 // 运行时:makeSound 函数里的 animal 引用,实际绑定的是 Dog 对象, // CPU 会去读取 dog 内存里的 vptr,找到 Dog 的虚表,执行 Dog::speak() makeSound(dog); // 编译时:同上,编译器通过。 // 运行时:CPU 读取 cat 内存里的 vptr,找到 Cat 的虚表,执行 Cat::speak() makeSound(cat); return 0; }动态绑定
是什么
动态绑定(Dynamic Binding):运行期决定“调用哪个函数地址”。编译器不写死地址,而是生成查虚表(vtable)的指令,运行时根据动态类型(实际对象类型)跳转。
编译期不写死地址,运行期决定调用哪个地址
什么情况会出现
| 必要条件 | 说明 |
|---|---|
① 函数必须是虚函数(有virtual) | 普通函数不配查表。 |
| ② 必须通过指针或引用调用 | 如果是对象实例(如B b),编译器看穿类型,直接静态绑定。 |
| ③ 派生类重写了该虚函数(或者至少存在继承关系) | 如果没重写,虽然机制上走了查表(动态绑定),但行为没变,不产生动态多态。 |
实现原理
靠动态绑定的原理实现的。
虚函数
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数
虚函数的表指针
class Base { p ublic: virtual void Func1() { cout << "Func1()" << endl; } protected: int _b = 1; char _ch = 'x'; }; sizeof(Base);//为12在32位下 vptr 4 + int 4 + char 1 + 补齐 3 = 12 struct对齐其中还储存一个东西叫vptr虚函数的表指针,指向虚函数表
虚函数表(虚表)
是什么
本质:它是一个函数指针数组(更准确地说是“地址数组”),存储在可执行文件的**只读数据段(
.rodata)**中。归属:每个类有一张独立的表。例如
A有一张表,B有一张表。内容:按虚函数声明顺序,依次存放该类的虚函数入口地址。
A的虚表:[0] -> A::test,[1] -> A::funcB的虚表:[0] -> A::test(没重写则沿用),[1] -> B::func(重写了则覆盖)
与对象的关系:每个对象头部隐藏了一个指针(vptr,占 8 字节),指向它所属类的虚表。
怎么用
编译器为每个含虚函数的类生成一张虚表,存放虚函数地址。构造对象时,自动将对象的 vptr 指向该类的虚表。调用虚函数时,编译器生成“查表指令”——先从对象中取 vptr,再从 vptr 指向的虚表中偏移取地址,最后跳转执行。这就是动态绑定的底层实现。
面试题(静态/动态绑定)
只考察动态绑定,动态多态和静态绑定
class A { public: virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;} virtual void test(){ func();} }; class B : public A { public: void func(int val = 0){ std::cout<<"B->"<< val <<std::endl; } }; int main(int argc ,char* argv[]) { A* a = new B; a->test(); B*p = new B; p->test(); return 0; //都打印B->1; }打印B->1,B->1,首先先从编译期来看待问题,
1.首先p->test(),可以看到p为B类且B类里test()为虚函数,这里便要走动态绑定,不写死地址
2.再看A类test里有func函数,但是我们也可以看到func也为虚函数,要走动态多态不写死地址,但是A::test内部,this的静态类型是A*,C++ 规定默认参数值根据静态类型决定,所以编译器去A::func的声明里取默认值,走静态绑定(机制),即1,并把这个1硬编码到即将生成的调用指令中(压栈传参)。如果当执行A::test时传入func的值就是func(1);func调用哪个取决于运行时给你的类的类型的虚函数表
3.形成虚函数表(虚表)
A的虚表:槽位0 →A::test,槽位1 →A::funcB的虚表:槽位0 →A::test(因为没重写,沿用 A 的),槽位1 →B::func(重写了,覆盖)
再从运行期来看
1.cup执行拿到p,指向堆上的B对象 -> 读取对象头部获得vptr,然后就找到了B的虚表
2.要执行test就读取B的虚表槽位0,发现是A::test
3.执行A::test,由于静态绑定写死了传入1,
4.要执行func,p指向的B对象中取vptr,查B的虚表,找func槽位(槽位1)。
5.发现是B::func地址,跳转执行B::func(int val),传入1
a->test可以看到他是属于静态类型位A*进入a的类里面,发现A::test为虚函数,所以走动态绑定,再看A::test里面的编码,func()可以在A类里面看到为虚函数所以也是走动态绑定,但是func()需要传入值,C++ 规定默认参数值根据静态类型决定,所以这是时候走的是静态绑定机制,即是1,func(1),也就是说当执行A::test()时候func(1)是走动态绑定的,到了运行期发现a的动态类型为B * 取出B类的vptr,查看虚表B的虚表:槽位0 →A::test(因为没重写,沿用 A 的),槽位1 →B::func(重写了,覆盖),要执行test读取槽位0,->a::test,再执行func(1),读取槽位1,执行B::func(1);
对于a->test()来说编译期来看进入的是A类的test(),发现是虚函数所以走动态绑定,接下来和上面雷同。
还有就是当基类的虚函数在子类里面被隐藏了,子类的虚函数表任然有该基类虚函数的地址,不会被取代。
override和final关键字
作用:就是编译期约束,他们不产生任何额外的运行时代码,零开销,用来帮你揪出代码中的笔误,并明确告知代码维护者你的设计意图。
override
强制检测是否真的重写的基类的虚函数(防止函数名或者参数对不上),仅静态检查
class A { virtual void func(int x) {} }; class B : public A { void func(double x) override {} // 编译报错!因为基类没有 func(double),强制你改回来! };final
强制终止继承链。修饰虚函数则禁止子类重写;修饰类则禁止被继承。
class A { virtual void func() final {} }; // A 说 func 到此为止 class B : public A { void func() override {}; // 编译报错!A 已经 final 了,不能重写! }; class A final {}; // A 类不允许有儿子 class B : public A {}; // 编译报错!无法从 final 类继承!重载,重写/覆盖,隐藏
重载(overlord),就是多个函数在同一作用域上,函数名相同,参数值不同,或者参数个数不同,返回值可以相同也可以不同 绑定时期:编译期
int speak(int a){;} int speak(char a){;} char speak(char a){;}重写/覆盖(override),就是基类的虚函数在子类,以同样的函数名,参数值相同,返回值相同,写了一遍。 绑定时期:运行期
隐藏:就是基类的函数(管你是不是虚函数),在子类,以同样的函数名,但是不符合重写的规则,就是隐藏,父子的成员变量,同样的变量名也称为隐藏 绑定时期:编译期
