多态(polymorphism)
多态(Polymorphism)是面向对象编程(OOP)的三大核心特性之一(另外两个是封装和继承),指的是同一接口(函数、方法)在不同对象上表现出不同行为的能力。简单来说,就是 “一个接口,多种实现”。
什么是虚(virtual)函数?
看下面的例子,注意在Base中加了virtual和没有加virtual的区别
#include <iostream>
using std::cout;
using std::endl;
class Base{
public://成员函数上面标注了一个virtual那么便是虚函数//virtualvoid print(){cout << "Base::print" << endl;}//virtualvoid display(){cout << "Base::display()" << endl;}private:long _base = 10;
};
class Derived : public Base{
public:void print(){cout << "Derived::print" << endl;}private:long _derived = 20;
};void test(){Derived d; Base * pbase = &d;//使用基类指针去指向派生类对象pbase->print();//pbase里面存储的便是派生类对象的地址pbase->display();//Base *指针决定了该指针只可以sizeof(Base)个字节cout << sizeof(Base) << endl; cout << sizeof(Derived) << endl;
}int main()
{test();return 0;
}
output :
Base::print
Base::display()
8
16
没加virtual时, Base * pbase = & derived,此时发生向上转型。通过pbase->display()方法调用时,
转换成display(pdbase),通过此时的类名Base以及参数,去代码区查找对应的函数,最终找到Base::display然后调用, print()同理
加virtual之后
class Base{
public://成员函数上面标注了一个virtual那么便是虚函数virtualvoid print(){cout << "Base::print" << endl;}virtualvoid display(){cout << "Base::display()" << endl;}private:long _base = 10;
};
output :
Derived::print
Derived::print
16
24
当Base的display函数加上了virtual关键字,变成了一个虚函数,Base对象的存储布局就改变了。在存储的开始位置会多加一个虚函数指针,该虚函数指针指向一张虚函数表(简称虚表),其中存放的是虚函数的入口地址
Derived继承了Base类,那么创建一个Derived对象,依然会创建出一个Base类的基类子对象
在Derived类中又定义了print函数,发生了覆盖的机制(override),覆盖的是虚函数表中虚函数的入口地址
总结:
不加virtual关键字时,当Base * pbase = & derived时,此时发生向上转型。通过pbase->display()方法调用时,因为没有virtual关键字,属于静态绑定。编译器根据指针类型推断出应该调用Base类的display()方法。
加virtual关键字时,当Base * pbase = & derived时,此时同样发生向上转型。通过pbase->display()方法调用时,由于有virtual关键字,属于动态绑定。编译器会通过vfptr查找到虚表,根据虚表确定正确的函数地址。virtual就相当于告诉编译器,不要去代码区遍历找成员函数,必须通过遍历虚表的方式找成员函数
在虚函数机制中virtual关键字的含义
1、虚函数是存在的;(存在)
2、通过间接的方式去访问;(间接)
3、通过基类的指针访问到派生类的函数,基类的指针共享了派生类的方法(共享)
虚函数的覆盖(override)
如果一个基类的成员函数定义为虚函数,那么它在所有派生类中也保持为虚函数,即使在派生类中省略了virtual关键字,也仍然是虚函数。虚函数一般用于灵活拓展,所以需要派生类中对此虚函数进行覆盖。覆盖的格式有一定的要求:
与基类的虚函数有相同的函数名;
与基类的虚函数有相同的参数个数;
与基类的虚函数有相同的参数类型;
与基类的虚函数有相同的返回类型。
我们在派生类中对虚函数进行覆盖时,很有可能写错函数的形式(函数名、返回类型、参数个数),等到要使用时才发现没有完成覆盖。这种错误很难发现,所以C++提供了关键字override来解决这一问题。
关键字override的作用:
在虚函数的函数参数列表之后,函数体的大括号之前,加上override关键字,告诉编译器此处定义的函数是要对基类的虚函数进行覆盖。
class Base{
public:virtual void display() const{cout << "Base::display()" << endl;}
private:long _base;
};class Derived
: public Base
{
public://想要在派生类中定义虚函数覆盖基类的虚函数//很容易打错函数名字,同时又不会报错//没有完成有效的覆盖/* void dispaly() const{ //不会报错 *//* void dispaly() const override //编译器会报错 */void display() const override{cout << "Derived::display()" << endl;}
private:long _derived;};
覆盖 总结:
(1)覆盖是在虚函数之间的概念,需要派生类中定义的虚函数与基类中定义的虚函数的形式完全相同;
(2)当基类中定义了虚函数时,派生类去进行覆盖,即使在派生类的同名的成员函数前不加virtual,依然是虚函数;
(3)发生在基类派生类之间,基类与派生类中同时定义形式相同的虚函数。覆盖的是虚函数表中的入口地址,并不是覆盖函数本身。
多态的核心思想
通过基类定义统一的接口,派生类根据自身特性重写(override)接口的实现。当使用基类指针或引用指向派生类对象时,调用接口会自动执行派生类的实现,而无需关心对象的具体类型。
多态的实现条件
- 继承关系:存在基类和派生类的继承结构。
- 虚函数:基类中声明虚函数(用
virtual
关键字)。 - 重写:派生类中重写基类的虚函数(函数名、参数列表、返回值完全一致,推荐用
override
关键字显式标识)。 - 动态绑定:通过基类的指针或引用调用虚函数,程序在运行时根据对象实际类型选择对应函数版本。
#include <iostream>
using std::cout;
using std::endl;
//多态、虚函数、虚函数指针、虚函数覆盖四者有什么关系呢???
//虚函数:一个成员函数标注了virtual那么便是虚函数
//虚函数指针:成员函数标注了virtual之后的对象会存在一个虚函数指针
//虚函数覆盖:覆盖的是虚函数表里面的虚函数的入口地址
//多态:使用基类指针去指向派生类对象,并且使用基类指针调用虚函数
//便会触发多态
class Base{
public://成员函数上面标注了一个virtual那么便是虚函数virtualvoid print(){cout << "Base::print" << endl;}private:long _base = 10;
};
class Derived : public Base{
public:void print(){cout << "Derived::print" << endl;}//virtualprivate:long _derived = 20;
};
class Derived2 : public Base{
public:void print(){cout << "Derived2::print" << endl;}//virtualprivate:long _derived = 20;
};void test2(){//第一次:pbase指向Derived1类对象//第二次:pbase指向Derived2类对象Derived d;Derived2 d2;Base * pbase;//只需要将指针去指向不同的派生类对象//这个就是多态pbase = &d2;pbase->print();
}int main()
{test2();return 0;
}
多态的实现原理
多态的底层依赖虚函数表(vtable) 和虚函数指针(vptr):
- 虚函数表(vtable):每个包含虚函数的类会有一个全局的 vtable,存储该类所有虚函数的地址。派生类会继承基类的 vtable,并替换重写的虚函数地址。
- 虚函数指针(vptr):每个对象会包含一个 vptr,指向所属类的 vtable。
- 动态绑定:当通过基类指针 / 引用调用虚函数时,程序通过 vptr 找到对应 vtable,再根据函数索引找到实际要执行的函数地址(运行时确定)。
多态的优势
- 代码复用与扩展:新增派生类时,无需修改使用基类接口的代码(如
printInfo
函数),只需重写虚函数即可扩展功能(符合 “开闭原则”)。 - 简化逻辑:调用者只需关注基类接口,无需关心具体实现,降低代码复杂度。
- 灵活性:同一操作能适配不同对象,使程序更灵活、易维护。
总结
多态是面向对象编程的核心机制,它通过虚函数实现了 “接口与实现分离”,让代码具备更好的扩展性和复用性。在框架设计、插件系统、通用算法等场景中,多态是不可或缺的技术。
面试常考题
1、虚表存放在哪里?
在 C++ 中,虚函数表(vtable)的存储位置属于编译器实现细节,C++ 标准并未严格规定,但通常存放在进程的只读数据段(.rodata section) 或全局数据段(.data section) 中,具体取决于编译器和操作系统。
2、一个类中虚函数表有几张?
虚函数表(虚表)可以理解为是一个数组,存放的是一个个虚函数的地址
如果没有虚函数,那么此时不会有虚函数指针,也不会有虚表
单继承: 派生类继承了一个基类,该基类中定义了多个虚函数,那么此时会有一张虚函数表(基类有一张,派生类有一张)
多继承: 派生类继承了多个基类,多个基类中各自定义了各自的虚函数,那么此时便会有多张虚函数表
#include <iostream>
using namespace std;// 基类1:含虚函数
class Base1 {
public:virtual void func1() { cout << "Base1::func1()" << endl; }virtual void funcA() { cout << "Base1::funcA()" << endl; }
};// 基类2:含虚函数
class Base2 {
public:virtual void func2() { cout << "Base2::func2()" << endl; }virtual void funcB() { cout << "Base2::funcB()" << endl; }
};// 派生类:多继承Base1和Base2,并重写所有虚函数
class Derived : public Base1, public Base2 {
public:// 重写Base1的虚函数void func1() override { cout << "Derived::func1()" << endl; }// 重写Base2的虚函数void func2() override { cout << "Derived::func2()" << endl; }// 新增的虚函数virtual void func3() { cout << "Derived::func3()" << endl; }
};
虚表数量分析
Base1
有 1 份虚表,包含func1()
、funcA()
的地址。Base2
有 1 份虚表,包含func2()
、funcB()
的地址。Derived
会生成 2 份虚表:- 第 1 份虚表(对应
Base1
):
包含重写的func1()
、继承的funcA()
、新增的func3()
的地址。若派生类新增了虚函数,其地址会被追加到虚表末尾; - 第 2 份虚表(对应
Base2
):
包含重写的func2()
、继承的funcB()
的地址。
- 第 1 份虚表(对应
3、虚函数机制的底层实现是怎样的?
4.三个概念的区分:重载(overload) ,隐藏 (oversee),覆盖(override)
#include <iostream>
using namespace std;class Base {
public:virtual void func() { // 基类虚函数(可被重写)cout << "Base::func()" << endl;}void func(int x) { // 基类非虚函数(可被隐藏)cout << "Base::func(int) x=" << x << endl;}
};class Derived : public Base {
public:// 重写:与基类func()同名、同参数,覆盖基类虚函数void func() override {cout << "Derived::func()" << endl;}// 隐藏:与基类func(int)同名但参数不同(或参数相同但未重写)void func(double x) {cout << "Derived::func(double) x=" << x << endl;}
};int main() {Derived d;Base* bptr = &d;// 重写生效:多态调用派生类版本bptr->func(); // 输出 "Derived::func()"// 派生类中直接调用:优先使用派生类的func()和func(double)d.func(); // 输出 "Derived::func()"(重写的版本)d.func(3.14); // 输出 "Derived::func(double) x=3.14"(隐藏基类func(int))// 基类的func(int)被隐藏,需显式调用d.Base::func(10); // 输出 "Base::func(int) x=10"return 0;
}
在 C++ 中,隐藏(name hiding)和重写(override)是两种不同的机制,它们作用于不同的函数,因此可以在同一个派生类中同时存在,但不能对同一个基类虚函数既隐藏又重写。
- 可以共存:派生类中可以同时存在 “重写基类虚函数” 和 “隐藏基类其他同名函数” 的情况(如示例中
func()
被重写,func(int)
被func(double)
隐藏)。 - 不可对同一函数既重写又隐藏:对于基类的某个特定虚函数(如
func()
),要么被派生类重写(参数列表相同),要么被派生类的同名不同参数函数隐藏,二者互斥。
动态多态(虚函数机制)被激活的条件(重点*)
虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?其实激活条件还是比较严格的,需要满足以下全部要求:
基类定义虚函数
派生类中要覆盖虚函数 (覆盖的是虚函数表中的地址信息)
创建派生类对象
基类的指针指向派生类对象(或基类引用绑定派生类对象)
通过基类指针(引用)调用虚函数
最终的效果:基类指针调用到了派生类实现的虚函数。(如果没有虚函数机制,基类指针只能调用到基类的成员函数)
虚函数的限制
虚函数机制给C++提供了灵活的用法,但仍然受到了一些约束,以下几种函数不能设为虚函数:
1.构造函数不能设为虚函数
构造函数的作用是创建对象时完成数据的初始化,而虚函数机制被激活的条件之一就是要先创建对象,有了对象才能表现出动态多态。如果将构造函数设为虚函数,那此时构造未执行完,对象还没完成初始化,存在矛盾。
2.静态成员函数不能设为虚函数
虚函数的实际调用: this -> vfptr -> vtable -> virtual function,但是静态成员函数没有this指针,所以无法访问到vfptr
vfptr是属于一个特定对象的部分,虚函数机制起作用必然需要通过vfptr去间接调用虚函数。静态成员函数找不到这样特定的对象。
3.Inline函数不能设为虚函数
因为inline函数在编译期间完成替换,而在编译期间无法展现动态多态机制,所以起作用的时机是冲突的。如果同时存在,inline失效。
4.普通函数不能设为虚函数
虚函数要解决的是对象多态的问题,与普通函数无关,普通函数式非成员函数,不能设置为虚函数。
不同访问方式对虚函数的影响
虚函数的调用结果取决于 “访问方式”(对象直接调用 / 基类指针 / 引用调用),核心区分 “静态绑定” 和 “动态绑定”:
1. 通过派生类对象直接调用
- 非虚函数:静态绑定,调用派生类版本(若存在,隐藏基类版本)。
- 虚函数:直接调用派生类重写版本(无需指针 / 引用,因对象类型明确)。
class Base {
public:virtual void vfunc() { cout << "Base::vfunc" << endl; }void func() { cout << "Base::func" << endl; }
};class Derived : public Base {
public:void vfunc() override { cout << "Derived::vfunc" << endl; }void func() { cout << "Derived::func" << endl; }
};int main() {Derived d;d.vfunc(); // 调用Derived::vfunc(虚函数,对象类型明确)d.func(); // 调用Derived::func(非虚函数,隐藏基类版本)return 0;
}
2. 通过基类指针 / 引用指向派生类对象调用
- 虚函数:动态绑定,调用派生类重写版本(多态激活)。
- 非虚函数:静态绑定,调用基类版本(按指针类型决定)。
int main() {Derived d;Base* p = &d;p->vfunc(); // 动态绑定:调用Derived::vfunc(多态)p->func(); // 静态绑定:调用Base::func(按指针类型)return 0;
}
3. 通过派生类指针调用基类虚函数
需显式指定基类作用域,强制调用基类版本(绕过多态):
int main() {Derived d;Derived* p = &d;p->vfunc(); // 调用Derived::vfunc(默认)p->Base::vfunc(); // 显式调用Base::vfunc(绕过多态)return 0;
}
在构造函数和析构函数中访问虚函数
在 C++ 中,在构造函数和析构函数中调用虚函数时,不会触发多态行为,而是直接调用当前类(正在构造 / 析构的类)的虚函数版本。这是由对象的生命周期和虚函数表(vtable)的初始化 / 销毁顺序决定的,需要特别注意避免误用。
一、构造函数中调用虚函数
1. 行为特点
- 不会触发多态:构造派生类对象时,会先调用基类构造函数,再调用派生类构造函数。
- 调用当前正在构造的类的版本:在基类构造函数中调用虚函数,只会调用基类自己的虚函数版本(即使派生类重写了该函数)。
2. 原因分析
- 对象构造时,vptr(虚函数指针)的初始化顺序与构造函数一致:
- 先初始化基类部分,vptr 指向基类的 vtable;
- 再初始化派生类部分,vptr 才会切换到派生类的 vtable。
- 因此,基类构造函数执行期间,对象还未完全成为派生类对象,vptr 尚未指向派生类的 vtable,无法调用派生类的重写版本。
#include <iostream>
using namespace std;class Base {
public:Base() {cout << "Base::Base()" << endl;func(); // 构造函数中调用虚函数}virtual void func() {cout << "Base::func()" << endl;}
};class Derived : public Base {
public:Derived() {cout << "Derived::Derived()" << endl;func(); // 构造函数中调用虚函数}void func() override {cout << "Derived::func()" << endl;}
};int main() {Derived d; // 输出:// Base::Base()// Base::func() // 基类构造中调用基类版本// Derived::Derived()// Derived::func() // 派生类构造中调用派生类版本return 0;
}
析构函数中调用虚函数同理
析构函数设为虚函数(重点)
class Base
{
public:Base(): _base(new int(10)){ cout << "Base()" << endl; }virtual void display() const{cout << "*_base:" << *_base << endl;}~Base(){if(_base){delete _base;_base = nullptr;}cout << "~Base()" << endl;}private:int * _base;
};class Derived
: public Base
{
public:Derived(): Base(), _derived(new int(20)){cout << "Derived()" << endl;}virtual void display() const override{cout << "*_derived:" << *_derived << endl;}~Derived(){if(_derived){delete _derived;_derived = nullptr;}cout << "~Derived()" << endl;}private:int * _derived;
};void test0(){Base * pbase = new Derived();pbase->display();delete pbase;//编译器会进行类型检查,pbase指向的空间是一个Derived对象//所以会调用Derived的析构函数 —— 需要让析构函数设为虚函数,Derived析构函数会在虚表中覆盖Base析构函数的地址//这样通过pbase才能调用到Derived析构函数//Derived析构函数执行完,会自动调用Base的析构函数(没有走虚表这个途径) —— 析构函数本身的机制
}
output
Base()
Derived()
*_derived:20
~Base() // 只调用了基类析构函数,派生类的_derived内存泄漏
问题原因:
- 非虚析构函数的调用是静态绑定(编译期根据指针类型
Base*
决定调用Base
的析构函数)。 - 派生类
Derived
的_derived
指针未被delete
,导致内存泄漏。
解决方案:将基类析构函数声明为虚函数
只需修改Base
的析构函数,添加virtual
关键字:
class Base {
public:// ... 其他成员 ...// 基类析构函数声明为虚函数virtual ~Base() { // 关键修改:添加virtualif(_base){delete _base;_base = nullptr;}cout << "~Base()" << endl;}
};
执行结果(正确情况):
Base()
Derived()
*_derived:20
~Derived() // 先调用派生类析构函数,释放_derived
~Base() // 再调用基类析构函数,释放_base
修复原理:
- 基类析构函数为虚函数时,派生类析构函数会自动成为虚函数(即使不写
virtual
),并覆盖基类虚表中的析构函数地址。 delete pbase
时,通过基类指针的vptr
找到派生类的虚表,调用Derived
的析构函数(动态绑定)。- 派生类析构函数执行完毕后,会自动调用基类析构函数(析构函数的链式调用机制),确保所有资源释放。
当编译器处理派生类的析构函数时,会自动在其函数体末尾插入基类析构函数的调用代码。
例如,对于:
~Derived() { /* 派生类析构逻辑 */ }
编译器会将其隐式扩展为:
~Derived() {/* 派生类析构逻辑 */Base::~Base(); // 编译器自动添加基类析构调用
}
这种自动插入机制保证了:只要调用了派生类析构函数,就一定会触发基类析构函数的调用,形成链式关系。
为什么析构函数需要设为虚函数?
- 防止资源泄漏:当派生类有动态分配的资源(如
_derived
指针)时,必须通过派生类析构函数释放。 - 多态场景的必然要求:若基类可能被继承,且存在 “基类指针指向派生类对象” 的场景(如多态),析构函数必须设为虚函数,否则
delete
时无法正确调用派生类析构函数。
总结
- 规则:只要类可能被继承,且可能通过基类指针删除派生类对象,就必须将基类析构函数声明为虚函数。
- 效果:确保
delete
基类指针时,先调用派生类析构函数,再调用基类析构函数,避免资源泄漏。 - 注意:派生类析构函数无需显式写
virtual
(但建议写override
明确意图),会自动继承虚属性。
纯虚函数
纯虚函数(Pure Virtual function)是 C++ 中一种特殊的虚函数,它没有具体实现,仅作为接口声明,强制派生类必须重写(override)该函数才能实例化对象。纯虚函数是实现 “接口类” 和 “抽象类” 的核心机制,用于定义统一接口但不提供默认实现。
一、纯虚函数的定义形式
在虚函数声明的末尾加上 = 0
,即可将其定义为纯虚函数:
class 类名 {
public:virtual 返回值类型 函数名(参数列表) = 0; // 纯虚函数
};
= 0
表示该函数没有默认实现,是 “纯虚” 的。- 纯虚函数可以有声明,但不能在类内提供实现(若要提供实现,必须在类外)。
二、包含纯虚函数的类:抽象类
- 抽象类(Abstract Class):包含纯虚函数的类称为抽象类。
- 特性:
- 抽象类不能实例化对象(无法创建
类名 对象名
这样的实例)。 - 抽象类的派生类必须重写所有纯虚函数,否则派生类仍为抽象类(也不能实例化)。
- 抽象类可以包含普通成员变量和非纯虚函数(有实现的虚函数或普通函数)。
- 抽象类不能实例化对象(无法创建
三、纯虚函数的示例
#include <iostream>
using namespace std;// 抽象类:包含纯虚函数
class Shape { // 形状(抽象概念,无法实例化)
public:// 纯虚函数:声明接口,无实现virtual double area() const = 0; // 计算面积virtual void draw() const = 0; // 绘制图形// 普通成员函数(可以有实现)void printInfo() const {cout << "面积:" << area() << endl; // 调用纯虚函数(依赖派生类实现)}
};// 派生类1:重写所有纯虚函数(可实例化)
class Circle : public Shape {
private:double radius;
public:Circle(double r) : radius(r) {}// 重写纯虚函数double area() const override {return 3.14 * radius * radius;}void draw() const override {cout << "绘制圆形(半径:" << radius << ")" << endl;}
};// 派生类2:重写所有纯虚函数(可实例化)
class Rectangle : public Shape {
private:double width, height;
public:Rectangle(double w, double h) : width(w), height(h) {}double area() const override {return width * height;}void draw() const override {cout << "绘制矩形(宽:" << width << ",高:" << height << ")" << endl;}
};int main() {// Shape s; // 错误:抽象类不能实例化对象// 通过抽象类指针实现多态Shape* shape1 = new Circle(5.0);Shape* shape2 = new Rectangle(3.0, 4.0);shape1->draw(); // 多态调用:绘制圆形shape1->printInfo(); // 调用普通函数,内部依赖纯虚函数area()shape2->draw(); // 多态调用:绘制矩形shape2->printInfo();delete shape1;delete shape2;return 0;
}
输出结果:
绘制圆形(半径:5)
面积:78.5
绘制矩形(宽:3,高:4)
面积:12
四、纯虚函数的特殊用法
纯虚函数的类外实现
纯虚函数可以在类外提供实现(但仍需在派生类中重写才能实例化),通常用于提供 “默认实现”,但强制派生类显式重写(如需使用默认实现,派生类可在重写时调用基类版本)。class Base { public:virtual void func() = 0; // 纯虚函数声明 };// 类外提供纯虚函数的实现 void Base::func() {cout << "Base::func() 默认实现" << endl; }class Derived : public Base { public:void func() override {Base::func(); // 调用基类的默认实现cout << "Derived::func() 扩展实现" << endl;} };
抽象类作为接口
若抽象类中所有成员函数都是纯虚函数,且没有成员变量,则该类可视为 “接口(Interface)”,仅定义行为规范(类似 Java 中的interface
)。// 接口类:仅含纯虚函数 class Printable { public:virtual void print() const = 0; // 打印接口virtual ~Printable() = default; // 接口类建议声明虚析构函数 };
五、纯虚函数的核心作用
- 定义接口规范:强制派生类实现特定功能(如
Shape
的area()
和draw()
),确保所有派生类遵循统一接口。 - 实现抽象概念:对于无法实例化的抽象概念(如 “形状”“动物”),用抽象类表示,仅通过派生类(如 “圆形”“狗”)实例化具体对象。
- 支持多态:抽象类指针 / 引用可指向任何派生类对象,调用纯虚函数时触发多态,实现 “同一接口,不同实现”。
六、注意事项
- 抽象类不能实例化:试图创建抽象类的对象会导致编译错误。
- 派生类必须重写所有纯虚函数:否则派生类仍是抽象类,无法实例化。
- 抽象类可以作为基类:通过抽象类指针 / 引用实现多态是其主要用途。
- 接口类建议声明虚析构函数:若通过基类指针删除派生类对象,虚析构函数确保派生类析构函数被调用(避免资源泄漏)。
总结
纯虚函数是 C++ 中定义接口和抽象类的关键工具,它通过 “强制派生类实现” 和 “支持多态” 两大特性,确保代码的规范性和灵活性。在框架设计、插件系统等场景中,纯虚函数是实现 “开闭原则”(对扩展开放,对修改关闭)的核心机制。