深度探索cpp对象模型(3)

data语意学

空类(啥都没有),但是其大小是1byte,编译器塞给它一个char,为了让这个类的每个对象都有唯一的地址。

64位机子 指针8 byte 32位 4 byte

内存对齐:什么是内存对齐?把每一个数据单元放在适当的位置,比如对齐系数是4,一个struct里面有一个char,一个int,它的sizeof结果不是5,而是8,因为char后面加了3个byte。为什么?cpu读写内存是按字为单位来读写的,32位是4字节,64位是8字节,内存对齐的作用一个是加快读写操作,否则当一个变量跨字的时候,就需要两次的读写,还有一个是为了可移植性,有些cpu遇到未对齐的就直接罢工了

定义类的时候,里面的typedef 别名声明 要放在开头,不然会发生错误的匹配(把在typedef之前的匹配成外层作用域的 p91)

nonstatic 数据成员(不管继承来的,还是自己的)放在class object里面,也就是放在对象里,每个对象有自己的,先基类的后自己的

static 数据成员(同样不管继承来的,还是自己的)放在程序的data区 or bss区,每个类只有一份实例,即使没有类的对象产生,static数据成员就已经存在了

对一个nonstatic 数据成员的存取,做法是对象的起始位置+一个偏移量,当不存在虚基类时, 通过对象名或对象指针或引用访问非静态数据成员没有区别,因为offset固定好了,在编译的时期就已经知道了,所以这个操作是在编译期做的。

但是当存在虚基类的时候,对象是一个指针或者引用的时候,由于无法确定其实际类型,所以无法知道内存布局,也就不知道offset,所以这个存取操作要延迟至执行期,加个间接引导来解决

普通继承和虚继承的这个区别的原因在于, 普通继承的类对象的内存布局在编译时就可以决定, 而存在虚继承时则需要在运行时决定,

讨论class object里的布局:

规定:同一个access level的成员按声明的顺序排列

不同access level的先后不做要求,但一般都是按声明的顺序连续排列

规定:继承不会修改父类的内存布局

一般分成两个部分,一个不变区域,一个共享区域,不变区域放的是非虚基类部分,按照先基类,再自己的顺序,基类按声明的顺序,然后假如有虚函数存在的话,每个有虚函数类的有个虚表指针,指向一个虚函数表,表里存的是指针,前面的指向type_info object(用以支持RTTI),后面的指向各个虚函数,虚表指针放在一开始还是结尾都有,各有各的好,放在结尾,对象可以跟C兼容,放在开头,在多重继承中, 通过类成员指针访问虚函数会更容易实现。然后是共享区域放的是虚基类的部分,然后为了实现能够存取这个共享部分,我们就需要在它的直接派生类里面的不变区域放一个指针去指向这个虚基类成员。

但是这有两个问题,第二个问题我没看懂。第一个问题是可能会有很多个虚基类,那这样的指针可能会有很多个,解决方法有两种,一个是放一个指针,指向一个虚基类表,表里每一项放的都是指向一个虚基类的指针,还有种就是在虚表的前面加n项,记录n个虚基类距离这个类的偏移

还有个就是 指向 data members的指针:

& point_3d::x   结果是x坐标在class object 的偏移位置(可能传回来的值会被+1)

& object.x  结果是object这个对象的x成员在内存中的真正位置

还有几个效率的问题:

一个用虚拟继承实现的类,存取它的数据成员,效率低,因为间接存取的原因

用指向data member的指针 存取数据成员, (.* 或者 ->* )效率比指针指向已绑定成员的方式(也就是 &object.x)低,比直接存取  object.x 的低,当然优化过后可能都一样。

以下具体的参考: https://zhuanlan.zhihu.com/p/61585620

 

这一章主要进一步讨论C++对象的内存布局, 特别是在引入继承, 虚函数, 多继承, 虚继承后对内存布局的影响, 还包含编译器对相关特性的实现方式和优化.

注意, 下面的代码运行于Archlinux 4.18 x86_64, 编译器是gcc 8, 使用gdb 8调试.

不含数据成员的类对象

对于不存在继承和虚函数的类, 没有数据成员时, 其大小至少是1 byte, 以保证变量有唯一的地址. 当加上虚函数后, 由于有虚函数指针, 对象大小等于一个指针的大小, 32位系统中是4 bytes, 64位系统中是8 bytes. 看下面的代码:

struct Empty {};
struct VirtualEmpty
{
    virtual void f() {}
};

Empty a;
Empty b;

cout<<sizeof(Empty)<<endl; // 输出为1
cout<<sizeof(VirtualEmpty)<<endl; // 输出为8

cout<<&a<<' '<<&b<<endl; // 在输出中可以看到b的地址比a的地址大一.

但是, 当其作为基类时, 在某些情况下则不必遵循上面的要求, 可以在子类中将其优化掉, 节省所占空间. 例如下面的情况:

struct Base {};
struct Derived : Base
{
    int64_t i;
};

cout<<sizeof(Base)<<endl; // 输出为1
cout<<sizeof(Derived)<<endl // 输出为8

显然这里没有必要保留额外空间来表示基类对象. 上面说过, 为空对象保留空间的原因是保证其有唯一地址, 避免出现不同对象的地址相同的情形. 但是在这里, 子类地址就可以作为父类地址, 不会出现不同对象地址相同的情形. 但是即使是继承, 也有不能进行优化的情况:

  • 子类的第一个非静态数据成员的类型和空基类相同.
  • 子类的第一个非静态数据成员的基类类型和空基类相同.

不难看出, 这两种情况下, 会有两个空基类对象(父类对象和子类数据成员对象)连续出现, 如果优化掉, 将不能区别二者. 示例如下:

struct Base {};

struct Derived1 : Base // 情况一
{
    Base b;
    int64_t i;
}d1;

struct Derived2
{
    Base b;
};
struct Derived3 : Base
{
    Derived2 d2;
    int64_t i;
}d3;

cout<<sizeof(Derived1)<<endl; // 输出为16, 基类对象和成员b各占1 byte, 由于内存对齐补齐8 bytes
cout<<sizeof(Derived2)<<endl; // 输出为1
cout<<sizeof(Derived3)<<endl; // 输出为16, 基类对象和成员d2各占1 byte, 由于内存对齐补齐8 bytes

cout<<&d1<<' '<<&d1.b<<endl; // 前者(基类对象地址)比后者小1
cout<<&d3<<' '<<&d3.d2.b<<endl; // 前者(基类对象地址)比后者小1

对于空类作为虚基类的情况, 同样可以进行优化. 例如下面的代码:

struct Base {};
struct Derived1 : virtual Base {};
struct Derived2 : virtual Base {};
struct Derived3 : Derived1, Derived1 {};
struct Derived4 : Derived1, Derived1
{
    Base b;
}d4;

cout<<sizeof(Derived3)<<endl; // 输出为16
cout<<sizeof(Derived4)<<endl; // 输出为24

cout<<&d4<<endl; // 输出为0x55c6986ffe70
cout<<dynamic_cast<Base*>(&d4)<<endl; // 输出为0x55c6986ffe70
cout<<&(d4->b)<<endl; // 输出为0x55c6986ffe80

为了实现虚继承, 类Derived1和Derived2包含一个指针. 而虚基类Base被优化掉了, 因此Derived3大小为16 bytes. 而Derived4中由于包含类型是Base的非静态成员, 需要占据8 bytes, 即Derived4大小为24 bytes. 注意这里基类被优化了, 子类数据成员没有被优化. 测试显示, 即使这个成员不是第一个或最后一个, 编译器仍然不会优化.

虽然标准没有规定非静态数据成员在内存中的排列顺序, 但是一般实现都是按照声明顺序排列. 而由于内存对齐的要求, 仅仅改变成员的声明顺序可能产生不同大小的对象, 例如下面的声明:

struct Test1 // 大小为16 bytes
{
    int64_t i1;
    char c1; // c1 和 c2 被放置在一个字(16 bytes)中
    char c2;
};
struct Test2 // 大小为24 bytes
{
    char c1;
    int64_t i1;
    char c2;
};
struct Test3 // 大小为16 bytes
{
    int64_t i1;
    int32_t i2; // i2,c1,c2 被放置在一个字(16 bytes)中
    char c1;
    char c2;
};

由于计算机是以字(32位机为4 bytes, 64位机为8 bytes)为单位来读写, 因此内存对齐可以加快存取操作. 否则当一个变量跨字时, 读取这个变量就需要两次内存读. 但是这可能会增加需要的内存空间, 这就需要程序员仔细安排变量顺序, 以保证获得最佳的空间利用率.

而对于普通类的静态数据成员, 则具有独立于对象的静态生存期, 保存在全局数据段中. 模板类的静态数据成员如果没有被显式特化或实例化, 则在使用时会被隐式特化, 只有当特化/实例化后才是有效定义的. 有下面几种情况, 而这几种都可以归到C++14引入的 variable template(变量模板), 参考cppreference.

struct Test1
{
    template<typename T> static T val; // 非模板类的模板静态成员.
};
template<typename T> T Test1::val = 0;

template<typename T>
struct Test2
{
    static T val; // 模板类的非模板静态成员.
};
template<typename T> T Test2<T>::val = 0;

template<typename T1>
struct Test3
{
    template<typename T2> static std::pair<T1, T2> val; // 模板类的模板静态成员.
};
template<typename T1>
template<typename T2>
std::pair<T1, T2> Test2<T1>::val = std::make_pair(T1(1), T2(2));

auto var = Test3<int>::val<float>; // 即pair<int, float>(1, 2)

数据成员的存取

静态数据成员

对静态成员, 通过对象或对象指针访问和通过类名访问没有区别, 编译器一般会将二者统一为相同形式. 类成员指针不能指向静态成员, 因为对静态成员取地址得到的是一个该成员的指针. 如:

class A
{
public:
    static int x;
};
&A::x; // 其类型是 int*

因为类静态成员都是保存在全局数据段中, 如果不同类具有相同名字的静态成员, 就需要保证不会发生名称冲突. 编译器的解决方法是对每个静态数据成员编码(这种操作称为name-mangling), 以得到一个独一无二的名称.

非静态数据成员

不存在虚基类时, 通过对象名或对象指针访问非静态数据成员没有区别. 存在虚基类时, 通过对象指针访问非静态数据成员需要在运行时才能确定, 因为无法确定指针所指对象的实际类型, 也就不能判断对象的内存布局, 也就不知道对象中该数据成员的偏移. 普通继承和虚继承的这个区别的原因在于, 普通继承的类对象的内存布局在编译时就可以决定, 而存在虚继承时则需要在运行时决定, 详情见下文虚继承对内存布局的影响的讨论.

继承对对象布局的影响

单继承

最简单的一种情况, 单继承不会修改父类的内存布局, 例如父类由于内存对齐产生的额外空间在子类中不会被消除, 而是保持原样. 所以下面的代码中, 子类大小是24 bytes, 而不是16 bytes.

struct Base // 16 bytes
{
    int64_t i1;
    char c1;
};
struct Derived : Base // 24 bytes
{
    char c2;
};

其原因是如果消除了这些额外空间, 将子类对象赋值给父类对象时就可能会在父类对象的额外空间位置赋值, 这改变了程序的语义, 显然是不合适的.

加上多态

为了支持动态绑定, 编译器需要在对象中添加虚表指针(vptr), 指向虚表. 虚表中包含类的类型信息和虚函数指针, 值得注意的是, vptr并不是指向虚表的起始地址, 很多时候该地址之前会保存着对象的类型信息, 程序通过此类型信息实现RTTI. 而vptr初值的设置和其所占空间的回收, 则分别由构造函数和析构函数负责, 编译器自动在其中插入相应代码. 这是多态带来的空间负担和时间负担.

那么vptr放在什么位置呢? 这是由编译器决定的, gcc将其放在对象头部, 这导致对象不能兼容C语言中的struct, 但是在多重继承中, 通过类成员指针访问虚函数会更容易实现. 如果放在对象末尾则可以保证兼容性, 但是就需要在执行期间获得各个vptr在对象中的偏移, 在多重继承中尤其会增加额外负担.

多重继承

标准并没有规定不同基类在布局中的顺序, 但是大多数实现按照继承声明顺序安排. 多重继承给程序带来了这些负担:

  • 将子类地址赋值给基类指针变量时, 如果是声明中的第一个基类, 二者地址相等, 可以直接赋值. 否则, 需要加上一个偏移量, 已获得对应对象的地址.
  • 上面的直接加偏移并不能保证正确性, 设想子类指针值为0, 直接加上偏移后指向的是一个内容未知的地址. 正确做法应该是将0值赋给基类指针变量. 因此, 需要先判断基类指针是否为0, 再做处理. 而对于引用, 虽然其底层是指针, 但是不需要检查是否为0, 因为引用必须要绑定到一个有效地址, 不可能为0.

虚拟继承

主要问题是如何实现只有一个虚拟基类. 主流方案是将虚拟基类作为共享部分, 其他类通过指针等方式指向虚拟基类, 访问时需要通过指针或其他方式获得虚拟基类的地址. gcc的做法是将虚基类放在对象末尾, 在虚表中添加一项, 记录基类对象在对象中的偏移, 从而获得其地址. 我们可以通过gdb调试来看看具体情况.

struct B
{
    int64_t i1 = 1;
    virtual void f()
    {
        cout<<"B::f() called\n";
    }
};
struct D1 : virtual B
{
    int64_t i2 = 2;
};
struct D2 : virtual B
{
    int64_t i3 = 3;
};

struct D3 : D1, D2
{
    int64_t i4 = 4;
}d3;

for(int i = 0 ; i < sizeof(d3)/8; ++i)
    cout<<"d3["<<i<<"] = 0x"<<std::hex<<*((int64_t*)&d3 + i)<<endl;

首先用g++编译, 载入gdb中

# g++ main.cc -g
# gdb a.out

之后, 设置断点, 运行程序, 再通过下面的命令查看对象d3的虚表.

(gdb) p d3
$2 = {<D1> = {<B> = {_vptr.B = 0x555555557c58 <vtable for D3+72>, i1 = 1}, _vptr.D1 = 0x555555557c28 <vtable for D3+24>, i2 = 2}, <D2> = { _vptr.D2 = 0x555555557c40 <vtable for D3+48>, i3 = 3}, i4 = 4}
(gdb) p /a *((void**)0x555555557c28-3)@10
$4 = {0x28,
      0x0,
      0x555555557d20 <_ZTI2D3>,
      0x18,
      0xfffffffffffffff0,
      0x555555557d20 <_ZTI2D3>,
      0x0,
      0xffffffffffffffd8,
      0x555555557d20 <_ZTI2D3>,
      0x555555555446 <B::f()>}

可以发现, _vptr.D1等于*(int64_t *)&d3, _vptr.D2等于*((int64_t *)&d3 + 2), _vptr.B等于*((int64_t *)&d3 + 5). 显然分别是各个对象的vptr的值. gdb的第二个命令是打印部分虚表内容, -3指定起始位置, 10指定长度. 可见_vptr.D1指向输出的第四个, _vptr.D2指向输出的第七个, 二者指向位置的地址减3即为对应对象和基类对象的偏移. 同样可以看到前一个是当前对象的类型信息. 如果在C++中直接访问虚表, 可以用下面的代码, 这和上面用gdb打印虚表等效:

int64_t *vptr = (int64_t *)*(int64_t *)&d3; // D1的虚表地址.
for(int i = -3; i < 7; ++i)
    cout<<"_vptr.D1["<<i<<"] = 0x"<<std::hex<<*(vptr+i)<<endl;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值