首页 > 编程语言 >深入理解C++多态-虚函数

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

时间:2024-05-23 21:55:02浏览次数:21  
标签:函数 void 多态 C++ public Base 内存 class

引言

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();
}

甲同学实现的方法比较直白简单,有多少种类型的图形就定义多少种类,维护和绘制都需要根据图形类型数量来修改。
当我要新增一种图形类型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,然后继承该类来实现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;
};

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_metaclass 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的地址没有被更新,还是指向基类的虚函数地址。

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

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

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

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

  1. 虚函数不能是静态的

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

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

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

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

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

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

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

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

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

未完待续… …

标签:函数,void,多态,C++,public,Base,内存,class
From: https://blog.csdn.net/LeoLei8060/article/details/139158456

相关文章

  • 在C++中,将类的成员函数(也称为方法)作为参数传递
    在C++中,你可以将类的成员函数(也称为方法)作为参数传递,但这通常涉及到使用函数指针或者更现代的C++11及以后版本的std::function和lambda表达式。不过,更常见的是传递成员函数指针,但请注意,成员函数指针与常规函数指针在语法和使用上有所不同,因为成员函数需要访问类的特定实例(即对象)。......
  • 掌握pandas cut函数,一键实现数据分类
    pandas中的cut函数可将一维数据按照给定的区间进行分组,并为每个值分配对应的标签。其主要功能是将连续的数值数据转化为离散的分组数据,方便进行分析和统计。1.数据准备下面的示例中使用的数据采集自王者荣耀比赛的统计数据。导入数据:#2023年世冠比赛选手的数据fp=r"D......
  • 单例模式c++实现
    单例模式是一种创建型设计模式,它保证一个类仅有一个实例,并提供一个全局访问点来访问这个唯一实例。下面是一个简单的C++实现单例模式的例子:cppincludeincludeclassSingleton{private:staticSingleton*instance;staticstd::mutexmtx;Singleton(){}//私有构造函......
  • 友元函数
    特点友元函数不是成员函数,所以友元函数没有this指针。它可以访问类的私有(private)和保护(protected)成员。友元函数通常定义在类外,但在类中声明为友元。注意友元的定义要么放在最开始,要么放在最后。友元函数在类的定义中,可以使用friend关键字来声明一个友元函数。classMyCl......
  • c++ 语法 引用
      引用变量是一个别名,也就是说,它是某个已存在变量的另一个名字。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。C++引用vs指针引用很容易与指针混淆,它们之间有三个主要的不同:不存在空引用。引用必须连接到一块合法的内存。一旦引用被初始化为......
  • C++ 的 mutable 引出的一系列思考
    阅读多线程实战第六章第二节时,看到mutable关键词的使用,突然忘记它的含义=>  https://github.com/xiaoweiChen/CPP-Concurrency-In-Action-2ed-2019/blob/master/content/chapter6/6.2-chinese.md 进而引申到mutable的使用=> C++的mutable关键字......
  • mysql 取最后一条数据的函数
    在MySQL中,要获取表中的最后一条数据,通常会使用ORDERBY子句结合LIMIT子句来实现。但是,如果您的表中没有明确的排序字段,或者想要获取实时的最后一条数据(例如,在插入新数据后),您可以使用LAST_INSERT_ID()函数,这个函数返回最后一个被插入的自增ID值。如果您的表设置了自增主键,那么在插......
  • Linux C++ IDE
    在linux下开发C++一般使用cmake,而我们也需要一个IDE来提高开发效率,之前使用过VSCode,这个需要装各种插件进行许多配置才能成为一个真正的IDE,后来知道了Clion是一个更好的选择。那么我们怎么使用呢?官网下载安装包,可以试用一个月,然后我们可以去淘宝买个共享帐号,我......
  • c++ 迭代器
     c++迭代器,可以理解成指针的泛化。迭代器与指针:迭代器(Iterator)是指针(pointer)的泛化,提供了对对象的间接访问。迭代器针对容器,而指针类型针对数组。迭代器与模板:模板使得算法独立于存储的数据类型,即任何数据类型都可以使用该程序设计。而迭代器使得算法独立于使用的容器类型,即任......
  • 力扣-636. 函数的独占时间
    1.题目题目地址(636.函数的独占时间-力扣(LeetCode))https://leetcode.cn/problems/exclusive-time-of-functions/题目描述有一个单线程CPU正在运行一个含有n道函数的程序。每道函数都有一个位于 0和n-1之间的唯一标识符。函数调用存储在一个调用栈上:当一个函......