C++对象模型与虚函数的理解

C++对象模型与虚函数的理解

背景

之前看过c++对象模型这本书,加上自己工作中的一些经验,做一下总结吧。

首先,从我入职时做的第一个项目谈起吧,那是我的第一个项目,至今两三年了,但是记忆犹深啊。感谢我的师傅和合作伙伴老余。项目的背景是这样的:

主机A是小端机器,主机B是大端机器,A和B之间互相通信,通信的协议是自定义的,实现语言为C/C++,他们之间的通信需要做字节序转换。由于项目初期,没有在AB发送消息之前做消息编码(比如,开源工具protobuf,ASN1协议编码等),而是直接将消息的码流直接发送出去,因此,在收发消息的时候,需要对码流进行处理。实现的方案是在主机A实现一个公共的转码模块,消息离开A时转换成大端序,消息进入A时转换成小端序。

以上,就是项目的背景。那么,讲到这里都还没有提到C++对象模型相关的话题。我个人对对象模型的理解是:变量是存在内存中的,访问一个变量,需要两个基本的要素,一是地址,二是该变量的大小,比如,一个char类型的指针(char * ptr = 0x1234568),告诉我们的信息就是该指针指向的内存地址是0x12345678,变量占用的地址是(0x12345678-0x12345679)一个字节的空间;变量占用的内存空间,以及内存空间的布局就需要对象模型来描述。

就字节序转换的项目,首先就需要知道消息结构的描述,比如说,这个消息的大小是多少,消息的各个成员的偏移等信息。

从下面的代码片段开始本文的主题吧:

#include<iostream>
using namespace std;
template<typename T>
void printObjWithByte(T obj)
{
   int i = 0;
   cout<<hex;
   for(;i < sizeof(T);i++)
   {
       if(i % sizeof(void*) == 0)
       {
           cout<<endl;
       }
       cout<<(unsigned int)(*((char*)&obj + i))<<" ";
   }
   cout<<endl;
}

struct Base
{
    int i;
    char b;
};

int main()
{
    Base b;
    b.i = 0x12345678;
    b.b = 'a';
   
    printObjWithByte(b);
}
编译运行
g++ model.cpp -o mod
./mod
78 56 34 12 61 7f 0 0

从打印对象的byte流,可以看出以下信息:

  1. 我的运行的机器是小端的(小端模式,是指数据的高字节保存在内存的高地址中,其中b.i中的12是数据的高位,在内存中在低地址(相对78来说));
  2. 对象b的大小是8个字节,其中i占4个字节,成员变量b占1个字节,补齐占3个字节。

根据大小端的定义,如果将上述对象a的码流转换为大端,为如下:

12 34 56 78 61 7f 0 0

如上码流的小端到大端的转换,需要的前提是要具备类Base的内存的描述信息,比如通过类Base的定义,知道成员变量i的类型是int,占4个字节,码流转换时需要做大小端转换,成员变量b的类型是char,占1个字节,不需要大小端转换,还多出3个字节的补齐位,作为偏移量,不需要转换。

关于对象的内存描述,可以通过解析编译器编译的中间产物.o文件获取,然后用xml或者json表示:

<Base>
    <Element>
        <name "i" />
        <type "int" />
        <size 4 />
    </Element>
    <Element>
        <name "b" />
        <type "char" />
        <size 1 />
    </Element>
</Base>

C++对象模型

上面的例子,可以知道,了解类的内存布局是至关重要的。下面就C++中关于虚函数表以及继承与对象模型的关系做简要介绍。

一、虚函数表

给Base类添加两个虚函数

struct Base
{
   virtual void fun1()
   {
         cout<<"Base::fun1..."<<" ,"<<i<<endl;
   }
   virtual void fun2()
   {
         cout<<"Base::fun2..."<<" ,"<<i<<endl;
         
   }
   int i;
   char b;
};

编译执行
g++ model.cpp -o mod
./mod
48 e 40 0 0 0 0 0 
78 56 34 12 61 0 0 0

可以看出,当增加两个虚函数Base的实例由之前的8个字节增加到16个字节。

当把上面函数申明中的virtual去掉,运行看一下结果为:

78 56 34 12 61 7f 0 0

由此,可以看出,添加virtual申明的函数,在对象的内存布局中添加了8个字节的数据。virtual 关键字声明该函数为虚函数,子类可以覆写该函数,实现多态。C++里面编译器为了实现多态的机制,当一个类声明了virtual关键字的函数,会插入一个虚函数表指针(vptr)。我的机器是64位的,指针的大小为64位,也就是8个字节,该指针指向一个虚函数表(vptable),该虚函数表用于存储v声明为virtual的虚函数的地址。因此,Base在声明了virtual关键字的函数之后,对象比之前大了8个字节也就不足为奇了。根据这个描述,可以得到Base类的内存描述如下图所示:

可以通过以下方法获取到虚函数表里面的函数以验证上述描述:

int main()
{
    Base b;
    b.i = 0x12345678;
    b.b = 'a';

    typedef void (*FUN)(Base*);
    cout<<hex;
    FUN fun1 = (FUN)(*(ptrdiff_t*)(*(ptrdiff_t*)&b));
    fun1(&b);
    
    FUN fun2 = (FUN)(*((ptrdiff_t*)*(ptrdiff_t*)&b + 1));
    fun2(&b);    


    //printObjWithByte(b);
}
编译运行:
Base::fun1... ,12345678
Base::fun2... ,12345678

通过上述方法,成功地获取了虚函数表的函数,并成功调用。

注:

  • typedef void (*FUN)(Base*); 声明FUN类型,该类型与Base的两个成员函数fun1和fun2有相同的调用方法,类的非静态的成员函数隐藏this指针。
  • (FUN)(*(ptrdiff_t*)(*(ptrdiff_t*)&b)); &b即b的地址,即this指针。通过上面的描述,知道this指针指向的内存位置的首地址存储虚函数表指针(vptr),vptr指向虚函数表(vptable),因此,从this-->vptable,类似于二级指针的寻址。(ptrdiff_t*)&b表示将this指针转换为ptrdiff_t类型的指针(vptr为指针占用8个字节,这里是获取vptr的值,如果是64位机器可以用long long替代ptrdiff_t,32位可以用int替代);(*(ptrdiff_t*)&b))解引用得到vptable的首地址,*(ptrdiff_t*)(*(ptrdiff_t*)&b)取得虚函数表的第一个函数指针,也即fun1。

二,继承与虚函数表

继承时,子类的内存区域布局依次为父类(多继承,则按继承的先后顺序依次布局),最后,为自己的成员对象。继承父类时,当然包含父类的虚函数指针,多个父类则子类会存在多个虚函数指针。通过这个描述,当Derived继承自Base和Base1时的内存布局,可以用下面的描述。

先看定义:

struct Base2
{
    virtual void fun3()
    {
        cout<<"Base2::fun3..."<<" ,"<<j<<endl;
    }
    int j;
};

struct Derived:Base,Base2
{
   void fun2()
   {
        cout<<"Derived::fun2..."<<", "<<i<<endl;
   }
};

int main()
{
    Base b;
    b.i = 0x12345678;
    b.b = 'a';

    typedef void (*FUN)(Base*);
    cout<<hex;
    FUN fun1 = (FUN)(*(ptrdiff_t*)(*(ptrdiff_t*)&b));
    fun1(&b);
    
    FUN fun2 = (FUN)(*((ptrdiff_t*)*(ptrdiff_t*)&b + 1));
    fun2(&b);    

    Derived d;
    d.i = 0x12345678;
    d.b = 'a';
    d.j = 0x55555555;
    d.i1 = 0x78563412;
    printObjWithByte(d);
}
编译运行:
Base::fun1... ,12345678
Base::fun2... ,12345678

ffffffa8 10 40 0 0 0 0 0 
78 56 34 12 61 0 0 0 
ffffffc8 10 40 0 0 0 0 0 
55 55 55 55 12 34 56 78

当继承多个含有虚函数指针父类的时候,会有多个虚函数指针,通过同样的方式,可以获取第二个父类的虚函数表,比如用下面的代码可以获取Base2的虚函数:

int main()
{
    Base b;
    b.i = 0x12345678;
    b.b = 'a';

    typedef void (*FUN)(Base*);
    cout<<hex;
    
    Derived d;
    d.i = 0x12345678;
    d.b = 'a';
    d.j = 0x55555555;
    d.i1 = 0x78563412;
    FUN fun2 = (FUN)(*((ptrdiff_t*)*(ptrdiff_t*)&d + 1));
    fun2(&b);
    typedef void (*FUN2)(Base2*);
    FUN2 fun3 = (FUN2)(*((ptrdiff_t*)*(ptrdiff_t*)((char*)&d + sizeof(Base)) + 0));
    fun3(&d);
}
编译运行:
Derived::fun2..., 12345678
Base2::fun3... ,55555555

通过运行结果,得出如下结论:

  1. 多继承继承内存布局:父类(继承父类顺序依次排列),自己的成员变量;
  2. 覆写父类的虚函数,虚函数表里面的对应的虚函数地址会被替换为覆写的函数地址。这也就是实现多态的原理。注:多态的三要素:虚函数、继承并覆写、父类的指针或引用指向子类的对象。

通过上面的分析,可以得到下图:

三、虚继承

这里,简单做一下虚继承的实现。见下面代码:

#include<iostream>

using namespace std;

struct B
{
    int i;
};

struct B1:B
{
};

struct B2:B
{
};

struct D:B1,B2
{
};

int main()
{
   D d;
   cout<<d.i<<endl;
}
这里编译时,会报错:
virtulDerive.cpp: In function ‘int main()’:
virtulDerive.cpp:25:12: error: request for member ‘i’ is ambiguous
    cout<<d.i<<endl;
            ^
virtulDerive.cpp:7:9: note: candidates are: int B::i
     int i;
         ^
virtulDerive.cpp:7:9: note:                 int B::i

从报错,可以看出,d.i时i的二义性引起的。如下图:

为了解决这个问题,C++引入了虚继承,即继承的时候加上virtual关键字。

struct B1:virtual B
{
};

struct B2:virtual B
{
};

struct D:B1,B2
{
};

int main()
{
   D d;
   d.i = 1234;
   cout<<d.i<<endl;
}
编译可以通过,运行得到:
1234

virtual声明的继承会在类中插入一个虚基类表指针,指向虚基类表,虚基类表里面描述的是虚基类与当前类的地址的偏移量。如下图所示:

该解决方案,解决了二义性的问题,使得D只有一份B。但是存在缺点:通过D访问B的成员的时候,需要经过多次计算才能得到B的位置。关键在于这个计算的次数与虚继承的层数有关,比如,当B通过虚继承继承自A,则D在访问A的成员的运算次数时访问B的两倍。这样,就造成运算的不稳定,对于语言设计来讲,希望同样的操作运算的效率应该是一样的。因此,做C++的编码时,尽量避免使用虚继承。

总结

已经一年多没怎么使用C++了,但是还是会经常看一下C++相关的知识。个人认为作为开发人员还是很有必要了解一下C++的相关知识的。这样有助于理解一些编码的细节。

编辑于 2019-08-18 00:38