继承、多态与 virtual 基础:C++ 如何表达可替换行为
C++ 的继承和多态用于表达“不同类型可以以同一接口被使用”。 它不是代码复用的万能工具。 继承会建立类型层级,虚函数会引入运行时派发,基类析构会影响资源释放。 初学阶段要先理解可替换行为,再决定是否需要继承。
基类和派生类
class Shape {
public:
double x = 0;
double y = 0;
};
class Circle : public Shape {
public:
double radius = 1;
};
Circle 继承 Shape。
它拥有 Shape 的 public 成员,也有自己的 radius。
Circle c;
c.x = 10;
c.radius = 5;
这种继承表达“Circle 是一种 Shape”。 如果只是“包含一个 Shape”,组合可能更合适。
public 继承表达 is-a
public 继承意味着派生类对象可以被当作基类对象使用。
void move_to_origin(Shape& shape) {
shape.x = 0;
shape.y = 0;
}
Circle circle;
move_to_origin(circle);
这只有在派生类确实满足基类契约时才合理。 如果只是复用字段,不要用 public 继承。
成员访问控制
| 访问控制 | 类外访问 | 派生类访问 |
|---|---|---|
public |
可以 | 可以 |
protected |
不可以 | 可以 |
private |
不可以 | 不可以 |
protected 不是默认好选择。
它会让派生类依赖基类内部实现。
基类内部改动会影响所有派生类。
虚函数实现运行时多态
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
class Circle : public Shape {
public:
explicit Circle(double r) : radius_(r) {}
double area() const override {
return 3.14159 * radius_ * radius_;
}
private:
double radius_;
};
area 是虚函数。
通过基类引用调用时,会根据对象真实类型选择实现。
void print_area(const Shape& shape) {
std::cout << shape.area() << '\n';
}
这就是多态。
纯虚函数定义接口
= 0 表示纯虚函数。
包含纯虚函数的类通常不能直接实例化。
class Renderer {
public:
virtual ~Renderer() = default;
virtual void render() = 0;
};
它表达接口。 具体类实现接口。
class OpenGLRenderer : public Renderer {
public:
void render() override;
};
接口应该小而稳定。 不要把大量不相关能力塞进同一个基类。
override 防止签名写错
class Circle : public Shape {
public:
double area() const override;
};
override 告诉编译器:这个函数必须覆写基类虚函数。
如果签名写错,编译器会报错。
没有 override 时,可能意外定义了新函数,导致多态不生效。
工程代码应坚持使用 override。
基类析构函数必须考虑 virtual
如果可能通过基类指针删除派生对象,基类析构函数必须是 virtual。
class Base {
public:
virtual ~Base() = default;
};
否则:
Base* p = new Derived();
delete p;
可能只调用基类析构,派生类资源无法释放。 这是资源释放风险。
对象切片
按值传递基类会丢掉派生部分。
void draw(Shape shape); // 高风险
传入 Circle 时,只复制 Shape 子对象。
动态类型信息丢失。
多态对象应通过引用、指针或智能指针传递。
void draw(const Shape& shape);
组合优先于继承
如果关系不是“是一种”,而是“拥有一个”,用组合。
class Window {
public:
void draw();
private:
Renderer& renderer_;
};
组合让依赖更明确。 继承会把基类接口和实现细节带进派生类。 大多数复用场景不需要继承。
final 表达不再派生
class Circle final : public Shape {
public:
double area() const override;
};
final 表示不能再继承。
它能表达设计边界,也可能帮助优化器去虚化。
不要随意给公共扩展点加 final。
抽象层级要浅
继承层级过深会带来问题:
- 构造析构顺序难理解。
- 虚函数覆写关系难审计。
- 基类变化影响范围大。
- 测试替身复杂。
- ABI 更不稳定。
优先使用小接口和组合。 不要把类型层级设计成家谱。
多态和性能
虚函数调用通常需要间接跳转。 这可能影响内联和分支预测。 但多数业务场景不应该先优化它。
性能敏感路径要用 profile 判断。 可能选择:
- 虚函数。
- 模板静态多态。
std::variant。- 函数对象。
- 手写函数表。
抽象方式取决于集合是否开放、是否跨 ABI、是否性能敏感。
工程风险
继承和多态常见风险:
- 基类析构非 virtual。
- 忘写
override。 - 构造函数或析构函数中调用依赖派生状态的虚函数。
- 按值传递多态对象导致切片。
- 用继承复用实现而不是表达 is-a。
protected暴露过多内部状态。- 层级过深导致审计困难。
- 跨动态库暴露 C++ 多态类造成 ABI 风险。
这些问题会在后续虚表和 ABI 文章里继续深入。
小结
继承表达类型替代关系。 虚函数表达运行时可替换行为。 多态让代码依赖接口而不是具体实现。 但继承也会带来生命周期、资源释放、性能和 ABI 风险。 C++ 里更稳妥的原则是:能组合就组合,确实需要开放运行时行为时再使用虚接口。