首页 > 代码库 > 【深度探索C++对象模型】第一章 关于对象

【深度探索C++对象模型】第一章 关于对象

第一章 关于对象(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 的内存布局。    
    在下一章——构造函数语意学中,我们将了解关于类的构造函数的更多知识。