继承最容易被误解成“少写几段重复代码”。真正有用的场景更具体:多个类型遵守同一业务协议,调用者只依赖这个协议,而每个对象在运行时执行自己的实现。
本文用报价系统贯穿全部概念。普通商品按标价计费,批量优惠在达到门槛后全部打折,限量优惠只给前几件打折。三种对象都能回答“某个 SKU 买若干件多少钱”,但计算细节不同。
我们会从业务关系出发,再进入对象布局、动态绑定、抽象基类、生命周期、访问控制、切片和多态复制。多重继承与虚继承有独立的问题模型,本篇不展开。
公有继承表达的是 is-a:派生类对象在任何只要求基类协议的地方都应当可用。
假设调用者只知道下面的接口:
class Quote {
public:
Quote(std::string sku, double price);
const std::string& sku() const noexcept;
virtual double net_price(std::size_t quantity) const;
virtual ~Quote() = default;
protected:
double unit_price() const noexcept;
private:
std::string sku_;
double price_ = 0.0;
};BulkQuote 如果公有继承 Quote,它必须继续满足这份协议:
class BulkQuote : public Quote {
public:
BulkQuote(std::string sku, double price,
std::size_t minimum, double discount);
double net_price(std::size_t quantity) const override;
};调用者可以不知道折扣字段,也不必知道有哪些派生类型。它只需要一个 Quote&:
double checkout_line(const Quote& item, std::size_t quantity) {
return item.net_price(quantity);
}可替换性不只看函数签名。假如某个派生类把 net_price(0) 改成“清空库存”,即使函数类型完全匹配,它也破坏了基类承诺,因而不是合理的公有继承。
可以用三个问题筛选继承关系:
如果目的只是复用一个折扣计算器,成员对象通常更直接:
class CampaignQuote {
private:
Quote base_quote_;
DiscountPolicy policy_;
};这叫组合。组合表达“有一个”,不会向外承诺 CampaignQuote 本身就是 Quote。先判断业务关系,再决定语法。
公有继承首先是接口关系,其次才可能带来实现复用。只想拿到几段现成实现时,优先检查组合能否把依赖边界写得更清楚。
派生对象不是两个彼此独立的对象拼在一起。它是一个完整对象,其中包含一个基类子对象,再增加派生层需要的状态。
以 BulkQuote 为例,可以用下面的概念模型理解:
标准没有要求编译器按这张表给出可移植的字节布局。常见实现会让带虚函数的对象保存一个虚表指针,每个类共享一张虚表。这个模型能解释分派,但业务代码不应读取或猜测这些隐藏数据。
公有继承允许派生指针和引用向上转换:
BulkQuote bulk{"BK-17", 80.0, 10, 0.25};
Quote* base_pointer = &bulk;
Quote& base_reference = bulk;这两行没有复制 bulk。指针和引用只定位到它内部的 Quote 基类子对象,完整的 BulkQuote 仍然存在。
因此要区分两个类型:
上例中,base_reference 的静态类型是 Quote&,它所引用对象的动态类型是 BulkQuote。
反方向不会自动成立。任意 Quote* 未必指向 BulkQuote,所以不能直接赋给 BulkQuote*。确实需要检查时可以使用 dynamic_cast:
if (auto* bulk_pointer = dynamic_cast<BulkQuote*>(base_pointer)) {
// 只有实际对象属于 BulkQuote 时才进入这里
}如果业务代码经常向下转换,通常说明共同接口缺少真正需要的操作。先检查接口,再把 dynamic_cast 当作局部工具。
向上转换指针或引用不会切片;把派生对象按值初始化成一个新的基类对象才会切片。两者看起来都在“变成基类”,对象语义完全不同。

虚函数把“调用哪个版本”的决定推迟到运行时。调用者只写一次 item.net_price(quantity),具体算法由实际对象选择。
下面是一个可独立运行的 C++17 程序:
#include <algorithm>
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <string>
#include <string_view>
#include <utility>
class Quote {
public:
Quote(std::string sku, double price)
: sku_(std::move(sku)), price_(price) {}
const std
输出为:
regular REG-1 -> 960.00
bulk BULK-1 -> 720.00
limited LIMIT-1 -> 880.00三个调用最终都进入 print_price(const Quote&)。net_price 是虚函数,所以第二次和第三次调用分别进入 BulkQuote 与 LimitedQuote 的覆盖版本。
动态绑定通常需要满足这些条件:
限定调用会明确绕过动态分派。BulkQuote 中的 Quote::net_price(quantity) 就是主动调用普通价格实现,避免递归回到自身。
非虚函数按静态类型选择。若一个操作需要随运行时对象变化,就应在共同接口中明确声明为虚函数;若行为必须固定,则保持非虚。

覆盖要求函数类型匹配。函数名相同还不够,参数、const、引用限定等都可能改变函数类型。
下面的代码故意写错:
struct BaseRule {
virtual double apply(std::size_t quantity) const;
};
struct BrokenRule : BaseRule {
double apply(std::size_t quantity) override; // 少了 const
};因为写了 override,编译器会直接拒绝这段声明。去掉 override 虽可能让它通过编译,但问题没有消失:派生类只是声明了另一个同名函数,经 BaseRule& 调用时仍会进入基类版本。
派生声明不必重复 virtual,虚属性会沿继承链保留。推荐写法是:
double apply(std::size_t quantity) const override;final 解决另一类问题。它不是检查本次覆盖,而是关闭后续扩展点:
struct AuditedRule : BaseRule {
double apply(std::size_t quantity) const override final;
};
struct LastRule final : BaseRule {
double apply(std::size_t quantity) const override;
};final 表示更下层类不能再次覆盖该虚函数。final 表示这个类不能再被派生。override final 可以同时验证本次覆盖并终止后续覆盖。不要为了“更安全”到处加 final。它适合表达确定的封闭决策,例如安全审计步骤不允许被绕过,或者这个具体类没有合理子类型。
名字隐藏与覆盖是两件事。派生类一旦声明同名成员,基类中的整组同名重载会先被隐藏:
#include <iostream>
#include <string>
struct RuleIndex {
void find(int id) const { std::cout << "id=" << id << '\n'; }
};
struct CampaignIndex : RuleIndex {
using RuleIndex::find;
void find(const std::string&
using RuleIndex::find; 把基类重载引入派生作用域,使 find(7) 和 find("summer") 都能参与正常重载决议。override 只适用于虚函数覆盖,不能用来恢复普通重载。
有时基类能提供合理默认行为。Quote 的普通计价就是这种情况,所以它可以直接创建对象。
折扣中间层不同。它知道门槛和折扣率,却不知道折扣应用于全部商品还是仅限前几件。让它随便提供一个默认算法会掩盖未完成的设计,因此把操作声明为纯虚函数:
class DiscountQuote : public Quote {
public:
DiscountQuote(std::string sku, double price,
std::size_t threshold, double discount)
: Quote(std::move(sku), price),
threshold_(threshold), discount_(discount) {}
double net_price(std::size_t
= 0 使 net_price 成为纯虚函数,DiscountQuote 因而是抽象类:
// DiscountQuote unfinished{"X", 80.0, 10, 0.25}; // 编译错误
BulkQuote finished{"X", 80.0, 10, 0.25}; // 可以创建抽象不等于“没有实现”。抽象基类可以有数据成员、构造函数、普通成员函数,甚至可以为纯虚函数在类外提供定义。它只表示当前类型仍缺少创建完整对象所需的具体行为。
如果基类只定义协议而没有共享状态,它可以非常小:
class PriceRule {
public:
virtual double apply(std::size_t quantity, double unit_price) const = 0;
virtual ~PriceRule() = default;
};这样的接口让调用者依赖稳定的操作集合,让每个具体类保存自己的状态。抽象层应当来自真实的共同概念,不要为了“看起来面向对象”先造一个空泛基类。
还有一个析构细节:纯虚析构函数也必须有定义,因为销毁派生对象时最终仍会执行基类析构。多数接口直接写 virtual ~Base() = default; 更清楚。

派生对象的基类部分必须先有效,派生层才能使用它。构造顺序是:
析构顺序完全相反。先执行最派生层析构函数体,再销毁成员,最后销毁基类子对象。
下面的程序同时验证构造顺序和虚析构:
#include <iostream>
#include <memory>
class TraceQuote {
public:
TraceQuote() { std::cout << "TraceQuote ctor\n"; }
virtual double net_price() const { return 80.0; }
virtual ~TraceQuote() {
std::cout << "TraceQuote dtor\n";
}
};
输出为:
TraceQuote ctor
TraceBulk ctor
price=60
TraceBulk dtor
TraceQuote dtorstd::unique_ptr<TraceQuote> 只公开基类指针类型,但实际对象是 TraceBulk。离开作用域时,它经基类指针删除对象。基类析构是虚函数,所以先执行 TraceBulk 析构,再执行 TraceQuote 析构。
若多态基类析构不是虚函数,经基类指针删除派生对象会产生未定义行为。不能通过某次运行“好像没出错”来证明安全。常用声明是:
virtual ~Quote() = default;构造函数和析构函数中的虚调用还有一条边界:分派只到当前正在构造或析构的层级。基类构造期间,派生部分尚不存在;基类析构期间,派生部分已经销毁。因此不要让基类构造或析构依赖派生覆盖完成工作。
“有虚函数却没有虚析构”的基类是高风险接口。只要对象可能经基类指针拥有并释放,就应立即明确析构策略,而不是把责任留给调用者猜。
访问控制回答“谁能直接使用这个名字”。它与继承方式相关,但不是同一个问题。
公有继承会让基类的公有接口继续对派生类用户公开,同时让基类的受保护成员保持受保护。它正好支持“派生对象可当作基类使用”。
protected 的“有条件访问”比表格看起来更严格。派生类不能借任意基类对象读取受保护成员:
class Quote {
protected:
double unit_price_ = 0.0;
};
class BulkQuote : public Quote {
public:
double compare_with(const BulkQuote& other) const {
return other.unit_price_; // 可以:对象表达式属于当前派生类型
}
double inspect(const Quote& other) const
这条规则阻止一个派生类把基类的受保护状态变成面向所有基类对象的后门。
更稳妥的设计通常让数据保持 private,只给派生实现提供窄的受保护操作:
class Quote {
protected:
double unit_price() const noexcept { return price_; }
private:
double price_ = 0.0;
};这样派生类依赖“读取单价”这项能力,而不是依赖字段名称和存储方式。以后把价格换成整数分、带币种值类型或延迟加载,修改面会小得多。
private 继承会把基类公有接口变成派生实现细节,不再表达公开的 is-a。它有专门用途,但普通业务复用通常用成员对象更清楚。
对象切片发生在派生对象被按值复制成基类对象时。只复制基类子对象,派生状态不再属于新对象。

BulkQuote bulk{"BULK-1", 80.0, 10, 0.25};
const Quote& reference = bulk;
std::cout << reference.net_price(12) << '\n'; // 720:保留动态类型
Quote sliced = bulk;
std::cout << sliced.net_price(12) << '\n按值形参也会切片:
void print_bad(Quote item); // 会复制成 Quote
void print_good(const Quote& item); // 保留动态类型基类值容器同样如此:
std::vector<Quote> wrong;
wrong.push_back(bulk); // 保存的是切片后的 Quote多态容器应使用间接存储:
std::vector<std::unique_ptr<Quote>> items;每个槽位都是同一种智能指针,但所指对象可以是不同派生类型。所有权也变得清楚:元素离开容器时自动经虚析构释放。
不要把 std::vector<BulkQuote*> 转成 std::vector<Quote*>。即使单个 BulkQuote* 能向上转换,两个模板实例也没有这种关系。否则接收 vector<Quote*> 的函数可以插入 LimitedQuote*,原来的批量报价容器就被破坏了。
指针容器避免了切片,却没有自动解决深复制。只复制 unique_ptr 不允许,共享地址也不是复制对象。常见协议是虚 clone():
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Quote {
public:
Quote(std::string sku, double price)
: sku_(std::move(sku)), price_(price) {}
const std
输出为:
REG-1 | regular | 160.00
BULK-1 | bulk | 720.00
total = 880.00Basket::add 只接收 const Quote&,但虚 clone() 会在运行时进入正确的复制函数。普通报价复制成 Quote,批量报价复制成 BulkQuote,折扣状态没有丢失。
设计多态基类时,可以按下面的顺序检查:
clone()。override,真正封闭的位置是否带 final。为报价系统增加 WeekendQuote:周末购买超过 3 件时总价打 9 折。要求它能经 Quote& 计价、能放入 Basket、能正确深复制,并且不允许更下层类再次改变计价规则。