当前位置:   article > 正文

深入理解C++多态-虚函数_c++多态虚函数的理解

c++多态虚函数的理解

引言

C++多态的实现方式可以分为静态多态和动态多态,其中静态多态主要有函数重装和模板两种方式,动态多态就是虚函数。
下面我们将通过解答以下几个问题的方式来深入理解虚函数的原理:

  1. 为什么要引入虚函数?(用来解决什么问题)
  2. 虚函数底层实现原理
  3. 使用虚函数时需要注意什么?

正文

为什么要引入虚函数?

在回答这个问题之前,我们先看一个示例:
假设我们正在开发一个图形编辑器,其中包含各种类型的图形元素,比如圆形、矩形、多边形等。我们要如何管理所有图形对象呢?

  • 甲同学的方案
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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

甲同学实现的方法比较直白简单,有多少种类型的图形就定义多少种类,维护和绘制都需要根据图形类型数量来修改。
当我要新增一种图形类型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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

这种方式的扩展性、可维护性都是最差的。

  • 乙同学的方案
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方法
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

乙同学将图形抽象出一个基类Shape,然后继承该类来实现CircleRectangle;同时将通用接口设计成虚函数,派生类重写虚函数,在运行时根据对象来调用哪个类的函数。
这种方式既简化了代码,又提高了可扩展性和可维护性。

具体来说,虚函数解决的主要问题是如何在不完全知道对象类型的情况下,调用正确的函数。在没有虚函数的情况下,函数的调用在编译时就已经确定了(这称为静态绑定)。但是,如果我们想要在运行时根据对象的实际类型来决定调用哪个函数(动态绑定),就需要使用虚函数。

虚函数底层实现原理

我们先介绍一下虚函数实现原理中最重要的两个东西:虚函数表(也称虚表,vtable)和虚指针(也称虚表指针,vptr)。

虚函数表

每个包含虚函数的类或其派生类都会拥有一个虚函数表。这个表是一个编译时生成的静态数组,存储在每个类的定义中。
虚函数表主要包含以下元素:

  • 虚函数指针:表中的每一个条目都是指向类中每个虚函数的指针。这包括从基类继承来的虚函数,如果在派生类中被重写,则指向新的函数地址。
  • 类型信息:在支持运行时类型识别(RTTI)的系统中,虚函数表还可能包含指向类型信息的指针,这有助于typeiddynamic_cast等操作。

虚指针

虚指针是每个对象中的一个隐含成员,如果该对象的类包含虚函数。在对象构造时,编译器设置这个虚指针指向相应类的虚函数表。

每次通过类的实例调用虚函数时,程序会首先通过虚指针访问虚函数表,然后通过虚函数表定位到具体的函数地址并调用。这个过程是在运行时完成的,因此允许函数调用根据对象的实际类型动态绑定,而非编译时决定。

想要了解虚函数的实现原理,就需要先了解类的内存布局,通过内存布局来直观地学习虚函数的原理。

内存布局

普通类的内存布局
class N {
public:
	void funA() { std::cout << "funA()" << std::endl; }
	void funB() { std::cout << "funB()" << std::endl; }

	int a;
	int b;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

class N的内存布局如下:

1>class N	size(8):
1>	+---
1> 0	| a
1> 4	| b
1>	+---
  • 1
  • 2
  • 3
  • 4
  • 5

想要看一个类的内存布局,只需要通过添加命令行:/d1 reportSingleClassLayoutXXX(其中XXX就是你想要看的类名)即可。

普通的类只会存储数据成员。

  • 普通的类中为什么没有维护成员函数呢?

类的成员函数在编译后存储在程序的代码段中,被程序中所有对象共享。
因为一个类的不同实例对象所执行的成员函数是一样的,没有必要在实例对象中再复制维护了。所有同类的实例对象使用相同的函数代码(通过隐含的this指针来访问对象的成员变量和成员函数),不仅节省内存,也使得程序更加高效。

这里不再详细介绍函数调用的原理了,这是最基础的知识… …

基类的内存布局
class Base {
public:
	virtual void vFunA() = 0;
	virtual void vFunB() {}
	void funA() {}
	void funB() {}

	int a;
	int b;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

class Base是一个带虚函数的类,可以看到它的内存布局和普通类有很大的区别。
class Base中的{vfptr}是一个指向虚函数表(vftable)的指针。
Base::$vftable@就是虚函数表,其中&Base_metaclass Base的元数据(该类的类型信息,用于运行时类型识别)。虚函数表内主要是维护该类的虚函数地址。

派生类A的内存布局
class A : public Base {
public:
	virtual void vFunA() override {}
	virtual void vFunB() override {}
	void funA() {}
	void funB() {}

    int c;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

派生类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;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

派生类B和A的主要区别就是没有重写虚函数vFunB,所以在虚函数表中可以看到虚函数vFunB的地址没有被更新,还是指向基类的虚函数地址。

所以,从上面四个类的内存布局可以看出:

  1. 只要写了虚函数,就会多生成一个虚函数表,并且还有虚指针指向虚函数表。
  2. 派生类继承基类,并重写虚函数后,虚函数表对应的虚函数地址将被更新。

使用虚函数时需要注意什么?

使用虚函数时需要遵循以下规则:

  1. 虚函数不能是静态的

虚函数的目的是为了实现动态多态,和静态函数在本质上是冲突的。

  1. 要实现运行时多态性,必须使用基类类型的指针或引用来访问虚函数

如果调用是通过对象实例(而非指针或引用),则会发生静态绑定,在编译时,编译器确定了要调用的函数版本,这种确定不会延迟到运行时。

  1. 虚函数的原型在派生类和基类中必须保持一致

虚函数的原型指的是虚函数的名称、返回类型、参数列表、const属性。
这句话的意思就是说派生类重写的虚函数需要和基类的虚函数名称、返回类型、参数列表、const属性都保持一致。

  1. 类可以有虚析构函数,但不能有虚构造函数
  • 首先我们先分析前半句:类可以有虚析构函数

其实在继承关系中,析构函数必须是虚函数。因为当析构函数不是虚函数,那么通过基类指针释放派生类对象时,只能调用基类的析构函数,导致派生类中的部分资源无法释放。

  • 后半句:但不能有虚构造函数

调用虚函数是通过虚指针定位到虚函数表,然后找到对应的虚函数地址。如果构造函数是虚函数,那么调用构造函数是不是需要先通过虚指针来定位虚函数表了,但虚指针的初始化发生在构造函数阶段,所以这里有冲突。

未完待续… …

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/950543
推荐阅读
相关标签
  

闽ICP备14008679号