3.4 继承与数据成员内存布局

Q1:具体继承(单一继承)且无多态的情况

• 具体继承(对应于虚拟继承)并不会增加空间或存取时间上的额外负担

• 在这种情况下,类对象的内存存储情况如下:

Eg:

            class X
            {
                int x;
                char c;
            };

            class Y : public X
            {
                int y; 
            };

X对象内存布局为:

这里写图片描述

• 把两个原本不相干的类凑成一个基类与继承类对,会产生以下两个易犯错误:

1) 重复设计相同操作的函数,如:

class A
            {
            public:
                A(int i = 0) :vala(i){}
                void operator +=(const A & a)
                {
                    vala += a.vala;
                }
                int vala;
            };

            class B : public A
            {
            public:
                B(int i = 0, int j = 0) :A(i), valb(j){}

                void operator+=(const B & b)
                {
                    A::operator+=(b);  //调用子对象A部分的加法操作符作为 B 加法操作符的一部分 
                    valb += b.valb;
                }
                int valb;
            };

2) 为了表现“class体系的抽象化”而膨胀所需的空间,C++语言保证“出现在派生类中的基类子对象具有其完成原样性”。如:

            class X 
            {
                int val;
                char ca;
                char cb;
            };

此时类 X 的大小为8字节,假设为了逻辑性,将其进行扩展更为合适,即扩展为 A、B两个类,则:

            class A
            {
                int val;
                char ca;
            };

            class B : public A
            {
                char cb;
            };

此时,类 A 的大小为8字节,类 B 的大小为12字节,比原来多了50%,由于抽象体系的需求膨胀了所需空间。

*备注:“出现在派生类中的基类子对象的完整原样性”的含义:(以上述类A,类B为例子)

• 类A 的大小为 8 字节,其中,4字节是 val 所占空间,1 字节是 ca所占空间,剩下三字节为对齐填补字节

• 对B的大小按以下两种情况分类:

1) 基类子对象具有完整原样性

    • B 类的大小为 12字节,其中,8字节为 A 类子对象的大小(包含三字节填充字节),1字节为 cb 所占空间,剩余3字节为对其填补字节。

    • 此时,B类的子对象 A 是一个完整的A 类,即sizeof(B::A) = sizeof(A),包含了 A 的填充字节,保证了基类子对象的完整原样性(VS中如此)

2) 基类子对象不具有完成原样性

    • B 类大小为8字节,其中5字节为 A 类子对象的大小(取掉 A 的填充字节),1字节为 cb 所占空间,剩余2字节为填补字节

    • 此时,B类子对象是一个不完整的 A类,取掉了A 中的填充字节,并将派生类的成员 cb与基类子对象A 捆绑在一起,不具有基类子对象的完整原样性(G++中如此)

    • 这种情况有可能存在缺陷,当将一个 A 类对象强制复制给一个B 类对象时,将会覆盖B类对象中与 基类子对象A 捆绑在一起的部分成员。如图所示:

这里写图片描述

Q2:具体继承(单一继承)与多态的情况

• 即在基类中提供虚函数接口,供继承层次中类对象实现多态性

• 在这种情况下,基类与派生类类对象的布局情况如下:

Eg:

        class X
        {
            int x;
            char c;
            virtual void func();
        };

        class Y : public X
        {
            int y; 
            void func();
        };

这里写图片描述

• 多态的加入带来的空间与存储时间上的额外负担

1) 导入一个与基类有关的 virtual table,用来存放申明的每一个虚函数的地址, 再增加一行或两行,用来支持运行时类型识别

2) 在每个类对象中导入 vptr,使每个对象找到相应的 virtual table

3) 加强构造函数,使它能够为 vptr 设置初值,让其指向类对应的虚函数表

4) 加强析构函数,使它能析构“与类相关的 virtual table”的 vptr

• 关于 vptr 放在类对象的哪里?

1) 类对象的尾部

    优点:这样就可以保留 base class C struct 的对象布局,因为允许在C 代码中也可使用

    缺点:在多重继承的情况下,则,“从类对象起始点开始量起”的 offset 必须在执行期准备,甚至class vptr之间的offset也必须知道

2) 类对象的头部

    优点:在多重继承的情况下,通过指向类成员的指针就可调用虚函数

    缺点:丧失了C语言的兼容性

Q3:多重继承的情况

• 对多重继承的基类与派生类之间的内存布局情况如下:

Eg:

        class A
        {
            char a;
            virtual void func1();
        };

        class B : public A
        {
            char b;
            void func1();
        };

        class C
        {
            char c;
            virtual void func2();
        }

        class D : public B, public C
        {
            char d;
        }

继承关系如下图所示:

这里写图片描述

其中各个类的内存布局如下所示 :

这里写图片描述

• 对于一个多重派生对象,将其地址指定给“最左端”(即继承层次中最先声明的)的基类的指针,情况与单一继承时相同,因为二者将指向相同的地址;而第二个或再后继的基类的地址则需要将地址进行修改,加上中间基类子对象的大小。如:

  • 对于上述例子中,对D 类对象而言,其第一个声明的基类为类B(而类B第一个基类对象为类A),第二个声明的基类为类C,进行以下操作:
    Eg:
    D dobj;
    A * pa = &dobjl;              //A类子对象的地址与D类对象地址相同,因为A类子对象是D类对象声明的第一个基类
    B * pb = &dobj;              //B类子对象的地址与D类对象地址相同,原因如上
    C * pc = &dobj;             //C 类子对象的地址与D类对象不同,需要进行内部转换
    D * pd = &dobj;

    //①:
    //对 C * pc = &dobj 进行如下转换

    pc = (C*)((char*)&dobj + sizeof(B));               //加上中间基类子对象B的sizeof


    //②:
    //对指针复制,有时候不能直接进行拷贝或转换,可能需要先判断指针是否为0

    pa = pd;
    pb = pd;    //这两种情况只需要简单拷贝地址即可

    pc = pd; 

    //不能直接进行如下转换:

    pc = (C*)((char*)pd + sizeof(B));  //因为当 pd = 0 时,直接转换会使得 pc = sizeof(B),这是错误的行为,因此转换前需要判断 pd 是否为0

    //以下为正确操作:

    pc = pd ?  (C*)((char*)pd + sizeof(B)) : 0;

• 对于多重继承情况下,存取第二个或后继的基类中的一个数据成员时,并不需要付出额外的成本,因为成员的位置在编译时已经固定了,存取操作只是一个简单的offset运算,就像单一继承一样,无论是经过一个指针,一个引用或是一个对象来存取。

Q4:虚拟继承的情况

• 实现虚拟继承的难点在于:

• 以 iostream 类型为例,需要找到一个有效的方法,将 istream 与 ostream 各自维护的一个 ios子对象抽取出来,折叠为一个由 iostream 维护的单一的 ios 子对象

• 并要保存基类与派生类指针(引用)之间的多态指定操作

• 一般实现方法为:类中如果含有一个或多个虚基类子对象,则将被分割为两部分:一个不变区域,一个共享区域

1) 不变区域中,不管后继如何衍化,总有固定的offset,这部分的数据可以直接存取

2) 共享区域中,数据的位置可能会因为每次派生操作而有所变化,此时只能使用简介存取(如变成共享基类时,基类子对象的位置发生变化)

• 如何存取类的共享部分?间接存取的三种策略:

1) cfront编译器会在每一个派生类对象中安插一些指针,每个指针指向一个虚基类,要存取继承得来的虚基类成员,可通过相关指针间接完成。存在两种缺点:

• 每个对象必须针对每个虚基类增加一个额外的指针。希望类对象有用固定的负担。本缺陷通过下述两种办法可以进行克服

• 由于虚拟继承串链的加长,导致间接存取层次的增加。希望有固定的存取时间。本缺陷通过拷贝操作获得所有局部虚基类指针,放到派生对象中,牺牲空间获取了时间。如下图

2) 微软引入 virtual base class table,每个类对象如果有一个以上的虚基类,编译器就会安插一个指针指向虚基类表,真正的虚基类指针则放在表中。提供了固定负担。如下图

3) 在virtual function table中放置虚基类的offset(而不是地址),将虚基类偏移与虚函数地址混杂在一起,使虚函数表可以通过正负值分别索引虚函数地址与虚基类偏移。提供固定负担。如下图

Eg: 针对下述类继承关系讨论不同访问策略时类对象的内存布局:

        class A
        {
            char a;
            //虚函数
        };
        class B : public virtual A
        {
            char b;
        };
        class C : public virtual A
        {
            char c;
        };
        class D : public B , public C
        {
            char d;
        };

继承关系如下图所示:

这里写图片描述

①.指针简介访问策略:

这里写图片描述

②.虚基类表访问策略:

这里写图片描述

③.混合虚函数访问策略:

这里写图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值