赞
踩
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)。
每个包含虚函数的类或其派生类都会拥有一个虚函数表。这个表是一个编译时生成的静态数组,存储在每个类的定义中。
虚函数表主要包含以下元素:
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
的元数据(该类的类型信息,用于运行时类型识别)。虚函数表内主要是维护该类的虚函数地址。
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
的虚函数地址了。
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属性都保持一致。
其实在继承关系中,析构函数必须是虚函数。因为当析构函数不是虚函数,那么通过基类指针释放派生类对象时,只能调用基类的析构函数,导致派生类中的部分资源无法释放。
调用虚函数是通过虚指针定位到虚函数表,然后找到对应的虚函数地址。如果构造函数是虚函数,那么调用构造函数是不是需要先通过虚指针来定位虚函数表了,但虚指针的初始化发生在构造函数阶段,所以这里有冲突。
未完待续… …
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。