继承

C++ 继承把基类子对象纳入派生类,并建立可替换的类型关系;重点掌握访问控制、构造顺序、对象切片和虚继承。

难度 进阶 时长 标准深度约 10分钟
版本 C++23
what

继承让派生类对象包含一个基类子对象,并可在满足访问规则时当作基类使用。公有继承应表达真实的「是一种」关系,而不只是复用几行代码。

trap

按值接收或保存基类会切掉派生部分;同名成员还可能隐藏基类重载,而不是重写它。

fix

通过基类引用或智能指针保留动态类型,对虚函数使用 override,并让需要多态销毁的基类拥有公有虚析构函数。

是什么,为什么存在

C++ 继承定义一种类之间的关系。被继承的类型是 基类(base class) ,声明基类列表的新类型是 派生类(derived class) 。派生类的完整对象包含基类子对象,还可以增加自己的数据和操作。

继承解决的是共同接口与类型替换问题。若 SavingsAccount 公有继承 Account,接受 const Account& 的代码也能接收 SavingsAccount。调用者只依赖账户契约,不必为每一种账户增加重载。

公有继承带着语义承诺:凡是基类允许的操作,派生类都应保持其前置条件、后置条件和不变量。这个要求通常称为 里氏替换原则(Liskov substitution principle) 。仅仅拥有相似字段,或想复用实现,并不足以证明两个类型适合公有继承。

你会在框架扩展点、异常层次、设备驱动接口和异构对象集合中遇到继承。若目标只是把一个实现部件放进另一个类型,组合通常更直接:成员关系表达「有一个」,继承关系表达「是一种」。

工作原理

派生类在类名后写基类列表,例如 class SavingsAccount : public Account。每个完整的 SavingsAccount 对象都含有一个 Account 子对象。基类的 private 成员仍然存在,只是派生类成员不能直接命名它们;派生类应通过基类的 publicprotected 接口操作这部分状态。

继承说明符也是一种 访问说明符(access specifier) 。它改变基类的 publicprotected 成员通过派生类暴露时的最高访问级别,但不会把基类的 private 成员改成可访问成员。

基类成员public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可直接访问不可直接访问不可直接访问

只有公有且无歧义的基类关系通常允许外部代码隐式向上转换。protected 继承把这种转换留给派生类及友元,private 继承则只留给当前类及友元。class 的基类访问默认为 privatestruct 默认为 public,因此公共层次最好始终显式写出 public

对象的继承结构可以看成嵌套的子对象,而不是把两个类的文本拼在一起:

构造派生类完整对象时,虚基类先初始化,然后按基类列表中的声明顺序初始化直接基类,再按成员声明顺序初始化派生类成员,最后进入派生类构造函数体。析构顺序相反。初始化器列表的书写顺序不能改变这些规则。

派生类构造函数负责为直接基类选择构造函数。若没有写基类初始化器,编译器会尝试调用该基类的默认构造函数。基类没有可访问的默认构造函数时,遗漏初始化器就是编译错误。

名字查找与虚分派是两件事。派生类声明某个名称时,可能隐藏基类中的整组同名重载;using Base::name 可以把基类重载重新引入候选集。选中虚函数签名后,通过基类引用或指针调用才会按对象的动态类型分派,具体规则见 cpp/virtual-functions

公有继承支持从派生类引用或指针到可访问、无歧义基类的向上转换。转换指向完整对象内部的基类子对象,不会复制对象。按值转换则不同:它只构造一个基类对象,派生类新增的状态和动态类型都会丢失。

示例

下面三个程序依次展示基类初始化、通过公共接口替换类型,以及按值传递造成的对象切片。它们都使用本地 g++ 的 C++23 模式编译并运行,输出来自真实执行结果。

公有继承与向上转换

SavingsAccount 显式初始化自己的 Account 子对象。print_owner() 接受基类引用,因此传入派生类对象时不复制,也不切片。

public_inheritance.cpp
#include <iostream>
#include <string>
#include <utility>

class Account {
public:
    explicit Account(std::string owner) : owner_(std::move(owner)) {}
    const std::string& owner() const { return owner_; }

private:
    std::string owner_;
};

class SavingsAccount : public Account {
public:
    SavingsAccount(std::string owner, int cents)
        : Account(std::move(owner)), cents_(cents) {}

    int balance() const { return cents_; }

private:
    int cents_;
};

void print_owner(const Account& account) {
    std::cout << "owner: " << account.owner() << '\n';
}

int main() {
    SavingsAccount account{"Mina", 12500};
    print_owner(account);
    std::cout << "balance: " << account.balance() << '\n';
}
owner: Mina
balance: 12500

构造 account 时,Account 子对象先取得所有者名称,随后 cents_ 初始化。print_owner(account) 使用隐式向上转换,把引用绑定到同一个完整对象中的基类部分。

owner_ 是基类的 private 成员。它没有因继承而消失,也没有变成派生类可直接访问的数据;SavingsAccount 和外部函数都通过 owner() 读取它。

通过基类引用保留动态类型

公共接口可以为不同派生类型提供统一入口。这里只展示继承与替换的连接;虚函数表、纯虚函数和析构策略在 cpp/virtual-functions 中展开。

virtual_dispatch.cpp
#include <iostream>

class ShippingRule {
public:
    virtual int fee(int grams) const {
        return 300 + grams / 10;
    }

    virtual ~ShippingRule() = default;
};

class ExpressRule : public ShippingRule {
public:
    int fee(int grams) const override {
        return 700 + grams / 10;
    }
};

void print_fee(const ShippingRule& rule, int grams) {
    std::cout << "fee: " << rule.fee(grams) << '\n';
}

int main() {
    ShippingRule standard;
    ExpressRule express;
    print_fee(standard, 2000);
    print_fee(express, 2000);
}
fee: 500
fee: 900

两次调用的静态接口都是 ShippingRule,第二次却保留了 ExpressRule 的动态类型,因此执行重写后的 fee()override 要求编译器确认函数签名确实覆盖基类虚函数;若遗漏 const 或写错参数类型,程序会在编译期失败。

基类析构函数是虚函数,因为这个接口允许派生对象经基类指针拥有和销毁。示例没有动态分配,但把销毁契约写在基类中,能让之后使用 std::unique_ptr<ShippingRule> 的代码保持安全。

观察对象切片

按值形参会从实参的基类子对象构造一个新的基类对象。这个过程叫作 对象切片(object slicing) ,它不会保留原对象的派生部分。

object_slicing.cpp
#include <iostream>
#include <string>

class Notice {
public:
    virtual std::string channel() const {
        return "generic";
    }

    virtual ~Notice() = default;
};

class SmsNotice : public Notice {
public:
    std::string channel() const override {
        return "sms";
    }
};

void print_value(Notice notice) {
    std::cout << "value: " << notice.channel() << '\n';
}

void print_reference(const Notice& notice) {
    std::cout << "reference: " << notice.channel() << '\n';
}

int main() {
    SmsNotice notice;
    print_value(notice);
    print_reference(notice);
}
value: generic
reference: sms

print_value() 中的局部 Notice 已是独立基类对象,所以虚调用也只能得到 genericprint_reference() 没有构造新对象,引用仍指向原来的 SmsNotice,于是输出 sms

异构集合也有同样区别。std::vector<Notice> 存放的是基类值,会切片;需要保留动态类型时,可保存 std::unique_ptr<Notice>,或在所有权位于别处时保存生命周期明确的引用包装器。

陷阱

修复: 逐项检查派生类能否接受基类的全部有效输入,并保持相同后置条件和不变量。若关系只是「使用这个部件」,把部件设为成员;只有需要受限实现复用时才考虑非公有继承,而且不要把它描述成子类型关系。

修复: 不转移所有权时使用 Base&const Base&。需要异构所有权时使用 std::unique_ptr<Base>;需要值语义时,为层次设计明确的多态复制操作,或改用 std::variant 等封闭表示。

修复: 对准备重写的虚函数写 override。若派生类还需要保留基类重载集,在派生类作用域加入 using Base::name,并用基类引用和派生类对象分别编译测试调用。

修复: 允许多态销毁的基类应提供公有虚析构函数,常见写法是 virtual ~Base() = default。若类型明确禁止经基类接口销毁,可使用受保护的非虚析构函数;不要留下公有非虚析构函数让调用者误用。

修复: 不要让基类构造或析构逻辑依赖派生重写。把完成对象后才能执行的工作移到普通成员函数或工厂中,并在对象完整构造后调用;清理则交给各层自己的析构函数和成员对象。

修复: 先判断两条路径是否真的需要共享同一个基类身份。需要共享时,两条中间路径都应虚继承共同基类,并由最派生类初始化它;若两份基类状态各有含义,就保留非虚继承并显式命名访问路径。

深入 多重继承与虚继承

多重继承与虚继承

多重继承让一个类拥有多个直接基类。若这些基类表达彼此独立的接口,而且没有冲突状态,结构通常容易理解。困难出现在两条继承路径再次汇合到同一个祖先时:普通继承会沿每条路径各创建一份祖先子对象。

虚继承(virtual inheritance) 让多条指定路径共享一份虚基类子对象。它解决的是共同基类身份,而不是普通虚函数分派。最终完整对象中的最派生类负责初始化虚基类;中间类写出的虚基类初始化器只在该中间类本身是最派生类时生效。

下面的 DocumentVersionedAudited 两条路径继承 Record。两条路径都使用虚继承,所以转换得到的两个 Record* 指向同一个子对象。

virtual_diamond.cpp
#include <iostream>
#include <string>
#include <utility>
class Record {
public:
    explicit Record(std::string id) : id_(std::move(id)) {}
    const std::string& id() const { return id_; }

private:
    std::string id_;
};

class Versioned : virtual public Record {
public:
    explicit Versioned(int revision) : Record{"unused"}, revision_(revision) {}
    int revision() const { return revision_; }

private:
    int revision_;
};

class Audited : virtual public Record {
public:
    Audited() : Record{"unused"} {}
};

class Document : public Versioned, public Audited {
public:
    Document(std::string id, int revision)
        : Record{std::move(id)}, Versioned{revision}, Audited{} {}
};

int main() {
    Document document{"policy", 7};
    Record* via_version = static_cast<Versioned*>(&document);
    Record* via_audit = static_cast<Audited*>(&document);
    std::cout << "id: " << document.id() << '\n';
    std::cout << "revision: " << document.revision() << '\n';
    std::cout << std::boolalpha << "same base: " << (via_version == via_audit) << '\n';
}
id: policy
revision: 7
same base: true

Document 的初始化器 Record{std::move(id)} 决定共享虚基类的内容。VersionedAudited 中的 Record{"unused"} 在构造 Document 时被忽略,但在直接构造这些中间类的完整对象时仍有作用。

何时不用虚继承

虚继承不是看到菱形就机械添加的标记。如果两条路径代表两个不同角色,各自确实需要一份祖先状态,那么合并它们会改变模型。先写出对象应拥有一份还是两份共同状态,再决定继承形式。

虚基类还会让构造责任越过直接基类边界,并使转换与对象布局更复杂。不要依赖特定编译器的指针偏移、对象大小或隐藏表结构;语言保证的是子对象与转换语义,不是某个 ABI 的字节布局。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?