☰
C++虚函数和多态超详细分析
2026/10/21 15:31:15 网站建设 项目流程

前言


"多态"是面向对象三大特性里最容易被讲得含糊的一个。很多资料把它等同于"父类指针指向子类对象,调用同名函数执行子类版本",这句话描述了现象,却没解释机制:为什么必须是指针或引用?为什么构造函数里调用虚函数不会走到子类?为什么基类析构函数不加virtual就会资源泄漏?为什么把派生类按值传给基类参数,多态就"消失"了?


C++ 的多态分为两类:编译期多态(函数重载、模板、CRTP)和运行期多态(虚函数)。本文聚焦后者,从静态绑定与动态绑定的区别讲起,说明虚函数表这一主流实现方式(标准并不规定实现细节),再讨论override/final、纯虚函数与抽象类、构造析构期间的特殊规则,以及对象切片等高频陷阱。示例以 C++17 为基准。


先把结论放在前面:虚函数解决的是"通过基类接口调用,实际执行派生类实现"的问题;它依赖运行期类型,因此只能通过指针或引用触发。任何按值传递、按值存储的场景都会发生对象切片,多态随之丢失。


一、静态绑定与动态绑定


静态绑定(static binding,也称早期绑定)在编译期就能确定调用哪个函数,普通函数调用、重载决议、非虚成员函数、模板实例化都属于这一类。动态绑定(dynamic binding,晚期绑定)把选择推迟到运行期,依据的是对象的动态类型而非表达式的静态类型。


// 适用于 C++17
struct Base {
void normal() const { /* 非虚 */ }
virtual void poly() const { /* 虚函数 */ }
};
struct Derived : Base {
void normal() const { /* 隐藏基类版本 */ }
void poly() const override { /* 覆盖基类版本 */ }
};

void call(const Base& b) {
b.normal(); // 静态绑定:调用 Base::normal
b.poly(); // 动态绑定:看实际对象,可能是 Derived::poly
}

关键条件是通过指针或引用调用。如果写成Base b = derived;,发生的是切片:derived的派生部分被丢弃,b是一个纯粹的Base对象,动态类型就是Base,怎么调用都不会走到Derived。同理,在容器里存std::vector<Base>会切片,必须存std::vector<std::unique_ptr<Base>>或std::reference_wrapper<Base>。


动态绑定还有一个前提:调用表达式必须能确定为"对某个对象的虚函数调用"。通过obj.Base::poly()这种限定写法会显式关闭动态绑定,直接调用指定版本;在构造函数/析构函数中调用虚函数也会退化为当前正在构造/析构的类版本(下一节详述)。


二、虚函数表:主流实现方式


标准只规定行为,不规定实现。主流编译器(GCC、Clang、MSVC)采用的方案是虚函数表(virtual table,vtable)加虚表指针(vptr):



  • 每个含虚函数的类(更准确地说,每个需要动态分派的类)有一张虚表,本质是一个函数指针数组,按声明顺序(具体顺序由 ABI 决定)存放各虚函数的地址。

  • 每个该类的对象在内存布局中(通常在最前面)有一个隐藏的 vptr,指向所属类的虚表。

  • 调用p->poly()时,编译器生成"取 vptr → 按固定偏移取函数指针 → 间接调用"的序列。


由此可以解释几个常见现象:



  1. 虚函数调用比普通函数多一次间接寻址,通常难以内联(除非编译器能通过去虚化 devirtualization 证明具体类型)。但不要据此断言"虚函数一定慢多少"——具体开销取决于调用频率、缓存命中、能否去虚化,应当用实际测量而不是拍脑袋。

  2. 对象变大:每个多态对象多一个指针大小的 vptr。空类sizeof为 1,含一个虚函数的类在 64 位下通常是 8 或 16 字节(取决于是否还有别的成员),这是实现细节,标准不保证。

  3. 虚表是每个类一份,不是每个对象一份;对象只存 vptr。

  4. 构造期间 vptr 会被设置为当前正在构造的类的虚表,构造完成后(进入派生类构造函数时)再改为派生类的表——这正是构造函数里调用虚函数不会分派到派生类的原因。


需要强调:虚拟继承(virtual inheritance)的布局更复杂,通常引入虚基类表指针,不同 ABI 差异很大。不要依赖sizeof、成员偏移、vptr 位置写代码,这些都不是标准保证的可移植行为。


dynamic_cast和typeid能工作,也依赖编译器生成的运行时类型信息(RTTI),它与虚表机制配套,但同样是实现层面的东西。


三、override、final 与纯虚函数


C++11 引入的override是最值得养成的习惯。虚函数覆盖要求函数名、参数列表、const/volatile 限定、引用限定全部一致(返回类型可以协变)。少写一个const,编译器不会报错,而是认为你声明了一个新函数,悄悄隐藏了基类版本——这是最难查的 bug 之一。


// 适用于 C++17
struct Base {
virtual void run() const;
virtual ~Base() = default;
};
struct Derived : Base {
void run() override; // 编译错误:缺少 const,没有覆盖任何函数
};

加上override后,编译器会检查"确实覆盖了某个基类虚函数",签名不匹配立即报错。final用在虚函数上表示禁止进一步覆盖,用在类上表示禁止继承。它既是给编译器的约束,也是给读者的文档。


纯虚函数用= 0声明,含纯虚函数的类是抽象类,不能实例化。纯虚函数可以有定义(必须在类外定义),派生类仍必须覆盖它才能成为具体类:


// 适用于 C++17
struct Interface {
virtual void start() = 0;
virtual ~Interface() = default;
};
void Interface::start() { /* 可选的默认实现 */ }

struct Impl : Interface {
void start() override { Interface::start(); /* 再做自己的事 */ }
};

纯虚析构函数也必须提供定义,否则派生类析构时链接会失败。


关于访问权限有一个常被误解的点:虚函数可以是private的,仍然可以被派生类覆盖。访问控制检查的是"调用点能否访问这个名字",与动态分派无关。NVI(Non-Virtual Interface)模式正是利用这一点:基类提供 public 非虚函数作为入口,内部调用 private 虚函数,从而把"做什么"和"怎么做"分离。


虚函数不能被模板化(不能是函数模板),因为虚表需要在编译期确定条目;但虚函数可以是模板类的成员,这是两回事。


四、构造、析构与对象切片


构造函数和析构函数中调用虚函数,不会分派到派生类。原因很直接:基类构造时派生类部分尚未构造,调用派生版本会访问未初始化的成员;基类析构时派生类部分已经销毁。此时 vptr 指向当前类的虚表,调用的是当前类的版本。如果当前类是抽象类且该函数是纯虚的,甚至可能链接失败或运行时出错——总之不要在构造/析构里调用虚函数,尤其是纯虚函数。


基类析构函数必须是virtual,否则通过基类指针delete派生对象是 UB。实际后果通常是只调用了基类析构函数,派生类的资源(new出来的内存、文件句柄)没有释放。标准规定:如果基类析构函数非虚,通过基类指针删除派生对象是未定义行为,不是"只泄漏一点"这么轻。


// 适用于 C++17
struct BadBase {
~BadBase() = default; // 非虚
};
struct BadDerived : BadBase {
~BadDerived() { /* 释放资源 */ }
};

void leak(BadBase* p) {
delete p; // UB:不会调用 ~BadDerived
}

对象切片(object slicing)发生在按值传递或按值存储时:派生类对象被截断成基类子对象,派生类的成员和覆盖行为全部丢失。它不会报错,行为看起来"正常",只是多态静默失效。避免方式是传引用/指针,容器存智能指针。


// 适用于 C++17
void by_value(Base b); // 切片
void by_ref(const Base& b); // 保持多态

还有一个相关规则:默认实参是静态绑定的。即使虚函数分派到了派生类,默认实参仍取静态类型(基类)声明里的值。这经常导致"参数怎么不是我写的那个"的困惑。


完整示例


下面实现一个简单的图形系统,演示虚函数分派、纯虚接口、虚析构和override:


// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Shape {
public:
virtual ~Shape() = default; // 多态基类必须虚析构
virtual std::string name() const = 0; // 纯虚:接口
virtual double area() const = 0;
virtual void print() const { // 虚函数可以有默认实现
std::cout << name() << " area=" << area() << '\n';
}
};

class Circle : public Shape {
public:
explicit Circle(double r) : r_(r) {}
std::string name() const override { return "Circle"; }
double area() const override { return 3.141592653589793 * r_ * r_; }
private:
double r_;
};

class Rectangle : public Shape {
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
std::string name() const override { return "Rectangle"; }
double area() const override { return w_ * h_; }
private:
double w_, h_;
};

class Square final : public Rectangle { // final:禁止再被继承
public:
explicit Square(double s) : Rectangle(s, s) {}
std::string name() const override { return "Square"; }
};

void render(const std::vector<std::unique_ptr<Shape>>& shapes) {
for (const auto& s : shapes) {
s->print(); // 基类 print 内部再分派 name/area
}
}

int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Rectangle>(2.0, 3.0));
shapes.push_back(std::make_unique<Square>(2.0));
render(shapes);

// 切片演示:按值传递会丢失派生部分
Circle c(1.0);
// 下面这行会编译报错:Shape 是抽象类,无法实例化,也就无法切片
// Shape sliced = c;
(void)c;
return 0;
}

上面被注释掉的那行是刻意写的反例:Shape含纯虚函数,本身不能实例化,所以切片在编译期就被挡住了——这是抽象基类的一个额外好处。如果基类不是抽象的(比如Shape的area有默认实现),Shape sliced = c;就不会报错,多态静默丢失。


把render改成按值接收std::vector<std::unique_ptr<Shape>>也不行,unique_ptr不可拷贝,编译期会报错;如果换成std::vector<Shape>,则会在push_back处因抽象类无法实例化而失败。真正危险的是基类可实例化的情况。


按标准语义,程序会依次打印Circle area=3.14159、Rectangle area=6、Square area=4。三行都来自基类Shape::print,其中name()与area()都是虚函数,分别动态分派到各自的派生类实现;Square没有覆盖area(),但它继承的Rectangle::area()使用构造时传入的s, s,所以结果是 4。浮点输出的具体位数由std::cout的默认格式决定。


常见坑点



  1. ❌ 多态基类析构函数不写virtual


✅ 声明virtual ~Base() = default;,否则通过基类指针delete派生对象是 UB



  1. ❌ 覆盖虚函数时漏写const或参数类型差一点


✅ 一律加override,让编译器替你检查签名



  1. ❌ 在构造函数或析构函数里调用虚函数,指望走到派生类


✅ 此时只分派到当前类版本;把需要多态的行为推迟到对象完全构造之后



  1. ❌ 用std::vector<Base>存放派生对象


✅ 用std::vector<std::unique_ptr<Base>>,避免对象切片



  1. ❌ 以为虚函数靠"同名"就能覆盖


✅ 覆盖要求签名一致;签名不同会隐藏基类重载,需要using Base::f;才能一并可见



  1. ❌ 派生类里定义同名函数后,基类的其他重载"消失"了还以为编译器有 bug


✅ 名字隐藏规则如此,using声明可以把基类重载引入派生类作用域



  1. ❌ 认为虚函数调用一定是"一次 indirect call,没有别的成本"


✅ 还有去虚化失败、无法内联、缓存不友好等影响;是否值得虚化要按场景测量



  1. ❌ 依赖sizeof或对象内存布局判断 vptr 位置


✅ 布局由 ABI 决定,标准不保证;跨编译器代码不要碰这些假设


总结




主题规则后果



动态绑定条件通过指针或引用调用虚函数按值传递会切片,多态失效

覆盖要求签名完全一致(返回类型可协变)加override让编译器检查

析构多态基类必须虚析构否则delete基类指针是 UB

构造/析构中调用只分派到当前类版本不要依赖此时的动态类型

纯虚函数类变抽象,可提供定义派生类必须覆盖才能实例化

默认实参静态绑定分派到派生类也用基类默认值

名字隐藏派生类同名声明隐藏基类所有重载需要using Base::f;



虚函数和多态的"超详细"之处,不在于记住 vtable 长什么样,而在于理解三组分离:静态类型与动态类型、名字查找与函数分派、对象生命周期与可调用性。把这三组关系理清,什么时候该用虚函数、什么时候该用模板或std::variant、什么时候该把行为下沉到派生类,就有了判断依据。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询