【C++ 多态】—— 礼器九鼎,釉下乾坤,多态中的 “风水寻龙诀“
欢迎来到一整颗红豆的博客✨,一个关于探索技术的角落,记录学习的点滴📖,分享实用的技巧🛠️,偶尔还有一些奇思妙想💡
本文由一整颗红豆原创✍️,感谢支持❤️!请尊重原创📩!欢迎评论区留言交流🌟
个人主页 👉 一整颗红豆
本文专栏 ➡️C++ 进阶之路

礼器九鼎,釉下乾坤,多态中的 "风水寻龙诀"
多态的概念
众所周知,面向对象有三大特性,封装、继承和多态!
多态(Polymorphism) 是面向对象编程的核心特性,允许用统一的接口操作不同类型的对象,并根据对象实际类型执行不同的行为。C++中的多态分为编译时多态和运行时多态。编译时多态(静态多态)
编译时多态(Compile-Time Polymorphism) 又称为 静态多态,是一种在 代码编译阶段就能确定具体调用行为的机制。它的核心特点是 基于静态类型系统,通过代码结构直接决定调用哪个函数或操作,无需运行时动态查找。编译时多态的实现方式有,函数重载,运算符重载和模板。
函数重载 Function Overloading
- 通过 参数列表不同(类型/数量/顺序)定义同名函数
示例:
voidprint(int x){ std::cout <<"int: "<< x << std::endl;}voidprint(double x){ std::cout <<"double: "<< x << std::endl;}voidprint(constchar*s){ std::cout <<"const char *: "<< s << std::endl;}调用策略:
intmain(){print(10);// 调用 print(int)print(3.14);// 调用 print(double)print("Hello");// 调用 print(const char*)}输出:
int: 10 double: 3.14 const char *: Hello 有关函数重载的原理及应用不再过多说明,详见博文 👉 C++函数重载
运算符重载(Operator Overloading)
- 原理:为自定义类型重定义运算符行为
示例:
classComplex{private:double real;double imag;public:Complex(double r =0,double i =0):real(r),imag(i){}// 重载 + 运算符 Complex operator+(const Complex &other){returnComplex(real + other.real, imag + other.imag);}voidprint(){ std::cout << real <<" + "<< imag <<"i"<< std::endl;}};调用策略:
intmain(){// 运算符重载调用 Complex c1(1,2); Complex c2(3,4); Complex c3 = c1 + c2; std::cout <<"Complex addition: "; c3.print();}输出:
Complex addition: 4 + 6i 同样有关运算符重载的知识这里也不再过多讲解,在类和对象中已经讲的很详细了,忘了就快去复习一下吧!! 👉 C++ 类和对象 进阶篇
模板(Templates)
- 原理:通过泛型编程生成类型特化代码
函数模板示例:
template<typenameT> T Max(T a, T b){return(a > b)? a : b;}调用策略:
intmain(){ std::cout <<Max(3,5)<< std::endl;// 生成 int 版本 std::cout <<Max(2.7,3.14)<< std::endl;// 生成 double 版本return0;}输出:
53.14有关函数模板更深入的讲解,请参考博文👉 C++ 函数模板
类模板示例:
template<typenameT>classStack{public:voidpush(const T &item){ _elements.push_back(item);} T pop(){}private: std::vector<T> _elements;};调用策略:
intmain(){ Stack<int> int_Stack;// 存储整数的栈 Stack<string> str_Stack;// 存储字符串的栈return0;}编译时多态的特点
静态绑定(Static Binding)
- 定义:函数调用在编译阶段确定,编译器根据调用时的 静态类型 直接绑定到具体实现。
原理:
intadd(int a,int b);// 函数Adoubleadd(double a,double b);// 函数Bintmain(){add(3,5);// 编译时直接绑定到函数Aadd(3.0,5.0);// 编译时直接绑定到函数Breturn0;}- 编译器通过函数签名(函数名 + 参数类型)匹配最佳候选
- 生成的目标代码中直接写入函数地址,无运行时决策
下面是静态绑定和动态绑定 (后面要讲的虚函数的原理) 的区别
| 特性 | 静态绑定 | 动态绑定(虚函数) |
|---|---|---|
| 决策时机 | 编译时 | 运行时 |
| 性能开销 | 无额外开销 | 虚表查找(1~2 次指针跳转) |
| 灵活性 | 固定 | 可动态切换 |
类型安全(Type Safety)
模板类型推导:
template<typenameT> T Max(T a, T b){return(a > b)? a : b;}intmain(){Max(3,5.0);// 编译出错:T 同时推导为 int 和 double,类型不一致return0;}- 编译出错:
double和int类型不一致,模板无法正确推导

- 编译器严格检查模板参数类型一致性
- 避免隐式类型转换导致的意外行为
错误检测时机:
std::vector<int> v; v.push_back("Hello,World");// 编译错误:参数类型不匹配静态多态,又叫做 编译时多态,显而易见在编译器进行编译时,就会对多态的正确性进行检测,如果发现有错误,则无法编译通过,所以是类型安全的,这也是其优点之一
无运行时开销
代码直接生成
// 模板函数template<typenameT> T square(T x){return x * x;}intmain(){square(5);// 生成 int square(int x) { return x*x; }square(3.14);// 生成 double square(double x) { return x*x; }return0;}- 每个类型特化生成独立的机器代码
- 调用时直接跳转到具体函数,无间接寻址,提高运行效率
以上都是编译时多态的优点,其实编译时多态也有缺点,就是会导致代码膨胀,二进制文件的体积过大!
代码膨胀(Code bloat)
模板实例化机制:
template<typenameT>classWrapper{ T data;/*...*/};intmain(){ Wrapper<int> w1;// 生成 int 特化版本 Wrapper<double> w2;// 生成 double 特化版本return0;}- 每个不同类型参数生成完全独立的二进制代码
- 可能导致可执行文件体积显著增大
凡事都有两面性,直接生成代码,避免了间接寻址带来的性能损耗,无运行时开销,提高了效率,但同时也生成多种不同版本的二进制代码,代码膨胀,会导致编译链接后生成的可执行文件体积增大!
运行时多态(动态多态)
要理解运行时多态,首先要知道虚函数的概念,因为C++多态的核心机制就是派生类对基类虚函数的重写。
认识虚函数(Virtual function)
虚函数(Virtual Function) 是实现 运行时多态(动态多态) 的核心机制。它允许通过基类指针或引用调用派生类的重写函数,是面向对象编程中实现“一个接口,多种实现”的关键工具。
虚函数的概念:
- 虚函数 是用
virtual关键字声明的成员函数,用于实现 运行时多态。 - 类成员函数前面加
virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。 - 它允许派生类 重写(
Override) 基类的函数实现,并通过基类指针/引用调用派生类的版本。
虚函数的定义:
基类声明虚函数:
classAnimal{public:virtualvoidspeak(){// 使用 virtual 关键字 std::cout <<"Animal sound\n";}};派生类重写虚函数:
classDog:publicAnimal{public:voidspeak()override{// 使用 override 明确重写(C++11) std::cout <<"Woof!\n";}};通过基类调用:通过基类指针/引用调用虚函数时,实际调用的是 对象实际类型 的函数:
intmain(){ Animal *animal =newDog(); animal->speak();// 输出 "Woof!"(调用 Dog 的实现)delete animal;return0;}输出:
Woof!虚函数的重写/覆盖(override和final)
虚函数重写(Override) 是指派生类重新定义基类的虚函数,实现 同签名不同行为。它是实现运行时多态的关键机制。虚函数重写的必要条件
| 条件 | 说明 |
|---|---|
| 基类函数为虚函数 | 基类函数必须使用 virtual 声明 |
| 函数签名一致 | 派生类函数必须与基类的 函数名、参数类型/数量/顺序、const 限定符 完全一致 |
| 访问权限允许 | 派生类函数访问权限不能比基类更严格(如基类为 public,派生类不能为 private) |
注意:
- 派生类在重写基类虚函数时,在不加
virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。 - 不过要牢记,即使派生类继承下来的虚函数即便不加
virtual关键字也可以重写基类的虚函数,构成多态!
虚函数重写时的常见错误:
函数签名不一致:
- 错误原因:派生类中重写的函数与基类虚函数的参数列表、返回类型(非协变类型)或 const 修饰符不一致。
- 后果:编译器不会报错,但函数不会正确覆盖基类虚函数,而是形成隐藏(
hide)。这个之前讲过,派生类会隐藏和基类中函数签名相同的函数。
示例:
classBase{public:virtualvoidfunc(int){}};classDerived:publicBase{public:voidfunc(double){}// 参数类型不一致,未覆盖基类 func(int)};没有使用 override 关键字:
- 错误原因:未在派生类中使用 C++11 引入的
override关键字明确标记重写。 - 后果:若函数签名错误,编译器可能不会提示错误,导致隐藏而非覆盖。
classDerived:publicBase{public:voidfunc(int)override{}// 使用 override 强制编译器检查覆盖};基类虚函数未声明为 virtual:
- 错误原因:基类函数未用
virtual修饰,但派生类试图重写。 - 后果:派生类函数与基类函数是独立的,无法通过基类指针/引用调用多态。
示例:
classBase{public:voidfunc(){}// 非虚函数};classDerived:publicBase{public:voidfunc(){}// 隐藏基类函数,无法多态调用};注意:
- -
override是 C++11 引入的一个新特性,它并非强制要求。在没有override关键字时,满足条件重写的条件同样可以构成虚函数的重写,只是若派生类函数使用override声明,但未正确重写基类虚函数(如函数名、参数列表或常量性不匹配),编译器会报错,有助于在编译阶段发现错误。 - C++11还提供了一个关键字叫做
final,如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。
classCar{public:virtualvoidDrive()final{}};classBenz:publicCar{public:virtualvoidDrive(){ std::cout <<"Benz"<< std::endl;// 编译出错,无法重写final修饰的虚函数}};趁热打铁,接下来我们看一道有关多态场景的选择题:
以下程序输出结果是什么()
- A: A->0
- B: B->1
- C: A->1
- D: B->0
- E: 编译出错
- F: 以上都不正确
classA{public:virtualvoidfunc(int val =1){ std::cout <<"A->"<< val << std::endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(int val =0){ std::cout <<"B->"<< val << std::endl;}};intmain(int argc,char*argv[]){ B *p =new B; p->test();return0;}正确的答案是B哦!不知道你做对了没有,可以想想以下两个问题哦:
1. func是否满足虚函数的重写条件?
2. 默认参数的确定实在编译时还是运行时呢?
纯虚函数(Pure Virtual Function)和抽象类
纯虚函数(Pure Virtual Function)
是一种没有具体实现的虚函数,其存在的目的是强制派生类必须实现该函数。它的声明方式是在虚函数声明末尾添加 = 0。
例如;
virtual 返回类型 函数名(参数)=0;// 纯虚函数纯虚函数的作用:
- 定义接口规范:纯虚函数为所有派生类定义一个必须实现的统一接口。
- 创建抽象类:包含纯虚函数的类称为抽象类,它不能被直接实例化(不能创建对象)。
- 强制派生类实现:所有直接继承自抽象类的派生类必须重写(override)纯虚函数,否则派生类也会成为抽象类。
简单来说就是,如果基类定义了纯虚函数,那么这个基类被称为抽象类,不能用来创建对象,同时继承了该类的派生类必须重写该纯虚函数,否则派生类也将成为抽象类。这样一来,基类可以提供一个统一的接口,具体实现交给不同的派生类实现。
例如:
// 抽象基类(包含纯虚函数)classAnimal{public:virtualvoidsound()const=0;// 纯虚函数virtual~Animal(){}// 虚析构函数(重要!)};// 派生类必须实现 sound()classDog:publicAnimal{public:voidsound()constoverride{// 重写纯虚函数 std::cout <<"Woof!"<< std::endl;}};classCat:publicAnimal{public:voidsound()constoverride{// 重写纯虚函数 std::cout <<"Meow!"<< std::endl;}};纯虚函数可以有实现(但通常不需要)
- C++允许基类为纯虚函数提供默认实现,但派生类仍需显式重写,注意纯虚函数的实现必须在类外!
classAnimal{public:virtualvoidsound()const=0;};// 纯虚函数的默认实现(罕见用法)必须实在类外实现的!voidAnimal::sound()const{ std::cout <<"Default animal sound"<< std::endl;}classDog:publicAnimal{public:voidsound()constoverride{Animal::sound();// 调用基类的默认实现 std::cout <<"Woof!"<< std::endl;}};虚析构函数(Virtual Destructor)
看下面一个例子:
classBase{// 基类(无虚析构函数)public:~Base(){ std::cout <<"Base destructor\n";}};classDerived:publicBase{// 派生类(持有动态资源)public:int*data;Derived(){ data =newint[100];// 动态分配内存}~Derived(){delete[] data;// 释放内存 std::cout <<"Derived destructor\n";}};intmain(){ Base *obj =newDerived();// 基类指针指向派生类对象delete obj;// 仅调用了Base基类的析构函数return0;}输出:
Base destructor 不难发现,当我们用基类的指针指向派生类,释放基类指针时,只调用了基类的析构函数,释放了基类中的资源,并没有调用派生类的析构函数,这会导致什么问题呢?
是不是会导致资源没有正确释放,派生类中的data指向的100个 int 内存未被释放,会导致内存泄漏。
如果基类中还有其他动态资源,比如文件句柄、数据库连接等资源,这些资源也会泄漏。会对整个程序造成重大影响!这时就需要使用虚析构函数来解决问题 !
虚析构函数(Virtual Destructor) 是 C++中用于解决多态对象资源释放问题的关键机制。它通过动态绑定确保通过基类指针删除派生类对象时,派生类和基类的析构函数都能被正确调用,避免资源泄漏。- 虚析构函数是用
virtual关键字声明的析构函数。 - 当基类指针指向派生类对象时,若基类的析构函数是虚函数,删除该指针会触发动态绑定,确保调用实际对象类型的析构函数。
classBase{public:virtual~Base(){// 声明为虚析构函数 std::cout <<"Base destroyed\n";}};classDerived:publicBase{public:~Derived()override{// 重写虚析构函数 std::cout <<"Derived destroyed\n";}};intmain(){ Base *obj =newDerived();delete obj;// 正确调用Derived和Base的析构函数return0;}输出:
Derived destroyed Base destroyed 还记得之前讲到的,虚函数重写的要求吗?派生类的析构函数与基类的析构函数名称都不一样!怎么能构成重写呢?
实际上,基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual
关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了virltual修饰,派⽣类的析构函数就构成重写。
虚析构函数的原理:(TODO)
涉及到虚函数表,我们放在多态的原理中讲,相信大家看到那里自然就会明白!
协变(Covariant Return Types)
C++虚函数的协变允许派生类在重写基类虚函数时,将返回类型替换为基类函数返回类型的派生类指针或引用。
简单来说就是: 派生类重写基类虚函数时,可以与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解⼀下即可。
协变条件:
- 基类和派生类的虚函数返回类型必须为指针或引用。
- 派生类返回类型必须是基类返回类型的直接或间接派生类。
- 函数参数列表必须完全相同。
举个例子:
// 基类classFruit{public:virtual Fruit*clone()const{// 虚函数,返回Fruit*(基类指针)returnnewFruit(*this);}virtual~Fruit(){}};// 派生类classApple:publicFruit{public: Apple*clone()constoverride{// 协变:返回Apple*(派生类指针)returnnewApple(*this);}voidsayName()const{ std::cout <<"I am an Apple!"<< std::endl;}};intmain(){ Fruit* fruit =newApple();// 基类指针指向派生类对象 Fruit* cloned = fruit->clone();// 调用派生类的clone()// 验证协变特性if(Apple* apple =dynamic_cast<Apple*>(cloned)){ apple->sayName();// 成功调用Apple特有方法}else{ std::cout <<"Cloning failed!"<< std::endl;}delete fruit;delete cloned;return0;}输出:
I am an Apple!返回类型
- 基类虚函数返回 Fruit*
- 派生类重写时返回 Apple*(Apple 是 Fruit 的派生类)
- 符合协变要求:返回类型是基类返回类型的 派生类指针/引用
多态行为
- 通过基类指针 Fruit* 调用 clone(),实际调用 Apple::clone()
- 返回的 Apple* 可以隐式转换为 Fruit*,但保留了实际类型信息
动态类型验证
- 使用 dynamic_cast 将 Fruit* 转回 Apple*,验证协变正确性
- 调用 Apple 类的特有方法 sayName()
对于协变大家了解即可,其实底层原理也是多态的虚函数表指针。
重载(Overload)、重写(Override)、隐藏(Hide)的对比

| 特性 | 重载(Overload) | 重写(Override) | 隐藏(Hide) |
|---|---|---|---|
| 定义 | 同一作用域内,同名函数参数不同 | 派生类重写基类虚函数 | 派生类同名函数遮蔽基类同名函数 |
| 作用域 | 同一类或同一命名空间 | 基类与派生类之间 | 基类与派生类之间 |
| 函数签名要求 | 函数名相同,参数列表不同 | 函数名、参数、返回类型均相同 | 函数名相同,参数可同可不同 |
| virtual关键字 | 不需要 | 基类函数必须为虚函数 | 不需要 |
| 多态性 | 无 | 支持动态多态(运行时绑定) | 无(静态绑定) |
| 示例场景 | 同一类中的多个构造函数 | 派生类重写基类的虚函数 | 派生类定义与基类同名的非虚函数 |
重载(Overload)
- 重载规则:在同一作用域内,函数名相同,但参数列表不同(参数类型、数量、顺序不同)。
- 返回类型无关:仅返回类型不同不构成重载,会导致编译错误。
- 典型场景:同一类中的多个构造函数、工具函数。
classCalculator{public:// 重载示例intadd(int a,int b){return a + b;}doubleadd(double a,double b){return a + b;}// 参数类型不同intadd(int a,int b,int c){return a + b + c;}// 参数数量不同};重写(Override)
- 重写规则:派生类重新定义基类的 虚函数,要求函数名、参数列表、返回类型完全一致(协变返回类型例外)。
- 多态性:通过虚函数表(
vtable)实现运行时多态。 - 必须使用
virtual:基类函数声明为virtual,派生类建议使用override明确意图(C++11+)。
举例就省略,上面我们刚讲过。
隐藏(Hide)
- 隐藏规则:派生类定义与基类同名的函数(无论参数是否相同),导致基类同名函数被隐藏。
两种形式:
- 同名同参非虚函数:派生类函数隐藏基类函数(即使基类函数非虚)。
- 同名不同参:派生类函数隐藏基类所有同名函数(包括重载版本)。
classBase{public:voidfunc(){ std::cout <<"Base::func()\n";}voidfunc(int){ std::cout <<"Base::func(int)\n";}// 重载版本};classDerived:publicBase{public:// 隐藏基类的所有 func 函数(包括重载)voidfunc(){ std::cout <<"Derived::func()\n";}};intmain(){ Derived d; d.func();// 正确:调用 Derived::func()// d.func(1); // 错误!Base::func(int) 被隐藏 d.Base::func(1);// 正确:显式调用基类函数return0;}多态的原理
虚函数表(vtable)
虚函数表的概念
- 虚函数表(
vtable) 是在编译期间,编译器为每个包含虚函数的类生成的静态表,存储该类所有虚函数的地址,生成后不可修改。 - 虚函数指针(
vptr) 是每个对象实例中隐含的指针,指向其所属类的虚函数表。
内存布局示例
看下面一道题:
下面程序 在32位程序的运行结果是什么()
(32位下指针大小为4个字节)
- A. 编译报错
- B. 运行报错
- C. 8
- D. 12
classBase{public:virtualvoidFunc1(){ std::cout <<"Func1()"<< std::endl;}protected:int _b =1;char _ch ='x';};intmain(){ Base b; std::cout <<sizeof(b)<< std::endl;return0;}正确答案为D,12个字节,你回答对了吗?
成员变量内存布局:
- int _b:占4字节。
- char _ch:占1字节。
内存对齐:
- 结构体的总大小需对齐到4字节(int的对齐要求)。
- char _ch后需填充3字节以满足对齐。
C++中类和C语言中的结构体都满足内存对齐的规则,所以成员变量一共占了8个字节,那么还有另外4个字节是什么呢?
还多⼀个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v 代表virtual,f代表function)。⼀个含有虚函数的类中都至少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
- 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同⼀张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
- 派生类由两部分构成,继承下来的基类和自己的成员,⼀般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
- 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
派生类的虚函数表中包含:
- 基类的虚函数地址,
- 派生类重写的虚函数地址完成覆盖,
- 派生类自己的虚函数地址三个部分。
举个例子:这是一个基类
classBase{public:virtualvoidfunc1(){}virtualvoidfunc2(){}int data;};对象内存布局:
+----------------+ | vptr | --> 指向Base的虚函数表 | int data | +----------------+ 虚函数表内容:
Base的vtable: +----------------+ |&Base::func1 ||&Base::func2 | +----------------+ 虚函数表的生成规则
虚表在编译期间由编译器生成,程序启动时加载到内存,生命周期与程序一致。
单继承场景
派生类的虚函数表基于基类的虚函数表扩展:
- 若派生类重写基类虚函数,替换表中对应的函数指针。
- 若派生类新增虚函数,追加到表末尾。
classDerived:publicBase{public:voidfunc1()override{}// 重写基类的func1virtualvoidfunc3(){}// 新增虚函数};Derived的vtable:
+----------------+ |&Derived::func1| // 重写基类func1 |&Base::func2 | // 未重写,保留基类func2 |&Derived::func3| // 新增虚函数 +----------------+ 多重继承场景
每个基类对应独立的虚函数表,派生类合并所有基类的表并调整vptr偏移。
classBase1{virtualvoidf1();};classBase2{virtualvoidf2();};classDerived:publicBase1,publicBase2{voidf1()override{}voidf2()override{}};Derived对象内存布局:
+----------------+ | Base1的vptr | -->[&Derived::f1]| Base1成员变量 || Base2的vptr | -->[&Derived::f2]| Base2成员变量 || Derived成员变量 | +----------------+ 多态是如何实现的
多态(Polymorphism) 主要通过 虚函数(virtual functions) 和 虚函数表(vtable) 实现,核心是动态绑定(Dynamic Binding)。
动态绑定和静态绑定
动态绑定(Dynamic Binding) 和 静态绑定(Static Binding) 是函数调用的两种不同解析机制,直接影响程序的执行行为。
| 特性 | 静态绑定(早绑定) | 动态绑定(晚绑定) |
|---|---|---|
| 解析时机 | 编译时确定调用的具体函数 | 运行时根据对象实际类型确定调用的函数 |
| 实现机制 | 函数地址直接硬编码到代码中 | 通过虚函数表(vtable)和虚指针(vptr)动态查找 |
| 性能 | 高(无运行时开销) | 较低(需要查表和间接调用) |
| 灵活性 | 低(固定行为) | 高(支持多态) |
| 应用场景 | 普通函数、非虚成员函数、模板函数 | 虚函数(多态调用) |
静态绑定(Static Binding)
工作机制
- 编译时确定:编译器根据调用者的 静态类型(声明类型)直接绑定函数地址。
- 无运行时决策:无论实际对象是什么类型,调用的函数在编译时已固定。
典型场景
- 非虚成员函数调用:
classBase{public:voidnonVirtualFunc(){ std::cout <<"Base\n";}};classDerived:publicBase{public:voidnonVirtualFunc(){ std::cout <<"Derived\n";}};intmain(){ Base* obj =newDerived(); obj->nonVirtualFunc();// 输出 "Base"(静态绑定)delete obj;return0;}对于上面这个例子:
函数未声明为虚函数
- Base 类的 nonVirtualFunc() 没有使用 virtual 关键字,因此它是一个普通成员函数,不具备多态性。
指针类型决定调用
- obj 的类型是
Base*,编译器在编译时根据指针的静态类型(即Base*)确定调用Base::nonVirtualFunc(),与指针实际指向的对象类型无关。
派生类函数是“隐藏”而非“重写”
- Derived 类中的 nonVirtualFunc() 并未覆盖基类函数,而是定义了一个同名函数。这种现象称为 函数隐藏(NameHiding)。
- 若通过 Derived 类对象或指针调用 nonVirtualFunc(),才会调用派生类版本。
- 通过基类指针调用时,只能访问基类的函数。不具备多态性。
- 全局函数调用:
voidfunc(){/* ... */}func();// 静态绑定- 模板函数实例化:
template<typenameT>voidtemplateFunc(T t){/* ... */}templateFunc(42);// 编译时生成针对int的版本动态绑定(Dynamic Binding)
工作机制
- 运行时确定:通过虚函数表(vtable)和虚指针(vptr)动态查找函数地址。
- 依赖虚函数:只有虚函数(标记为 virtual 的成员函数)支持动态绑定。
典型场景
- 虚函数调用:
classBase{public:virtualvoidvirtualFunc(){ std::cout <<"Base\n";}};classDerived:publicBase{public:voidvirtualFunc()override{ std::cout <<"Derived\n";}};intmain(){ Base* obj =newDerived(); obj->virtualFunc();// 输出 "Derived"(动态绑定)delete obj;return0;}- 多态对象销毁:(虚析构函数)
classBase{public:virtual~Base(){}// 虚析构函数};classDerived:publicBase{public:~Derived()override{/* 释放派生类资源 */}}; Base* obj =newDerived();delete obj;// 动态调用~Derived()这里我们就可以讲解虚析构函数的原理了,虚析构函数通过动态绑定(运行时多态)确保调用实际对象类型的析构函数。delete 操作符通过 vptr 找到实际对象的析构函数,实现动态调用。
析构函数的调用顺序
当销毁一个派生类对象时,析构函数的调用顺序是 “从派生类到基类” 的逆向构造顺序:
- 先执行派生类的析构函数:释放派生类独有的资源(如动态内存、文件句柄等)。
- 自动调用基类的析构函数:释放基类的资源。
这种顺序由编译器自动管理,确保所有资源按正确顺序释放。
如果基类的析构函数没有被定义为虚函数,那么在析构时就不会触发动态绑定,实际上会通过静态绑定直接指向该指针的类型对象,即基类,从而只调用基类的析构函数,释放基类的资源,导致派生类的析构函数无法被调用,造成内存泄漏等问题。
看到这里,相信大家已经明白了为什么析构函数要定义为虚函数。也明白了多态的核心机制——动态绑定
还有一个值得注意的点是:虚函数的默认参数是静态绑定的
- 虚函数的默认参数在编译时确定,与动态绑定的函数体无关
再看我们之前的那道题,相信现在这道题对你来说已经是小菜一碟了
以下程序输出结果是什么()
- A: A->0
- B: B->1
- C: A->1
- D: B->0
- E: 编译出错
- F: 以上都不正确
classA{public:virtualvoidfunc(int val =1){ std::cout <<"A->"<< val << std::endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(int val =0){ std::cout <<"B->"<< val << std::endl;}};intmain(int argc,char*argv[]){ B *p =new B; p->test();return0;}结合我们上面讲到的知识点,现在已经不难理解,B重写了A的 func() 虚函数,当执行 p->test() ,这句代码时,会调用B中继承下来的基类的 test() 函数,从而执行 func() 函数,由于类B重写了基类的 func() 函数,会根据指针p指向的实际类型,执行动态绑定,实际上执行的是B中重写的 func() 函数,但由于默认参数是静态绑定,默认参数的取值由调用方的静态类型决定,与动态绑定的函数实现无关。test() 函数在 A 类中定义,其内部的 func() 调用根据 A 的静态类型确定默认参数为 1。在编译时就已经确定了,所以 val 的值是基类中的默认参数的值,为1。
完整的调用链:
p->test() → A::test() → func()(动态绑定到B::func(),但默认参数来自A的声明) 所以最终的输出结果就是: B->1动态绑定的实现依赖虚函数表
真实流程:
- 通过实际指向的对象找到 vptr。
- 通过 vptr 找到虚函数表。
- 根据函数在表中的位置调用实际函数。
虚函数表的位置
最后补充一个知识点,本质上虚函数也是函数,编译后也是一段指令,虚函数的地址放在了对象的虚函数表中,那么虚函数表存放在哪里呢?
关于虚函数表的存放位置,C++标准并没有明确规定,是交给编译器来实现的,不同的编译器实现可能不同,但是一般情况都存放在程序的只读数据段(.rodata), 虚函数表的内容在编译期就已确定,且在运行时不可修改,适合存放在只读内存中。通过如下代码验证:
classBase{public:virtualvoidfunc1(){ cout <<"Base::func1"<< endl;}virtualvoidfunc2(){ cout <<"Base::func2"<< endl;}voidfunc5(){ cout <<"Base::func5"<< endl;}protected:int a =1;};classDerive:publicBase{public:// 重写基类的func1virtualvoidfunc1(){ cout <<"Derive::func1"<< endl;}virtualvoidfunc3(){ cout <<"Derive::func1"<< endl;}voidfunc4(){ cout <<"Derive::func4"<< endl;}protected:int b =2;};intmain(){int i =0;staticint j =1;int*p1 =newint;constchar*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("Person虚表地址:%p\n",*(int*)p3);printf("Student虚表地址:%p\n",*(int*)p4);printf("虚函数地址:%p\n",&Base::func1);printf("普通函数地址:%p\n",&Base::func5);return0;}Visual Studio 2022平台下输出:
栈:010FF954 静态区:0071D000 堆:0126D740 常量区:0071ABA4 Person虚表地址:0071AB44 Student虚表地址:0071AB84 虚函数地址:00711488 普通函数地址:007114BF 在 Visual Studio 2022 平台下虚函数表是存储在常量区的。
总结多态调用
+----------------------------------------------------------------+ | 多态实现流程总结 | +----------------------------------------------------------------+ |||1. 定义基类虚函数 || - 基类中声明虚函数: virtual void func();||||2. 派生类重写虚函数 || - 派生类中 override: void func() override;||||3. 编译器生成虚函数表(vtable) || +---------------------------+ ||| 基类 vtable |||| - &Base::func ||| +---------------------------+ ||| 派生类 vtable |||| - &Derived::func |(重写后替换基类地址)|| +---------------------------+ ||||4. 对象内存布局 || +---------------------------+ ||| vptr | --> 指向 vtable ||| 基类成员变量 |||| 派生类成员变量 ||| +---------------------------+ ||(vptr 在对象内存首部,占 4/8 字节)||||5. 动态绑定过程(运行时) || +----------------------------------------------------------+||| 通过基类指针调用虚函数: obj->func();|||||||| a. 访问 obj 的 vptr |||| b. 通过 vptr 找到 vtable |||| c. 查表调用实际函数地址 &Derived::func ||| +----------------------------------------------------------+||||6. 虚析构函数保障 || - 基类声明虚析构函数: virtual ~Base()|| - 派生类析构函数自动重写 || - delete 基类指针时,触发完整析构链 ||| +----------------------------------------------------------------+ C++多态通过虚函数表和动态绑定机制实现,允许基类指针或引用在运行时根据实际对象类型调用对应的派生类方法:编译器为每个含虚函数的类生成虚函数表(存储函数地址),对象内置虚表指针(vptr)指向所属类的虚表,当通过基类指针调用虚函数时,程序通过 vptr 查表定位实际函数地址,实现运行时决议,同时虚析构函数确保对象销毁时正确调用派生类析构逻辑,从而支持面向对象中"同一接口,多种实现"的核心特性。
写在最后
本文到这里就结束了,有关C++更深入的讲解,还有更多的文章为大家讲解,敬请期待!感谢您的观看!
如果你觉得这篇文章对你有所帮助,请为我的博客 点赞👍收藏⭐️ 评论💬或 分享🔗 支持一下!你的每一个支持都是我继续创作的动力✨!🙏
如果你有任何问题或想法,也欢迎 留言💬 交流,一起进步📚!❤️ 感谢你的阅读和支持🌟!🎉
祝各位大佬吃得饱🍖,睡得好🛌,日有所得📈,逐梦扬帆⛵!