Data 语义学(2)

时间:2023-03-10 07:16:25
Data 语义学(2)

四、"继承"与 Data Member

  (1). 只有继承没有多态

  先来看个例子  

 #include <iostream>

 using namespace std;

 class A
{
public:
//
private:
int val;
char c1;
char c2;
char c3;
}; int main()
{
cout << sizeof(A) << endl;
return ;
}

  输出的结果是 8 ,这个比较容易理解:

    a. val 占用 4 bytes。

    b. c1,c2,c3各占 1 byte,3 个 bytes。

    c. 边界调整(alignment)---调整到 word 需要 1 byte

  一共 8 个!那么在看下面这个例子:

  

 #include <iostream>

 using namespace std;

 class A
{
public:
//
private:
int val;
char c1;
}; class B: public A
{
public:
//
private:
char c2;
}; class C: public B
{
public:
//
private:
char c3;
}; int main()
{
cout << sizeof(C) << endl;
return ;
}

  这下输出结果是多少呢?可以肯定的是,C中也含有 val、c1、c2 和  c3,那么输出结果是不是也是 8呢?当然不是,是 16 !为什么呢?解释如下:

  C++ 规定出现在 derived class 中的 ”base class subobject“ 有其完整原样性。我们从下至上看看各个类在内存中的布局!

  class A 中含有两个变量 val 和 c1,其中 val 占有 4 个 bytes,c1 占有 1 个byte加起来 5 个 bytes。再加上边界调整填补 3 bte,实际会占用 8 byte。

  class B 继承了 class A 并增加了一个自己的 nonstatic data member c2,类型时 char,占有 1 byte,轻率的程序员会以为:c2 会 c1 放到一起构成 2 bytes,这样内存补齐只需补 2 bytes,class B的大小应该还是 8 bytes。然而,class B 的 c2 是被放在 class A 所填补的 3 bytes 的后面,此时 class B 的大小是 9 bytes ,在进行边界调整填补 3 bytes,实际会占用 12 bytes。同样 class C 的大小是 16 bytes ,class B 的大小 + c3 + 边界调整!下图会让你一目了然:

  Data 语义学(2) 图 1

  很多人会认为这种做法很愚蠢,在 class B 继承 A 的时候,将 c1 和 c2 放在一起,填补 2 bytes,在 class C 继承 class B 的时候,将 c1、c2 和 c3放到一起,填补 1 byte,这样 class A、class B、class C 的大小都是 8 bytes,比之前那样省很多空间!!!事实上是如此吗?这种做法会破坏C++中规定的 “出现在 derived class 中的 ”base class subobject“ 有其完整原样性”,会是一些代码导致致命的错误,不信你看:

  加入我们声明以下一组指针:

    B *pb;

    A *pa1,*pa2;// 这两个指针可以用来指向 class A,B,C。

  如果执行操作:

    *pa1 = *pa2;

  应该执行一个默认的“memberwise”复制操作(复制一个个的 members)对象是值得 class A 的那部分哦。如果 pa1指向一个 class B 或 class C ,那么就会将 pa2 所指的内容中 class A 的部分复制给 class B 或 class C 的 class A 的部分!如果将 A、B 和 C 的c1、c2 和 c3 捆版到一起,去填补空间,上述那些语义就没办法保留了!由于捆绑而导致的内存复制出现错误,请看下图,会更容易理解:

  Data 语义学(2)

  所以知道,C++设计者们为什么宁愿浪费空间而不那么做的原因了吧?

  (2) 加上多态

  加上多态之后会使很多操作起来更有方便性,使动作在运行期决定。这使得 class 更加完美,但我们必须维持付出代价:

    a. 导入一个相关的 virtual table,用来存放他 class 所声明的每一个 virtual function 的地址。这个 table 元素的个数 = virtual function 的数目 + 1 或 2(用以支持 runtime identification)

    b. 在每一个 class object 中导入一个 vptr ,提供执行期的连接,使每个 object 能够找到相应的 virtual table。

    c. 加强 destructor,使它能够销毁那个被安插进去的 vptr。

  那这个指针到底放在哪呢?看如下代码: 

 class A
{
public:
int num;
virtual void fun(){};
} class B
{
public:
int num;
int num1;
virtual void fun(){}
}

  这时 class A 和 class B 的布局可能是这样:

  Data 语义学(2) 也有可能是这样   Data 语义学(2)

  如果我对 class A 和 class B 进行如下该写,那么 A 和 B 的布局又会是什么样子呢?

  该写如下:

  

  class A
{
public:
int num;
virtual void fun(){};
} class B: public A
{
public:
int num1;
virtual void fun(){}
}

  那么 class A 和 class B 的布局会如下:

  Data 语义学(2)  图2 还有可能。。。另外一种就不画了,只是把vptr 移到 num 上去罢了!

  从这个图中可以发现什么?对无论继承多少次 vptr 在所有的 base class 或者 derived class 中都只有一份!并且位置都相同,但是他们(vptr)指向的 virtual table 不相同哦!都指向内存中各自的 virual table 调用时分别从各自的 virtual table 中找 virtual functions 的地址进行调用!实现了多态的机制哦!

  (3). 多重继承

  还是先来看几个类的定义:    

 class base1
{
public:
// something
protected:
float fb1;
}; class dbase1: public base
{
public :
// something
protected:
float fdb1;
}; class base2
{
public:
// something
protected:
float fb2;
}; class Topclass :public dbase1,public base2
{
public:
// something
private:
float muble;
};

  他们的关系图如下

    Data 语义学(2)图 3

  他们在内存中的布局图如下(假设他们都有虚函数哈,给他们画了 vptr 指针):

  Data 语义学(2) 图4

  由他们的内存布局图可知,在class Topclass 中取 class base1 和 class dbase1 的时候可以直接取到,而取class base2就必需要做相应的转化了。请看下面的例子:

    Toplass tc;

    base1   *b1;

    dbase1 *db;

    base2  *b2;

  如果是 b1 = &tc或 db = &tc都是和单继承一样的;但如果是 b2 = &tc的操作就需要相应的转化咯,如下:

    pv = ((Topclass *)((char *) &tc)) + sizeof( dbase2);

  而如果将 tc定义成 Topclass *tc;呢?那么相应的操作是否是 pv = ((Topclass *)((char *) tc)) + sizeof( dbase2);呢?只能说你这样对了一半,应该是这样:

    pv = tc ? ((Topclass *)((char *) &tc)) + sizeof( dbase2) : 0;

  因为 tc 有可能是空指针哦。至于 reference 就不会这样了,因为不可能有一个空的引用!

  (4). 虚拟继承

  //一下可能有误,回头看

  之前我们就对虚拟继承的内存比较好奇,而且在我的博文中未讲,我们只知道会生成一个指针和一个 virtual base classs table 那这些东西到底是什么样子呢?今天将详细的展示给你们看:

 #include <iostream>

 using namespace std;

 class A
{
public:
//
virtual void foo(){};
private:
int val;
}; class B: public A
{
public:
//virtual void foo(){}
private: }; int main()
{
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
return ;
}

  以上是一个一般继承的代码,程序输出的结果大家也知道是什么了,是 8 和 8,他们内存布局图也就是我们"(2)加上多态"图 2 所画的那样。那么我稍作如下修改:

 #include <iostream>

 using namespace std;

 class A
{
public:
//
virtual void foo(){};
private:
int val;
}; class B: virtual public A  //这里将继承改成了虚拟继承
{
public:
//virtual void foo(){}
private: }; int main()
{
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
return ;
}

这个代码将输出什么呢?结果却变成了 8 和 12 啦,为什么呢?我画一下虚拟继承后的他们的内存布局:

  Data 语义学(2) 图 5

  你将这幅图和上面"(2)加上多态"那里画的图 2 对比一下,会发现这里会多了一个 _vptr_B 指针啦(我将上面的 _vptr 后面 加上了 _A 或 _B 以区别他们,这个没影响)。这也就是为什么上面第二个代码输出的结果是 8 和 12 了。那为什么要这么做呢?以下是我通过 vc 调试和书上所述的方式总结出来的:

  我们之前都有说过 virtual base class 是虚基类它在 derived class 中访问并不能像其他,而是另外安插了一个指针,该指针指向一个 virtual base class table 这个表放的是 virtual base class 在 derived class 中的 offset !这个指针就是上面所说的 _vptr_B。(在我的 VC 下)这个 _vptr_B 有两个作用:第一、用来指向 class B 的 virtual function table 。第二、用来指向 class B 的 virtual base class。有人说一个指针怎么会指向两个表呢?请看下图:

  Data 语义学(2) 图 6

  看上图明白了吧?virtual function table 和 virtual base class table 首地址是同一个。两个表合到一起了,我们就叫 virtual function table ,表的首地址在分界处,当取正索引时,取的是 VFT slots ,当取负索引时,取的是 VBC offset。这样就可以方便的访问虚基类和实现多态了!VBC offset 是指virtual base class 在 derived class中的偏移量哦!---不过大家注意了,这只是 VC 下的一个用来共享虚基类的一种方法,别的编译器可能不是采用这种方法。但是都大同小异了,都是为了能够方便的在 derived class 中访问共享的 virtual base class!