第一章 关于对象(Object Lessons)
—— 本书作者:Stanley B.Lippman
一、前言
什么是 C++ 对象模型:简单的说,就是 C++ 中面向对象的底层实现机制。
本书组织:
第 1 章,关于对象(Object Lessons),介绍 C++ 对象的基础概念,给读者一个粗略的了解。
第 2 章,构造函数语意学(The Semantics of Constructors),构造函数什么时候会被编译器合成?它给我们的程序效率带来了怎样的影响?
第 3 章,Data语意学(The Semantics of Data),讨论 data members 的处理。
第 4 章,Function语意学(The Semantics of Function),讨论类的各种成员函数,特别是 Virtual 。
第 5 章,构造、析构、拷贝语意学(Semantics of Construction, Destruction, and Copy),探讨如何支持 class 对象模型,以及 object 的生命周期。
第 6 章,执行期语意学(Runtime Semantics),临时对象的生与死,new 与 delete 的支持。
第 7 章,在对象模型的顶端(On the Cusp of the Object Model),专注于 exception handling, template support, runtime type identification(RTTI)。
读完此书,或者此系列blogs,会让你对 C++ 的 class 有更深的了解。你将知道虚函数的实现方式,以及它所带来的负担。等等等等,这里有你想知道关于 class 的一切。
在 C 语言中,“数据”和“处理数据的操作(函数)”是分开来声明的,也就是说,语言本身并没有支持“数据和函数”之间的关联性。我们把这种程序方法称为“程序性的”。例如,我们声明一个 struct Point3d:
typedef struct _Point3d { float x; float y; float z; } Point3d; |
欲打印一个 Point3D,我们可能需要这样一个函数:
void Point3d_print( const Point3d* pd ) { printf("(%g, %g, %g)", pd->x, pd->y, pd->z); } //%g和%G是实数的输出格式符号。它是自动选择%f和%e两种格式中较短的格式输出,并且不输出数字后面没有意义的零。 |
在 C++ 中,你可能会这样来设计一个双层或者三层的Point3D:
class Point { public: Point( float x = 0.0 ) : _x(x) {} float x() { return _x; } void x( float val ) { _x = val; } // ... protected: float _x; }; class Point2d : public Point { public: Point2d( float x = 0.0, float y = 0.0 ) : Point( x ), _y( y ) {} // ... protected: float _y; } class Point3d : public Point2d { public: Point3d( float x = 0.0, float y = 0.0, float z = 0.0 ) : Point2d( x, y ), _z( z ) {} // ... protected: float _z; } |
从软件工程的眼光来看,面向对象的特征,使得 C++ 比 C 看起来似乎更好,C 相对而言,更精瘦和简易,C++ 看起来似乎更复杂,但并不意味着 C++ 不更有威力。
当一个 Point3d 转换到 C++ 之后,第一个可能会问的问题是:加上了封装之后,布局成本增加了多少呢?答案是: class Point3d 并没有增加成本。三个 data members 直接内涵在每一个 class Object 之中,而 成员函数(member functions)虽然在 class 的声明之内,但却不会出现在 class 的对象实体(Object)中。每一个非 inline member function 只会诞生一个函数实体。而
inline function,会在其每一个使用者身上产生一个函数实体。后面你将看到,C++ 在布局和存取时间上主要的负担 是由 virtual 引起的。包括 虚函数 以及 虚基类。
二、C++ 的对象模型
首先,C++ 中,
2种成员变量(class data members):静态的(static) 和 非静态的(non-static); 3种成员函数(class member functions):静态的、非静态的 和 虚拟的(virtual)。 |
我们来看这么一个类:
class Point { public: Point( float valx ); virtual ~Point(); float x() const; static int PointCount(); protected: virtual ostream& print( ostream &os ) const; float _x; static int _point_count; }; |
那这个 class Point 在机器中将会被怎么表示呢?这有没有引起你的求知欲?
【注】原书这里介绍了 简单对象模型 和 表格驱动的对象模型 。这里跳过这两个,直接看 C++ 对象模型。
在 C++ 对象模型中,
非静态的(non-static)成员变量 被配置于每一个 class object 之内;
静态的(static)成员变量 则被存放在所有 class object 之外,也就是全局数据区。(问:如果是这样,我们的 class 怎么样去全局数据区找到属于它的 static 成员变量?别急,后面会有答案)。
静态和非静态的成员函数,也被配置于 每一个 class 的实体之外。
虚函数的配置方法是:
1. 每一个 class 产生出一堆指向 virtual functions 的指针,并把这些指针放在表格之中。这个表,既是所谓的 虚函数表(virtual table), 或 vtbl;
2. 每一个 class 的实体(object) 被添加了一个指针,指向相关的(注意不一定是同一个) virtual table。通常这个指针被称为 vptr。vptr 的设定和重置都有每一个 class 的 构造函数、析构函数、拷贝以及复制运算符。每一个 class 所关联的 type_info object( 用以支持 runtime type identification,
RTTI )也经由 virtual table 被指出来,通常是放在表格的第一个 slot 处。
三、C++ 如何支持多态
1. 经由一组隐含的转化操作。例如,把一个 派生类 的指针转化为一个指向其 public base type 的指针:
shape* ps = new circle();
2. 经由 virtual functions 机制:
ps->rotate();
3. 经由 dynamic_cast 和 typeid 运算符:
if ( circle *pc = dynamic_cast< circle* >(ps) )...
多态的主要用途,是经由一个共同的接口,来影响类型的封装,我们通常会把这个接口定义在一个抽象基类里面,然后再在派生类里重写这个接口。
四、需要多少内存来表现一个 class object?
猜想下面的代码的 sizeof 结果会是?
.eg.1.
class Base { public: Base(); ~Base(); }; // sizeof(Base) = ? |
.eg.2.
class Base { public: Base(); ~Base(); protected: double m_Double; int m_Int; char m_BaseName; }; // sizeof(Base) = ? |
究竟需要多少内存,才能表现一个 class 的 object 呢?一般而言有:
1. 其 非静态的成员变量( non-static data members ) 的总和大小。
2. 加上任何由于 内存对齐 的需求而填补上去的控件。
3. 加上为了支持 virtual 而由内部产生的任何额外的负担。
此外,需要注意的是,一个指针(或是一个 reference),不管它只想哪一种数据类型,指针本身所需内存大小是固定的。比如,在 win32下,一个指针的大小就是4个字节(byte)。
问题的答案:
第一题: 答案是1。 class Base 里只有构造函数和析构函数,由前面的内容所知,class 的 member functions 并不存放在 class 以及其实例内,因此,sizeof(Base) = 1。是的,结构不是0,而是1,原因是因为,class 的不同实例,在内存中的地址各不相同,一个字节只是作为占位符,用来给不同的实例分配不同的内存地址的。 第二题:答案是16。 double 类型的变量占用8个字节,int 占了4个字节,char 只占一个字节,但这里它会按 int 进行对齐,Base 内部会填补3个字节的内存大小。最后的大小就是 8 + 4 + 1 + 3 = 16。 大家可以调整三个成员变量的位置,看看结果会有什么不同。 |
五、指针的类型
Base* p_Base; int* p_Int; vector<string> * p_vs; |
请问,一个指向 Base class 的指针和一个指向 int 的指针是如何产生不同的呢?
1. 以内存需求的观点来说,没有不同;在32位机器上,它们都需要4个字节的内存空间。
2. “指向不同内存的各指针”间的差异,在于其所寻址出来的 object 的类型不同。
也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其涵盖大小。比如:一个指向 int 的指针,假设其地址是 1000,在32位及其上,将涵盖地址空间 1000~1003.
那么,一个指向地址 1000 的 void* 的指针,将涵盖怎样的地址空间呢?没错,我们并不知道!这就是为什么一个类型为 void* 的指针,只能够含有一个地址,而不能够通过它操作所指的 object 的缘故。
所以,转型(cast)其实是一种编译器指令,它所做的,并不是改变指针所含的真正地址,而是教导编译器该去如何解释指针所涵盖的地址空间。
六、小结
第一章——关于对象。本章初步介绍了C++的对象模型是怎样的,后面的章节将继续讨论这个对象模型的底层实现机制。
在读完本篇文章之后,你应该理解:
- 如何计算 sizeof(classA) 的大小;
- 了解 class 的内存布局。
在下一章——构造函数语意学中,我们将了解关于类的构造函数的更多知识。
【深度探索C++对象模型】第一章 关于对象