背景
之前看过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流,可以看出以下信息:
- 我的运行的机器是小端的(小端模式,是指数据的高字节保存在内存的高地址中,其中b.i中的12是数据的高位,在内存中在低地址(相对78来说));
- 对象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
通过运行结果,得出如下结论:
- 多继承继承内存布局:父类(继承父类顺序依次排列),自己的成员变量;
- 覆写父类的虚函数,虚函数表里面的对应的虚函数地址会被替换为覆写的函数地址。这也就是实现多态的原理。注:多态的三要素:虚函数、继承并覆写、父类的指针或引用指向子类的对象。
通过上面的分析,可以得到下图:
三、虚继承
这里,简单做一下虚继承的实现。见下面代码:
#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++的相关知识的。这样有助于理解一些编码的细节。