C++ 动态多态详解

前言

"多态"是 C++ 面向对象编程中最重要的概念之一,也是初次接触时最容易和继承、重载、模板混在一起的概念。

这篇文章不从术语定义开始死记,而是沿着一个实际问题逐步推导:

text 复制代码
我们想用同一套代码处理不同种类的对象
    ↓
普通函数为什么做不到自动选择行为
    ↓
virtual怎样让调用在运行时发生分派
    ↓
如何用抽象类设计统一接口
    ↓
怎样正确管理多态对象的生命周期
    ↓
编译器通常怎样通过虚函数表实现它
    ↓
多继承、RTTI、智能指针和工程设计
    ↓
什么时候应该使用动态多态,什么时候不应该

理解完整条主线后,virtual 就不再是一个孤立的关键字,而是一整套类型抽象、运行时分派和对象生命周期机制的入口。


第一章:什么是多态

1.1 从"同一个动作,不同表现"说起

Polymorphism,中文叫"多态"。

这个单词可以拆成:

text 复制代码
poly  = 多种
morph = 形态

在程序设计中,多态可以通俗地理解为:

使用同一种调用方式,面对不同类型的对象时,表现出不同的行为。

例如,程序中有三种图形:

text 复制代码
圆形 Circle
矩形 Rectangle
三角形 Triangle

它们都可以执行"绘制"操作:

cpp 复制代码
circle.draw();
rectangle.draw();
triangle.draw();

调用形式都叫 draw(),但实际绘制过程不同:

text 复制代码
Circle::draw()     绘制圆形
Rectangle::draw()  绘制矩形
Triangle::draw()   绘制三角形

"相同接口,不同实现"就是多态最直观的表现。

1.2 C++ 中常见的两类多态

C++ 中常见的多态可以分成两大类:

text 复制代码
静态多态 Static Polymorphism
    在编译期决定调用哪个实现

动态多态 Dynamic Polymorphism
    在运行时决定调用哪个实现

这里的"静态"和"动态"不是说对象会不会移动,而是在说:

编译器在什么时候确定最终调用目标?


第二章:先简单区分静态多态与动态多态

2.1 函数重载属于静态多态

Overload,中文叫"重载"。

cpp 复制代码
#include <iostream>
#include <string>

void print(int value) {
    std::cout << "整数:" << value << '\n';
}

void print(const std::string& value) {
    std::cout << "字符串:" << value << '\n';
}

int main() {
    print(10);
    print(std::string{"hello"});
}

编译器看到参数类型后,就能在编译阶段决定:

text 复制代码
print(10)
    → print(int)

print(std::string{"hello"})
    → print(const std::string&)

程序运行前,调用目标已经确定,因此属于静态多态。

2.2 模板也可以实现静态多态

cpp 复制代码
template<class T>
void drawObject(const T& object) {
    object.draw();
}

调用:

cpp 复制代码
drawObject(circle);
drawObject(rectangle);

编译器会分别生成适用于 CircleRectangle 的代码。选择仍然发生在编译期。

2.3 动态多态解决什么问题

假设程序运行时才知道应该创建哪一种图形:

text 复制代码
用户输入1 → 创建圆形
用户输入2 → 创建矩形
配置文件变化 → 创建另一种图形
插件加载成功 → 创建插件提供的图形

此时对象的具体类型可能无法在编译时确定。

我们希望把不同对象统一放在一起:

text 复制代码
一组图形
    Circle
    Rectangle
    Triangle

然后只写一次循环:

cpp 复制代码
for (const auto& shape : shapes) {
    shape->draw();
}

循环不关心每个对象究竟是什么图形。运行到某个对象时,再调用该对象真正的 draw()

这就是动态多态要解决的核心问题。


第三章:没有 virtual 时发生了什么

3.1 先建立继承关系

Inheritance,中文叫"继承"。

先定义一个公共基类:

cpp 复制代码
#include <iostream>

class Shape {
public:
    void draw() const {
        std::cout << "绘制普通图形\n";
    }
};

class Circle : public Shape {
public:
    void draw() const {
        std::cout << "绘制圆形\n";
    }
};

这里:

text 复制代码
Shape   → 基类 Base Class
Circle  → 派生类 Derived Class

public Shape 表示 Circle 公开继承 Shape

从类型关系上可以说:

text 复制代码
Circle is a Shape
圆形是一种图形

公开继承会保留这种"是一种"的对外关系,因此外部代码可以把 Circle* 转换成 Shape*protectedprivate 继承主要用于实现复用,不会向普通外部代码公开同样的替换关系,通常不用于建立常规多态接口。

3.2 基类指针可以指向派生类对象

cpp 复制代码
Circle circle;
Shape* pointer = &circle;

这种转换叫:

text 复制代码
Upcasting
= 向上转换

把更具体的 Circle* 转换成更抽象的 Shape*,通常可以隐式完成。

现在调用:

cpp 复制代码
pointer->draw();

输出却是:

text 复制代码
绘制普通图形

为什么不是"绘制圆形"?

因为基类的 draw() 还不是虚函数。编译器主要根据表达式的静态类型 Shape* 决定调用:

cpp 复制代码
Shape::draw()

这种在编译期确定调用目标的方式叫:

text 复制代码
Static Binding
= 静态绑定
= Early Binding
= 早绑定

第四章:virtual 让调用目标推迟到运行时

4.1 第一个完整的动态多态程序

只需要把基类函数声明为虚函数:

cpp 复制代码
#include <iostream>

class Shape {
public:
    virtual void draw() const {
        std::cout << "绘制普通图形\n";
    }

    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "绘制圆形\n";
    }
};

class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "绘制矩形\n";
    }
};

void paint(const Shape& shape) {
    shape.draw();
}

int main() {
    Circle circle;
    Rectangle rectangle;

    paint(circle);
    paint(rectangle);
}

输出:

text 复制代码
绘制圆形
绘制矩形

paint() 的参数始终是:

cpp 复制代码
const Shape& shape

但它接收到 Circle 时调用 Circle::draw(),接收到 Rectangle 时调用 Rectangle::draw()

这种机制叫:

text 复制代码
Dynamic Binding
= 动态绑定

Runtime Dispatch
= 运行时分派

Late Binding
= 晚绑定

4.2 实现经典动态多态的三个条件

教科书中通常把经典动态多态总结为三个条件:

  1. 存在继承关系。
  2. 基类声明虚函数。
  3. 通过基类指针或基类引用调用虚函数。
cpp 复制代码
Circle circle;

Shape* pointer = &circle;
Shape& reference = circle;

pointer->draw();   // Circle::draw()
reference.draw();  // Circle::draw()

更准确地说,关键是:

对多态对象进行虚函数调用时,调用目标可以根据对象的动态类型决定。

即使直接通过对象表达式调用虚函数,语言规则仍然保留虚调用语义;只是当编译器明确知道对象类型时,往往可以直接确定目标并进行优化。


第五章:静态类型、动态类型与动态分派

5.1 两种类型必须分清

看下面的代码:

cpp 复制代码
Circle circle;
Shape* pointer = &circle;

pointer 涉及两种类型:

text 复制代码
静态类型 Static Type
    Shape*
    编译器从变量声明中直接看到的类型

动态类型 Dynamic Type
    Circle
    pointer当前真正指向对象的类型

调用普通成员函数时,静态类型非常重要。

调用虚函数时,运行时还会考虑动态类型。

cpp 复制代码
pointer->draw();

可以通俗地理解为:

text 复制代码
编译器看到pointer是Shape*
    ↓
确认draw是虚函数
    ↓
运行时查看pointer真正指向什么对象
    ↓
发现真实对象是Circle
    ↓
调用Circle::draw()

5.2 基类引用同样保留动态类型

cpp 复制代码
Circle circle;
Shape& shape = circle;

shape.draw(); // Circle::draw()

引用没有复制对象,它只是给原对象提供另一个访问入口,因此原对象仍然是 Circle


第六章:overridefinal 与正确覆盖

6.1 什么是覆盖

Override,中文常叫"覆盖"或"重写"。

派生类使用相同函数签名,重新实现基类虚函数:

cpp 复制代码
class Shape {
public:
    virtual void draw() const;
};

class Circle : public Shape {
public:
    void draw() const override;
};

基类第一次声明时写 virtual。派生类中的函数即使不再写 virtual,仍然是虚函数:

cpp 复制代码
void draw() const;

但强烈推荐写:

cpp 复制代码
void draw() const override;

因为 override 会让编译器检查"这个函数是否真的覆盖了基类虚函数"。

6.2 一个容易发现不了的错误

cpp 复制代码
class Shape {
public:
    virtual void draw() const;
};

class Circle : public Shape {
public:
    void draw() override; // 错误:少了const
};

Circle::draw()Shape::draw() const 不是同一个函数签名。

写了 override 后,编译器立即报错。如果不写,程序可能悄悄定义出一个新的同名函数,导致通过基类引用调用时仍然进入基类实现。

常见的不匹配原因包括:

  • 参数类型不同;
  • 参数数量不同;
  • 成员函数末尾的 const 不同;
  • 引用限定符不同;
  • noexcept 规则不兼容。

关于 noexcept,可以先记住:

text 复制代码
基类虚函数承诺noexcept
    派生类覆盖不能取消这个承诺

基类虚函数没有承诺noexcept
    派生类可以给出更严格的noexcept承诺

例如:

cpp 复制代码
class Base {
public:
    virtual void run() noexcept;
};

class Derived : public Base {
public:
    void run() noexcept override;
};

6.3 final 禁止继续覆盖

Final,中文可以理解为"最终的、不可继续扩展的"。

禁止更下层派生类继续覆盖某个函数:

cpp 复制代码
class Circle : public Shape {
public:
    void draw() const final;
};

上面的面积计算使用了 C++20 的 <numbers>。如果使用较早标准,可以自行定义合适精度的圆周率常量。

禁止一个类继续被继承:

cpp 复制代码
class Circle final : public Shape {
};

final 可以表达清晰的设计意图,也可能帮助编译器判断虚函数的最终目标。


第七章:重载、覆盖与名字隐藏

这三个概念名称相近,但不是一回事。

7.1 重载:同一作用域中的同名函数

Overload,重载:

cpp 复制代码
void print(int value);
void print(double value);

编译器根据参数决定调用哪个版本。

7.2 覆盖:派生类重新实现虚函数

Override,覆盖:

cpp 复制代码
class Base {
public:
    virtual void print(int value);
};

class Derived : public Base {
public:
    void print(int value) override;
};

7.3 隐藏:派生类同名声明遮住基类名字

Name Hiding,中文叫"名字隐藏"。

cpp 复制代码
class Base {
public:
    void print(int value);
    void print(double value);
};

class Derived : public Base {
public:
    void print(const char* value);
};

Derived 中的同名 print 会隐藏基类的整组 print

cpp 复制代码
Derived object;
object.print(10); // 可能无法找到期望的Base::print(int)

可以使用 using 把基类重载重新引入:

cpp 复制代码
class Derived : public Base {
public:
    using Base::print;

    void print(const char* value);
};

记忆方法:

text 复制代码
重载:同一个作用域,参数不同
覆盖:继承体系中,重新实现虚函数
隐藏:派生类同名声明遮住基类名字

第八章:为什么多态通常使用指针或引用

8.1 按值复制会发生对象切片

cpp 复制代码
Circle circle;
Shape shape = circle;

这叫:

text 复制代码
Object Slicing
= 对象切片

可以把派生类对象想象成:

text 复制代码
Circle对象
┌─────────────────┐
│ Shape基类部分   │
├─────────────────┤
│ Circle派生部分  │
└─────────────────┘

执行:

cpp 复制代码
Shape shape = circle;

目标对象的类型就是 Shape,只能容纳基类部分:

text 复制代码
shape对象
┌─────────────────┐
│ Shape基类部分   │
└─────────────────┘

Circle 的派生部分被"切掉"了。

因此:

cpp 复制代码
shape.draw();

调用的是 Shape::draw(),因为 shape 已经是一个真正、独立的 Shape 对象。

8.2 多态参数不要按值接收

错误或不符合多态目的的写法:

cpp 复制代码
void paint(Shape shape) {
    shape.draw();
}

传入 Circle 时会复制成 Shape,产生切片。

正确做法通常是:

cpp 复制代码
void paint(const Shape& shape) {
    shape.draw();
}

如果允许空值,也可以使用指针:

cpp 复制代码
void paint(const Shape* shape) {
    if (shape != nullptr) {
        shape->draw();
    }
}

8.3 多态容器不能直接存基类对象

cpp 复制代码
std::vector<Shape> shapes;
shapes.push_back(Circle{});

这里同样发生对象切片。

多态集合通常存储智能指针:

cpp 复制代码
std::vector<std::unique_ptr<Shape>> shapes;

后面会详细讲解。


第九章:纯虚函数、抽象类与接口

9.1 基类一定要提供默认实现吗

并不是所有图形都存在一种有意义的"通用绘制方法"。

如果 Shape 只负责规定接口,不提供默认实现,可以把函数声明为纯虚函数:

cpp 复制代码
class Shape {
public:
    virtual void draw() const = 0;
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

Pure Virtual Function,中文叫"纯虚函数"。

语法:

cpp 复制代码
virtual 返回类型 函数名(参数) = 0;

这里的 = 0 不是把函数赋值为数字零,而是 C++ 表示纯虚函数的特殊语法。

9.2 什么是抽象类

Abstract Class,中文叫"抽象类"。

只要类中还有纯虚函数没有得到最终实现,这个类就是抽象类,不能直接创建对象:

cpp 复制代码
Shape shape; // 编译错误

但可以声明它的指针和引用:

cpp 复制代码
Shape* pointer = nullptr;
Shape& reference = someShape;

派生类实现所有纯虚函数后,才能成为可实例化的具体类:

cpp 复制代码
#include <numbers>

class Circle : public Shape {
public:
    explicit Circle(double radius)
        : radius_(radius) {}

    void draw() const override {
        std::cout << "绘制圆形\n";
    }

    double area() const override {
        return std::numbers::pi
             * radius_
             * radius_;
    }

private:
    double radius_;
};

9.3 接口类

Interface,中文叫"接口"。

C++ 没有单独的 interface 关键字。通常用只包含抽象操作和虚析构函数的抽象类表达接口:

cpp 复制代码
class Drawable {
public:
    virtual void draw() const = 0;
    virtual ~Drawable() = default;
};

接口主要说明"对象能做什么",而不关心对象内部怎样完成。

9.4 纯虚函数也可以拥有定义

纯虚函数仍然可以在类外提供函数体:

cpp 复制代码
class Shape {
public:
    virtual void draw() const = 0;
};

void Shape::draw() const {
    std::cout << "执行公共绘制准备工作\n";
}

派生类仍然需要覆盖它,才能让类变为具体类;但派生类可以显式调用这个公共实现:

cpp 复制代码
void Circle::draw() const {
    Shape::draw();
    std::cout << "绘制圆形\n";
}

这不是最常用的写法,但它说明:

text 复制代码
"纯虚"主要表示派生类必须提供最终覆盖,
不等于这个函数绝对不能有定义。

第十章:虚析构函数------多态对象生命周期的底线

10.1 危险场景

假设派生类持有资源:

cpp 复制代码
class Shape {
public:
    virtual void draw() const = 0;

    ~Shape() {
        std::cout << "销毁Shape\n";
    }
};

class Circle : public Shape {
public:
    Circle()
        : data_(new int[100]) {}

    ~Circle() {
        delete[] data_;
        std::cout << "销毁Circle\n";
    }

    void draw() const override {}

private:
    int* data_;
};

然后:

cpp 复制代码
Shape* shape = new Circle;
delete shape;

如果基类析构函数不是虚函数,通过基类指针删除派生类对象会产生:

text 复制代码
Undefined Behavior
= 未定义行为

程序可能只执行 Shape 析构函数,派生类资源不能正确释放;也可能出现其他不可预测结果。

10.2 正确写法

cpp 复制代码
class Shape {
public:
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

现在:

cpp 复制代码
Shape* shape = new Circle;
delete shape;

析构顺序为:

text 复制代码
Circle::~Circle()
    ↓
Shape::~Shape()

常用设计原则是:

如果一个类准备作为多态基类使用,通常应当提供 public 虚析构函数。

10.3 什么时候可以使用受保护的非虚析构函数

还有一种较少见但合理的设计:

cpp 复制代码
class Base {
protected:
    ~Base() = default;
};

它表达:

text 复制代码
允许继承Base,
但禁止外部通过Base*执行delete。

可以把常见原则总结成:

多态基类的析构函数要么 public 且 virtual,要么 protected 且 non-virtual。

这条经验通常称为:

text 复制代码
Public virtual or protected non-virtual destructor
= 公开虚析构,或者受保护的非虚析构

10.4 纯虚析构函数也必须有定义

析构函数可以声明为纯虚函数:

cpp 复制代码
class Shape {
public:
    virtual ~Shape() = 0;
};

但必须在类外提供定义:

cpp 复制代码
Shape::~Shape() = default;

原因是销毁派生类对象时,最后仍然需要执行基类析构部分。


第十一章:构造和析构期间为什么不能依赖动态多态

11.1 基类构造期间,派生类还没有准备好

cpp 复制代码
class Base {
public:
    Base() {
        run();
    }

    virtual void run() {
        std::cout << "Base::run\n";
    }
};

class Derived : public Base {
public:
    void run() override {
        std::cout << "Derived::run\n";
    }
};

创建:

cpp 复制代码
Derived object;

基类构造函数中的 run() 调用的是:

text 复制代码
Base::run

而不是 Derived::run

构造顺序是:

text 复制代码
先构造Base部分
    ↓
再构造Derived部分

执行 Base 构造函数时,Derived 的成员尚未构造完成。如果此时进入 Derived::run(),它可能访问尚未初始化的成员,因此 C++ 限制了构造期间的虚分派。

11.2 析构期间也是同样道理

析构顺序与构造相反:

text 复制代码
先析构Derived部分
    ↓
再析构Base部分

进入 Base 析构函数时,Derived 部分已经被销毁,因此虚调用不会重新进入派生类实现。

11.3 实用建议

不要在构造函数和析构函数中依赖虚函数完成派生类特有行为。

尤其不要在构造或析构期间调用没有可用基类实现的纯虚函数。这种设计可能导致未定义行为、链接错误或运行时终止。

如果初始化过程确实需要派生类参与,可以考虑:

  • 构造完成后显式调用初始化函数;
  • 使用工厂函数分阶段创建;
  • 把所需数据作为构造参数传给基类;
  • 使用非虚辅助函数完成基类自己的初始化。

第十二章:编译器通常怎样实现虚函数

12.1 vptrvtable

主流 C++ 编译器通常使用两个结构实现动态分派:

text 复制代码
vptr
= Virtual Table Pointer
= 虚函数表指针

vtable
= Virtual Function Table
= 虚函数表

可以把多态对象的常见布局粗略理解成:

text 复制代码
Circle对象
┌────────────────────────┐
│ vptr ─────────────────────→ Circle的虚函数表
│ Shape基类成员          │       draw → Circle::draw
│ Circle成员radius_      │       area → Circle::area
└────────────────────────┘       析构 → Circle::~Circle

虚函数表通常保存一组函数地址。同一种动态类型的对象通常共享对应的虚函数表,每个对象内部保存指向表的指针。

12.2 一次虚函数调用的简化过程

cpp 复制代码
Shape* shape = new Circle{2.0};
shape->draw();

可以粗略理解为:

text 复制代码
1. 通过shape找到Circle对象
2. 从对象中读取vptr
3. 通过vptr找到Circle的虚函数表
4. 在draw对应的槽位取得函数地址
5. 调用Circle::draw()

虚函数在不同虚函数表中通常占据相应位置:

text 复制代码
Shape虚函数表
槽位0 → Shape::draw
槽位1 → Shape::area
槽位2 → Shape析构

Circle虚函数表
槽位0 → Circle::draw
槽位1 → Circle::area
槽位2 → Circle析构

这解释了为什么调用方只知道 Shape*,仍然能够找到 Circle 的实现。

12.3 这是常见实现,不是标准强制布局

C++ 标准规定动态多态应该表现出什么行为,但通常不规定对象必须怎样布局。

因此:

text 复制代码
vptr和vtable
    是主流编译器的常见实现方式

具体放在哪里、有几个、怎样排列
    属于编译器和ABI的实现细节

ABI 是:

text 复制代码
Application Binary Interface
= 应用二进制接口

它规定编译后的模块怎样在二进制层面协作,例如对象布局、函数调用约定、名字修饰和虚函数表结构。

12.4 虚函数带来的典型成本

常见成本包括:

  • 对象中可能增加一个或多个虚函数表指针;
  • 调用虚函数通常多一次间接寻址;
  • 间接调用可能增加内联难度;
  • 多态对象经常经由指针访问,可能降低缓存局部性。

但"有虚函数就一定很慢"是不准确的。

现代编译器可能执行:

text 复制代码
Devirtualization
= 去虚拟化

如果编译器能够证明真实类型,就可能把虚调用优化成普通直接调用,甚至继续内联。

例如:

cpp 复制代码
Circle circle;
circle.draw();

编译器明确知道对象是 Circle,通常不需要真的查虚函数表。

性能是否重要,应通过实际测量判断,而不是看到 virtual 就下结论。


第十三章:虚函数中的特殊语言规则

13.1 默认参数不是动态绑定

虚函数本体可以动态绑定,但默认参数根据调用点看到的静态类型决定。

cpp 复制代码
class Base {
public:
    virtual void print(int value = 1) const {
        std::cout << "Base: " << value << '\n';
    }
};

class Derived : public Base {
public:
    void print(int value = 2) const override {
        std::cout << "Derived: " << value << '\n';
    }
};

Derived object;
Base& reference = object;
reference.print();

输出:

text 复制代码
Derived: 1

原因:

text 复制代码
函数体
    根据动态类型选择Derived::print

默认参数
    根据静态类型Base&选择1

因此不要在覆盖函数中修改默认参数。更稳妥的做法是让默认值只出现在对外的非虚接口中。

13.2 访问权限与虚函数分派是两回事

派生类可以覆盖基类的 private 虚函数:

cpp 复制代码
class Base {
public:
    void run() {
        execute();
    }

private:
    virtual void execute() {
        std::cout << "Base\n";
    }
};

class Derived : public Base {
private:
    void execute() override {
        std::cout << "Derived\n";
    }
};
cpp 复制代码
Derived object;
object.run(); // Derived

访问控制检查"调用者能不能直接访问这个名字",虚分派决定"已经形成虚调用后进入哪个最终覆盖函数"。两者不是同一阶段。

这种写法引出了:

text 复制代码
NVI
= Non-Virtual Interface
= 非虚接口模式

基类提供稳定的 public 非虚函数,内部调用 protected 或 private 虚函数,把流程控制和可定制步骤分开。

13.3 限定名调用会关闭动态分派

cpp 复制代码
Circle circle;
Shape& shape = circle;

shape.draw();        // 动态分派到Circle::draw
shape.Shape::draw(); // 显式调用Shape::draw

写出 Shape::draw() 相当于明确指定目标,不再进行正常动态分派。

13.4 构造函数不能是虚函数

创建对象之前必须先知道要分配多大空间、使用哪种布局,因此构造函数不能声明为虚函数:

cpp 复制代码
// 错误
virtual Shape();

如果运行时需要创建不同类型对象,通常使用工厂函数,后面会讲。

13.5 静态成员函数不能是虚函数

虚函数依赖具体对象及其动态类型,静态成员函数不依赖任何对象,也没有 this 指针,因此不能是虚函数:

cpp 复制代码
// 错误
virtual static void create();

13.6 函数模板不能直接成为虚函数

cpp 复制代码
class Base {
public:
    template<class T>
    virtual void process(const T& value); // 错误
};

虚函数表需要一组可以确定的函数槽位,而函数模板可以针对无数类型产生不同实例,二者模型不匹配。

需要区分:

text 复制代码
类模板中的普通成员函数
    可以是虚函数

成员函数模板本身
    不能是虚函数

例如下面是允许的:

cpp 复制代码
template<class T>
class Base {
public:
    virtual void process(const T& value) = 0;
};

Base<int> 实例化时,process(const int&) 是确定的普通虚函数。

13.7 友元函数不能直接是虚函数

Friend Function,中文叫"友元函数"。

友元函数不是类的成员函数,而虚函数必须是非静态成员函数,所以友元函数不能直接声明为虚函数。


第十四章:协变返回类型与多态复制

14.1 什么是协变返回类型

Covariant Return Type,中文叫"协变返回类型"。

覆盖函数的返回类型通常需要一致,但类类型的指针或引用存在特殊规则:

cpp 复制代码
class Shape {
public:
    virtual Shape* cloneRaw() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    Circle* cloneRaw() const override {
        return new Circle(*this);
    }
};

基类返回 Shape*,派生类可以返回更具体的 Circle*

协变主要适用于相关类类型的原生指针或引用,不会自动扩展到所有模板包装类型。

14.2 构造函数不能虚,但复制需求仍然存在

假设只有一个基类指针:

cpp 复制代码
const Shape& shape = getShape();

我们不知道它实际是 Circle 还是 Rectangle,但希望复制出同样真实类型的新对象。

普通复制:

cpp 复制代码
Shape copy = shape;

会产生切片,而且抽象基类甚至不能这样创建。

常用解决方案是:

text 复制代码
Virtual Constructor Idiom
= 虚拟构造函数惯用法

它不是真的让构造函数变成虚函数,而是通过虚函数 clone() 创建正确类型。

14.3 使用智能指针实现 clone

cpp 复制代码
#include <memory>

class Shape {
public:
    virtual std::unique_ptr<Shape> clone() const = 0;
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    explicit Circle(double radius)
        : radius_(radius) {}

    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Circle>(*this);
    }

    void draw() const override {
        std::cout << "绘制圆形\n";
    }

private:
    double radius_;
};

使用:

cpp 复制代码
std::unique_ptr<Shape> copy =
    original->clone();

虽然接口返回 std::unique_ptr<Shape>,内部真正创建的是 Circle

unique_ptr<Circle> 可以移动转换成 unique_ptr<Shape>。这里不能把派生类返回类型直接改成 unique_ptr<Circle> 来形成协变,因为 unique_ptr<Circle>unique_ptr<Shape> 是两个不同模板实例,不属于语言规定的协变返回类型。


第十五章:用智能指针管理多态对象

15.1 为什么优先使用 unique_ptr

直接写:

cpp 复制代码
Shape* shape = new Circle{2.0};

需要手动:

cpp 复制代码
delete shape;

一旦中间发生异常、提前返回或控制流变复杂,就容易忘记释放。

更推荐:

cpp 复制代码
std::unique_ptr<Shape> shape =
    std::make_unique<Circle>(2.0);

unique_ptr 表示:

text 复制代码
Exclusive Ownership
= 独占所有权

同一时刻主要由一个 unique_ptr 负责对象生命周期,离开作用域时自动删除对象。

基类必须有虚析构函数,才能保证 unique_ptr<Shape> 正确删除 Circle

15.2 建立多态对象集合

cpp 复制代码
#include <memory>
#include <vector>

std::vector<std::unique_ptr<Shape>> shapes;

shapes.push_back(
    std::make_unique<Circle>(2.0)
);

shapes.push_back(
    std::make_unique<Rectangle>(3.0, 4.0)
);

for (const auto& shape : shapes) {
    shape->draw();
    std::cout << shape->area() << '\n';
}

容器保存的都是:

cpp 复制代码
std::unique_ptr<Shape>

但每个指针管理的真实对象可以不同。

text 复制代码
unique_ptr<Shape> → Circle
unique_ptr<Shape> → Rectangle
unique_ptr<Shape> → Triangle

这正是动态多态最常见的工程形态。

15.3 工厂函数

Factory Function,中文叫"工厂函数"。

它把"创建哪一种具体对象"的判断集中起来:

cpp 复制代码
#include <memory>
#include <stdexcept>
#include <string_view>

std::unique_ptr<Shape>
createShape(std::string_view type) {
    if (type == "circle") {
        return std::make_unique<Circle>(2.0);
    }

    if (type == "rectangle") {
        return std::make_unique<Rectangle>(
            3.0,
            4.0
        );
    }

    throw std::invalid_argument{
        "unknown shape type"
    };
}

调用方只依赖抽象接口:

cpp 复制代码
auto shape = createShape("circle");
shape->draw();

调用方不需要知道具体类的构造细节。

15.4 什么时候使用 shared_ptr

shared_ptr 表示:

text 复制代码
Shared Ownership
= 共享所有权

多个 shared_ptr 可以共同拥有一个对象:

cpp 复制代码
std::shared_ptr<Shape> shape =
    std::make_shared<Circle>(2.0);

auto anotherOwner = shape;

最后一个所有者销毁时,对象才被释放。

只有真正存在共享生命周期语义时才应使用 shared_ptr。不能因为"不知道谁负责释放"就默认使用共享所有权;那往往只是所有权设计不清晰。

还要注意循环引用:

text 复制代码
A通过shared_ptr拥有B
B又通过shared_ptr拥有A

双方引用计数永远不能归零。此时一方通常应使用:

text 复制代码
weak_ptr
= 弱引用智能指针

weak_ptr 可以观察 shared_ptr 管理的对象,但不增加强所有权计数。


第十六章:RTTI、dynamic_casttypeid

16.1 什么是 RTTI

RTTI 是:

text 复制代码
Run-Time Type Information
= 运行时类型信息

它让程序在运行时查询多态对象的真实类型,相关工具主要包括:

text 复制代码
dynamic_cast
typeid
std::type_info

16.2 向上转换通常是安全的

cpp 复制代码
Circle circle;
Shape* shape = &circle;

这是从派生类向基类转换,通常可以隐式完成。

16.3 向下转换可能失败

Downcasting,中文叫"向下转换"。

cpp 复制代码
Shape* shape = getShape();

假设 Circle 还提供了自己特有的 setRadius() 成员函数。如果想判断 shape 是否真的指向 Circle,可以使用:

cpp 复制代码
if (Circle* circle =
        dynamic_cast<Circle*>(shape)) {
    circle->setRadius(10.0);
}

指针版本转换失败时返回:

cpp 复制代码
nullptr

引用版本失败时抛出:

text 复制代码
std::bad_cast
cpp 复制代码
try {
    Circle& circle =
        dynamic_cast<Circle&>(*shape);

    circle.setRadius(10.0);
} catch (const std::bad_cast&) {
    std::cout << "不是Circle\n";
}

用于向下转换的基类通常必须是多态类型,也就是至少拥有一个虚函数。

16.4 为什么不能随意使用 static_cast 向下转换

cpp 复制代码
Shape* shape = new Rectangle{3.0, 4.0};

Circle* circle =
    static_cast<Circle*>(shape);

static_cast 不会进行这种运行时类型检查。shape 实际指向 Rectangle,却被当成 Circle 使用,后续行为可能产生未定义行为。

如果真实类型不确定,应使用 dynamic_cast;如果程序频繁需要向下转换,则应该重新检查接口设计。

16.5 typeid

cpp 复制代码
#include <typeinfo>

if (typeid(*shape) == typeid(Circle)) {
    std::cout << "真实类型是Circle\n";
}

当表达式引用的是多态对象时:

cpp 复制代码
typeid(*shape)

可以得到动态类型。

但:

cpp 复制代码
typeid(shape)

查询的是指针表达式本身的类型,也就是 Shape*,不是所指对象的动态类型。

16.6 少写类型判断,多设计虚函数

如果程序到处都是:

cpp 复制代码
if (dynamic_cast<Circle*>(shape)) {
    ...
} else if (
    dynamic_cast<Rectangle*>(shape)
) {
    ...
}

说明调用方仍然在手动区分每一种具体类型。

通常更好的方式是把变化行为放进虚函数:

cpp 复制代码
shape->draw();
shape->area();

不过 dynamic_cast 并非绝对错误。它在插件接口、编辑器对象查询、多继承横向转换和确实需要派生类能力的边界位置仍然有合理用途。

16.7 智能指针怎样安全转换

对于 shared_ptr,标准库提供了配套转换函数:

cpp 复制代码
std::shared_ptr<Shape> shape =
    std::make_shared<Circle>(2.0);

std::shared_ptr<Circle> circle =
    std::dynamic_pointer_cast<Circle>(
        shape
    );

if (circle) {
    circle->setRadius(10.0);
}

std::dynamic_pointer_cast 内部进行安全的运行时类型检查。失败时返回空 shared_ptr,成功时得到一个共享同一控制块的新智能指针。

不要为了转换而写出:

cpp 复制代码
// 不推荐:容易制造重复所有权和重复释放
std::shared_ptr<Circle>(
    static_cast<Circle*>(shape.get())
);

对于 unique_ptr,标准库没有完全对应的 dynamic_pointer_cast。因为转换失败时必须明确谁继续拥有原对象,所有权处理更敏感。通常应优先通过良好的虚函数接口避免向下转换;确实需要转换时,要专门设计清晰的所有权转移函数。

16.8 多继承中的横向转换

Cross Cast,中文可以叫"横向转换"。

假设一个具体对象同时实现两个接口:

cpp 复制代码
class Drawable {
public:
    virtual void draw() const = 0;
    virtual ~Drawable() = default;
};

class Serializable {
public:
    virtual void save() const = 0;
    virtual ~Serializable() = default;
};

class Document
    : public Drawable,
      public Serializable {
public:
    void draw() const override {}
    void save() const override {}
};

已知一个 Drawable*,可以询问同一个真实对象是否也实现 Serializable

cpp 复制代码
Drawable* drawable = getDrawable();

if (auto* serializable =
        dynamic_cast<Serializable*>(
            drawable
        )) {
    serializable->save();
}

转换不是沿着单条继承链简单向下走,而是在同一个最派生对象的不同基类接口之间移动,因此叫横向转换。


第十七章:多重继承、菱形继承与虚继承

17.1 多重继承

Multiple Inheritance,中文叫"多重继承"。

一个类可以同时继承多个基类:

cpp 复制代码
class Drawable {
public:
    virtual void draw() const = 0;
    virtual ~Drawable() = default;
};

class Serializable {
public:
    virtual void save() const = 0;
    virtual ~Serializable() = default;
};

class Document
    : public Drawable,
      public Serializable {
public:
    void draw() const override {
        std::cout << "绘制文档\n";
    }

    void save() const override {
        std::cout << "保存文档\n";
    }
};

这类"继承多个纯接口"的设计通常比继承多个带复杂状态的基类更容易控制。

17.2 多态子对象与指针调整

多重继承对象内部可能包含多个基类子对象:

text 复制代码
Document对象
┌─────────────────────┐
│ Drawable基类子对象  │
├─────────────────────┤
│ Serializable子对象  │
├─────────────────────┤
│ Document自己的成员  │
└─────────────────────┘

Document* 转成不同基类指针时,指针数值可能需要调整:

cpp 复制代码
Document document;

Drawable* drawable = &document;
Serializable* serializable = &document;

两个基类子对象未必位于相同地址。

编译器可能生成一小段辅助代码调整 this 指针,这类辅助代码常被称为:

text 复制代码
Thunk
= 调整跳板或辅助调用代码

具体布局属于 ABI 实现细节。

17.3 菱形继承问题

Diamond Inheritance,中文叫"菱形继承"。

text 复制代码
        Object
       /      \
   Drawable  Named
       \      /
        Widget

示例:

cpp 复制代码
class Object {
public:
    int id = 0;
};

class Drawable : public Object {};
class Named : public Object {};

class Widget
    : public Drawable,
      public Named {};

Widget 中包含两份 Object 基类子对象:

cpp 复制代码
Widget widget;

// 有歧义:到底访问哪一份Object::id?
// widget.id = 10;

17.4 虚继承解决共享基类问题

Virtual Inheritance,中文叫"虚继承"。

cpp 复制代码
class Drawable
    : virtual public Object {};

class Named
    : virtual public Object {};

现在 Widget 最终只共享一份 Object 子对象。

需要特别区分:

text 复制代码
虚函数 virtual function
    解决运行时函数分派

虚继承 virtual inheritance
    解决多条继承路径共享同一基类子对象

它们都使用 virtual 关键字,但解决的是完全不同的问题。

虚继承还会让对象布局和构造责任更复杂:最派生类负责初始化虚基类。


第十八章:单分派、双分派与访问者模式

18.1 普通虚函数是单分派

Dispatch,中文叫"分派",表示根据某些信息选择最终函数。

C++ 普通虚函数主要是:

text 复制代码
Single Dispatch
= 单分派

例如:

cpp 复制代码
shape->draw();

最终调用哪个 draw(),主要根据 shape 所指对象的动态类型决定。

18.2 行为同时依赖两个动态类型怎么办

假设要处理不同图形之间的碰撞:

text 复制代码
Circle与Circle碰撞
Circle与Rectangle碰撞
Rectangle与Rectangle碰撞

结果同时依赖左右两个对象的真实类型。

这种需求叫:

text 复制代码
Double Dispatch
= 双分派

C++ 没有直接内置通用的双分派语法,常见解决办法包括:

  • Visitor Pattern,访问者模式;
  • 两次虚函数调用;
  • dynamic_cast 组合;
  • std::variantstd::visit
  • 手工类型表。

18.3 访问者模式的核心思想

Visitor Pattern,中文叫"访问者模式"。

它通过两次动态调用,让操作同时感知对象类型和访问者类型。经典结构大致如下:

cpp 复制代码
class Circle;
class Rectangle;

class ShapeVisitor {
public:
    virtual void visit(const Circle&) = 0;
    virtual void visit(const Rectangle&) = 0;
    virtual ~ShapeVisitor() = default;
};

class Shape {
public:
    virtual void accept(
        ShapeVisitor& visitor
    ) const = 0;

    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void accept(
        ShapeVisitor& visitor
    ) const override {
        visitor.visit(*this);
    }
};

第一次分派:

text 复制代码
shape->accept(visitor)
    根据shape动态类型进入Circle::accept

第二次调用:

text 复制代码
visitor.visit(*this)
    选择visit(const Circle&)

访问者模式适合"类型集合相对稳定、操作经常增加"的场景,但代码结构较重,不应为了展示技巧而滥用。


第十九章:动态多态中的设计原则

19.1 多态不等于"有继承就行"

真正合理的继承应该表达:

text 复制代码
Derived is a Base
= 派生类确实是一种基类

如果只是想复用代码,组合往往更合适:

cpp 复制代码
class Report {
private:
    Formatter formatter_;
};

这叫:

text 复制代码
Composition
= 组合

常见经验是:

优先考虑组合;只有在需要真正的可替换类型关系时,再使用 public 继承。

19.2 里氏替换原则

LSP 是:

text 复制代码
Liskov Substitution Principle
= 里氏替换原则

通俗地说:

任何需要基类对象的地方,换成派生类对象后,程序的基本语义仍然应该合理。

例如基类承诺:

text 复制代码
resize(width, height)
可以独立修改宽和高

某个派生类却强制宽高永远相等,就可能破坏调用方预期。语法上能继承,不代表语义上适合继承。

19.3 开闭原则

OCP 是:

text 复制代码
Open-Closed Principle
= 开闭原则

含义是:

text 复制代码
对扩展开放
对修改关闭

有了稳定的 Shape 接口,增加 Triangle 时,绘制循环通常不必修改:

cpp 复制代码
for (const auto& shape : shapes) {
    shape->draw();
}

19.4 依赖倒置原则

DIP 是:

text 复制代码
Dependency Inversion Principle
= 依赖倒置原则

高层代码依赖抽象接口,不直接依赖具体实现:

cpp 复制代码
void render(const Shape& shape) {
    shape.draw();
}

render() 依赖 Shape 抽象,而不是写死 Circle

19.5 接口隔离原则

ISP 是:

text 复制代码
Interface Segregation Principle
= 接口隔离原则

不要让所有派生类被迫实现一个巨大接口。

与其:

cpp 复制代码
class Object {
public:
    virtual void draw() = 0;
    virtual void save() = 0;
    virtual void print() = 0;
    virtual void send() = 0;
};

不如拆分成:

text 复制代码
Drawable
Savable
Printable
Sendable

具体类型只实现自己真正支持的能力。


第二十章:动态多态常见设计模式

动态多态是许多经典设计模式的基础。

20.1 策略模式

Strategy Pattern,中文叫"策略模式"。

把可替换算法抽象成统一接口:

cpp 复制代码
class SortStrategy {
public:
    virtual void sort(
        std::vector<int>& values
    ) const = 0;

    virtual ~SortStrategy() = default;
};

程序运行时可以替换不同排序策略。

20.2 状态模式

State Pattern,中文叫"状态模式"。

把不同状态下的行为放进不同状态对象,而不是写一大串 if-else

20.3 模板方法模式

Template Method Pattern,中文叫"模板方法模式"。

这里的"模板"不是 C++ template 关键字,而是"流程模板"。

基类定义固定流程,虚函数提供可变化步骤:

cpp 复制代码
class Task {
public:
    void execute() {
        prepare();
        run();
        finish();
    }

    virtual ~Task() = default;

private:
    void prepare() {
        std::cout << "公共准备\n";
    }

    virtual void run() = 0;

    void finish() {
        std::cout << "公共收尾\n";
    }
};

这也是 NVI 的一种典型使用方式。

20.4 工厂方法和抽象工厂

Factory Method,工厂方法,用于把对象创建过程放到可扩展接口中。

Abstract Factory,抽象工厂,用于创建一组相互关联的对象。

20.5 观察者、命令、装饰器

text 复制代码
Observer Pattern
    观察者模式
    通过统一接口通知不同观察者

Command Pattern
    命令模式
    把操作封装成多态对象

Decorator Pattern
    装饰器模式
    在保持接口一致的情况下组合额外行为

设计模式不是必须照搬的代码模板,而是对常见设计问题的命名和经验总结。


第二十一章:什么时候不应该使用动态多态

动态多态很有价值,但不是唯一选择。

21.1 静态多态

如果具体类型在编译期已知,可以使用模板或 Concepts:

cpp 复制代码
template<class T>
void drawObject(const T& object) {
    object.draw();
}

优点:

  • 容易内联;
  • 不要求共同基类;
  • 可以保留值语义;
  • 编译期可以进行更强约束。

代价:

  • 模板实现通常需要放在头文件;
  • 可能增加编译时间和代码体积;
  • 运行时不容易替换未知类型。

21.2 std::variant

如果所有可能类型在编译期形成一个封闭集合:

cpp 复制代码
using ShapeValue =
    std::variant<Circle, Rectangle>;

可以使用:

cpp 复制代码
std::visit(
    [](const auto& shape) {
        shape.draw();
    },
    value
);

variant 适合:

text 复制代码
类型种类有限且已知
希望使用值语义
不想进行堆分配

动态多态则更适合:

text 复制代码
类型集合开放
未来可以增加新派生类
运行时需要替换实现
调用方只依赖稳定接口

21.3 类型擦除

Type Erasure,中文叫"类型擦除"。

它把具体类型隐藏在统一包装对象之后。标准库中的典型例子是:

cpp 复制代码
std::function<void()>

它可以保存函数指针、Lambda 和函数对象,调用方不必知道真实类型。

类型擦除同样可能在内部使用虚函数,但不会把继承要求暴露给使用者。

21.4 普通组合

如果只是想复用实现或替换某个内部部件,组合往往比继承清晰:

cpp 复制代码
class Renderer {
public:
    explicit Renderer(
        std::unique_ptr<Backend> backend
    )
        : backend_(std::move(backend)) {}

private:
    std::unique_ptr<Backend> backend_;
};

这里 Renderer 不需要"是一种 Backend",而是"拥有一个 Backend"。


第二十二章:性能、ABI 与工程边界

22.1 堆分配不是虚函数的必然要求

动态多态经常和 new、智能指针一起出现,但虚函数本身不要求对象必须位于堆上:

cpp 复制代码
Circle circle{2.0};
Shape& shape = circle;
shape.draw();

这里没有任何堆分配。

堆分配通常是因为:

  • 对象需要超出当前作用域;
  • 需要把不同大小的派生对象放进同一容器;
  • 需要转移或共享所有权;
  • 运行时才决定创建哪种对象。

22.2 二进制兼容问题

虚函数表和对象布局与 ABI 密切相关。

在共享库或插件接口中,下面的变化可能破坏二进制兼容:

  • 增加、删除或调整虚函数;
  • 改变继承关系;
  • 修改类成员布局;
  • 更换编译器或不兼容的编译选项;
  • 改变运行库配置。

跨动态库边界还要明确:

text 复制代码
对象由谁创建?
对象由谁销毁?
双方是否使用兼容的运行库?
异常是否允许跨边界传播?
STL类型能否安全跨边界?
接口如何进行版本管理?

大型插件接口常使用工厂函数和配套销毁函数,或者使用 PImpl 等技术降低 ABI 耦合。

PImpl 是:

text 复制代码
Pointer to Implementation
= 指向实现的指针

它把实现细节隐藏在单独对象中,减少头文件依赖和公开对象布局变化。

22.3 优化时先测量

动态多态可能带来间接调用成本,但程序性能通常还取决于:

  • 算法复杂度;
  • 内存访问模式;
  • 缓存命中率;
  • 堆分配次数;
  • 数据布局;
  • 编译器优化能力;
  • 实际调用频率。

如果某个虚函数内部执行大量工作,一次间接调用的成本可能微不足道。如果是在极高频、极短小的循环内调用,则可能值得比较静态多态、连续数据布局或批处理方案。


第二十三章:常见错误与排查方法

23.1 忘记虚析构函数

cpp 复制代码
class Base {
public:
    virtual void run() = 0;
    ~Base() = default; // 危险
};

多态基类通常应写:

cpp 复制代码
virtual ~Base() = default;

23.2 忘记 override

cpp 复制代码
void draw(); // 可能因为少了const而没有覆盖

推荐:

cpp 复制代码
void draw() const override;

23.3 按值传递基类

cpp 复制代码
void process(Shape shape);

会发生切片。改为:

cpp 复制代码
void process(const Shape& shape);

23.4 使用 vector<Base>

cpp 复制代码
std::vector<Shape> shapes;

无法保留派生类部分。通常改为:

cpp 复制代码
std::vector<std::unique_ptr<Shape>>
    shapes;

23.5 在构造函数中期待调用派生类覆盖

基类构造期间不会按完整派生类进行虚分派。把需要派生类参与的操作移到构造完成之后。

23.6 覆盖函数使用不同默认参数

函数实现动态绑定,默认参数静态绑定,容易产生意外组合。默认参数应保持一致,或者放在统一非虚接口中。

23.7 到处使用 dynamic_cast

偶尔的边界类型查询是合理的;如果核心业务逻辑到处判断具体类型,应考虑把行为移入虚函数或重新划分接口。

23.8 为了复用代码而滥用继承

继承首先表达类型关系,不只是代码复用工具。仅仅"内部要用另一个类的功能"时,优先考虑组合。

23.9 混淆虚函数与虚继承

text 复制代码
虚函数
    运行时选择函数

虚继承
    菱形继承中共享基类子对象

23.10 认为虚函数一定需要堆对象

栈对象同样可以通过基类引用进行动态分派。是否使用堆,取决于生命周期和所有权,不取决于 virtual 本身。


第二十四章:一个完整的多态程序

下面把抽象接口、虚析构、智能指针、工厂函数和多态集合串起来。

完整示例使用 C++20 的 std::numbers::pi,可以使用 -std=c++20 编译。

cpp 复制代码
#include <iostream>
#include <memory>
#include <numbers>
#include <stdexcept>
#include <string_view>
#include <vector>

class Shape {
public:
    virtual void draw() const = 0;
    virtual double area() const = 0;

    virtual std::unique_ptr<Shape>
    clone() const = 0;

    virtual ~Shape() = default;
};

class Circle final : public Shape {
public:
    explicit Circle(double radius)
        : radius_(radius) {}

    void draw() const override {
        std::cout
            << "绘制半径为"
            << radius_
            << "的圆形\n";
    }

    double area() const override {
        return std::numbers::pi
             * radius_
             * radius_;
    }

    std::unique_ptr<Shape>
    clone() const override {
        return std::make_unique<Circle>(
            *this
        );
    }

private:
    double radius_;
};

class Rectangle final : public Shape {
public:
    Rectangle(double width, double height)
        : width_(width),
          height_(height) {}

    void draw() const override {
        std::cout
            << "绘制"
            << width_
            << "×"
            << height_
            << "的矩形\n";
    }

    double area() const override {
        return width_ * height_;
    }

    std::unique_ptr<Shape>
    clone() const override {
        return std::make_unique<Rectangle>(
            *this
        );
    }

private:
    double width_;
    double height_;
};

std::unique_ptr<Shape>
createShape(std::string_view type) {
    if (type == "circle") {
        return std::make_unique<Circle>(2.0);
    }

    if (type == "rectangle") {
        return std::make_unique<Rectangle>(
            3.0,
            4.0
        );
    }

    throw std::invalid_argument{
        "unknown shape type"
    };
}

void printShape(const Shape& shape) {
    shape.draw();
    std::cout
        << "面积:"
        << shape.area()
        << "\n\n";
}

int main() {
    std::vector<std::unique_ptr<Shape>>
        shapes;

    shapes.push_back(
        createShape("circle")
    );

    shapes.push_back(
        createShape("rectangle")
    );

    for (const auto& shape : shapes) {
        printShape(*shape);
    }

    auto copiedShape =
        shapes.front()->clone();

    std::cout << "复制出的图形:\n";
    printShape(*copiedShape);
}

观察它们之间的关系:

text 复制代码
Shape抽象类
    规定所有图形都必须提供哪些行为
        ↓
Circle和Rectangle
    实现各自不同的行为
        ↓
createShape工厂
    在运行时创建具体对象
        ↓
unique_ptr<Shape>
    统一管理不同派生对象的生命周期
        ↓
vector<unique_ptr<Shape>>
    把不同图形放进同一个集合
        ↓
shape->draw()和shape->area()
    根据真实对象类型进行运行时分派
        ↓
clone()
    在不知道具体类型时完成多态复制

这就是现代 C++ 中一套完整、常见的动态多态使用方式。


第二十五章:把全部知识连成一条主线

现在回到最开始的问题:我们为什么需要动态多态?

text 复制代码
不同类型拥有相同概念上的操作
    ↓
使用public继承表达"派生类是一种基类"
    ↓
基类用virtual声明可变化行为
    ↓
派生类用override提供具体实现
    ↓
基类指针或引用保留对象动态类型
    ↓
运行时根据动态类型选择最终覆盖函数
    ↓
抽象类和纯虚函数用于只规定接口
    ↓
虚析构保证通过基类正确销毁派生对象
    ↓
unique_ptr管理独占所有权
    ↓
工厂函数负责运行时创建具体对象
    ↓
clone解决多态对象复制问题
    ↓
RTTI在确有需要时查询真实类型
    ↓
多继承和虚继承处理更复杂的类型关系
    ↓
设计原则保证继承在语义上仍然合理

最后需要牢牢记住的不是某个语法,而是下面四个问题:

问题一:调用为什么进入派生类函数

因为基类把函数声明为虚函数,调用表达式对应的对象具有派生类动态类型,运行时选择了最终覆盖函数。

问题二:对象为什么不能按基类值传递

因为按值复制会创建独立基类对象,派生部分被切掉,动态类型也随之丢失。

问题三:对象由谁销毁

所有权必须清晰。优先使用 unique_ptr;确实共享生命周期时使用 shared_ptr;多态基类通常需要虚析构函数。

问题四:这里真的需要动态多态吗

如果类型集合开放、运行时需要替换实现、调用方只依赖稳定接口,动态多态通常很合适。

如果类型在编译期已知,或者类型集合固定并且希望保留值语义,则模板、Concepts、std::variant、类型擦除或普通组合可能更合适。


结语

C++ 动态多态的核心可以浓缩成一句话:

通过抽象基类规定统一接口,通过虚函数让不同派生对象在运行时表现出各自行为,再用正确的所有权和生命周期设计安全地管理这些对象。

virtual 只是入口。真正完整的动态多态还包括:

  • 继承关系是否合理;
  • 静态类型和动态类型的区别;
  • 覆盖、重载和隐藏;
  • 对象切片;
  • 抽象类与纯虚函数;
  • 虚析构和对象生命周期;
  • 构造析构期间的分派规则;
  • 虚函数表的常见实现原理;
  • 默认参数、访问控制和协变返回;
  • 智能指针、工厂与多态复制;
  • RTTI 和安全类型转换;
  • 多重继承、虚继承与双分派;
  • 设计原则、性能、ABI 和替代方案。

当这些内容能够串成一条逻辑链时,动态多态就不再是一组零散规则,而会成为一种可以主动选择、正确实现并安全维护的程序设计工具。

相关推荐
此生决int1 小时前
深入理解C++系列(06)——模版初阶与STL
开发语言·c++
c238562 小时前
《枚举算法 “翻译官”:用 enum class 给 “游游的礼盒” 算分》
c语言·c++·算法
noipp2 小时前
推荐题目:洛谷 P5843 [SCOI2012] Blinker 的噩梦
c语言·数据结构·c++·算法·游戏·洛谷·luogu
鱼子星_2 小时前
《算 · 法》题目解析篇(1):最近公共祖先问题,线段树,最长回文子序列
c++·算法·动态规划·递归
计算机内卷的N天4 小时前
CMake与Visual Studio的使用
c++·ide·visual studio
汉克老师4 小时前
GESP2026年3月认证C++七级( 第三部分编程题(1、物流网络))精讲
c++·最短路·gesp7级
鱼子星_4 小时前
【C++】深入剖析list:list及其双向迭代器实现
开发语言·数据结构·c++·笔记·stl·list
tudousisi2224 小时前
P4667 [BalticOI 2011] Switch the Lamp On复盘
c++
江屿风5 小时前
【C++笔记】【二叉搜索树】流食般投喂
开发语言·数据结构·c++·笔记