搜索

C++ 教程

C++基础

第1章 搭建开发环境与第一个程序 第2章 基本数据类型与变量 第3章 运算符与表达式 第4章 流程控制语句 第5章 数组与字符串 第6章 函数基础 第7章 指针基础 第8章 引用与const限定 第9章 动态内存管理 第10章 结构体与联合体 第11章 函数进阶

面向对象

第12章 面向对象编程入门 第13章 封装与访问控制 第14章 运算符重载 第15章 继承与派生 第16章 多态与虚函数

STL与进阶

第17章 模板编程基础 第18章 标准模板库(STL)容器 第19章 STL算法与迭代器 第20章 异常处理机制 第21章 文件输入输出 第22章 字符串类string 第23章 命名空间与作用域 第24章 类型转换操作符

现代C++

第25章 智能指针 第26章 移动语义与右值引用 第27章 Lambda表达式 第28章 多线程编程基础 第29章 模板元编程入门 第30章 设计模式基础 第31章 C++11/14新特性 第32章 C++17/20核心特性

实战进阶

第33章 性能优化技巧 第34章 调试与测试 第35章 项目结构与构建 第36章 网络编程入门 第37章 数据库连接操作 第38章 图形界面开发基础 第39章 综合项目实战 第40章 进阶学习路线

第16章 多态与虚函数

第16章 多态与虚函数

进阶 👤 已掌握继承,想写出更灵活、可扩展代码的开发者 🔧 设计可扩展的框架、插件系统或处理同一接口的不同实现
本章你将学到
  • 多态的概念与两种实现方式
  • 虚函数的工作原理与vptr/vtable
  • 如何正确使用虚析构函数
核心概念多态虚函数虚函数表

想象一下,你有一个通用的“绘图”指令。当你对“圆形”对象执行这个指令时,它画出一个圆;对“方形”对象执行时,它画出一个方形。这个指令本身是统一的,但具体行为由实际对象决定。这就是多态的核心思想——用统一的接口处理不同的底层形态。

多态是什么?

生活类比: 就像你按下电器的“开关”按钮,电视会开机,空调会启动制冷,具体行为由电器本身决定。
技术定义: 多态(Polymorphism)指同一操作作用于不同的对象,可以产生不同的执行结果。在C++中,它允许通过基类的指针或引用来调用派生类的成员函数。

没有多态的代码是僵化的。我在一个图形渲染项目中见过,为了绘制不同形状,代码里充满了if (type == CIRCLE) drawCircle() else if (type == SQUARE) drawSquare()...。每增加一个新形状,就要修改所有判断逻辑,维护成本极高。多态正是为了解决这类问题而生。

编译时多态 vs. 运行时多态

C++支持两种多态,它们的绑定时机和实现机制完全不同。

特性编译时多态(静态多态)运行时多态(动态多态)
实现方式函数重载、运算符重载、模板虚函数、继承
绑定时机编译期间确定调用哪个函数程序运行时根据对象实际类型确定
性能开销无额外开销,编译期已解析有间接调用开销(查虚函数表)
灵活性低,类型在编译期必须已知高,可在运行时动态决定行为
典型关键字无virtual

本章聚焦于运行时多态,它是面向对象设计灵活性的基石。实现它的关键,就是virtual关键字。

虚函数:动态绑定的钥匙

基类指针
Shape* ptr
→
虚函数表指针
vptr
→
虚函数表
vtable
↓
实际对象
Circle obj
↗

在基类成员函数声明前加上virtual,它就变成了虚函数。派生类可以(但不是必须)重新定义(覆盖)它。当通过基类指针或引用调用虚函数时,程序会根据指针实际指向的对象类型来决定调用哪个版本。

// 基类
class Shape {
public:
    // 声明为虚函数
    virtual void draw() const {
        std::cout << "Drawing a generic shape." << std::endl;
    }
    virtual ~Shape() {} // 虚析构函数,后面会讲
};

// 派生类
class Circle : public Shape {
public:
    // 覆盖基类虚函数(override关键字是C++11的好习惯)
    void draw() const override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

int main() {
    Shape* shapePtr = new Circle();
    shapePtr->draw(); // 输出:Drawing a circle. (调用Circle::draw)
    delete shapePtr;
    return 0;
}

关键点: 如果draw()不是虚函数,那么shapePtr->draw()将永远调用Shape::draw(),因为编译器在编译时根据指针的静态类型(Shape*)就确定了函数地址。虚函数机制将这一决定推迟到运行时。

虚函数表(vtable)的工作原理

这是多态的实现核心。编译器会为每个包含虚函数的类生成一个虚函数表(vtable),这是一个函数指针数组,记录了该类所有虚函数的实际地址。同时,每个该类对象会隐含一个指向其vtable的指针(vptr)。

调用 ptr->virtualFunc()
→
通过ptr找到对象的vptr
→
通过vptr找到类的vtable
→
在vtable中索引到virtualFunc的地址
→
跳转到该地址执行

这个间接寻址过程会产生轻微的性能开销(通常一次指针解引用和一次数组索引)。在实际开发中,对于性能极其敏感的代码段(如内层循环中每秒调用数百万次的函数),你需要权衡是否使用虚函数。但在绝大多数应用场景下,这点开销换来的设计灵活性是完全值得的。

常见误区

  • 构造函数不能是虚函数。 因为vptr是在构造函数中初始化的,在构造完成前,对象类型还不完整。
  • 静态成员函数不能是虚函数。 静态函数属于类而非对象,没有this指针,无法通过vptr机制调用。
  • 内联函数与虚函数。 声明为virtual的函数也可以被编译器内联,但仅当通过对象直接调用时(编译期可知具体类型)。通过指针/引用的虚调用永远是动态的,无法内联。

纯虚函数与抽象类

有时,基类中的虚函数没有合理的默认实现,它只是为派生类定义一个必须实现的接口。这时可以使用纯虚函数。

class Shape {
public:
    // 纯虚函数,语法是 = 0
    virtual void draw() const = 0;
    // 包含纯虚函数的类是抽象类
    virtual ~Shape() = default;
};

包含至少一个纯虚函数的类称为抽象类。你不能创建抽象类的实例(Shape s;会编译错误),但可以创建指向它的指针和引用。抽象类强制派生类提供特定功能的实现,是定义接口契约的强大工具。

插件系统架构

  • 定义抽象基类Plugin,包含纯虚函数initialize(), execute()
  • 每个具体插件(如FilterPlugin, ExportPlugin)继承并实现这些接口
  • 主程序只通过Plugin*操作所有插件,无需知道具体类型

图形界面控件

  • 抽象基类Widget定义draw(), handleEvent()
  • 具体控件如Button, TextBox, Slider继承自Widget
  • 界面管理器维护一个Widget*列表,统一绘制和处理事件

游戏实体系统

  • 基类Entity声明虚函数update(), render()
  • Player, Enemy, Item等派生类实现各自逻辑
  • 游戏循环遍历Entity*数组调用更新和渲染,代码简洁清晰

必须使用虚析构函数

这是C++多态中最重要的规则之一,也是新手最容易踩的坑。如果你通过基类指针删除派生类对象,而基类的析构函数不是虚函数,那么结果是未定义行为,通常会导致派生类的部分资源泄漏。

class Base {
public:
    ~Base() { std::cout << "Base destructor" << std::endl; } // 非虚!
};
class Derived : public Base {
public:
    int* largeArray = new int[1000];
    ~Derived() {
        delete[] largeArray;
        std::cout << "Derived destructor" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 灾难!只调用 ~Base(),~Derived() 不会被调用,largeArray 内存泄漏。
    return 0;
}

将基类析构函数声明为虚函数后,通过基类指针删除时,会正确调用整个派生链上的析构函数。我的经验法则是:如果一个类可能被继承(即作为基类),就将其析构函数声明为虚函数。 即使它当前没有虚函数。这几乎不会带来额外成本(只是多了一个vtable条目),但能避免潜在的严重bug。

编者提示: 关于动态内存管理和资源释放的更多细节,特别是如何安全地使用new和delete,请回顾第9章《动态内存管理》。本章的虚析构函数规则是确保资源安全释放的关键一环。

override与final关键字(C++11)

C++11引入了两个关键标识符,让虚函数的使用更安全、意图更清晰。

  • override:显式声明该函数旨在覆盖基类的虚函数。如果基类中没有相同签名的虚函数,编译器会报错。这能防止因拼写错误或参数列表不匹配导致的意外行为。
  • final:用于类(该类不能被继承)或虚函数(该函数在派生类中不能被覆盖)。
class Base {
public:
    virtual void foo(int);
    virtual void bar() const;
    void baz();
};

class Derived : public Base {
public:
    void foo(int) override; // 正确,覆盖 Base::foo(int)
    // void foo() override; // 错误!基类没有 void foo() 函数
    void bar() override; // 错误!签名不匹配(少了const)
    // void baz() override; // 错误!Base::baz() 不是虚函数
    virtual void xyz() final; // 此函数在进一步派生类中不可覆盖
};

class FinalClass final : public Derived {
    // 不能覆盖 xyz(),因为它在Derived中被声明为final
};
// class TryInherit : public FinalClass { }; // 错误!FinalClass是final的

我强烈建议你在所有意图覆盖虚函数的地方都使用override。它能让你在编译期就发现错误,而不是在运行时遇到难以调试的奇怪行为。

动手试一试

  1. 创建一个Logger抽象基类,包含纯虚函数void log(const std::string& message)。
  2. 派生两个具体日志器:ConsoleLogger(将信息输出到控制台)和FileLogger(将信息追加到文件“log.txt”中)。
  3. 编写一个函数void processData(Logger& logger),它接收一个Logger引用,在处理数据的几个关键步骤调用logger.log()。
  4. 在main函数中,分别用ConsoleLogger和FileLogger对象调用processData,观察不同的输出效果。体会通过基类接口操作不同派生类对象的多态威力。

检验你的理解

  1. 下面代码的输出是什么?为什么?
    class A { public: virtual void f() { cout << "A"; } };
    class B : public A { public: void f() { cout << "B"; } };
    int main() {
        A* a = new B();
        a->f();
        delete a;
    }
  2. 假设类Base的析构函数不是虚函数,但类Derived在其析构函数中释放了动态内存。通过Base* ptr = new Derived(); delete ptr;删除对象会导致什么问题?
  3. override关键字的主要作用是什么?它解决了什么问题?

本章小结

  • 多态允许通过基类接口操作派生类对象,实现“一个接口,多种实现”,极大提升了代码的灵活性和可扩展性。
  • 虚函数是实现运行时多态的关键,通过虚函数表(vtable)和虚函数表指针(vptr)机制在运行时动态绑定函数调用。
  • 纯虚函数和抽象类用于定义接口契约,强制派生类实现特定行为,不能实例化。
  • 基类析构函数必须声明为虚函数,以确保通过基类指针删除派生类对象时,能正确调用整个对象析构链,避免资源泄漏。
  • C++11的override和final关键字能增强代码安全性,明确设计意图,应在现代C++项目中积极使用。

意见反馈

微信二维码 微信扫码关注 抖音二维码 抖音扫码关注