2.2 继承下的对象布局


2.2 继承下的对象布局

本节摘要:继承在内存里的实现朴素得惊人——派生类对象的开头嵌着一个完整的基类子对象。正因布局连续且基类在前,"基类指针指向派生类对象"才天然成立;public、protected、private 三种继承只是不同强度的接口继承,布局照拼不误。本节用字节级布局图拆解单继承与多继承,解释菱形继承为何需要虚继承。

学习目标

阅读完本节,你应当能够:

  1. 画出单继承下派生类对象的字节布局;
  2. 解释基类指针指向派生类对象时地址为何不变(单继承);
  3. 说出多继承下指针调整的代价与菱形继承的内存浪费;
  4. 区分三种继承方式的语义与适用场景。

一、派生对象里嵌着基类子对象

class Point { public: double x = 0, y = 0; // 16 字节 }; class ColorPoint : public Point { public: int color = 0; // 4 字节 + 4 对齐填充 }; static_assert(sizeof(Point) == 16); static_assert(sizeof(ColorPoint) == 24);

sizeof(ColorPoint) == 24 透露了全部秘密:派生类的内存 = 基类子对象(16 字节)+ 自己的成员(4 字节)+ 尾部对齐(4 字节)。基类排在最前面(主流实现如此,标准未强制但各家一致)。

图:单继承的对象布局与指针等价性

图:单继承的对象布局与指针等价性

这张图直接解释了一个基础问题:为什么 Point* p = &colorPoint; 合法且开销为零?因为 ColorPoint 的开头就是一个货真价实的 Point,指针指向对象首地址,天然落在基类子对象上——这就是"is-a"关系的内存实现。

二、三种继承:接口的三档闸门

public 继承保持 is-a:基类的 public 接口原样开放,是最常见的继承。protected 继承把继承来的 public 接口降为 protected,只有再派生的类能用。private 继承(class 默认)把一切继承来的接口关进 private,外部完全不可见:

class Widget : private Impl { // 按实现继承 不按接口继承 public: using Impl::helper; // 按需放行个别函数 };

private 继承表达"用基类的实现,但不是基类的一种",多数场景有更好的替代——组合(成员对象),只有需要访问 protected 成员或需要空基类优化(EBO:无状态基类不占派生类空间)时才考虑它。判断标准一句话:能回答"派生类是不是基类的一种"就用 public 继承,否则用组合。

三、多继承与菱形继承

一个类可以继承多个基类,子对象按声明顺序拼接。多继承立刻带来两笔开销:第二基类起指针转换要加偏移;两个基类若有同名成员,调用必须限定。真正的深水区是菱形继承

struct Device { int id; }; struct Screen : public Device { }; struct Touch : public Device { }; struct Phone : public Screen, public Touch { }; // sizeof(Phone) 里有两份 Device! // phone.id; // 编译错误:来自 Screen 还是 Touch? // phone.Screen::id; // 必须限定

Phone 对象里嵌了两份 Device 子对象,id 二义。解法是虚继承

struct Screen : virtual public Device { }; struct Touch : virtual public Device { }; struct Phone : public Screen, public Touch { }; // Phone 里只有一份 Device 由虚基类机制协调

代价是每个虚继承的子对象要多带指针(或偏移表)定位共享的 Device 子对象,访问虚基类成员也多一次间接——运行时布局复杂化。工程界的普遍态度:菱形继承能免则免,需要"多个接口 + 一份共享状态"时,用接口类(纯虚函数、无数据)做多继承,数据放进唯一的具体基类。标准库的流体系(istream、ostream 都虚继承 ios_base)是教科书级的正面对照。

四、布局知识的三个回报

**回报一:看懂对象切片。**按值传参或按值赋值给基类对象时,只拷贝基类子对象部分,派生类成员被"切"掉:

void render(Point p); // 按值! ColorPoint cp{1, 2, 255}; render(cp); // p 只拿到 x y color 被切掉 // 若 Point 有虚函数 p 的虚表指针也是 Point 的 多态失效

想保留多态必须经由指针或引用传递——引用不拷贝对象,只是给同一块内存起了别名。切片多数时候是 bug 而非特性,接口设计一律 const Base&

回报二:理解空类优化。sizeof(空类) == 1(保证不同对象地址不同),但作为基类时可以 0 占用(空基类优化)。标准库大量利用这一点把无状态比较器、分配器"藏"进容器而不增大体积——这也是为什么 std::vector 的三个指针旁边塞着一个分配器,体积却常年是 12 个字节。

**回报三:字段布局可预测。**继承不改基类布局,派生类成员接在后面(同访问级别内按声明序)。依赖布局的代码(序列化、哈希整个对象)可以把"布局假设"写成 static_assert(sizeof(Derived) == N) 固化。

💡 关键直觉:C++ 的继承是"布局层面的组合 + 访问层面的闸门"。理解了"派生对象里嵌着基类",指针转换、切片、多继承偏移这些考题就不再是背诵题,而是同一张布局图的不同局部。

本节要点回顾

  • 基类子对象拼接在头部:单继承下基类指针零成本指向派生对象,is-a 的物理实现。
  • 三种继承是接口闸门:public 表达 is-a,其余表达"借实现",多数场合组合优于继承。
  • 多继承按序拼接:第二基类起转换需偏移;菱形继承双份基类用虚继承消除,代价是间接寻址。
  • 切片是按值拷贝的副产品:想多态就传指针或引用,永不按值传基类。
  • 布局可固化:EBO、static_assert 是布局知识的两项直接工程产出。

下一节给对象装上函数表:虚表指针如何让同一条调用语句命中不同的函数体。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U