引言
C++多态的实现方式可以分为静态多态和动态多态,其中静态多态主要有函数重装和模板两种方式,动态多态就是虚函数。
下面我们将通过解答以下几个问题的方式来深入理解虚函数的原理:
- 为什么要引入虚函数?(用来解决什么问题)
- 虚函数底层实现原理
- 使用虚函数时需要注意什么?
正文
为什么要引入虚函数?
在回答这个问题之前,我们先看一个示例:
假设我们正在开发一个图形编辑器,其中包含各种类型的图形元素,比如圆形、矩形、多边形等。我们要如何管理所有图形对象呢?
- 甲同学的方案:
class Circle {
public:
void draw() const {
// 实现绘制圆形的代码
}
};
class Rectangle {
public:
void draw() const {
// 实现绘制矩形的代码
}
};
// 管理图形对象:
std::vector<Circle*> circle_shapes;
std::vector<Rectangle*> rectangle_shapes;
circle_shapes.push_back(new Circle());
rectangle_shapes.push_back(new Rectangle());
// 刷新绘制图形
for (auto shape : circle_shapes) {
shape->draw();
}
for (auto shape : rectangle_shapes) {
shape->draw();
}
甲同学实现的方法比较直白简单,有多少种类型的图形就定义多少种类,维护和绘制都需要根据图形类型数量来修改。
当我要新增一种图形类型Polygon
时,就需要新增以下代码:
class Polygon {
public:
void draw() const {
// 实现绘制矩形的代码
}
};
// 管理图形对象:
std::vector<Polygon*> polygon_shapes;
polygon_shapes.push_back(new Polygon());
// 刷新绘制图形
for (auto shape : polygon_shapes) {
shape->draw();
}
这种方式的扩展性、可维护性都是最差的。
- 乙同学的方案:
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数,使得Shape成为抽象基类
};
class Circle : public Shape {
public:
void draw() const override {
// 实现绘制圆形的代码
}
};
class Rectangle : public Shape {
public:
void draw() const override {
// 实现绘制矩形的代码
}
};
// 管理图形对象:
std::vector<Shape*> shapes;
shapes.push_back(new Circle());
shapes.push_back(new Rectangle());
// 刷新绘制图形
// 通过基类指针调用适当的draw方法
for (auto* shape : shapes) {
shape->draw(); // 在运行时决定调用哪个类的draw方法
}
乙同学将图形抽象出一个基类Shape
,然后继承该类来实现Circle
和Rectangle
;同时将通用接口设计成虚函数,派生类重写虚函数,在运行时根据对象来调用哪个类的函数。
这种方式既简化了代码,又提高了可扩展性和可维护性。
具体来说,虚函数解决的主要问题是如何在不完全知道对象类型的情况下,调用正确的函数。在没有虚函数的情况下,函数的调用在编译时就已经确定了(这称为静态绑定)。但是,如果我们想要在运行时根据对象的实际类型来决定调用哪个函数(动态绑定),就需要使用虚函数。
虚函数底层实现原理
我们先介绍一下虚函数实现原理中最重要的两个东西:虚函数表(也称虚表,vtable)和虚指针(也称虚表指针,vptr)。
虚函数表
每个包含虚函数的类或其派生类都会拥有一个虚函数表。这个表是一个编译时生成的静态数组,存储在每个类的定义中。
虚函数表主要包含以下元素:
- 虚函数指针:表中的每一个条目都是指向类中每个虚函数的指针。这包括从基类继承来的虚函数,如果在派生类中被重写,则指向新的函数地址。
- 类型信息:在支持运行时类型识别(RTTI)的系统中,虚函数表还可能包含指向类型信息的指针,这有助于
typeid
和dynamic_cast
等操作。
虚指针
虚指针是每个对象中的一个隐含成员,如果该对象的类包含虚函数。在对象构造时,编译器设置这个虚指针指向相应类的虚函数表。
每次通过类的实例调用虚函数时,程序会首先通过虚指针访问虚函数表,然后通过虚函数表定位到具体的函数地址并调用。这个过程是在运行时完成的,因此允许函数调用根据对象的实际类型动态绑定,而非编译时决定。
想要了解虚函数的实现原理,就需要先了解类的内存布局,通过内存布局来直观地学习虚函数的原理。
内存布局
普通类的内存布局
class N {
public:
void funA() { std::cout << "funA()" << std::endl; }
void funB() { std::cout << "funB()" << std::endl; }
int a;
int b;
};
class N
的内存布局如下:
1>class N size(8):
1> +---
1> 0 | a
1> 4 | b
1> +---
想要看一个类的内存布局,只需要通过添加命令行:
/d1 reportSingleClassLayoutXXX
(其中XXX就是你想要看的类名)即可。
普通的类只会存储数据成员。
- 普通的类中为什么没有维护成员函数呢?
类的成员函数在编译后存储在程序的代码段中,被程序中所有对象共享。
因为一个类的不同实例对象所执行的成员函数是一样的,没有必要在实例对象中再复制维护了。所有同类的实例对象使用相同的函数代码(通过隐含的this
指针来访问对象的成员变量和成员函数),不仅节省内存,也使得程序更加高效。
这里不再详细介绍函数调用的原理了,这是最基础的知识… …
基类的内存布局
class Base {
public:
virtual void vFunA() = 0;
virtual void vFunB() {}
void funA() {}
void funB() {}
int a;
int b;
};
class Base
的内存布局如下:
1>class Base size(12):
1> +---
1> 0 | {vfptr}
1> 4 | a
1> 8 | b
1> +---
1>Base::$vftable@:
1> | &Base_meta
1> | 0
1> 0 | &Base::vFunA
1> 1 | &Base::vFunB
class Base
是一个带虚函数的类,可以看到它的内存布局和普通类有很大的区别。class Base
中的{vfptr}
是一个指向虚函数表(vftable
)的指针。Base::$vftable@
就是虚函数表,其中&Base_meta
是class Base
的元数据(该类的类型信息,用于运行时类型识别)。虚函数表内主要是维护该类的虚函数地址。
派生类A的内存布局
class A : public Base {
public:
virtual void vFunA() override {}
virtual void vFunB() override {}
void funA() {}
void funB() {}
int c;
};
class A
的内存布局如下:
1>class A size(16):
1> +---
1> 0 | +--- (base class Base)
1> 0 | | {vfptr}
1> 4 | | a
1> 8 | | b
1> | +---
1>12 | c
1> +---
1>A::$vftable@:
1> | &A_meta
1> | 0
1> 0 | &A::vFunA
1> 1 | &A::vFunB
派生类A的内存布局和基类又不一样了。
因为class A
继承class Base
,所以内存布局就包含了基类的数据,然后才是自己的成员c
。
这里需要注意的是虚函数表中,虚函数地址发生了变化,原来虚函数表中的虚函数地址分别是&Base::vFunA
和&Base::vFunB
,现在虚函数地址被更新成class A
的虚函数地址了。
派生类B的内存布局
class B : public Base {
public:
virtual void vFunA() override {}
void funA() {}
void funB() {}
int d;
};
class B
的内存布局如下:
1>class B size(16):
1> +---
1> 0 | +--- (base class Base)
1> 0 | | {vfptr}
1> 4 | | a
1> 8 | | b
1> | +---
1>12 | d
1> +---
1>B::$vftable@:
1> | &B_meta
1> | 0
1> 0 | &B::vFunA
1> 1 | &Base::vFunB
派生类B和A的主要区别就是没有重写虚函数vFunB
,所以在虚函数表中可以看到虚函数vFunB
的地址没有被更新,还是指向基类的虚函数地址。
所以,从上面四个类的内存布局可以看出:
- 只要写了虚函数,就会多生成一个虚函数表,并且还有虚指针指向虚函数表。
- 派生类继承基类,并重写虚函数后,虚函数表对应的虚函数地址将被更新。
使用虚函数时需要注意什么?
使用虚函数时需要遵循以下规则:
- 虚函数不能是静态的
虚函数的目的是为了实现动态多态,和静态函数在本质上是冲突的。
- 要实现运行时多态性,必须使用基类类型的指针或引用来访问虚函数
如果调用是通过对象实例(而非指针或引用),则会发生静态绑定,在编译时,编译器确定了要调用的函数版本,这种确定不会延迟到运行时。
- 虚函数的原型在派生类和基类中必须保持一致
虚函数的原型指的是虚函数的名称、返回类型、参数列表、const属性。
这句话的意思就是说派生类重写的虚函数需要和基类的虚函数名称、返回类型、参数列表、const属性都保持一致。
- 类可以有虚析构函数,但不能有虚构造函数
- 首先我们先分析前半句:类可以有虚析构函数
其实在继承关系中,析构函数必须是虚函数。因为当析构函数不是虚函数,那么通过基类指针释放派生类对象时,只能调用基类的析构函数,导致派生类中的部分资源无法释放。
- 后半句:但不能有虚构造函数
调用虚函数是通过虚指针定位到虚函数表,然后找到对应的虚函数地址。如果构造函数是虚函数,那么调用构造函数是不是需要先通过虚指针来定位虚函数表了,但虚指针的初始化发生在构造函数阶段,所以这里有冲突。
标签:函数,void,多态,C++,public,Base,内存,class From: https://blog.csdn.net/LeoLei8060/article/details/139158456未完待续… …