这篇笔记主要写一下 C++ 的继承机制,以及它在内存中实际发生了什么。
继承的本质是内嵌
C++class Base {
int a;
};
class Derived : public Base {
int b;
};
Derived 的内存布局就是:先放一个完整的 Base 子对象,再放 Derived 自己的成员。
Plain TextDerived 对象
+-------------------+
| Base::a (4 字节) | <- Base 子对象
+-------------------+
| Derived::b (4字节)|
+-------------------+
这解释了两件事。第一,Derived* 可以隐式转成 Base*——因为 Base 子对象就在对象的起始位置,指针值甚至不用变。第二,把一个 Derived 对象按值赋给 Base 变量时会发生切片(slicing):只有 Base 那部分被拷走,b 被悄悄丢掉。
C++Derived d;
Base b = d; // 切片:d 的 Derived 部分没了
Base& r = d; // 没问题:引用不拷贝
多态永远要通过指针或引用,就是因为按值传递会切片。
三种继承权限
C++class D1 : public Base { }; // public 继承
class D2 : protected Base { }; // protected 继承
class D3 : private Base { }; // private 继承
它们规定的是基类成员在派生类中的可见性上限:
| 基类成员 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 不可访问 | 不可访问 | 不可访问 |
public 继承表达的是 is-a 关系:任何接受 Base 的地方都应该能接受 Derived——这就是里氏替换原则。
private 继承表达的是 implemented-in-terms-of:想复用基类的实现,但不想让外界知道这层关系。绝大多数时候,用组合(成员变量)代替 private 继承会更清晰。只有当你需要重写基类的虚函数,或者需要空基类优化时,private 继承才有它的位置。
protected 继承在实践中几乎用不到。
构造与析构的调用链
构造从最基类开始,析构反向进行:
C++struct A { A() { puts("A()"); } ~A() { puts("~A()"); } };
struct B : A { B() { puts("B()"); } ~B() { puts("~B()"); } };
B b;
// 输出:A() B() ~B() ~A()
派生类构造函数在执行自己的函数体之前,会先调用基类构造函数。不显式指定就调用基类的默认构造函数;要传参数必须写在初始化列表里:
C++class Derived : public Base {
public:
Derived(int x) : Base(x), y_(x * 2) {}
private:
int y_;
};
构造函数里不要调虚函数
C++struct Base {
Base() { init(); } // 危险
virtual void init() { puts("Base"); }
};
struct Derived : Base {
void init() override { puts("Derived"); }
};
Derived d; // 输出 "Base",不是 "Derived"
在 Base 的构造函数执行时,Derived 那部分还没有被构造,虚表指针此时指向的仍然是 Base 的虚表。语言这样规定是对的——否则会调用到一个访问未初始化成员的函数——但它常常不是写代码的人期望的。析构函数里同理。
基类析构函数必须是虚的
C++Base* p = new Derived();
delete p; // Base 的析构函数不是 virtual 的话,~Derived() 不会被调用
这是未定义行为,实际后果通常是 Derived 独有的资源泄漏。规则很简单:只要一个类可能被当作基类通过指针删除,它的析构函数就必须是 virtual。反过来,如果一个类不打算被继承,用 final 标记它,既表达了意图也让编译器能做去虚化优化。
名字隐藏
派生类中同名的函数会隐藏基类的所有同名重载,哪怕参数列表不同:
C++struct Base {
void f(int);
void f(double);
};
struct Derived : Base {
void f(const char*); // 隐藏了 Base::f 的全部两个重载
};
Derived d;
d.f(42); // 编译错误:42 无法转成 const char*
这不是重载解析的问题,而是名字查找在找到 Derived::f 之后就停止了,根本没看 Base。要把基类的重载拉回来:
C++struct Derived : Base {
using Base::f;
void f(const char*);
};
多重继承与菱形问题
C++ 允许一个类继承多个基类。这带来了菱形继承:
C++struct Animal { int age; };
struct Bird : Animal {};
struct Fish : Animal {};
struct FlyingFish : Bird, Fish {}; // 里面有两份 Animal
FlyingFish 对象中有两个独立的 Animal 子对象,ff.age 会直接产生二义性编译错误,必须写 ff.Bird::age。
解法是虚继承:
C++struct Bird : virtual Animal {};
struct Fish : virtual Animal {};
struct FlyingFish : Bird, Fish {}; // 只有一份 Animal
虚继承的代价不小:对象里需要额外的虚基类指针来定位共享的子对象,成员访问多一次间接寻址,而且最派生类要负责构造虚基类——中间层写的 Animal(...) 初始化会被忽略,这经常让人意外。
实践中的建议:多重继承只用在一个实现基类 + 若干纯接口这种形态上。纯接口(只有纯虚函数、没有数据成员)不会引发菱形问题,也就不需要虚继承。这也是 Java 和 C# 只允许单继承加多接口的原因。
继承的运行时那一半——虚函数和虚表——在《C++ #8 多态》里展开。