深入理解C++对象模型(一)
hahahanba Lv1

第一章:关于对象

在C语言中,“数据” 和“处理数据的操作 (函数)” 是分开来声明的,也就是说,语言本身并没有支持“数据和函数” 之间的关联性。我们把这种程序方法称为程序性的(procedural),由一组 “分布在各个以功能为导向的函数中” 的算 法所驱动,它们处理的是共同的外部数据。举个例子,如果我们声明一个struct Point3d,像这样:

1
2
3
4
5
6
typedef struct point3d
{
float x;
float y;
float z;
} Point3d;

但当我们想打印Point3d这个数据结构时,我们就得定义一个像这样的函数:

1
2
3
4
void Point3d_print(const Point3d *pd)
{
printf("(%g, %g, %g)", pd->x, pd->y, pd->z);
}

或者定义一个宏,抑或是直接在函数中完成打印操作,也可以经由一个前置处理宏来完成。

而在C++中,*Point3d* 有可能采用独立的“抽象数据类型(abstract data type, ADT)” 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Point3d
{
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : _x(x), _y(y), _z(z) {}
float x() { return _x; }
float y() { return _y; }
float z() { return _z; }

void x(float xval) { _x = xval; }
// ... etc ...
private:
float _x;
float _y;
float _z;
};

inline ostream&
operator<< (ostream &os, const Point3d &pt)
{
os << "(" << pt.x() << ","
<< pt.y() << ", " << pt.z() << ")";
};

很明显,C和C++不只在程序风格上有显著的不同,在程序的思考上也有明显的差异。

加上封装后的布局成本

当我们将Point3d转换到C++之后,第一个可能想到的问题就是:加上了封裝之后,程序的布局成本是否增加了? 答案是class Point3d没有增加成本。这是因为:

  • 三个 data merabers 直接内含在每一个class object 之中,就像C struct 的情况一样。
  • 而member functions虽然含在class的声明之内,却不出现在object 之中。
  • 每一个non-inline member function 只会诞生一个函数实例。至于每一个“拥有零个或一个定义” 的inline function 则会在其每一个使用者 (模块) 身上产生一个函数实例。

Point3d支持封装这一点并未带给它任何额外的成本。而且C++在布局以及存取时间上主要的额外负担是由virtual 引起的:

  • virtual function 机制:保存vtable和通过vtable找到函数地址。
  • virtual base class 机制:通过指针来找到基类的成员。

1.1 C++对象模型

在C++中有:

  • 两种类数据成员:static 和 nonstatic
  • 三种类成员函数:static 、nonstatic 和 virtual

下面我们通过class Point来看一下,在机器中类是如何模塑 (modeling) 出各种data members 和function members 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point
{
public:
Point(float xval);
virtual ~Point();

float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream &os) const;

float _x;
static int _point_count;
};

1.1.1 简单对象模型

在简单模型中,一个对象由一系列的 slots 构成,每一个 slot 指向一个 members。Members 按其声明顺序,各被指定一个 slot。每一个 data member 或 function member 都有自己的一个slot。

image

在这个简单模型下,members 本身并不放在对象内,只有“指向member 的指针” 才放在对象内(避免了“members 有不同的类型,因而需要不同的存储空间” 的问题)。对象中的 members 是通过 slot 的索引值来寻址的,本例之中_x的索引是6,_point_counrt 的索引是7。

需要注意这个模型并没有被应用于实际产品上,不过关于索引或 slot 个数的观念, 倒是被应用到C++的“指向成员的指针” (pointer-to-member)观念之中。

1.1.2 表格驱动对象模型

如果对所有类中所有对象都有一致的表达方式的话,就可以采用下面的模型。该对象模型把所有与members 相关的信息抽出来,放在一个 data member table 和一个 member functiontable 之中,class object 本身则内含指向这两个表格的指针。Member function table 是一系列的 slots,每一个slot 指向一个member function;Data member table 则直接持有 data 本身。(没有实际应用于真正的 C++ 编译器身上,但 member function table 这个观念却成为支持virtual functions的一个有效方案)

image

1.1.3 C++对象模型

前两个模型有两个主要问题:引入的指针过多,空间浪费严重;另一个则是添加的索引层次过多,导致数据存取性能较低。C++对象模型是从简单对象模型派生而来,并对内存空间和存取时间做了优化。在此模型下:

  • Nonstatic data members 放在每一个类对象之内;
  • static data members 存放在类对象之外;
  • Static和nonstatic function members 也被放在类对象之外。

image

  • 虚函数机制则由以下的 2 个的两个步骤来支持:
    • 每一个class会产生一个虚表(vtbl, vtable),虚表中存放着一系列指向虚函数的指针;
    • 每一个class 对象内添加一个指针vptr,指向相对应的vtable。vptr的设定与重置由每一个class的构造函数、析构函数和拷贝赋值运算符自动完成。
    • 通常每一个 class 所关联的 type_info 对象的指针保存在vtable 的第一个 slot 中,用于支持RTTI (runtime type identification)
  • 需要清楚的明白一点是: 一个 vtable 对应一个 class,一个 vptr 才对应一个 class object,必须区分开这 2 个概念。

1.2 关键词所带来的差异

  • C++优先判断一个语句为声明: 当语言无法区分一个语句是声明还是表达式时,就需用用一个超越语言范围的规则。

  • struct 和 class 关键字的意义:

    • 它们之间在语言层面并无本质的区别,更多的是概念和编程思想上的区别。

    • struct 用来表现那些只有数据的集合体 POD(Plain OI’ Data)、而 class 则希望表达的

      是 ADT(abstract data type)的思想;

    • 由于这2个关键字在本质是无区别,所以class并没有必须要引入,但是引入它的确非常令人满意,因为这个语言所引入的不止是这个关键字,还有它所支持的封装和继承的哲学;

    • 由于这2个关键字在本质是无区别,所以class并没有必须要引入,但是引入它的确非常令人满意,因为这个语言所引入的不止是这个关键字,还有它所支持的封装和继承的哲学;

  • C++只保证处于同一个 access section 的数据,一定会以声明的次序出现在内存布局当中。 C++标准只提供了这一点点的保证。

  • 与 C 兼容的内存布局: 组合,而非继承,才是把 C 和 C++结合在一起的唯一可行的方法。 只有使用组合时,才能够保证与 C 拥有相同的内存布局,使用继承时的内存布局是不受 C++ Standard 所保证的(很多编译器也可行,但是标准未定义!)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct C_point {...};
    class Point
    {
    public:
    operator C_point() { return _c_point; }
    // ...
    private:
    C_point _c_point;
    // ...
    };

1.3 对象的差异

C++程序设计模型直接支持三种programming paradigms。

  • 程序模型 (procedural model): 就像C一样,一条语句接一条语句的执行或者函数跳转;
  • 抽象数据类型模型 (abstract data type model, ADT): 此模型所谓的“抽象”是和一组表达式(public 接口) 一起提供的,那时其运算定义仍然隐而未明;
  • 面向对象模型 (object-oriented model): 在此模型中有一些彼此相关的类型,通过一个抽象的base class (用以提供共同接口)被封装起来 。C++ 通过class 的pointers和references来支持多态,这种程序设计风格就称为“面向对象” 。

虽然你可以直接或间接处理继承体系中的一个base class object,但只有通过 pointer 或reference 的间接处理,才支持OO程序设计所需的多态性质

在C++,多态只存在于一个个的public class 体系中。举个例子,px可能指向某个类型的object,或指向根据public 继承关系派生而来的一个子类型。Nonpublic 的派生行为以及类型为void*的指针可以说是多态的,但它们并没有被语言明确地支持,也就是说它们必须由程序员通过显式的转换操作来管理。

C++以下列方式支持多态

  • 经由一组隐式的转化操作。例如把一个derived class指针转化为一个指向其public base type的指针:

    1
    shape *ps = new circle();
  • 经由virtual function 机制:

    1
    ps->rotate();
  • 经由dynamic_cast和typeid运算符:

    1
    if (circle *pc = dynamic_cast<circle*>(ps)) ...

多态的主要用途是经由一个共同的接又来影响类型的封装,这个接又通常被定义在一个抽象的base class 中。例如Library_materials class就为Book、ridea、 Pupper 等 subtype 定义了一个接口。这个共享接口是以virtual function 机制引发的, 它可以在执行期根据object 的真正类型解析出到底是哪一个西数实例被调用。

需要多少内存才能够表现一个claso bject ?一般而言要有:

  • 其nonstatic data members 的总和大小;
  • 加上任何由于位对齐 (alignment) 的需求而填补(padding) 上去的空间(可能存在于 members 之间,也可能存在于集合体边界);
  • 加上为了支持 virtual 机制而由内部产生的任何额外负担(overhead)。

一个指针 (或是一个reference)。本质上,一个reference通常是以一个指针来实现的,而object 语法如果转换为间接手法,就需要一个指针),不管它指向哪一种数据类型,指针本身所需的内存大小是固定的

1.3.1 指针的类型

一个指向ZooAnimal 的指针是如何地与一个指向整数的指针或一个指向template Array 的指针有所不同呢 ?

以内存需求的观点来说,没有什么不同!它们三个都需要有足够的内存来放置一个机器地址。“指向不同类型之各指针” 间的差异, 既不在其指针表示法不同 ,也不在其内容 (代表一个地址)不同,而是在其所寻址出来的 object 类型不同。也就是说,“指针类型”会教导编译器如何解释某个特定地址中的内存内容及其大小:

1
2
3
4
5
6
7
8
9
10
11
12
class ZooAnimal
{
public:
ZooAnimal();
virtual ~ZooAnimal();
// ...
virtual void rotate();

protected:
int loc;
String name;
};
  • 一个指向地址 1000 的整数指针,在 32 位机器上,将涌盖地址空间1000~1003 (32位机器上的整数是4-bytes)。
  • 如 果 String 是传统的 8-bytes (包括一个4-bytes 的字符指针和一个用来表示字符串长度的整数),那么一个 ZooAnimal 指针将横跨地址空间1000~1015。

那么,一个指向地址1000 而类型为void*的指针,将涵盖怎样的地址空间呢? 是的,我们不知道!这就是为什么一个类型为void*的指针只能够持有一个地址,而不能够通过它操作所指的object的缘故。
所以,转换 ( cast )其实是一种编译器指令。大部分情况下它并不改变一个指针所含的真正地址,它只影响 “被指出之内存的大小和其内容” 的解释方式

1.3.2 加上多态以后

现在考虑一个Bear类,作为一种ZooAnimal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Bear : public ZooAnimal
{
public:
Bear();
~Bear();
// ...
void rotate();
virtual void dance();
// ...
protected:
enum Dances { ... };

Dances dances_known;
int cell_block;
};
Bear b("Yogi");
Bear *pb = &b;
Bear &rb = *pb;

b,pb,rb会有怎样的内存需求? 不管是pointerreference都只需要一个word的空间。Bear object需要24bytes, 也就是 ZooAnimal 的16bytes加上Bear所带来的8bytes。其可能的内存布局如下:

image

多态只能由”指针“或”引用“来实现,根本原因在于:

  • 指针和引用(通常以指针来实现)的大小是固定的(一个word),而对象的大小却是可变的。其类的指针和引用可以指向(或引用)子类,但是基类的对象永远也只能是基类,没有变化则不可能引发多态。
  • 一个point或reference绝不会引发任何”与类型有关的内存委托操作“,在指针类型转换时会受到的改变的只有它们所指向的内存的大小和解释方式而已。
 评论