《深度探索c++对象模型》第三章笔记

非原创,在学习

目录

 3 Data语意学( The Semantics of Data )

3.1 Data Member的绑定(The Binding of a Data Member)

3.2 Data Member 的布局( Data Member Layout)

3.3 Data Member的存取

Static Data Members

Nonstatic Data Members

3.4“继承”与Data Member

只要继承不要多态(Inheritance without Polymorphism )

加上多态(Adding Polymorphism )

多重继承(Multiple Inheritance)

虚拟继承(Virtual Inheritance)

3.5 对象成员的效率(Object Member Efficiency )

3.6 指向Data Members的指针(Pointer to Data Members)

 “指向Members的指针”的效率问题


 3 Data语意学( The Semantics of Data )

计算以下代码的sizeof结果

#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <cstring>

class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};

int main(int argc, char**argv) {
    std::cout << "The size of X is " << sizeof(X) << std::endl;
    std::cout << "The size of Y is " << sizeof(Y) << std::endl;
    std::cout << "The size of Z is " << sizeof(Z) << std::endl;
    std::cout << "The size of A is " << sizeof(A) << std::endl;
    
    system("pause");
    return 0;
}

而它们之间的继承关系如下图

 上述X,Y,Z,A 中没有任何一个class内含明显的数据,其间只表示了继承关系。所以一般会认为每一个class的大小都应该是0。当然不对!即使是 class的大小也不为0。

实际结果

一个空的class大小为1,但是实际它并不是空的,它有一个隐晦的1 byte,那是被编译器安插进去的一个char,这使得这个class的两个object得以在内存中配置独一无二的地址:

X a, b;
if (&a == &b) {
    std::cerr << "yipes!" << std::endl;
}

不输出。

而Y和Z 的大小都是8,这个大小和机器有关,也和编译器有关。事实上Y和Z的大小受到三个因素的影响:

1 语言本身所造成的额外负担( overhead)。当语言支持virtual base 是,就会导致一些额外负担.在 derived class 中,这个额外负担反映在某种形式的指针身上,它或者指向virtual base class subobject,或者指向一个相关表格;表格中存放的若不是virtual base class subobject的地址,就是其偏移量( offset) 。

2 编译器对于特殊情况所提供的优化处理。Virtual base class X subobject的1 bytes大小也出现在 class Y和Z身上。传统上它被放在derived class的固定(不变动)部分的尾端。某些编译器会对empty virtual baseclass提供特殊支持。

3 Alignment的限制。class Y 和Z 的大小截至目前为5 bytes。在大部分机器上,群聚的结构体大小会受到alignment的限制,使它们能够更有效率地在内存中被存取。在32位机器上,alignment是4 bytes,所以class Y和Z必须填补3 bytes。最终得到的结果就是8 bytes.

Empty virtual base class已经成为C++OO设计的一个特有术语了.它提供一个virtual interface,没有定义任何数据。某些新近的编译器(译注)对此提供了特殊处理(请看[SUN94a])。在这个策略之下,一个empty virtual base class被视为derived class object最开头的一部分,也就是说它并没有花费任何的额外空间。这就节省了上述第2点的 1 bytes(译注:因为既然有了members,就不需要原本为了empty class而安插的一个char),也就不再需要第3点所说的 3 bytes的填补.只剩下第1点所说的额外负担.在此模型下,Y和Z的大小都是4而不是8。

 class A的大小由以下几点决定

  • 被大家共享的唯一一个class X实体,大小为l byte.
  • Base class Y 的大小,减去“因virtual base class X而配置”的大小,结果是 4 bytes. Base class Z 的算法亦同。加起来是8 bytes.
  • class A自己的大小: 0 byte.
  • class A 的alignment数量(如果有的话)。前述三项总合,表示调整前的大小是9 bytes. class A 必须调整至4 bytes边界,所以需要填补3bytes。结果是12 bytes.
     

详见:C++类大小详尽讲解_c++类的大小_StudyWinter的博客-CSDN博客

对于 static data members,则被放置在程序的一个global data segment中,不会影响个别的class object的大小。在程序之中,不管该class被产生出多少个objects(经由直接产生或间接派生), static data members永远只存在一份实体(译注:甚至即使该class没有任何object实体,其static data members也已存在)﹒但是一个template class 的static data members的行为稍有不同。

每一个 class object因此必须有足够的大小以容纳它所有的nonstatic datamembers。有时候其值可能令你吃惊﹐因为它可能比你想象的还大,原因是:
1、由编译器自动加上的额外data members,用以支持某些语言特性(主要是各种virtual特性)。
2.因为alignment(边界调整)的需要。

3.1 Data Member的绑定(The Binding of a Data Member)

早期C++的两种防御性程序设计风格:

1 把所有的data nembers放在 class声明起头处,以确保正确的绑定:

class Point3d {
    // 防御性风格,在类声明处先放置所有的成员数据
    float x, y, z;
public:
    float X() const { return x;}
    // ...etc...
};

2 把所有的 inline functions,不管大小都放在 class声明之外:

class Point3d {
public:
    // 把所有inline都转移到类外
    Point3d();
    float X() const;
    void X(float) const;
    // ...
};

inline float Point3d::X() const {
    return x;
}

// ...etc...

3.2 Data Member 的布局( Data Member Layout)

已知下面一组数据成员

class Point3d {
public:
    // ...

private:
    float x;
    static list<Point3d*>* freeList;
    float y;
    static const int chunkSize = 250;    // 静态常量?
    float z;
};

Nonstatic data members在 class object中的排列顺序将和其被声明的顺序一样,任何中间介人的 static data members 如 freeList和chunkSize都不会被放进对象布局之中。在上述例子里,每一个Point3d对象是由三个float 组成,次序是x,y,z。static data members存放在程序的 data segment中,和个别的 class objects无关。

C++ Standard要求,在同个access section (也就是private.public. protected等区段)中,members的排列只需符合“较晚出现的 members在 class object 中有较高的地址”这一条件即可(请看C++tStandard 9.2节)﹒也就是说,各个members并不一定得连续排列.什么东西可能会介于被声明的 members之间呢? members的边界调整( alignment)可能就需要填补一些bytes.对于C和C++而言,这的确是真的,对当前的C++编译器实现情况而言,这也是真的。

C++ Standard 也允许编译器将多个access sections之中的 data members自由排列,不必在乎它们出现在 class声明中的次序.也就是说,下面这样的声明:

class Point3d {
public:
    // ...

private:
    float x;
    static list<Point3d*>* freeList;

private:
    float y;
    static const int chunkSize = 250;    // 静态常量?

private:
    float z;
};

其class object 的大小和组成都和我们先前声明的那个相同,但是members的排列次序则视编译器而定.编译器可以随意把y或z或什么东西放为第一个,不过就我所知,当前没有任何编译器会这么做。

下面这个template function,接受两个data members,然后判断谁先出现在class object之中.如果两个members都是不同的 access sections 中的第一个被声明者,此函数就可以用来判断哪一个section先出现

template <class class_type,
          class data_type1,
          class data_type2>
char* access_order (
    data_type1 class_type::*mem1,
    data_type2 class_type::*mem2)
{
    assert(mem1 != mem2);
    return mem1 < mem2 ? "member 1 occurs first" : "member 2 occurs first";
}

上述函数还可这样被调用

access_oeder(&Point3d::z, &Point3d::y);

于是 class_type会被绑定为Point3d,而data_type1和data_type2会被绑定为float

3.3 Data Member的存取

已知下面这段程序代码:

Point3d origin;
origin.x = 0.0;

你可能会问x的存取成本是什么?答案视x和Point3d 如何声明而定。x可能是个static member,也可能是个nonstatic member. Point3d可能是个独立(非派生)的class,也可能是从另一个单一的base class 派生而来;虽然可能性不高,但它甚至可能是从多重继承或虚拟继承而来。

Point3d origin, *pt = &origin;

// 下面两句有没有什么重大差异
origin.x = 0.0;
pt->x = 0.0;

Static Data Members

每一个static data member只有一个实体,存放在程序的 data segment之中.每次程序参阅(取用)static member,就会被内部转化为对该唯一的extern 实体的直接参考操作。例如:

// origin.chunkSize = 250;
Point3D::chunkSize  = 250;

// pt->chunkSize = 250;
Point3D::chunkSize  = 250;

从指令执行的观点来看,这是C+语言中“通过一个指针和通过一个对象来存取member,结论完全相同”的唯一一的种情况.这是因为“经由member selection operators (译注:也就是∵运算符)对一个static data member进行存取操作”只是语法上的一种便宜行事而已。member 其实并不在 class object 之中,因此存取static members并不需要通过class object.

但如果chunkSize是一个从复杂继承关系中继承而来的member,又当如何?或许它是一个“virtual base class的virtual base class”(或其它同等复杂的继承结构)的 member也说不定。哦,那无关紧要,程序之中对于static members还是只有唯--一-个实体,而其存取路径仍然是那么直接.

若取一个static data mcrnber的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为 static member 并不内含在一个class object之中。例如:

&Point3d::chunkSize;

会得到如下的内存地址

const int*

如果有两个classes,每一个都声明了一个static member freeList,那么当它们都被放在程序的 data segment时,就会导致名称冲突.编译器的解决方法是暗中对每一个static data member 编码(这种手法有个很美的名称: name-mangling ) ,以获得一个独一无二的程序识别代码。有多少个编译器,就有多少种namemangling做法!通常不外乎是表格啦、语法措辞啦等等.任何name-mangling做法都有两个要点:

1.一种算法,推导出独一无二的名称。
2.万一编译系统(或环境工具)必须和使用者交谈,那些独一无二的名称可以轻易被推导回到原来的名称。

Nonstatic Data Members

Nonstatic data members直接存放在每一个class object 之中.除非经由明确的( explicit)或暗喻的( implicit) class object,没有办法直接存取它们。只要程序员在一个member function 中直接处理一个nonstatic data member,所谓“implicitclass object”就会发生。例如下面这段码:

Point3d
Point3d::translate(const Point3D& pt) {
    x += pt.x;
    y += pt.y;
    z += pt.z;
}

表面上所看到的对于x,y,z的直接存取,事实上是经由一个“"implicit class object"(由this指针表达)完成。事实上这个函数的参数是:

// 成员函数的内部转化
// 这里注意this指针的类型
Point3d
Point3d::translate(Point3d* const this, const Point3D& pt) {
    this->x += pt.x;
    this->y += pt.y;
    this->z += pt.z;
}

欲对一个nonstatic data member进行存取操作,编译器需要把class object的起始地址加上 data member 的偏移量( offset)。举个例子,如果:

origin._y = 0.0;

那么地址&origin._y将等于

&origin + (&Point3d::_y - 1);

这里有意思

请注意其中的-1操作。指向 data member 的指针,其offset值总是被加上1,这样可以使编译系统区分出“一个指向data member 的指针,用以指出 class 的第一个member”和“一个指向 data member 的指针,没有指出任何member”两种情况。

每一个nonstatic data member的偏移量( offset)在编译时期即可获知,甚至如果member属于一个base class subobject(派生自单一或多重继承串链)也是一样.因此,存取一个nonstatic data member,其效率和存取一个C struct member或一个nonderived class的member是一样的。

现在让我们看看虚拟继承。虚拟继承将为“经由base class subobject存取class members”导入一层新的间接性,譬如:

Point3d *pt3d;
pt3d->_x = 0.0;

其执行效率在_x是一个struct member、一个class member、单一继承、多重继承的情况下都完全相同。但如果_x是一个virtual base class 的member,存取速度会比较慢一点。下一节我会验证“继承对于member布局的影响”。在我们尚未进行到那里之前,请回忆本节一开始的一个问题:以两种方法存取x坐标,像这样:

origin.x = 0.0;
pt->x = 0.0;

“从origin存取”和“从pt存取”有什么重大的差异?

答案是“当Point3d是一个derived class,而在其继承结构中有一个virtual base class,并且被存取的member(如本例的x)是一个从该virtual base class继承而来的member时,就会有重大的差异”。这时候我们不能够说pt必然指向哪一种 class type(因此我们也就不知道编译时期这个member真正的offset位置)﹐所以这个存取操作必须延迟至执行期,经由一个额外的间接导引,才能够解决.但如果使用origin,就不会有这些问题,其类型无疑是Point3d class,而即使它继承自virtual baseclass,members的 offset位置也在编译时期就固定了。一个积极进取的编译器甚至可以静态地经由origin就解决淖对x的存取。

3.4“继承”与Data Member

在C++继承穰型中,一个derived class object所表现出来的东西,是其自己的 members 加上其base classes) members的总和。至于 derived class members和base class(es) menhers的排列次序并未在C++ Standard中强制指定;理论上编译器可以自由安排之。在大部分编译器上头,base class members总是先出现,但属于virtual base class的除外(一般而言,任何一条规则一旦碰上 virtual baseclass就没辄儿,这里亦不例外)。

了解了这种继承模型之后,你可能会问,如果我为2D(二维)或3D(三维)坐标点提供两个抽象数据类型如下:

// 支持抽象数据类型
class Point2d {
public:
    // 构造函数
    // 操作
    // 访问函数

private:
    float x, y;
};


// 支持抽象数据类型
class Point3d {
public:
    // 构造函数
    // 操作
    // 访问函数

private:
    float x, y, z;
};

这和“提供两层或三层继承结构,每一层(代表一个维度)是一个class,派生自较低维层次”有什么不同?

下面各小节的讨论将涵盖“单一继承且不含virtual functions”、“单一继承并含virtual functions”、“多重继承”、“虚拟继承”等四种情况。下图是 Point2d和Point3d 的对象布局图,在没有virtual functions的情况下(如本例),它们和C struct完全一样。

只要继承不要多态(Inheritance without Polymorphism )

想象一下,程序员或许希望,不论是2D或3D 坐标点,都能够共享同一个实体,但又能够继续使用“与类型性质相关(所谓type-specific)”的实体。我们有一个设计策略,就是从 Point2d派生出一个Point3d,于是 Point3d将继承x和y坐标的一切(包括数据实体和操作方法)。带来的影响则是可以共享“数据本身”以及“数据的处理方法”,并将其局部化。一般而言,具体继承( concrete inheritance),译注:相对于虚拟继承( virtual inheritance)并不会增加空间或存取时间上的额外负担。

#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <cstring>
#include <list>

class Point2d {
public:
    Point2d(float x = 0.0, float y = 0.0)
        : _x(x), _y(y) {};

    float getX() const { return _x;}
    float getY() const { return _y;}

    void setX(float newX) { _x = newX; }
    void setY(float newY) { _y = newY; }
    // const对象不能调用非const的成员函数,所以get函数要加const
    void operator += (const Point2d& rhs) {
        _x += rhs.getX();
        _y += rhs.getY();
    }

    // ...more member

protected:
    float _x, _y;
};


// 继承具体的类
class Point3d : public Point2d {
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
        : Point2d(x, y), _z(z) {};

    float getZ() const { return _z;}
    void setZ(float newZ) { _z = newZ; }

    void operator += (const Point3d& rhs) {
        Point2d::operator += (rhs);
        _z += rhs.getZ();
    }

    // ...more member

protected:
    float _z;
};

int main(int argc, char**argv) {


    system("pause");
    return 0;
}

const对象不能调用非const的成员函数,所以get函数要加const

这里是修改后的代码,原文给出的代码有些问题......

自己实践才是coding能力提升的唯一途径

这样设计的好处就是可以把管理x和y坐标的程序代码局部化。此外这个设计可以明显表现出两个抽象类之间的紧密关系。当这两个classes独立的时候,Point2d object和 Point3d object的声明和使用都不会有所改变。所以这两个抽象类的使用者不需要知道 objects是否为独立的 classes类型,或是彼此之间有继承的关系。

把两个原本独立不相干的 classes 凑成一对“typelsubtype”,并带有继承关系,会有什么易犯的错误呢﹖经验不足的人可能会重复设计一些相同操作的函数.以我们例子中的constructor和 operator+= 为例,它们并没有被做成inline函数(也可能是编译器为了某些理由没有支持inline member functions) . Point3d object的初始化操作或加法操作,将需要部分的 Point2d object和部分的 Point3dobject作为成本。一般而言,选择某些函数做成inline函数,是设计class 时的一个重要课题。

第二个易犯的错误是,把一个class分解为两层或更多层,有可能会为了“表现class体系之抽象化”而膨胀所需空间.C++语言保证“出现在derived class 中的 base class subobject有其完整原样性”,正是重点所在。这似乎有点难以理解最好的解释方法就是彻底了解一个例程,从一个具体的 class开始:

#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <cstring>
#include <list>

class Concrete {
public:
    // ...
private:
    int val;
    char c1;
    char c2;
    char c3;
};

int main(int argc, char**argv) {
    std::cout << sizeof(Concrete) << std::endl;

    system("pause");
    return 0;
}

结果

 在一部 32位机器中,每一个Concrete class object的大小都是8 bytes,细分如下:
1. val占用4 bytes;
2. c1 、c2和 c3各占用1 bytes;
3. alignment(调整到word边界)需要1 bytes.

现在假设,经过某些分析之后,我们决定了一个更逻辑的表达方式,把Concrete分裂为三层结构:

#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <cstring>
#include <list>

class Concrete1 {
public:
    // ...
private:
    int val;
    char c;
};

class Concrete2 : public Concrete1{
public:
    // ...
private:
    char c1;
};

class Concrete3 : public Concrete2{
public:
    // ...
private:
    char c2;
};




int main(int argc, char**argv) {

    std::cout << sizeof(Concrete1) << std::endl;
    std::cout << sizeof(Concrete2) << std::endl;
    std::cout << sizeof(Concrete3) << std::endl;

    system("pause");
    return 0;
}

结果

从设计的观点来看,这个结构可能比较合理。但从效率的观点来看,我们可能会受困于一个事实:现在Concrete3 object的大小是16 bytes,比原先的设计多了一倍。

现在优化了 

Concrete1内含两个members: val和 bitl,加起来是5 bytes。而一个Concrete1 object实际用掉8 bytes,包括填补用的 3 bytes,以使object能够符合一部机器的 word边界。不论是C或C++都是这样。一般而言,边界调整( alignment)是由处理器(( processor)来决定的。

 在声明一组指针

Concrete2* pc2;
Concrete1* *pc1_1, *pc1_2;

其中,pc1_1和pc1_2两者都可以指向前述三种对象,下面这个指定操作

*pc1_2 = *pc1_1;

应该执行一个默认的“memberwise”复制操作(复制一个个的members) ,对象是被指的 object的 Concrete1那一部分.如果pcl_1 实际指向一个Concrete2 object或 Concrete3 object,则上述操作应该将复制内容指定给其Concrete1 subobject.

然而,如果C++语言把derived class members(也就是 Concrete2::bit2或Concrete3:.bit3)和Concretel subobject捆绑在一起,去除填补空间,上述那些语意就无法保留了,那么下面的指定操作:

pc1_1 = pc2;        // 令pc1_1指向Concrete2对象
*pc1_2 = *pc1_1;

就会将“被捆绑在一起、继承而得的”members内容覆盖掉.程序员必须花费极大的心力才能找出这个“臭虫”! 就会将“被捆绑在一起、继承而得的”会员内容覆盖掉.程序员必须花费极大的心力才能找出这个“臭虫”!

加上多态(Adding Polymorphism )

如果我要处理一个坐标点,而不打算在乎它是一个Point2d或Point3d实例,那么我需要在继承关系中提供一个virtual function接口。让我们看看如果这么做,情况会有什么改变:

此处Point2d的声明与【只要继承不要多态】处对比

class Point2d {
public:
    Point2d(float x = 0.0, float y = 0.0)
        : _x(x), _y(y) {};


    float getX() const { return this->_x; }
    void setX(float x) { this->_x = x; }

    float getY() const{ return this->_y; }
    void setY(float y) { this->_y = y; }



    // 加上z的保留空间
    virtual float getZ() const { return 0.0; }
    virtual void setZ(float) {}

    // 设定以下的运算符为virtual
    virtual void
    operator += (const Point2d& rhs) {
        this->_x += rhs.getX();
        this->_y += rhs.getY();
    }

    // ...

protected:
    float _x, _y;
};

只有当我们企图以多态的方式( polymorphically)处理2d 或3d 坐标点时,在设计之中导入一个virtual接口才显得合理。也就是说,写下这样的码:

virtual foo(Point2d& p1, Point2d& p2) {
    // ...
    p1 += p2;
    // ...
}

其中 p1和p2可能是2d也可能是3d 坐标点。这并不是先前任何设计所能支持的。这样的弹性,当然正是面向对象程序设计的中心。支持这样的弹性,势必给我们的 Point2d class带来空间和存取时间的额外负担:

  • 导入一个和Point2d有关的 virtual table,用来存放它所声明的每一个virtual functions的地址。这个table 的元素数目一般而言是被声明的virtual functions的数目,再加上一个或两个slots(用以支持runtimetype identification) .
  • 在每一个class object中导入一个vptr,提供执行期的链接,使每一个object能够找到相应的virtual table.
  • 加强constructor,使它能够为vptr设定初值,让它指向class所对应的virtual table。这可能意味着在 derived class和每一个base class 的constructor中,重新设定vptr 的值。其情况视编译器的优化的积极性而定。
  • 加强destructor,使它能够抹消“指向class之相关virtual table”的vptr。要知道,vptr很可能已经在 derived class destructor中被设定为derived class 的virtual table地址。记住,destructor的调用次序是反向的:从derived class到 base class。一个积极的优化编译器可以压抑那些大量的指定操作。

以下是新的 Point3d声明:

此处Point2d的声明与【只要继承不要多态】处对比

class Point3d : public Point2d{
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0)
        : Point2d(x, y), _z(z) {};


    float getZ() const { return this->_z; }
    void setZ(float z) { this->_z = z; }


    // 设定以下的运算符为virtual
    void operator += (const Point2d& rhs) {
        // 注意上行是Point2d&,而非Point3d&
        Point2d::operator += (rhs);
        this->_z += rhs.getZ();
    }

    // ...

protected:
    float _z;
};

译注:上述新的Point2d和Point3d声明,最大一个好处是,你可以把operator+= 用在以个Point3d对象和一个Point2d对象身上:

int main(int argc, char**argv) {
    Point2d p2d(2.1, 2.2);
    Point3d p3d(3.1, 3.2, 3.3);
    p3d += p2d;
    std::cout << p3d.getX() << std::endl;
    std::cout << p3d.getY() << std::endl;
    std::cout << p3d.getZ() << std::endl;

    std::cout << "----------------------------------------" << std::endl;

    Point3d p31(2.1, 2.2, 2.3);
    Point3d p32(3.1, 3.2, 3.3);
    p31 += p32;
    std::cout << p31.getX() << std::endl;
    std::cout << p31.getY() << std::endl;
    std::cout << p31.getZ() << std::endl;


    system("pause");
    return 0;
}

结果

学到了

虽然class的声明语法没有改变,但每一件事情都不一样了:两个z() member functions以及operator+=()运算符都成了虚拟函数;每一个Point3d class object内含一个额外的vptr member(继承自Point2d);多了一个Point3d virtual table;此外,每一个virtual member function的调用也比以前复杂了。

目前在C++编译器那个领域里有一个主要的讨论题目:把vptr放置在class object的哪里会最好?在 cfront编译器中,它被放在 class object的尾端,用以支持下面的继承类型,如图所示:

// 加了命名空间,方便策略
namespace Test02
{
    struct no_virts
    {
        int d1, d2;
    };

    class has_virts : public no_virts
    {
    public:
        virtual void foo() {}
        // ...

    private:
        int d3;
    };

    void test()
    {
        no_virts* p = new has_virts;
    }

}

没有问题

 把vptr放在 class object的尾端,可以保留base class C struct的对象布局,因而允许在C程序代码中也能使用。这种做法在C++最初问世时,被许多人采用。

到了C++2.0,开始支持虚拟继承以及抽象基类,并且由于面向对象典范(OOparadigm)的兴起,某些编译器开始把vptr 放到 class object的起头处。

把vptr放在 class object的前端,对于“在多重继承之下,通过指向classmembers 的指针调用virtual function”,会带来一些帮助。否则,不仅“从class object起始点开始量起”的 offset必须在执行期备妥,甚至与class vptr之间的 offset也必须备妥.当然,vptr放在前端,代价就是丧失了C语言兼容性。

图3.3显示 Point2d和Point3d 加上了virtual function之后的继承布局.注意此图是把vptr放在 base class的尾端。

多重继承(Multiple Inheritance)

单一继承提供了一种“自然多态( natural polymorphism)”形式。多重继承既不像单一继承,也不容易模塑出其模型。多重继承的复杂度在于derived class和其上一个base class 乃至于上上一个base class……之间的“非自然”关系。

namespace Test03 {
    class Point2d {
    public:
        // ...

    protected:
        float _x, _y;
    };

    class Point3d : public Point2d {
    public:
        // ...

    protected:
        float _z;
    };

    class Vertex {
    public:
        // ...拥有virtual接口,所以Vertex对象之中会有vptr

    protected:
        Vertex* next;
    };

    class Vertex3d : public Point3d, public Vertex {
    public:
        // ...

    protected:
        float mumble;
    };
}

多重继承的问题主要发生于derived class objects和其第二或后继的 baseclass objects之间的转换;不论是直接转换如下:

extern void mumble(const Vertex&);
Vertex3d v;
// 将一个Vertex3d转换为一个Vertex,这是不自然的
mumble(v);

对一个多重派生对象﹐将其地址指定给“最左端(也就是第一个) base class的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已(图3.4显示出多重继承的布局)。至于第二个或后继的 base class的地址指定操作,则需要将地址修改过:加上(或减去,如果downcast的话)介于中间的 base class subobject(s)大小,例如:

Vertex3d v3d;
Vertex* pv'
Point2d* p2d;
Point3d* p3d;

那么下面这个指定操作

pv = &v3d;

需要这样的内部转化:

// 虚拟C++代码
pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d));

而下面的指定操作:

p2d = &v3d;
p3d = &v3d;

都只需要简单拷贝其地址就行了。如果有两个指针如下:

Vertex3d* pv3d;
Vertex* pv;

那么下面的指定操作:

pv = pv3d;

不能够只是简单的被转换为:

// 虚拟C++码
pv = (Vertex*)((char*)pv3d + sizeof(Point3d));

因为如果pv3d为0,pv将获得sizeof(Point3d)的值,这是错误的!所以。对于指针,内部转换操作需要有一个测试条件:

// 虚拟C++码
pv = pv3d ? (Vertex*)((char*)pv3d + sizeof(Point3d)) : 0;

虚拟继承(Virtual Inheritance)

多重继承的一个语意上的副作用就是,它必须支持某种形式的“sharedsubobject继承”。一个典型的例子是最早的 iostream library: 多重继承的一个语意上的副作用就是,它必须支持某种形式的“Shared subobject继承”。一个典型的例子是最早iostream library:

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

 不论是 istream 或ostream都内含一个ios subobject。然而在iostream的对象布局中,我们只需要单一份ios subobject就好。语言层面的解决办法是导入所谓的虚拟继承:

class ios { ... };
class istream : public virtual ios { ... };
class ostream : public virtual ios { ... };
class iostream : public istream, public ostream { ... };

Class如果内含一个或多个virtual base class subobjects,像istream那样,将被分割为两部分:一个不变局部和一个共享局部。

不变局部中的数据,不管后继如何衍化,总是拥有固定的 offset (从object 的开头算起),所以这一部分数据可以被直接存取。至于共享局部,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只可以被间接存取。各家编译器实现技术之间的差异就在于间接存取的方法不同。以下说明三种主流策略。下面是 Vertex3d 虚拟继承的层次结构:

class Point2d {
public:
    // ...
protected:
    float _x, _y;
};

class Vertex : public virtual Point2d {
public:
    // ...

protected:
    Vertex* next;
};

class Point3d : public virtual Point2d {
public:
    // ...
protected:
    float _z;
};

class Vertex3d : public Vertex, public Point3d {
public:
    // ...
protected:
    float mumble;
};

继承结果图:

一般的布局策略是先安排好derived class的不变部分,然后再建立其共享部分。

然而,这中间存在着一个问题:如何能够存取class的共享部分呢? cfront编译器会在每一个derived class object中安插一些指针,每个指针指向一个virtual base class。要存取继承得来的 virtual base class members,可以使用相关指针间接完成。举个例子,如果我们有以下的Point3d运算符:

void Point3d:: operator += (const Point3d& rhs) {
    _x += rhs._x;
    _y += rhs._y;
    _z += rhs._z;
}

在cfront策略之下,这个运算符会被内部转换为:

// 虚拟C++码
_vbcPoint2d->_x += rhs._vbcPoint2d->_x;    // vbc意为virtual base
_vbcPoint2d->_y += rhs._vbcPoint2d->_y;
_z += rhs._z;

而一个derived class和一个base class的实例之间的转换,像这样:

Point2d* p2d = pv3d;

在cfront实现模型之下,会变成:

Point2d* p2d = pv3d ? pv3d->_vbcPoint2d : 0;

这样的实现模型有两个主要的缺点:

1.每一个对象必须针对其每一个virtual base class背负一个额外的指针,然而理想上我们却希望class object有固定的负担,不因为其virtualbase classes 的数目而有所变化。
2.由于虚拟继承串链的加长,导致间接存取层次的增加.这里的意思是.如果我有三层虚拟衍化,我就需要三次间接存取(经由三个virtual baseclass指针)。然而理想上我们却希望有固定的存取时间,不因为虚拟衍化的深度而改变。

MetaWare和其它编译器到今天仍然使用cfront的原始实现模型来解决第二个问题,它们经由拷贝操作取得所有的 nested virtual base class 指针,放到 derivedclass object之中。这就解决了“固定存取时间”的问题,虽然付出了一些空间上的代价。MetaWare提供一个编译时期的选项,允许程序员选择是否要产生双重指针。图3.5a说明这种“以指针指向base class”的实现模型。

至于第一个问题,一般而言有两个解决方法。Microsoft编译器引人所谓的virtual base class table。每一个class object 如果有一个或多个virtual base classes,就会由编译器安插一个指针,指向 virtual base class table。至于真正的 virtual base class指针,当然是被放在该表格中。虽然此法已行之有年,但我并不知道是否有其它任何编译器使用此法。说不定 Microsoft对此法提出专利,以至别人不能使用它。

看不下去了,先跳过

3.5 对象成员的效率(Object Member Efficiency )

这里做了几组实验,对比数据的存取效率:

(1)局部数据的加、减、赋值运算一千万次;

(2)将上述数据转换为C struct,成员为float,加、减、赋值运算一千万次;

(3)抽象成类,定义inline函数,传回reference,允许它出现在assignment两端;

(3)抽象成类,提供get/set函数

这里就不具体上代码了,很简单的运算,主要看上述思路

CC和NCC是编译器。

这里所显示的重点在于,如果把优化开关打开,“封装”就不会带来执行期的效率成本。使用inline存取函数亦然。

下一个测试:首先要介绍Point抽象化的一个三层单一继承表达法,然后再介绍Point抽象化的一个虚拟继承表达法。我要测试直接存取和inline存取(多重继承并不适用于这个模型,所以我决定放弃它)。三层单一继承表达法如下:

class Point1d { ... };                         // 维持x
class Point2d : public Point1d { ... };        // 维持y
class Point3d : public Point2d { ... };        // 维持z

“单层虚拟继承”是从Pointld 中虚拟派生出 Point2d;

“双层虚拟继承”则又从Point2d 中虚拟派生出 Point3d。

3.6 指向Data Members的指针(Pointer to Data Members)

考虑下面的 Point3d声明.其中有一个virtual function,一个static data member,以及三个坐标值:

class Point3d {
public:
    virtual ~Point3d();
    // ...

protected:
    static Point3d origin;
    float x, y, z;
};

每一个Point3d class object含有三个坐标值,依次为x,y,z,以及一个vptr。至于static data mermber origin,将被放在class object之外。唯一可能因编译器不同而不同的是vptr 的位置。

那么,取某个坐标成员的地址,代表什么意思?例如,以下操作所得到的值代表什么:

&Point3d::z;

上述操作将得到z坐标在 class object中的偏移量( offset)。最低限度其值将是工和y的大小总和,因为C+语言要求同一个access level 中的 members的排列次序应该和其声明次序相同.

然而vptr 的位置就没有限制.不过容我再说一次,实际上 vptr不是放在对象的头部,就是放在对象的尾部。在一部 32位机器上,每一个float是 4 bytes所以我们应该期望刚才获得的值要不是8,就是12(在32位机器上一个vptr是4 bytes) .

如果vptr放在对象的尾端,则三个坐标值在对象布局中的offset分别是0,4,8.如果vptr放在对象的起头,则三个坐标值在对象布局中的 offset分别是4,8,12.

namespace Test04
{
    class Point3d
    {
    public:
        virtual ~Point3d();
        // ...
    // 访问控制权限改成public,不然外部无法访问这些成员
    public:
        static Point3d origin;
        float x, y, z;
    };

    void test()
    {
        printf("&Point3d::x = %p\n", &Point3d::x);    // 这里输出的是偏移量
        printf("&Point3d::y = %p\n", &Point3d::y);    // 这里输出的是偏移量
        printf("&Point3d::z = %p\n", &Point3d::z);    // 这里输出的是偏移量
    }
}

int main(int argc, char **argv)
{
    Test04::test();
    system("pause");
    return 0;
}

结果

改用cout呢?

namespace Test04
{
    class Point3d
    {
    public:
        virtual ~Point3d();
        // ...

    public:
        static Point3d origin;
        float x, y, z;
    };

    void test1()
    {
        printf("&Point3d::x = %p\n", &Point3d::x);    // 这里输出的是偏移量
        printf("&Point3d::y = %p\n", &Point3d::y);    // 这里输出的是偏移量
        printf("&Point3d::z = %p\n", &Point3d::z);    // 这里输出的是偏移量
    }

    void test2()
    {
        std::cout << "&Point3d::x = " <<  &Point3d::x << std::endl;
        std::cout << "&Point3d::y = " <<  &Point3d::y << std::endl;
        std::cout << "&Point3d::z = " <<  &Point3d::z << std::endl;
    }

}

int main(int argc, char **argv)
{
    Test04::test1();
    Test04::test2();
    
    system("pause");
    return 0;
}

结果

输出不一样,这是为什么?
C++调用非静态的成员函数时,采用的是一种_thiscall的函数调用方式。我们输出操作符<<没有对void(_thiscall A:∵ *)()类型重载,编译器将这种类型转换为bool类型,所以输出了1;对于静态函数,其调用方式并非_thiscall,<<有对它的重载,因此类的静态函数可以直接用cout输出函数地址

详见:为何不用cout输出非静态成员函数地址,而要用printf_不用cout也可以输出?_哞哞哞咩咩咩的博客-CSDN博客

如何区分一个“没有指向任何 data membcr”的指针一个指向“第一个data member”的指针?考虑这样的例子:

float Point3d::*p1 = 0;
float Point3d::*p2 = &Point3d::x;
// Point3d::*的意思是:指向Point3d data member的指针类型

// 如何区分
if (p1 == p2) {
    cout << "p1 & p2 contain the same value";
    cout << "they must address the same member" << endl;
} else {
    cout << "They are two member" << endl;
}

结果

为了区分p1 和p2,每一个真正的 member offset值都被加上1。因此,不论编译器或使用者都必须记住,在真正使用该值以指出一个member 之前,请先减掉1。

认识“指向 data members的指针”之后,我们发现,要解释:

&Point3d::z;

&origin.z;

之间的差异,究非常明确了。

鉴于“取一个nonstatic data member 的地址,将会得到它在 class 中的 offset”,取一个“绑定于真正 class object身上的 data member”的地址,将会得到该member在内存中的真正地址

在多重继承之下,若要将第二个(或后继)base class的指针和一个“与derived class object绑定”之member结合起来,那么将会因为“需要加入 offset值”而变得相当复杂。例如,假设我们有:

struct Base1 { int val1; };
struct Base2 { int val2; };
struct Derived : Base1, Base2 {};

void func1(int Derived::* dmp, Derived* pd) {
    // 期望第一个参数得到的是一个“指向derived class之member”的指针
    // 如果传进来的却是一个“指向base class之memberde”的指针,会怎样
    pd->*dmp;
}

void func2(Derived* pd) {
    // bmp将成为1
    int Base2::*bmp = &Base2::val2;
    // bmp == 1
    // 但是在Derived中,val2 == 5
    func1(bmp, pd);
}

当bmp 被作为func1()的第一个参数时,它的值就必须因介入的 Basel class的大小而调整,否则func1()中这样的操作:

pd->*dmp;

将存取到 Base1:val1,而非程序员所以为的 Base2.:val2。要解决这个问题,必须:

// 经由编译器内部转换
func1(bmp + sizeof(Base1), pd);

然而,一般而言,我们不能够保证 bmp 不是0,因此必须特别留意之:

// 内部转化
// 防止bmp == 0
func1(bmp ? bmp + sizeof(Base1) : 0, pd);

测试:

namespace Test05
{
    struct Base1
    {
        int val1;
    };
    struct Base2
    {
        int val2;
    };
    struct Derived : Base1, Base2 {};

    void func1(int Derived::*dmp, Derived *pd)
    {
        // 期望第一个参数得到的是一个“指向derived class之member”的指针
        // 如果传进来的却是一个“指向base class之memberde”的指针,会怎样
        pd->*dmp;
    }

    void func2(Derived *pd)
    {
        // bmp将成为1
        int Base2::*bmp = &Base2::val2;
        // bmp == 1
        // 但是在Derived中,val2 == 5
        func1(bmp, pd);
    }

    void test() {
        printf("&base1::val1 = %p\n", &Base1::val1);        // 偏移量是0
        printf("&base2::val2 = %p\n", &Base2::val2);        // 偏移量是0
        printf("&Derived::val1 = %p\n", &Derived::val1);    // 偏移量是0
        printf("&Derived::val1 = %p\n", &Derived::val2);
    }
}

int main(int argc, char **argv)
{
    Test05::test();
    system("pause");
    return 0;
}

结果

 为什么第4个也是0

把Derived修改一下

namespace Test05
{
    struct Base1
    {
        int val1;
    };
    struct Base2
    {
        int val2;
    };
    // 这里添加了成员属性
    struct Derived : Base1, Base2 { int val; };

    void func1(int Derived::*dmp, Derived *pd)
    {
        // 期望第一个参数得到的是一个“指向derived class之member”的指针
        // 如果传进来的却是一个“指向base class之memberde”的指针,会怎样
        pd->*dmp;
    }

    void func2(Derived *pd)
    {
        // bmp将成为1
        int Base2::*bmp = &Base2::val2;
        // bmp == 1
        // 但是在Derived中,val2 == 5
        func1(bmp, pd);
    }

    void test() {
        printf("&base1::val1 = %p\n", &Base1::val1);        // 偏移量是0
        printf("&base2::val2 = %p\n", &Base2::val2);        // 偏移量是0
        printf("&Derived::val1 = %p\n", &Derived::val1);    // 偏移量是0
        printf("&Derived::val1 = %p\n", &Derived::val2);
        printf("&Derived::val = %p\n", &Derived::val);       // 偏移量是8
    }
}

int main(int argc, char **argv)
{
    Test05::test();
    system("pause");
    return 0;
}

结果

val的地址是8,表示前面有val1和val2

 “指向Members的指针”的效率问题

这里做了一个测试。

第一组:见3.5节(无继承)

第二组:取得一个“已绑定的member”的地址(即有对象,取对象成员属性的地址),如:

float* ax = &pA.x;             // pA是具体的对象,成员y、z同理
pB.*bx = pA.*ax - pB.*bz;
pB.*by = pA.*ay - pB.*bx;
pB.*bz = pA.*az - pB.*by;

第三组:取得“指向data member 之指针”的地址:

float Point3d:: *ax = &Point3d::x             // 成员y、z同理

结果

有继承

由于被继承的 data members是直接存放在 class object 之中,所以继承的引人一点也不会影响这些码的效率。虚拟继承所带来的主要冲击是,它妨碍了优化的有效性。为什么呢?在两个编译器中,每一层虚拟继承都导人一个额外层次的间接性。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值