【C++ Primer | 15】虚函数表剖析(一)

一、虚函数

1. 概念

多态指当不同的对象收到相同的消息时,产生不同的动作

  • 编译时多态(静态绑定),函数重载,运算符重载,模板。
  • 运行时多态(动态绑定),虚函数机制。

为了实现C++的多态,C++使用了一种动态绑定的技术。这个技术的核心是虚函数表(下文简称虚表)。本文介绍虚函数表是如何实现动态绑定的。

C++多态实现的原理:

  •  当类中声明虚函数时,编译器会在类中生成一个虚函数表
  • 虚函数表是一个存储成员函数地址的数据结构
  • 虚函数表是由编译器自动生成与维护的
  •  virtual成员函数会被编译器放入虚函数表中
  • 存在虚函数表时,每个对象中都有一个指向虚函数表的指针

 

2. 类的虚表

每个包含了虚函数的类都包含一个虚表。 
我们知道,当一个类(A)继承另一个类(B)时,类A会继承类B的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。

我们来看以下的代码。类A包含虚函数vfunc1,vfunc2,由于类A包含虚函数,故类A拥有一个虚表。

class A {
public:virtual void vfunc1();virtual void vfunc2();void func1();void func2();
private:int m_data1, m_data2;
};

虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。 
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

 

3. 虚表指针

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。 
为了指定对象的虚表,每个对象的内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚

4. 动态绑定

class A 
{
public:virtual void vfunc1();virtual void vfunc2();void func1();void func2();
private:int m_data1, m_data2;
};class B : public A 
{
public:virtual void vfunc1();void func1();
private:int m_data3;
};class C: public B 
{
public:virtual void vfunc2();void func2();
private:int m_data1, m_data4;
};

类A是基类,类B继承类A,类C又继承类B。类A,类B,类C,其对象模型如下图3所示。

由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类A的虚表(A vtbl),类B的虚表(B vtbl),类C的虚表(C vtbl)。类A,类B,类C的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。 

  • 类A包括两个虚函数,故A vtbl包含两个指针,分别指向A::vfunc1()和A::vfunc2()。 
  • 类B继承于类A,故类B可以调用类A的函数,但由于类B重写了B::vfunc1()函数,故B vtbl的两个指针分别指向B::vfunc1()和A::vfunc2()。 
  • 类C继承于类B,故类C可以调用类B的函数,但由于类C重写了C::vfunc2()函数,故C vtbl的两个指针分别指向B::vfunc1()(指向继承的最近的一个类的函数)和C::vfunc2()。 

虽然图3看起来有点复杂,但是只要抓住“对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数”这个特点,便可以快速将这几个类的对象模型在自己的脑海中描绘出来。[非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

【注意】非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

下面以代码说明,代码如下:

#include <iostream>
using namespace std;class Base {
public:virtual void f() { cout << "Base::f" << endl; }virtual void g() { cout << "Base::g" << endl; }virtual void h() { cout << "Base::h" << endl; }
};typedef void(*Fun)(void);  //函数指针
int main()
{Base b;//  这里指针操作比较混乱,在此稍微解析下://  *****printf("虚表地址:%p\n", *(int *)&b); 解析*****://  1.&b代表对象b的起始地址//  2.(int *)&b 强转成int *类型,为了后面取b对象的前四个字节,前四个字节是虚表指针//  3.*(int *)&b 取前四个字节,即vptr虚表地址////  *****printf("第一个虚函数地址:%p\n", *(int *)*(int *)&b);*****://  根据上面的解析我们知道*(int *)&b是vptr,即虚表指针.并且虚表是存放虚函数指针的//  所以虚表中每个元素(虚函数指针)在32位编译器下是4个字节,因此(int *)*(int *)&b//  这样强转后为了后面的取四个字节.所以*(int *)*(int *)&b就是虚表的第一个元素.//  即f()的地址.//  那么接下来的取第二个虚函数地址也就依次类推.  始终记着vptr指向的是一块内存,//  这块内存存放着虚函数地址,这块内存就是我们所说的虚表.//printf("虚表地址:%p\n", *(int *)&b);printf("第一个虚函数地址:%p\n", *(int *)*(int *)&b);printf("第二个虚函数地址:%p\n", *((int *)*(int *)(&b) + 1));Fun pfun = (Fun)*((int *)*(int *)(&b));  //vitural f();printf("f():%p\n", pfun);pfun();pfun = (Fun)(*((int *)*(int *)(&b) + 1));  //vitural g();printf("g():%p\n", pfun);pfun();
}

输出结果:

通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下

(Fun)*((int*)*(int*)(&b)+0);  // Base::f()
(Fun)*((int*)*(int*)(&b)+1);  // Base::g()
(Fun)*((int*)*(int*)(&b)+2);  // Base::h()

 

二、一般继承 

下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:

class Base
{
public:virtual void f() { cout << "Base::f()" << endl; }virtual void g() { cout << "Base::g()" << endl; }virtual void h() { cout << "Base::h()" << endl; }
};class Derive : public Base
{
public:virtual void f1() { cout << "Base::f1()" << endl; }virtual void g1() { cout << "Base::g1()" << endl; }virtual void h1() { cout << "Base::h1()" << endl; }
};

 

 请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:

对于实例:Derive d; 的虚函数表如下:

我们可以看到下面几点:

  • 虚函数按照其声明顺序放于表中。
  • 父类的虚函数在子类的虚函数前面。

 

三、一般继承(有虚函数覆盖) 

覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

class Base
{
public:virtual void f() { cout << "Base::f()" << endl; }virtual void g() { cout << "Base::g()" << endl; }virtual void h() { cout << "Base::h()" << endl; }
};class Derive : public Base
{
public:virtual void f() { cout << "Base::f1()" << endl; }virtual void g1() { cout << "Base::g1()" << endl; }virtual void h1() { cout << "Base::h1()" << endl; }
};

为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:

我们从表中可以看到下面几点,

  • 覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
  • 没有被覆盖的函数依旧。

这样,我们就可以看到对于下面这样的程序:

Base *b = new Derive();
b->f();

由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

 

四、单一的一般继承

 下面,我们假设有如下所示的一个继承关系:

注意,在这个继承关系中,父类,子类,孙子类都有自己的一个成员变量。而了类覆盖了父类的f()方法,孙子类覆盖了子类的g_child()及其超类的f()。

测试代码:

#include<iostream>
using namespace std;class Parent {
public:int iparent;Parent(): iparent(10) {}virtual void f() { cout << " Parent::f()" << endl; }virtual void g() { cout << " Parent::g()" << endl; }virtual void h() { cout << " Parent::h()" << endl; }
};class Child : public Parent {
public:int ichild;Child(): ichild(100) {}virtual void f() { cout << "Child::f()" << endl; }virtual void g_child() { cout << "Child::g_child()" << endl; }virtual void h_child() { cout << "Child::h_child()" << endl; }
};class GrandChild : public Child {
public:int igrandchild;GrandChild(): igrandchild(1000) {}virtual void f() { cout << "GrandChild::f()" << endl; }virtual void g_child() { cout << "GrandChild::g_child()" << endl; }virtual void h_grandchild() { cout << "GrandChild::h_grandchild()" << endl; }
};int main()
{typedef void(*Fun)(void);GrandChild gc;int** pVtab = (int**)&gc;cout << "[0] GrandChild::_vptr->" << endl;for (int i = 0; (Fun)pVtab[0][i] != NULL; i++) {Fun pFun = (Fun)pVtab[0][i];cout << "    [" << i << "] ";pFun();}cout << "[1] Parent.iparent = " << (int)pVtab[1] << endl;cout << "[2] Child.ichild = " << (int)pVtab[2] << endl;cout << "[3] GrandChild.igrandchild = " << (int)pVtab[3] << endl;
}

输出结果:

使用图片表示如下:

可见以下几个方面:

  • 虚函数表在最前面的位置。
  • 成员变量根据其继承和声明顺序依次放在后面。
  • 在单一的继承中,被overwrite的虚函数在虚函数表中得到了更新。

 

参考资料

  • c++中虚基类表和虚函数表的布局

 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/385477.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Leetcode | 02】二叉树、线性表目录

二叉树序号题号1 94. 二叉树的中序遍历 295. 不同的二叉搜索树 II396. 不同的二叉搜索树4 98. 验证二叉搜索树 5100. 相同的树6101. 对称二叉树7102. 二叉树的层次遍历8103. 二叉树的锯齿形层次遍历9104. 二叉树的最大深度10105. 从前序与中序遍历序列构造二叉树11106. 从中序与…

Leetcode 118. 杨辉三角

给定一个非负整数 numRows&#xff0c;生成杨辉三角的前 numRows 行。 在杨辉三角中&#xff0c;每个数是它左上方和右上方的数的和。 示例: 输入: 5 输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1] ] class Solution { public:vector<vector<int>> generate(…

管道符、重定向与环境变量

输入输出重定向 输入重定向&#xff1a;将文件内容导入到命令中&#xff1b;输出重定向&#xff1a;将命令执行后显示到屏幕上的内容导入到文件中&#xff0c;不在屏幕中显示。共分为&#xff1a;标准输入重定向&#xff08;文件描述符为0&#xff09;、标准覆盖输出&#xff0…

【C++ Primer | 0 】字符串函数实现

1. memcpy函数原型&#xff1a; void* memcpy(void* dst, const void* src, size_t size); void* memmove(void* dst, const void* src, size_t size); 分析&#xff1a; source和destin所指的内存区域可能重叠&#xff0c;但是如果source和destin所指的内存区域重叠,那么这个…

编写Shell脚本(批处理,一次执行多条命令)

Bash终端的优势&#xff1a;1.上下键重复执行命令&#xff1b;2.tab键自动补齐&#xff1b;3.提供有用的环境变量&#xff1b;4.批处理。 shell脚本文件建议以.sh为后缀。 其实vim创建文本文件时&#xff0c;对名字无要求&#xff0c;但最好规定格式。 echo $SHELL&#xff08…

判断用户的参数(条件测试语句)

说明$?: $&#xff1f;为上一次命令的执行返回值&#xff0c;若上一次命令正常执行&#xff0c;则返回0&#xff1b;若执行出错&#xff0c;则返回一个非0的随机数。比如创建一个已经存在的目录&#xff0c;则返回一个非0数。 另外&#xff0c;测试语句成立返回0&#xff0c…

流程控制语句(bash)

1.if控制语句 if then fi if then else fi if then elif then elif then else fi if 条件表达式 then 命令序列&#xff08;满足条件才执行&#xff09; #注意&#xff0c;如果if与then&#xff08;elif与then&#xff09;写在同一行&#xff0c;要用;隔开&#xff…

用户身份与文件的权限(普通权限、特殊权限、隐藏权限和文件控制列表ACL)

用户身份 root用户是存在于所有类UNIX操作系统中的超级用户&#xff0c;它拥有最高的系统所有权。root用户的用户身份号码UID为0&#xff0c;UID相当于用户的身份证号码一样&#xff0c;具有唯一性。管理员用户&#xff08;超级用户&#xff09;UID为0&#xff1b;系统用户UID为…

存储结构与磁盘划分

文件系统层次化标准&#xff08;FHS&#xff0c;file system hierarchy standard&#xff09; 在windows操作系统中&#xff0c;要找到一个文件需要先进入该文件所在的磁盘分区&#xff08;如C:\等 C:\ZSX\zsx.txt&#xff09;&#xff0c;然后在进入该分区下的一个具…

Linux中常用文件的含义

在Linux中配置了服务文件后&#xff0c;需要重启该服务&#xff0c;配置信息才会生效。 /etc/passwd 保存了系统中所有用户的信息&#xff0c;一旦用户的登陆终端设置为/sbin/nologin&#xff0c;则不再允许登录到系统 /etc/shadow与/etc/passwd均为用户信息文件 /…

64. 最小路径和

给定一个包含非负整数的 m x n 网格&#xff0c;请找出一条从左上角到右下角的路径&#xff0c;使得路径上的数字总和为最小。 说明&#xff1a;每次只能向下或者向右移动一步。 示例: 输入: [[1,3,1],[1,5,1],[4,2,1] ] 输出: 7 解释: 因为路径 1→3→1→1→1 的总和最小。…

Linux本地yum源配置以及使用yum源安装各种应用程序

将软件包传送到Linux中后&#xff0c;挂载&#xff0c;然后配置yum软件仓库&#xff0c;最后就可以使用yum来安装相应的应用程序了。假设挂载目录为/tmp/ruanjianbao&#xff0c;则下面说明配置本地yum仓库的过程&#xff1a; &#xff08;1&#xff09;cd /etc/yum.repos.d/…

gcc与g++编译器

首先在Linux(RHEL7.0)上安装gcc&#xff1a;yum install gcc gcc-c -y 其中gcc-c是为了能够编译c源代码&#xff0c;即g。 gcc为Linux C/C下重要的编译环境&#xff0c;是GUN项目中符合ANSIC标准的编译系统&#xff0c; gcc可以编译C、C、Objective-C、Java、Fortran、Pascal…

【Leetcode | 49】230. 二叉搜索树中第K小的元素

给定一个二叉搜索树&#xff0c;编写一个函数 kthSmallest 来查找其中第 k 个最小的元素。 说明&#xff1a; 你可以假设 k 总是有效的&#xff0c;1 ≤ k ≤ 二叉搜索树元素个数。 示例 1: 输入: root [3,1,4,null,2], k 1 3 / \ 1 4 \ 2 输出: 1 示例 2: 输入…

gcc编译器的整个工作过程

gcc hello.c ./a.out 或者 gcc hello.c -o hello ./hello ./表示执行当前目录下的可执行程序或脚本程序。 首先gcc需要调用预处理程序cpp&#xff0c;由它负责展开在源文件中定义的宏&#xff0c;并向其中插入“#include”语句所包含的内容&#xff1b;接着gcc会调用…

宏定义对调试代码的作用

以如下代码为例&#xff1a; //head.h #ifndef __HEAD_H__ #define __HEAD_H__#define NUM1 10 #define NUM2 20 #endif//sum.c #include <stdio.h> //直接在标准库中查找 #include "head.h" //先在工作目录中查找&#xff…

【第15章】多重继承

1. 虚基类介绍 多继承时很容易产生命名冲突&#xff0c;即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字&#xff0c;命名冲突依然有可能发生&#xff0c;比如非常经典的菱形继承层次。如下图所示&#xff1a; 类A派生出类B和类C&#xff0c;类D继承自类B和…

gcc编译器与g++编译器的区别

gcc与g编译器的程序文件分别为&#xff1a;/usr/bin/g和/usr/bin/gcc。 gcc 和 GCC 是两个不同的东西&#xff0c;GCC:GNU Compiler Collection(GUN 编译器集合)&#xff0c;它可以编译C、C、JAV、Fortran、Pascal、Object-C、Ada等语言。gcc是GCC中的GUN C Compiler&#xff0…

1. 排序算法

一、概述 假定在待排序的记录序列中&#xff0c;存在多个具有相同的关键字的记录&#xff0c;若经过排序&#xff0c;这些记录的相对次序保持不变&#xff0c;即在原序列中&#xff0c;r[i]r[j]&#xff0c;且r[i]在r[j]之前&#xff0c;而在排序后的序列中&#xff0c;r[i]仍…

1036. 跟奥巴马一起编程(15)

美国总统奥巴马不仅呼吁所有人都学习编程&#xff0c;甚至以身作则编写代码&#xff0c;成为美国历史上首位编写计算机代码的总统。2014年底&#xff0c;为庆祝“计算机科学教育周”正式启动&#xff0c;奥巴马编写了很简单的计算机代码&#xff1a;在屏幕上画一个正方形。现在…