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::func -
B的虚表:[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),就是基类的虚函数在子类,以同样的函数名,参数值相同,返回值相同,写了一遍。 绑定时期:运行期
隐藏:就是基类的函数(管你是不是虚函数),在子类,以同样的函数名,但是不符合重写的规则,就是隐藏,父子的成员变量,同样的变量名也称为隐藏 绑定时期:编译期