本节摘要:继承在内存里的实现朴素得惊人——派生类对象的开头嵌着一个完整的基类子对象。正因布局连续且基类在前,"基类指针指向派生类对象"才天然成立;public、protected、private 三种继承只是不同强度的接口继承,布局照拼不误。本节用字节级布局图拆解单继承与多继承,解释菱形继承为何需要虚继承。
阅读完本节,你应当能够:
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++ 的继承是"布局层面的组合 + 访问层面的闸门"。理解了"派生对象里嵌着基类",指针转换、切片、多继承偏移这些考题就不再是背诵题,而是同一张布局图的不同局部。
下一节给对象装上函数表:虚表指针如何让同一条调用语句命中不同的函数体。