ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

【C++进阶】:(2)深入理解多态、虚函数与虚表

2026/8/25 15:19:48 拓冰建站 浏览量
【C++进阶】:(2)深入理解多态、虚函数与虚表 前言在 C 面向对象中经常会提到三个核心思想封装 继承 多态前面学习继承的时候我们解决的是不同类之间如何建立父子关系以及如何复用基类已有的成员。但是继承本身还没有解决一个更关键的问题如果不同派生类都拥有同一个行为怎么通过一个统一的接口让程序自动执行各自不同的实现比如Animal ↓ ┌──────┬──────┐ Dog Cat它们都可以Speak();但是Dog::Speak() ↓ 汪汪 Cat::Speak() ↓ 喵喵如果调用者每次都需要先判断if (类型是Dog) { ... } else if (类型是Cat) { ... }那么随着派生类越来越多代码会越来越复杂。多态解决的就是这个问题使用统一的接口根据对象真正的类型在运行时选择对应的行为。一、多态的基本概念polymorphism翻译成中文就是多态——多种形态。放到程序中可以简单理解成同一个接口 不同对象 不同结果例如class Animal { public: virtual void Speak() { cout Animal::Speak() endl; } }; class Dog : public Animal { public: void Speak() override { cout 汪汪 endl; } }; class Cat : public Animal { public: void Speak() override { cout 喵喵 endl; } };我们写一个统一接口void MakeSound(Animal animal) { animal.Speak(); }使用Dog dog; Cat cat; MakeSound(dog); MakeSound(cat);输出汪汪 喵喵这里MakeSound()根本不需要知道传进来的到底是Dog 还是 Cat它只需要按照Animal这个统一接口工作。真正执行哪一个Speak()运行时再根据对象决定。这就是多态最核心的价值调用者面向统一接口编程而不同对象负责提供自己的实现。静态多态和动态多态C 中的多态可以从“什么时候决定调用哪个函数”这个角度分成两类。第一类是静态多态。比如函数重载void Print(int x) { cout int: x endl; } void Print(double x) { cout double: x endl; }调用Print(10); Print(3.14);编译器在编译阶段就已经知道Print(10) ↓ Print(int) Print(3.14) ↓ Print(double)函数模板也是类似templateclass T void Print(const T x) { cout x endl; }实际需要哪个模板实例也是编译时根据类型确定。所以函数重载 函数模板都属于典型的编译期多态 静态多态另一类就是这篇真正的重点运行时多态也叫动态多态。例如Animal* ptr dog; ptr-Speak();ptr的类型明明是Animal*但是它真正指向的是Dog于是调用的是Dog::Speak()到底执行哪个版本并不是单纯由Animal*这个静态类型决定而与指针运行时真正指向的对象有关。可以先这样记静态多态 ↓ 编译的时候已经决定 动态多态 ↓ 运行的时候根据真实对象决定二、多态的实现条件想让 C 真正出现运行时多态核心条件可以概括成继承 虚函数 重写 基类指针或引用调用1. 必须存在继承关系首先必须存在基类 ↓ 派生类例如class Vehicle { }; class Car : public Vehicle { };因为多态本质上依赖派生类对象可以通过基类接口进行统一管理。在通常的面向对象使用场景中一般采用public公有继承来表达Car is a Vehicle这种is-a关系。2. 基类必须存在虚函数普通成员函数void Run();如果需要参与运行时多态要在基类中声明成virtual void Run();例如class Vehicle { public: virtual void Run() { cout Vehicle running endl; } };这里virtual可以看作告诉编译器以后调用这个函数时不一定只按照指针/引用表面的类型决定请保留运行时动态选择的能力。虚函数只能是成员函数体系中的相应函数普通全局函数、静态成员函数和构造函数不能成为用于这种动态派发的虚函数而析构函数可以且在多态基类中通常应该设计为虚函数。3. 派生类重写虚函数例如class Vehicle { public: virtual void Run() { cout Vehicle running endl; } }; class Car : public Vehicle { public: void Run() override { cout Car running on road endl; } };这里Car::Run()对Vehicle::Run()进行了虚函数重写override。一般情况下重写要求函数名相同 参数列表相同 返回值类型相同也就是常说的三同。协变是返回值规则中的一个特殊例外后面再讲。4. 通过基类指针或引用调用这是很多初学者最容易漏掉的地方。假设Vehicle v; Car c;直接v.Run(); c.Run();当然分别执行自己的函数。但是这个例子并没有体现同一个基类接口根据不同运行时对象动态决定行为。真正典型的多态写法是Vehicle* p1 v; Vehicle* p2 c; p1-Run(); p2-Run();得到Vehicle running Car running on road或者通过引用Vehicle r1 v; Vehicle r2 c; r1.Run(); r2.Run();最值得理解的是两个指针类型都是 Vehicle* 但是 p1 真正指向 Vehicle p2 真正指向 Car所以最终行为不同。一个完整例子#include iostream #include string using namespace std; class Vehicle { public: Vehicle(const string name) : _name(name) {} virtual void Run() { cout _name 普通行驶 endl; } virtual void Stop() { cout _name 停止 endl; } protected: string _name; }; class Car : public Vehicle { public: Car(const string name) : Vehicle(name) {} void Run() override { cout _name 在公路上行驶 endl; } void Stop() override { cout _name 汽车刹车 endl; } }; class Ship : public Vehicle { public: Ship(const string name) : Vehicle(name) {} void Run() override { cout _name 在海面航行 endl; } void Stop() override { cout _name 抛锚停船 endl; } }; void Control(Vehicle v) { v.Run(); v.Stop(); } int main() { Car car(汽车); Ship ship(轮船); Control(car); Control(ship); return 0; }这里真正体现了多态设计的价值以后如果增加Plane Train Bike只需要新增派生类并实现相同接口。Control()本身不需要跟着写一大堆if else if else if这就是对扩展开放对已有调用逻辑尽量少修改。三、虚函数重写虚函数重写的基本语法并不难真正容易混的是几个特殊规则。协变派⽣类重写基类虚函数时与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引 ⽤派⽣类虚函数返回派⽣类对象的指针或者引⽤时称为协变。协变的实际意义并不⼤所以我们 了解⼀下即可。class A {}; class B : public A {}; class Person { public: virtual A* BuyTicket() { cout 买票-全价 endl; return nullptr; } }; class Student : public Person { public: virtual B* BuyTicket() { cout 买票-打折 endl; return nullptr; } }; void Func(Person* ptr) { ptr-BuyTicket(); } int main() { Person ps; Student st; Func(ps); Func(st); return 0; }虚析构函数这是多态里面非常重要的一块。假设class Base { public: ~Base() { cout ~Base() endl; } }; class Derived : public Base { public: ~Derived() { cout ~Derived() endl; } };然后Base* p new Derived; delete p;这里非常危险。我们的真实对象是Derived但删除时使用的是Base*如果基类析构函数不是虚函数这种通过基类指针删除派生类对象的行为无法正确完成完整的多态析构过程。正确设计应该是class Base { public: virtual ~Base() { cout ~Base() endl; } };于是delete p;会按照Derived 析构 ↓ Base 析构完整释放对象。例如class Base { public: Base() { _data new int[100]; } virtual ~Base() { delete[] _data; cout ~Base() endl; } private: int* _data; }; class Derived : public Base { public: Derived() { _buffer new char[1024]; } ~Derived() override { delete[] _buffer; cout ~Derived() endl; } private: char* _buffer; };使用Base* p new Derived; delete p;这样派生类自己的_buffer和基类的_data都能够正确释放。原文也把“通过基类指针删除派生类对象时使用虚析构”作为多态的重要资源安全问题。所以可以记住如果一个类准备作为多态基类使用尤其可能通过基类指针delete派生对象应当提供虚析构函数。实际工程里非常常见virtual ~Base() default;overrideC11 提供了一个非常实用的关键字override它并不是让函数“变成虚函数”的关键字。它真正的作用是告诉编译器我认为这个函数是在重写基类虚函数请帮我检查。例如class Base { public: virtual void Print(int x) { } };派生类不小心写成class Derived : public Base { public: void Print(double x) override { } };编译器会直接报错。因为Base Print(int) Derived Print(double)参数不同不构成重写。如果没有override这类错误有时候很难第一时间发现。更典型的是拼写错误virtual void Drive();结果派生类写成void Dirve() override;因为有override编译器马上就会提醒没有重写任何基类虚函数。所以我现在更推荐写void Run() override而不是单纯写void Run()override能把很多原本潜伏到运行逻辑里的错误提前变成编译错误。原文也专门用拼写错误展示了这一点。finalfinal则代表到这里结束不允许继续重写。例如class Base { public: virtual void Run() { } }; class Derived : public Base { public: void Run() final { } };如果继续class Child : public Derived { public: void Run() override { } };就会编译失败。因为Derived::Run()已经声明final不能继续重写。final也可以直接修饰类class Config final { };意味着Config 不能继续被继承重载、重写和隐藏这三个概念特别容易混。可以先从“发生在哪里”判断。重载发生在同一个作用域例如void Func(int); void Func(double);函数名相同参数列表不同。重写发生在基类 派生类而且基类函数必须是虚函数。例如class Base { public: virtual void Func(); }; class Derived : public Base { public: void Func() override; };它服务于运行时多态隐藏也是基类和派生类之间。例如class Base { public: void Func(int); }; class Derived : public Base { public: void Func(double); };因为不在同一个作用域所以Func(int) Func(double)不是重载。派生类的Func(double)会隐藏基类同名函数。如果要访问基类d.Base::Func(10);四、纯虚函数和抽象类有时候基类根本不应该给出某个行为的默认实现。例如Shape表示所有图形。但是你问Shape 的面积是多少其实没有意义。因为Rectangle Circle Triangle每一种图形的面积公式不同。这时候基类真正需要做的不是替所有派生类计算面积而是规定所有具体图形都必须提供“计算面积”这个接口。于是就可以使用virtual double Area() const 0;这就是纯虚函数。例如class Shape { public: virtual double Area() const 0; virtual double Perimeter() const 0; virtual ~Shape() default; };只要一个类中存在纯虚函数它就属于抽象类。例如class Rectangle : public Shape { public: Rectangle(double width, double height) : _width(width) , _height(height) { } double Area() const override { return _width * _height; } double Perimeter() const override { return 2 * (_width _height); } private: double _width; double _height; };再来一个class Circle : public Shape { public: Circle(double radius) : _radius(radius) { } double Area() const override { return 3.14159 * _radius * _radius; } double Perimeter() const override { return 2 * 3.14159 * _radius; } private: double _radius; };统一处理void PrintShape(const Shape shape) { cout 面积 shape.Area() endl; cout 周长 shape.Perimeter() endl; }于是Rectangle rect(10, 5); Circle circle(3); PrintShape(rect); PrintShape(circle);调用者只面向Shape工作。具体公式交给Rectangle Circle自行完成。抽象类真正重要的地方不是 0这个语法而是设计思想基类 ↓ 规定“必须做什么” 派生类 ↓ 决定“具体怎么做”这种设计在框架接口 图形系统 设备驱动 插件系统 策略模式 工厂模式里都非常常见。五、虚函数表到这里我们已经知道多态怎么用了。现在问题来了Base* p derived; p-Func();为什么一个Base*居然能在运行时找到Derived::Func()答案就涉及虚函数表 vtable 虚函数表指针 vptr虚表指针在主流 C ABI 的典型实现中一个具有虚函数的多态对象内部通常会包含一个隐藏的虚表指针用于找到该动态类型对应的虚函数表。原文使用 MSVC 的__vfptr名称来解释这一机制。例如class Base { public: virtual void Func1() { } virtual void Func2() { } private: int _data; };从概念上可以理解成Base对象 ┌────────────────┐ │ vptr │ ──────┐ ├────────────────┤ │ │ _data │ │ └────────────────┘ │ ↓ Base虚函数表 ┌─────────────┐ │ Base::Func1│ ├─────────────┤ │ Base::Func2│ └─────────────┘这里vptr负责告诉程序这个对象应该使用哪一张虚函数表。派生类重写后发生什么假设class Base { public: virtual void Func1(); virtual void Func2(); }; class Derived : public Base { public: void Func1() override; };那么从典型实现角度可以理解成Base虚表 Func1 → Base::Func1 Func2 → Base::Func2而 Derived 的虚表Func1 → Derived::Func1 Func2 → Base::Func2因为Func1()被重写了所以虚表中对应入口指向派生类版本。而Func2()没有被重写因此仍然使用基类实现。原文同样以“派生类重写后对应虚表入口指向派生版本”解释其实现机制。需要注意一个严谨点vptr、vtable是主流编译器实现 C 动态多态的典型机制但 C 语言标准本身并没有规定编译器必须使用某一种固定的虚表内存布局。所以如下理解对象 → vptr → vtable → 虚函数地址是非常有价值的。但是不要进一步认为所有编译器的虚表一定长得完全一样具体布局属于 ABI / 编译器实现细节。对象为什么会变大例如class A { public: void Func() { } private: int _a; };和class B { public: virtual void Func() { } private: int _a; };B的对象通常还需要保存vptr所以B对象大小往往比没有虚函数的A对应对象多出至少一个指针相关的空间最终大小还会受到平台位数、ABI 和内存对齐影响。原文也通过 32 位示例展示了带虚函数对象因为额外虚表指针而增大。例如 64 位环境中一个指针通常是8 bytes但最终sizeof(B)不能只简单写成成员大小 8还需要考虑内存对齐 编译器布局 继承结构静态绑定和动态绑定现在就可以理解为什么普通函数调用和虚函数多态调用不一样。普通函数obj.Func();如果调用目标在编译时能够确定就是静态绑定。可以粗略理解编译阶段 ↓ 函数地址确定 ↓ 直接调用而Base* p derived; p-Func();当满足运行时多态条件时需要按照对象的动态类型寻找对应虚函数实现p ↓ 对象 ↓ vptr ↓ 对应vtable ↓ 找到Func入口 ↓ 调用Derived::Func这就是动态绑定。多态有没有代价当然有。最明显的两部分空间 时间空间方面多态对象通常多一个vptr时间方面普通调用可能直接调用函数动态派发则通常需要对象 ↓ vptr ↓ 虚表 ↓ 函数地址 ↓ 调用多了一层或多层间接访问。不过在绝大多数正常业务代码里不应该为了省这点开销就放弃一个本来非常适合多态的设计。是否需要避免虚函数应该结合调用频率 性能要求 架构复杂度 可扩展性综合判断。六、高频问题与实战最后把这篇里几个非常值得单独记住的问题放在一起。虚函数的默认参数来看一个很经典的坑class Base { public: virtual void Func(int x 10) { cout Base: x endl; } }; class Derived : public Base { public: void Func(int x 20) override { cout Derived: x endl; } };然后Derived d; Base* p d; p-Func();你觉得输出如下结果吗Derived: 20实际上典型结果是Derived: 10原因在于这里有两个东西需要分开理解调用哪个虚函数 ↓ 动态绑定 默认参数取哪个值 ↓ 根据调用表达式的静态类型确定p的静态类型是Base*所以默认参数来自Base::Func(int x 10)但是对象真正是Derived所以函数体动态派发到Derived::Func()于是最终形成Derived函数体 Base默认参数10得到Derived: 10所以实际设计虚函数时最好不要让基类和派生类给同一个虚函数设置不同默认参数否则代码非常容易产生误解。利用多态实现计算器最后用一个稍微完整的例子把抽象类 纯虚函数 重写 基类引用 动态绑定 虚析构全部串起来。先设计一个抽象计算器#include iostream #include stdexcept using namespace std; class Calculator { public: Calculator(double left, double right) : _left(left) , _right(right) { } virtual double Calculate() const 0; virtual ~Calculator() default; protected: double _left; double _right; };这里Calculate()没有默认实现。它只规定所有具体运算器必须提供计算能力。加法class AddCalculator : public Calculator { public: AddCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left _right; } };减法class SubCalculator : public Calculator { public: SubCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left - _right; } };乘法class MulCalculator : public Calculator { public: MulCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { return _left * _right; } };除法class DivCalculator : public Calculator { public: DivCalculator(double left, double right) : Calculator(left, right) { } double Calculate() const override { if (_right 0) { throw runtime_error(除数不能为0); } return _left / _right; } };然后统一调用void PrintResult(const Calculator calculator) { cout calculator.Calculate() endl; }使用int main() { AddCalculator add(10, 5); SubCalculator sub(10, 5); MulCalculator mul(10, 5); DivCalculator div(10, 5); PrintResult(add); PrintResult(sub); PrintResult(mul); PrintResult(div); return 0; }输出15 5 50 2这个例子真正体现的是PrintResult()完全不用关心 - * /具体是哪种运算。以后新增取模 乘方 开方只需要新增派生类 重写 Calculate()统一调用接口本身不用跟着大改。这才是多态真正的价值。总结这一节内容很多但整个逻辑其实可以顺着一条线理解多态 ↓ 一个接口多种行为 ↓ 静态多态 / 动态多态 ↓ 运行时多态 ↓ 继承 virtual 重写 基类指针/引用 ↓ 虚函数重写 ↓ 协变 / 虚析构 / override / final ↓ 纯虚函数 ↓ 抽象类 ↓ vptr ↓ vtable ↓ 动态绑定 ↓ 运行时根据真实对象选择函数如果让我现在只用一句话理解 C 运行时多态我会写基类负责规定统一接口派生类负责提供不同实现再通过基类指针或引用在运行时根据对象的真实类型选择对应的虚函数。而底层可以进一步理解成基类指针 / 引用 ↓ 找到真实对象 ↓ 对象中的虚表指针 ↓ 对应类型的虚函数表 ↓ 找到需要调用的虚函数 ↓ 完成动态绑定学到这里以后前面继承里很多看起来只是“语法规定”的东西也开始真正串起来了。比如为什么派生类可以交给基类指针 为什么必须重写虚函数 为什么多态基类需要虚析构 为什么对象里多了一个指针 为什么同样一个 Base* 能表现出不同的行为这些问题最终都指向同一个核心通过统一的基类接口把“调用什么”与“具体怎么实现”分离开。这也是多态比单纯减少几行if/else更重要的地方——它真正改变的是程序组织和扩展代码的方式。