[关闭]
@zhengyuhong 2014-10-09T02:52:02.000000Z 字数 6950 阅读 1182

深入探索C++对象模型

读书笔记 C++


第一章、关于对象

1.0、加上封装后的布局成本增加了多少?

  答案是Point3d并没有增加成本。三个data members直接内含在每一个class object之中,就像C struct的情况一样。而member function虽然含在class的声明之内,却不出现在object之中。每一个非内联函数只会产生一个函数实例。至于内联函数则会在每一个对象产生一个函数实例。Point3d支持封装性质,这一点并未带给它任何空间或者执行器的不良后果。C++在布局以及存取时间上主要的额外负担是由virtual引起,包括:
虚函数机制
虚继承

1.1、C++对象模型

  C++对象模型中,非静态成员变量被放置于每一个对象之内,静态成员变量则存放在对象之外。静态成员函数与非静态成员函数均放置在对象之外。虚函数则是用以下两个步骤来支持:
每一个类产生一堆指向虚函数的指针,这一堆虚函数指针则放置在一个称为虚函数表(virtual table)当中。
  每一个对象则被安插一个指针vptr,指向虚函数表。vptr的设定(setting)与重置(resetting)都有每一个类的构造函数、析构函数、赋值运算符自动完成。每一个类都有一个关于的类型信息(type_info),用来支持RTTI,用一个指针指向,这个指针通常放置在虚函数表的第一个slot。

1.2、虚继承

C++支持多继承

  1. class iostream:public istream,public ostream{...};

甚至,继承也可以指定为虚拟(virtual,也就是共享的意思)

  1. class istream : virtual public ios{...};
  2. class ostream : virtual public ios{...};

  在虚继承的情况下,共享基类不管在继承链中被派生多少次,永远只存在一个实例。
那么虚继承中,派生类是如何在本质上构造共享基类呢?
简单对象模型:
  每一个派生类对象(离共享基类最远的派生类)内部有一个slot指向共享基类对象,这个体制的缺点是,间接性而导致空间与存取时间上的额外负担,优点是派生类对象的大小不会因为共享基类的改变而受到影响,共享基类改变时,派生类无需重新编译。
表格驱动对象模型
  就好像每一个虚函数表中内含每一个虚函数地址一样,每一个派生类对象都有一个bptr指向它所继承的基类表格(一个或者多个)。主要缺点是由于间接性而导致空间和存取时间上的额外负担,优点则是每一个派生类(包括中间的派生类)都有一致的表现形式:每一个派生类对象都在某一个位置安插一个bptr,与基类的大小、个数均为关系。第二个是基类表格改变时(增减基类)时,无需改变派生类,派生类无需重新编译。

1.3、关键词struct class

  什么时候一个程序员用struct取代class? 当它让一个人感觉比较好的时候。
  本人对于这一节看得不多,因为我没有在选择structclass的困扰,我一直是使用class,我只知道它们两者有一些区别

成员变量在内存中的顺序
  class中处于同一个访问级(public,protected,private)的数据必定保证以其声明的顺序出现在内存布局当中。然而被放置在多个访问级的各笔数据,排列顺序就不一定了。
同理,基类与派生类的成员变量的布局没有谁先谁后的强制规定,这个看各个厂商的编译器如何支持。
  struct在C++中的一个合理用途,是当要传递一个复杂的对象全部或者部分到某一个C函数时,struct声明可以将数据封装起来,并保证拥有与C兼容的空间布局。

1.4、多态

  对象的多态操作要求此对象必须可以经由一个指针或者引用来存取,不过指针与引用并不是多态的必要结果。如:

  1. const int maxSize = 100;
  2. int * vec = new int[maxSize];
  3. ...
  4. delete[] vec;

C++以一下方法支持多态

  1. shape *ps = new circle();
  1. ps->rotate();
  1. if(circle *pc = dynamic_cast<circle* >(ps))...

  多态的主要用途是由共同的基类指针操作不同的派生类,或者使共同接口操作派生自同一个基类的派生类的对象。根据执行期中对象的真正类型来解析出调用哪一个函数。

1.5、对象的大小

  需要多少内存才能能表现一个对象?一般而言有:

1.6、指针的类型

  一个指针,不管它指向哪一种类型,指针本身所需的内存大小是固定的。举一个例

  1. class ZooAnimal{
  2. public:
  3. ZooAnimal();
  4. virtual ~ZooAnimal();
  5. //...
  6. virtual void rotate();
  7. protected:
  8. int loc;
  9. String name;
  10. }

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

  1. ZooAnimal *px;
  2. int* pi;

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

1.7、加上多态之后

  1. class Bear:public ZooAnimal{
  2. public:
  3. Bear();
  4. ~Bear();
  5. void rotate();
  6. virtual void dance();
  7. protected:
  8. enum Dances{...};
  9. int cell_block();
  10. };
  11. Bear b;//假设b放在地址1000处

  一个Bear指针与ZooAnimal指针有什么不同?

  1. Bear b;
  2. ZooAnimal *pz = &b;
  3. Bear *pb = &b;

  它们都指向b的首地址,根据前面指针类型,它们的区别是pb所涵盖的地址包含整个b对象,而pz则所涵盖的地址只包含b对象中的ZooAnimal部分。
  除了ZooAnimal部分中出现的members,你不能使用pz来直接处理Bear的任何members,唯一例外就是虚函数机制,我pz调用rotate()时

  1. pz->rotate();

pz的类型将在编译时期决定以下两点:

  1. Bear b;
  2. ZooAnimal za = b;//引起切割sliced
  3. za.roate();

  前面1.1、C++对象模型中说到,vptr会在构造函数、析构函数、赋值运算符中会将**vptr**setting与resetting,当上述za = b;会发生切割,并且会重置za的vptr(如果有的话)
  一个指针或者引用之所以支持多态,是因为它们并不引发内存中任何“与类型有关的内存委托操作(type-dependent commitment)”会受到改变的,只是使得它们指向的内存的“大小与内容解释”发生变化。

第二章、构造函数的语意学

2.1、带有默认构造函数的成员变量

  如果一个类没有定义任何构造函数,但有一个拥有默认构造函数的成员变量m,那么这时编译器会自动合成一个有用的默认构造函数,用以初始化m。不过这个操作也是在默认构造函数真正需要时才合成,如此整个源码都没有定义一个这个类的变量,那么就没有使用到默认构造函数,所以就没有必要合成。而且合成的构造函数、拷贝函数、赋值运算符与析构函数都是以inline方式完成。初始化拥有默认构造函数的成员变量时编译器的责任。
  如果一个类没有定义任何构造函数,但有一个成员变量str,它没有默认构造函数,这时候编译器不会合成一个默认构造函数,初始化str是程序员的责任了。
  总的来说,初始化拥有默认构造函数的成员变量m时编译器的责任,在定义的构造函数中的初始化列表没有初始化m,编译器就自动插入至初始化列表当中并调用m默认构造函数,如果没有定义默认构造函数,就合成一个,然后插入初始化列表当中。

2.2、带有默认构造函数的基类

这里的基类base与上述的成员变量m一样,初始化拥有默认构造函数的基类的责任是编译器的责任。

2.3、带有虚函数的类

如下两种情况也需要合成默认构造函数

  1. 类声明(继承)虚函数
  2. 类派生自一个继承串链,其中有虚基类
    个人观点合成默认构造函数的作用是设置(setting)vptr虚函数表指针,以及设置(setting)vbptr虚基类的指针
    也如成员变量m的情况,即便是有构造函数,编译器也会在其中安插代码设置虚函数表指针、虚基类指针

2.4、拷贝构造函数的构造操作

memberwise 深层拷贝
bitwise 浅层拷贝
  并不是所有未定义有拷贝构造函数的类编译器都会为其合成拷贝构造函数,编译 器只有在必要的时候才会为其合成拷贝构造函数。所谓必要的时刻是指编译器在 普通手段无法完成解决“当一个类对象以另一个同类实体作为初值”时,才会合成 拷贝构造函数。也就是说,当常规武器能解决问题的时候,就没必要动用非常规 武器。
  如果一个类没有定义拷贝构造函数,通常按照“成员逐一初始化(Default Memberwise Initialization)”的手法来解决“一个类对象以另一个同类实体作为 初值”——也就是说把内建或派生的数据成员从某一个对象拷贝到另一个对象身上, 如果数据成员是一个对象,则递归使用“成员逐一初始化(Default Memberwise Initialization)”的手法。
  成员逐一初始化(Default Memberwise Initialization)具体的实现方式则是位 逐次拷贝(Bitwise copy semantics)1。也就是说在能使用这种常规方式 来解决“一个类对象以另一个同类实体作为初值”的时候,编译器是不需要合成拷 贝构造函数的。但有些时候常规武器不那么管用,我们就得祭出非常规武器了 ——拷贝构造函数。有以下几种情况之一,位逐次拷贝将不能胜任或者不适合来完 成“一个类对象以另一个同类实体作为初值”的工作。此时,如果类没有定义拷贝 构造函数,那么编译器将必须为类合成一个拷贝构造函数。
1. 当类内含一个成员对象,而后者的类声明有一个拷贝构造函数时(不论是设 计者定义的还是编译器合成的)。
2. 当类继承自一个声明有拷贝构造函数的类时(同样,不论这个拷贝构造函数 是被显示声明还是由编译器合成的)。
3. 类中声明有虚函数。
4. 当类的派生串链中包含有一个或多个虚基类。
写到这里,我感觉我明白了很多什么时候是编译器需要了,通过都跟上面四个有关了,其实默认构造函数、拷贝构造函数、赋值运算符与析构函数四个需要合成的时候都是要处理这上面四个问题。

2.4、程序转换为语意学

显式的初始化操作
已知有定义

  1. X x0;

下面的三个定义,每一个都是以x0来初始化其对象

  1. void foo(){
  2. X x1(x0);
  3. X x2 = x0;
  4. X x3 = X(x0);
  5. }

必要的程序转换有两个阶段:

  1. 重写每一个定义,其中的初始化操作会被剥除,剩下声明部分。
  2. 类的拷贝函数调用操作被安插进去
  1. void foo(){
  2. X x1;
  3. X x2;
  4. X x3;
  5. x1.X::X(x0);//是一个bitwise过程
  6. x2.X::X(x0);
  7. x3.X::X(x0);
  8. }

按值传递的参数初始化,有:

  1. void foo(X x0);
  2. //下面这样子调用方式
  3. X xx;
  4. foo(xx);

会被编译器转码为:

  1. X _temp;
  2. _temp.X::X(xx);//是一个bitwise过程
  3. foo(_temp);

  
返回值的初始化
已知下面这个函数定义:

  1. X bar(){
  2. X xx;
  3. //处理xx
  4. return xx;
  5. }

NVI的做法:
1. 首先加上一个额外的参数,类型是一个返回值类型的引用
2. 在return指令之前安插一个拷贝构造函数调用操作,以传入返回值
上述函数会被改写为:

  1. void bar(X &_result){
  2. X xx;
  3. xx.X::X();
  4. ...处理x
  5. _result.X::X(xx);
  6. return ;
  7. }

X xx = bar();
转换为

  1. X xx;
  2. bar(xx);
  3. <div class="md-section-divider"></div>

2.5、拷贝构造函数:要还是不要?

2.6、成员们的初始化队伍

在下列情况下,为了让你的程序能够被顺利编译,你必须使用member initialization list:

  1. 当初始化一个引用成员变量
  2. 当初始化一个const成员变量
  3. 当调用基类的含参的构造函数
  4. 当调用成员变量的含参构造函数

第三章、Data语意学

3.1、对象开销

每一个对象必须有足够的大小容纳它所有非静态成员变量,以及:
1. 由于编译器自动加上的额外成员变量,例如虚函数表指针、虚基类指针
2. 边界调整

3.2、Data Member的布局

  非静态成员变量在对象中的排列顺序如其声明顺序一样,至于静态成员变量则是放在程序的data segment中,与个别对象无关。C++标准规定,在同一个访问级别区域中,成员变量的排列只需要合乎晚出现在较高地址上即可,并不一定得连续排列,中间可以需要边界调整。
  至于内部使用的成员变量,如虚函数表指针与虚基类指针等,这个位置允许各厂商自行放置,可以在对象最前、最后甚至在成员变量中间

3.3、Data Member的存取

  对于静态成员变量,存放在类之外的全局变量区
  对于非静态成员变量,每一个非静态成员变量的偏移位置在编译时期即可获知,因此存取非静态成员变量的效率与C语言的结构体一样的。
  但是涉及虚继承以及多态时存取速度就会降低了。

  1. origin.x = 0;
  2. p->x = 0;
  3. <div class="md-section-divider"></div>

这两个的存取速度会因多态性而已,当有多态时,会涉及执行期的经过额外的索引才能解决,而origin就不会了,因为已经肯定知道它的类型了

3.4、继承与Data Member

在不涉及虚继承的时候,继承而来的成员变量与派生类的成员变量的数据布局应该与非继承的差不多。继承来的成员变量也没有规定是否在前或者后。

第四章、Function语意学

4.1、非静态成员函数

非静态成员函数应与普通函数的效率一样
实际上每一个非静态成员函数均被转换为普通函数,转换步骤:

  1. 改写函数签名以安插一个额外的参数到成员函数中,额外的参数就是this指针
    如:
  1. float
  2. Point3d::magnitude()
  3. 改写为:
  4. flaot magnitude(Point3d *const this);
  5. float
  6. Point3d::magnitude()const
  7. 改写为:
  8. flaot magnitude(const Point3d *const this);
  9. <div class="md-section-divider"></div>
  1. 将对每一个“对非静态成员变量的存取操作”改为经由this指针存取
  1. float
  2. Point3d::magnitude(){
  3. x *= 2;
  4. y *= 2;
  5. z *= 2;
  6. }
  7. 改写为:
  8. flaot magnitude(Point3d *const this){
  9. this->x *= 2;
  10. this->y *= 2;
  11. this->z *= 2;
  12. }
  1. 将函数名经过mangling处理,是它在程序员的名字独一无二

4.2、静态成员函数

静态成员函数没有this指针,所以就有以下特性

  1. 它不能够直接存取类中的非静态成员变量
  2. 它不能声明为const,volatile,virtual
  3. 它不需要经过对象、实例调用
    如果取一个静态成员函数的地址会得到它在内存的地址

4.3、虚函数

为了调用正确的虚函数,一个指针应有如下:

  1. 它指向对象的地址
  2. 对象的类型,根据这个类型能够决议出正确的虚函数地址
    由于一个指针本应存放地址而已,加上类型的话就会增加空间负担,使用指针不一定使用多态,还有打断了与C的兼容性,所以对象的类型信息放在对象中。如此一来指针还是存放对象的地址即可。
    在C++中对象的type_info是存放在虚函数表中,一般是第一个slot

第五章~第七章

接下来,还没看懂,这本书不容易看懂啊

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注