ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++虚函数全解析:多态机制、底层原理与避坑指南

C++虚函数全解析:多态机制、底层原理与避坑指南 写一篇针对C虚函数的经验总结类博文。虚函数这个主题看起来老生常谈但说真的我在实际开发里见过太多人栽在看起来会了一写就错的坑里。本文我把虚函数的性质、底层机制、纯虚函数、以及那些坑踩过的点全部梳理一遍尽量用大白话讲清楚。1. 虚函数到底是什么——先搞清楚多态的机制很多人背过虚函数是实现运行时多态的核心机制这句话但真问起来虚函数和普通成员函数到底差在哪十个里有八个说不利索。虚函数virtual function是在基类中声明为virtual的成员函数在派生类中可以重新定义覆盖调用时根据对象的实际类型而非指针或引用的静态类型来分派。这个实际类型三个字是全文的灵魂。普通成员函数在编译期就已经确定调用的是哪个版本虚函数则把这个决定推迟到了运行时。举一个最朴素的例子#include iostream class Animal { public: void speak() { std::cout Animal speaks std::endl; } virtual void virtualSpeak() { std::cout Animal virtualSpeak std::endl; } }; class Dog : public Animal { public: void speak() { std::cout Dog speaks std::endl; } void virtualSpeak() override { std::cout Dog virtualSpeak std::endl; } }; int main() { Animal* p new Dog(); p-speak(); // Animal speaks p-virtualSpeak(); // Dog virtualSpeak delete p; }同样一个指针一个走普通函数规则一个走虚函数规则输出完全不一样。speak()不声明virtual编译器根据Animal*这个静态类型直接调用了Animal::speak()Dog 里那个同名函数相当于完全隐藏了基类版本。virtualSpeak()则被标记为虚函数运行时会去查对象头上的虚函数表发现实际对象是Dog于是调用Dog::virtualSpeak()。这个例子就是虚函数所有性质的基础依赖对象的实际类型在运行时完成函数分派而正是因为这种动态分派才引出了后面一整套规则和坑。虚函数适合谁来学习说实话凡是写过类、做过继承的 C 开发者都必须掌握。不管你是做游戏引擎、客户端框架、服务端中间件虚函数几乎是无处不在的。它解决的问题非常明确让一段代码能够以统一的方式操作类型不同但行为相关的对象也就是多态。至于为什么需要它打个比方你给所有动物写了一套投喂逻辑来的是狗就汪汪叫地抢来的是猫就喵喵地凑。如果你不用虚函数就得在每个调用点用if (type Dog)去判断新加一种动物就要改一遍调用方代码维护成本爆炸。虚函数把这个对象到底是什么、该怎么交互的决策放进了对象自身调用方只需要对着基类接口说话系统可扩展性立刻拉满。2. 虚函数的完整性质清单——这些规则必须刻在心里这里有几条性质属于面试常考、开发常错的重灾区。我把完整清单整理出来每条都附带后果和原因解释。2.1 虚函数可继承、可覆盖且覆盖需保持函数签名一致虚函数在派生类中可以直接继承使用也可以重新定义。重新定义时必须保持返回类型、参数列表、const 限定符一致严格来说返回类型可以协变但那是例外中的例外新手先忘掉。从 C11 开始派生类覆盖虚函数应该加override关键字。它的作用不是让这个函数变成虚函数而是告诉编译器帮我检查如果在基类里找不到匹配的虚函数编译器直接报错。这个保护极其重要因为一不小心写错函数签名虚函数就悄悄变成了普通函数隐藏程序在运行时调用的是基类版本表面没报错逻辑已经错了。class Base { public: virtual void compute(int value); }; class Derived : public Base { public: void compute(int value) override; // 正确 void compute(double value) override; // 编译错误没有可覆盖的基类虚函数 };2.2 构造函数不能是虚函数析构函数建议必须是虚函数构造函数不能是虚函数原因很直接虚函数依赖虚函数表指针vptr来分派而 vptr 是在构造函数执行期间才初始化完成的。对象还没构造完虚函数表都还没有建立拿什么进行动态分派因此构造函数生来与虚函数绝缘——语法上C也直接禁止。析构函数则反过来只要这个类将来可能被派生析构函数就必须声明为虚。这是最典型、代价最大的坑class Base { public: ~Base() { std::cout Base destroyed std::endl; } }; class Derived : public Base { public: ~Derived() { std::cout Derived destroyed std::endl; } }; int main() { Base* p new Derived(); delete p; }这段代码输出只有Base destroyed。因为delete一个Base*时如果不走虚析构编译器只知道它要析构一个Base它根本不知道实际对象是Derived于是跳过Derived的析构逻辑。如果Derived里有申请的资源这就是内存泄漏加资源泄漏的双重灾难。正确做法是基类加virtual ~Base()这样 delete 时虚表找到实际对象类型先调用Derived::~Derived()再调用Base::~Base()析构链正确地自顶向下走完整。有一个容易混淆的点成员函数的析构顺序是从派生类到基类所以你希望基类析构被调用的时候派生类的部分已经先析构完毕了。这和构造函数从基类到派生类的顺序正好相反。2.3 静态成员函数不能是虚函数友元函数也不能是虚函数static成员函数属于类本身不属于某个对象不需要通过对象实例调用更不涉及实际类型分派——两个设计哲学直接冲突C 禁止static virtual组合。友元函数不是成员函数属于独立函数自然也没有虚函数一说。你可能见过虚友元这种奇技淫巧本质都是绕道成员函数间接实现不是友元自身成为虚函数。2.4 虚函数在基类和派生类中的访问权限可以不同这是一个很多人意识不到的坑虚函数在基类里是 public在派生类里改成 private 是完全合法的。C 对虚函数的分派基于对象的动态类型不基于静态类型也不基于访问控制。class Base { public: virtual void show() { std::cout Base show std::endl; } }; class Derived : public Base { private: void show() override { std::cout Derived show std::endl; } }; int main() { Base* b new Derived(); b-show(); // 编译通过运行输出 Derived show }show()在 Derived 里是 private但通过Base*调用时访问控制检查的是Base的调用点上下文也就是Base::show是不是 public。编译器检查通过后运行时沿着虚表又找到了Derived::show执行。函数签名、动态类型、调用点权限三者各管一摊。提示这条性质常被用来实现模板方法模式里基类调私有钩子函数的效果但团队里如果没人真的理解它出了 bug 排查成本极高。建议日常代码保持访问权限一致别炫技。2.5 虚函数可以重载但重载解析发生在编译期同一类里多个同名虚函数构成重载基类里可以有两个virtual void process(int)和virtual void process(const char*)。派生类覆盖时可以分别覆盖也可以只覆盖其中一个。注意区分两个机制重载overload是编译期静态解析覆盖override是运行期动态分派。它们叠加工作时先按参数列表在编译期选出具体调用哪个重载版本拿到函数签名后再走虚表分派找到该签名对应的派生类实现。这里最常见的坑是派生类写了一个同名但参数不同的函数你以为覆盖了实际上把基类的虚函数都隐藏了原来的重载关系全部失效。2.6 虚函数不能是模板成员函数模板成员函数本身不是真正的函数而是一个函数生成器只有在实例化时才生成具体函数。虚函数需要编译器在类布局阶段就确定函数地址并填入虚表两者时机冲突。C 明确规定成员函数模板不能声明为 virtual。如果你需要支持任意类型的接口一般用类型擦除或非虚模板方法转内部虚函数解决这个话题展开又是一篇文章这里只提个醒。2.7 虚函数可以被继承为虚函数也可以继续覆盖虚函数的虚本质是基类接口的一种属性标记它沿继承链传递。如果基类虚函数在中间层没有重新声明virtual它仍然是虚函数因为虚函数的虚属性存储在虚表里不会因为中间层的重复声明与否而丢失。实践中建议每一层都写virtual或override意图清楚审查代码的人不用沿类继承树再翻一遍。尤其当你做代码重构、中间插一层的时候漏一个override可能让系统静默退化。2.8 虚函数默认参数绑定静态类型而非动态类型这个是坑中之王。虚函数可以带默认参数但默认参数的解析发生在编译期基于指针或引用的静态类型和函数分派基于动态类型完全不同步。class Base { public: virtual void draw(int width 100) { std::cout Base draw width width std::endl; } }; class Derived : public Base { public: void draw(int width 200) override { std::cout Derived draw width width std::endl; } }; int main() { Derived d; Base* b d; b-draw(); // 输出 Derived draw width100 }函数调用的是Derived::draw默认参数却用的
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进