T.TAO
返回博客
/7 min read/Programming

C++ #6 继承

#C++#Programming#ComputerSystems

这篇笔记主要写一下 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 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
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 多态》里展开。

本系列文章

C++ 程序设计
  1. 01C++ #1 数据与内存
  2. 02C++ #2 Struct 和 Union
  3. 03C++ #3 指针与数组
  4. 04C++ #4 函数
  5. 05C++ #5 对象与类
  6. 06C++ #6 继承
  7. 07C++ #7 拷贝控制与运算符重载
  8. 08C++ #8 多态
  9. 09C++ #9 C++11 新特性
  10. 10C++ #10 C++14 新特性
  11. 11C++ #11 C++17 新特性