C++ 对象模型可以概括为以下两个部分
- 语言中直接支持
面向对象程序设计
的部分- 对于各种支持的
底层实现机制
语言中直接支持面向程序设计的部分,如构造函数、析构函数、虚函数、继承(单继承,多继承,虚继承)、多态等。重点在底层实现机制。
在 C 语言中,“数据” 和 “处理数据的操作(函数)”是分开来声明的。也就是说,语言本身并没有支持 “数据和函数” 之间的关联性。在 C++ 中,通过抽象数据类型(abstract data type,ADT),在类中定义数据和函数,来实现数据和函数直接的绑定。
概括来说,在 C++ 类中有两种成员数据:static、nonstatic;三种成员函数:static、nonstatic、virtual。
如下面的 Base 类的定义:
#include
using namespace std;
class Base{
private:
int iBase;
static int count;
public:
Base(int);
virtual ~Base(void);
int getIBase() const;
static int instanceCount();
virtual void print() const;
};
那么,Base 类在机器中如何构建出各种成员数据和成员函数的呢?
在介绍 C++ 使用的对象模型之前,介绍两种对象模型:简单对象模型(a simple object model)、表格驱动对象模型(a table-driven object model)。
所有的成员占用相同的空间(跟成员类型无关),
对象只是维护了一个包含成员指针的一个表
。表中放的是成员的地址,无论是成员变量还是函数,都是这样处理。对象并没有直接保存成员而是保存了成员的指针
这个模型在简单对象的基础上又添加了一个间接层。将成员分为函数和数据,并且用两个表格保存,然后是对象只保存了两个指向表格的指针。这个模型可以保证所有的对象具有相同的大小,比如简单对象模型中每个对象的大小于其成员的个数有关。其中数据成员表中包含实际数据;函数成员表中包含的实际函数的地址(与数据成员相比,多一次寻址)。
这个模型结合上面两种模型的特点,并对
内存存取
和空间
进行了优化。在此模型中,nonstatic 数据成员被放在对象内部;static 数据成员,static 和 nonstatic 成员函数均被放置在对象之外。
对于虚函数的支持分两步完成:
另外,虚函数表地址的前面设定了一个指向 type_info 的指针,RTTI(Run Time Type Identification)运行时类型识别是在编译器生成的特殊类型信息,包括对象继承关系,对象本身的描述,RTTI 是为多态而生成的信息,所以只有具有虚函数的对象才会生成。
这个模型的优点在于它的空间和存取时间的效率;但是也有缺点:如果应用程序本身未改变,但当所使用的类的 nonstatic 数据成员添加删除或修改时,需要重新编译。
模型验证测试
为了验证上述 C++ 对象模型,我们编写如下代码
#include
#include
using namespace std;
//获取普通成员函数的地址
template<typename dst_type,typename src_type>
dst_type pointer_cast(src_type src)
{
return *static_cast<dst_type*>(static_cast<void*>(&src));
}
class Base{
private:
int iBase;
static int count;
public:
Base(int i) : iBase(i){}
virtual ~Base(){}
int getIBase() const{ return iBase; }
static int instanceCount(){ count++; return count; }
virtual void print() const{cout << "printf" << endl; }
};
int Base::count = 0;
void test_base_model()
{
Base b1(1000);
cout << "对象b1 的其实内存地址:" << &b1 << endl;
cout << "type_info信息:" << (int*)*(int*)(&b1) - 1 << endl;
cout << "虚函数表地址:" << (int*)(&b1) << endl;
cout << "虚函数表——第一个函数地址:" << (int *)(*(int*)(&b1)) << endl;
cout << "虚函数表——第二个函数地址:" << (int *)(*(int*)(&b1)) + 1<< endl;
cout << endl;
cout << "推测数据成员 iBase 地址:" << (int*)(&b1) + 1 << ",值为:" << *((int*)(&b1) + 1) << endl;
cout << "普通函数 getIBase 的地址为:" << pointer_cast<void *>(&Base::getIBase) << endl;
cout << "静态函数instanceCount地址:" << pointer_cast<void *>(&Base::instanceCount) << endl;
}
int main()
{
test_base_model();
return 0;
}
根据 C++ 对象模型,实例化对象 b1 的起始内存地址,即虚函数表地址
- 虚函数表的第一个函数地址是虚析构函数地址;
- 虚函数表的第二个函数地址就是虚函数 print() 的地址,通过函数指针可以调用,进行验证;
- 推测数据成员 iBase 的地址为虚函数表的地址 + 1,(int *)(&b1) + 1;
- 静态数据成员和静态函数所在内存地址,与对象数据成员和函数成员位段不一样;
上面介绍了基本的 C++ 对象模型,引入继承之后,C++ 模型又是怎样的?
不管是单继承、多继承、还是虚继承,如果基于 “简单对象模型”,每一个基类都可以被派生类中的一个 slot 指出,该 slot 内包含基类对象的地址。这个机制的主要缺点是,因为间接性而导致空间和存取时间上的额外负担;优点是派生类对象的大小不会因其基类的改变而受影响。
如果基于 “表格驱动模型”,派生类中有一个 slot 指向基类表,表格中的每一个 slot 含有一个相关的基类地址(这个很像虚函数的地址)。这样每个派生类对象含一个 bptr,它会被初始化,指向其基类表。这种策略的主要缺点是由于间接性而导致的空间和存取时间上的额外负担;优点则是在每一个派生类对象中对继承都有一致的表现方式,每一个派生类对象都应该在某个固定位置上放置一个基类表指针,与基类的大小或数量无关。第二个优点是,不需要改变派生类对象本身,就可以放大,缩小或更改基类表。
不管上述哪一种机制,“间接性”的级数都将因为集成的深度而增加。C++ 实际模型是,对于一般继承是扩充已存在的虚函数表;对于虚继承添加一个虚函数表指针。
无重写,即派生类中没有于基类同名的虚函数
class Derived : public Base
{
public:
Derived(int d) : Base(d), iDerived(888){}
virtual ~Derived(){}
virtual void derived_print(){}
protected:
int iDerived;
};
Base、Derived的类图如下所示:
Base 的模型跟上面一样,不受继承影响。Derived 不是虚继承,所以是扩充已存在的虚函数表,所以结构如下图所示:
为了验证上述C++对象模型,我们编写如下测试代码。
void test_single_inherit_norewrite()
{
Derived d(999);
cout << "对象d'的起始位置为:" << &d << endl;
cout << "type_info信息:" << (int *)*(int *)(&d) - 1 << endl;
cout << "虚函数表的地址:" << (int*)(&d) << endl;
cout << "第一个虚函数地址:" << (int*)*(int*)(&d) << endl;
cout << "第二个虚函数地址:" << (int*)*(int*)(&d) + 1<< endl;
cout << "第三个虚函数地址:" << (int*)*(int*)(&d) + 2<< endl;
cout << "推测数据成员iBase地址:\t\t" << ((int*)(&d) +1) << "\t通过地址取得的值:" << *((int*)(&d) +1) << endl;
cout << "推测数据成员iDerived地址:\t" << ((int*)(&d) +2) << "\t通过地址取得的值:" << *((int*)(&d) +2) << endl;
}
派生类中重写了基类的 print() 函数
class Derived_Overrite : public Base
{
public:
Derived_Overrite(int);
virtual ~Derived_Overrite(void);
virtual void print(void) const;
protected:
int iDerived;
};
Base、Derived_Overwrite的类图如下所示:
重写print()函数在虚函数表中表现如下:
从单继承可以知道,派生类只是扩充了基类的虚函数表。如果是多继承的话,又是如何扩充的?
- 每个基类都有自己的虚表
- 子类的成员函数被放到了第一个基类的表中
- 内存布局中,其父类布局依次按声明顺序排列
- 每个基类的虚表中的 print() 函数都被 overwrite 成了子类的 print()。这样做是为了解决不同的基类类型的指针指向同一个子类实例,而能够调用到实际的函数。
上面3个类,Derived_Mutlip_Inherit继承自Base、Base_1两个类,Derived_Mutlip_Inherit的结构如下所示:
虚继承是为了解决重复继承中多个间接父类的问题的,所以不能使用上面简单的扩充并为虚基类提供一个虚函数指针(这样会导致重复继承的基类会有多个虚函数表)形式。
虚继承的派生类的内存结构,和普通继承完全不同。虚继承的子类,有单独的虚函数表,另外也单独保存一份父类的虚函数表,两部分之间用一个四个字节的 0x00000000 来作为分界。
派生类的内存中,首先是自己的虚函数表,然后是派生类的数据成员,然后是 0x0,之后就是基类的虚函数表,之后就是基类的数据成员。
如果派生类没有自己的虚函数,那么派生类也会有有一个指向虚函数表的 vptr,而后是派生类的变量,然后再是基类。虚继承中,即时派生类和基类都没有虚函数,派生类也会有 vptr,其指向的内存地址所存储的是 0x00。
因此,在虚继承中,派生类和基类(虚基类)的数据,是完全间隔的,先存放派生类自己的虚函数表和数据,中间以 0x 分界,最后保存基类的虚函数和数据。如果派生类重载了父类的虚函数,那么则将派生类内存中基类虚函数表的响应函数替换。
简单虚继承的2个类 Base、Derived_Virtual_Inherit1 的关系如下所示:
Derived_Virtual_Inherit1的对象模型如下图:
【标注】:Xxxx => -4 是分隔符的意思
菱形继承关系如下图:
至此,C++对象模型介绍的差不多了,清楚了C++对象模型之后,很多疑问就能迎刃而解了。下面结合模型介绍一些典型问题。
前面介绍了 C++ 对象模型,下面介绍 C++ 对象模型对访问成员的影响。其实清楚了 C++ 对象模型,就清楚了成员访问机制,给出一个大致的介绍。
class Base{
private:
public:
Base(){}
virtual ~Base(){}
virtual void print(){}
virtual void print_virtual(){}
};
class Derived : public Base
{
public:
Derived(){}
virtual ~Derived(){}
virtual void print(){}
virtual void print_virtual(){}
};
class Derived_Virtual : virtual public Base
{
public:
Derived_Virtual() {}
virtual ~Derived_Virtual() {}
virtual void print_derived_virtual(){}
protected:
private:
};
测试对象大小:
void test_size()
{
Base b;
Derived d;
Derived_Virtual dv;
cout << "sizeof(b) = " << sizeof(b) << endl;
cout << "sizeof(d) = " << sizeof(d) << endl;
cout << "sizeof(dv) = " << sizeof(dv) << endl;
}
- Base 中包含虚函数指针,所以 size 为 4字节
- Derived 单继承 Base,只是扩充了基类的虚函数表,不会新增虚函数表指针,所以 size 也为 4
- Derived_Virtual 虚继承 Base,根据前面的模型可知,派生类有自己的虚函数表及指针,并且有分隔符(0x00000000)4字节,然后是虚基类的虚函数表指针,所以 size = 4+4+4 = 12
那么一个空类(只有构造函数和析构函数(析构函数不是虚函数))的大小是否为0呢?
【举个栗子】
class Empty{
public:
Empty(){}
~Empty(){}
};
结果如上,并不是空的,它有一个隐晦的 1字节,那是被编译器安插进去的一个 char。这将使得这个class 的两个函数在类中有独一无二的地址。如果不给空类分配一定的空间,那么将无法使用该类的实例。
数据成员如何访问(直接取址)
跟实际对象模型相关联,根据
对象起始地址 + 偏移量
取得
静态绑定和动态绑定
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解析为执行特定的函数代码块被称为函数名绑定(binding, 又称联编)。在 C 语言中,这非常简单,因为每个函数名都对应一个不同的函数。在 C++ 中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。
然而编译器可以在编译过程中完成这种绑定,这称为静态绑定(static binding),又称为早期绑定(early binding)
。
然而虚函数使这项工作变得更加困难。使用哪一个函数不是能在编译阶段确定的,因为编译器不知道用户选择哪种类型。所以,编译器必须能够在程序运行时选择正确的虚函数的代码,这被称为动态绑定(dynamic binding),又称为晚期绑定(late binding)
。
使用虚函数是有代价的,在内存和执行速度等方面是有一定成本的,包括:
- 每个对象都将增大,增大量为存储虚函数表指针的大小
- 对于每个类,编译器都将创建一个虚函数地址表
- 对于每个函数调用,都需要执行一项额外的操作,即到虚函数表中查找地址。虽然非虚函数比虚函数效率稍高,但不具备动态联编能力。
函数成员如何访问(间接取址)
跟实际模型相关联,普通函数(static、nonstatic)根据编译、链接的结果直接获取函数地址;如果是虚函数根据对象模型,取出对于虚函数地址,然后在虚函数表中查找函数地址。
多态的实现
多态(Polymorphisn)在 C++ 中是通过虚函数实现的。通过上面的模型【“有重写的单继承”】知道,如果类中有虚函数,编译器就会自动生成一个虚函数表,对象中包含一个指向虚函数表的指针。能够实现多态的关键在于:虚函数是允许被派生类重写的,在虚函数表中,派生类函数覆盖基类函数。除此之外,还必须通过指针或引用调用方法才行,将派生类对象赋给基类对象。
上面2个类,基类 Base、派生类 Derived 都包含下面2个方法
void print() const;
virtual void print_virtual() const;
这两个方法的区别就在于一个是普通函数,一个是虚函数。
编写测试代码如下:
void test_polmorphisn()
{
Base b;
Derived d;
b = d;
b.print();
b.print_virtual();
Base *p;
p = &d;
p->print();
p->print_virtual();
}
根据模型推测只有 p->print_virtual() 才实现了多态,其他三个调用都是调用基类的方法
- b.print(); b.print_virtual();不能实现多态是因为
通过基类对象调用,而非指针或者引用,所以不能实现多态
- p->print();不能实现多态是因为,print() 函数
没有声明为虚函数
(virtual),派生类中也定义了print 函数只是隐藏了基类的 print 函数。
为什么析构函数设成虚函数是有必要的?
析构函数应当都是虚函数,除非明确该类不做基类(不被其他类继承)。基类的析构函数声明为虚函数,这样做是为了确保释放派生类对象时,按照正确的顺序调用析构函数。
从前面介绍的 C++ 对象模型可知,如果析构函数不定义成虚函数,那么派生类就不会重写基类的析构函数,再有多态行为的时候,派生类的析构函数不会被调用到(有内存泄漏的风险
)
【举个栗子】
void test_vitual_destructor()
{
Base *p = new Derived();
delete p;
}