ARTICLE DETAIL

建站实战干货

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

C++函数重写详解:从虚函数到override,彻底掌握多态核心机制

2026/9/7 16:00:06 拓冰建站 浏览量
C++函数重写详解:从虚函数到override,彻底掌握多态核心机制 1. 从“同名函数”说起为什么C需要函数重写这一机制很多初学者第一次接触“函数重写”这个概念时脑子里冒出来的第一个问题往往是函数名一样编译器怎么知道该调用哪一个如果只是换一个函数体为什么不直接改原函数我先从实际场景聊起。假设你在一家做图形引擎的公司写代码基础框架里有一个Shape类负责描述所有几何形状的公共行为。后来你要实现圆形、矩形、三角形这些具体形状它们各自的面积计算方式完全不同。如果每个形状的类都只能有一份固定实现那代码就会变成一长串if (type CIRCLE)或者switch (shapeId)每加一种新形状都得去改公共代码。这种写法在工程上非常痛苦一旦逻辑复杂起来改一处崩三处。函数重写override解决的就是这个痛点让子类对父类已有的虚函数提供自己的实现同时保持调用接口完全统一。也就是说同一句代码放在不同的对象身上执行的行为完全不一样。多态就是靠这一机制构建起来的。那它和“重载overload”有什么区别我见过的初学者最容易把这两个概念混在一起。简单说重载是同一作用域内函数名相同但参数列表不同编译器靠参数去区分调用版本这属于编译期行为。重写是父子类之间函数签名完全一样或兼容靠virtual关键字和对象类型去决定调用哪一个版本这属于运行期行为。如果再深挖一步函数重写背后还有两个容易混淆的兄弟概念一个是隐藏hide/name hiding一个是重写。隐藏是指子类定义了和父类同名但并非 virtual 的函数或者参数列表不一样的函数导致父类的同名函数在子类对象上被“遮住”了。很多人把隐藏误当重写结果调试半天发现调用的不是自己以为的那个函数这就是典型的“名字遮蔽”陷阱。这篇内容我会按这样的顺序展开先讲清楚函数重写的语法规则和核心机制再讲它运行时的底层原理接着是实操中必须避开的坑最后结合 C11 之后的现代特性聊一聊现代 C 项目里函数重写的最佳实践并附上一组常见的面试自测题。2. 函数重写的语法规则与核心原理拆解2.1 构成重写关系的三个条件从语法上说子类函数要构成对父类虚函数的重写必须同时满足几个条件。缺一个编译器都不会把它当成重写而是当成隐藏甚至直接报错。父类中的函数必须是虚函数用virtual修饰。子类函数必须与父类虚函数函数名相同。参数列表必须完全相同包括参数类型和顺序不包括返回类型的协变情况这个后面单独说。访问权限可以不同public/protected/private 都有各自的应用场景但一般建议保持一致避免不必要的困惑。举一个最基础的例子#include iostream using namespace std; class Shape { public: virtual double area() const { return 0.0; } }; class Circle : public Shape { public: double area() const override { return 3.14159 * radius_ * radius_; } private: double radius_ 1.0; }; class Rectangle : public Shape { public: double area() const override { return width_ * height_; } private: double width_ 3.0; double height_ 4.0; }; int main() { Shape* s1 new Circle(); Shape* s2 new Rectangle(); cout s1-area() endl; // 输出圆的面积 cout s2-area() endl; // 输出矩形的面积 delete s1; delete s2; return 0; }这里有个细节很多人第一次会忽略area()为什么要在末尾加一个const因为父类里的虚函数声明是virtual double area() const如果你在子类里写的函数没有末尾的const那么签名就不一样了编译器会认为这是一个全新的函数而不是对父类函数的重写。我在实际工作中验收过不少新人的代码这是最高频的出错点之一。2.2 override 关键字的作用请让编译器替你把关在 C11 之前写重写函数没有任何标识。编译器不会主动帮你检查这个“看起来像重写”的函数是不是真的构成了重写。如果父类接口改了名、改了参数而子类函数没跟上代码照样编译通过但行为就变成了隐藏程序跑起来一团糟还不好定位。C11 引入了override关键字专门解决这个问题。它的作用非常简单显式告诉编译器“这个函数是要重写父类虚函数的”如果实际并没有构成重写直接编译报错。class Circle : public Shape { public: double area() const override { // 如果父类没有 virtual double area() const这里就会报错 return 3.14159 * radius_ * radius_; } };我强烈建议所有人在写子类重写函数时一律加上override。这不是风格问题而是工程安全问题。它把一类“跑起来才发现不对”的逻辑错误提前到了编译阶段。2.3 返回类型的协变唯一合法的“签名不完全相同”严谨一点讲重写函数并不要求返回值类型完全一致。C 允许一种特殊情况如果父类虚函数返回的是某个类如基类的指针或引用子类重写函数允许返回这个类的派生类的指针或引用。这就是“返回类型协变”。class Animal { public: virtual Animal* clone() const { return new Animal(*this); } }; class Dog : public Animal { public: Dog* clone() const override { // 返回值从 Animal* 变为 Dog*仍然构成重写 return new Dog(*this); } };这在实现工厂方法、原型模式的时候非常好用但新手看到这种代码容易懵这里补充一个判断依据只要返回的是指针或者引用并且是“基类返回基类指针子类返回子类指针”这一方向编译器就认它是重写否则统统算签名不一致。2.4 为什么一定要有 virtual去掉会怎样有一种误解是“子类写同名函数就算重写”。不是的。如果没有virtual父类指针指向子类对象时调用的函数是编译期就确定的绑定到父类的版本。只有加了virtual才会走所谓的“动态绑定”在运行时根据对象真正所属的类型来决定调用哪一个实现。用一个例子来演示class Base { public: void say() { cout Base::say endl; } }; class Derived : public Base { public: void say() { cout Derived::say endl; } }; int main() { Base* p new Derived(); p-say(); // 输出 Base::say不是 Derived::say return 0; }这就是隐藏的典型表现。如果你希望输出Derived::say就必须把Base::say声明为virtual并且把Derived::say标记override。理解这个点等于理解了虚函数机制的第一扇门。3. 虚函数表与动态绑定函数重写运行的底层逻辑3.1 虚函数表vtable到底是什么为什么加了virtual就能在运行时“找到”正确的函数这个底层机制对写好 C 代码很重要尤其是你想理解多态性能开销、二进制兼容性或者排查疑难 bug 的时候。C 标准并没有规定虚函数必须用虚函数表实现但几乎所有主流编译器GCC、Clang、MSVC都采用了同一种做法每个包含虚函数的类都会在编译期间生成一张虚函数表vtable表中按声明顺序存放着该类所有虚函数的地址。每个对象的内存布局里会额外增加一个指针vptr指向所属类的虚函数表。当调用一个虚函数时编译器生成的汇编代码大致逻辑是从对象内存中取出 vptr通过 vptr 找到 vtable根据虚函数的声明顺序或编译器内部编号偏移取出对应的函数指针跳转到该地址执行。这个过程被称为“动态绑定”也叫“晚绑定”。之所以叫“晚”是因为函数地址不是在编译期确定的而是在运行期通过查表确定的。3.2 子类重写时 vtable 里发生了什么当子类重写了一个虚函数子类自己的 vtable 中对应位置的函数指针会被替换成子类函数的地址。没有重写的虚函数子类 vtable 中则保留父类函数的地址。所以你在子类对象上调用虚函数实际找到的永远是子类 vtable 中的那一个指针。如果你用父类指针指向子类对象指针的静态类型是Base*但对象本身的 vptr 指向的仍然是子类的 vtable。这就是为什么能实现“父类指针调出子类行为”。有一个很常见的面试题构造和析构函数里调用虚函数会发生什么答案是在基类的构造函数中调用虚函数不会调用到子类的重写版本析构函数中同理。原因是基类构造期间子类对象还没构造完成vptr 还指向基类的 vtable基类析构后子类成员已先行销毁vptr 同样不再指向子类 vtable。具体来说在基类构造函数的执行阶段对象的类型被视为基类类型。这是另一处“看起来不该这样但事实就是如此”的暗坑后面实战部分会再展开。3.3 加了 virtual 就一定性能很差吗很多人担心虚函数影响性能于是刻意避开多态设计。这种担心部分合理但往往被夸大了。一次虚函数调用的额外开销大致包括多访问一次 vptr 加一次 vtable 指针两次间接寻址因为函数地址运行期才确定编译器无法内联inline该函数。这在绝大多数业务代码里可以忽略不计。真正需要警惕的场景是高频循环里反复调用虚函数比如游戏引擎逐帧对大量单位执行 update或者数值计算里对每个元素调用虚函数。真到那一步合理的做法也不是放弃虚函数而是调整设计批量处理、模板替代动态多态等而不是凭空焦虑。这里给出一个直观对比非虚函数调用在汇编里通常是一条call指令直接跳转虚函数调用则多了解引用和偏移计算。实测中二者差距往往在纳秒级但如果是百万、千万次级别的调用累计差距就很可观了。4. 重写、重载、隐藏三兄弟的边界到底在哪4.1 三者对比速查表这块内容无论面试还是实际开发都绕不开。我把三个概念放在一张表里方便随时查阅概念作用范围函数签名要求virtual 要求绑定时机典型场景重载 overload同一类内或同一作用域函数名相同参数列表不同不要求编译期提供同一操作的多种入参版本重写 override父子类之间函数名、参数列表都必须相同父类函数必须是 virtual运行期动态绑定多态设计子类提供个性化实现隐藏 hide父子类之间函数名相同参数列表不限不要求编译期子类定义了与父类同名的非虚函数或不同参数函数4.2 隐藏是怎么“骗”过你的隐藏最坑的地方在于代码逻辑分析时一眼看过去觉得“这不就是重写嘛”。但运行结果告诉你调用的是父类的版本。尤其是你用父类指针或引用持有子类对象时隐藏和重写的差异会立即暴露出来。看这段代码class Base { public: virtual void show(int x) { cout Base::show(int): x endl; } void display() { cout Base::display endl; } }; class Derived : public Base { public: // 这是隐藏不是重写参数类型变了 void show(double x) { cout Derived::show(double): x endl; } // 这是隐藏不是重写父类没有加 virtual void display() { cout Derived::display endl; } }; int main() { Base* p new Derived(); p-show(42); // 调 Base::show(int) p-display(); // 调 Base::display return 0; }Derived::show(double)因为参数类型从int变成了double不构成重写Derived::display()因为父类版本没有virtual也不构成重写。两者都属于隐藏。这种情况下如果不加override标记编译器毫无反应运行结果可能与你直觉完全相反。这其实就是为什么现代 C 项目规范里普遍要求“重写必加 override”。4.3 为什么 C 的隐藏规则这么“反直觉”隐藏规则是 C 从 C 的“名字查找要先于函数重载匹配”机制里继承过来的。作用域查找的规则是先在当前作用域里找名字找到了就不再继续向外层作用域找。子类作用域里的show一旦被找到即使参数不匹配编译器也不会继续到父类作用域里寻找可以匹配的show于是父类版本被直接遮蔽。这在早期 C 中是一个令人头疼的设计选择如今已经无法改变只能靠using声明或者显式Base::show(...)来绕过。所以在实际工程里我很少在父类里放一个同名但参数列表不同的函数给子类“无意间”隐藏这属于自找麻烦。5. 析构函数的隐藏陷阱为什么父类析构函数必须写 virtual5.1 不写 virtual 的后果这是 C 面试题里的“老熟人”也是实际项目最常踩的坑之一。假设你有这样一个继承体系class Base { public: ~Base() { cout Base destroyed endl; } }; class Derived : public Base { public: ~Derived() { cout Derived destroyed endl; } int* array new int[100]; }; int main() { Base* p new Derived(); delete p; // }Base的析构函数没有声明为virtual。那么delete p时编译器看到的静态类型是Base*它调用析构函数时只调用Base的析构函数。结果是Derived的析构函数根本没执行array申请的内存直接泄漏。而且这不是那种“跑一次没事”的小隐患。内存泄漏通常不会立刻让程序崩溃但长时间运行后内存持续上涨最终 OOM排查困难。我在真实项目里见过因为一个析构函数漏加 virtual 导致服务端内存无限增长的问题最后靠valgrind一点一点定位出来。5.2 标准答案和最佳实践做法很简单只要一个类意图作为基类被继承并且可能通过基类指针删除子类对象析构函数就必须声明为 virtual。反过来如果这个类明确不作为基类那析构函数尽量不要加 virtual——因为加 virtual 会引入虚函数表导致对象体积增加一个指针大小并且类不再满足“标准布局类型”的某些条件损失的是内存和二进制兼容性。有一种流行的写法值得参考class Shape { public: virtual ~Shape() default; // 默认析构也标 virtual virtual double area() const 0; };现代 C 中对于不想被继承的类可以在类声明后面加final来阻止继承class FinalClass final { public: ~FinalClass() {} // 不需要 virtual因为不可能有子类 };这样既安全又不会有额外的虚函数表开销。6. 纯虚函数与抽象类在设计层面用好函数重写6.1 纯虚函数是“接口约定”如果一个虚函数在父类里没有合理的默认实现或者说父类本身就不该被实例化我们可以把虚函数写成纯虚函数class Shape { public: virtual double area() const 0; // 纯虚函数 virtual ~Shape() default; };有纯虚函数的类被称为抽象类它不能直接创建对象。抽象类的意义在于规定“子类必须提供什么接口”。任何继承它的类必须重写所有纯虚函数否则子类也依然是抽象类不能实例化。这种设计非常契合“接口隔离”和“依赖倒置”原则。调用方只需要依赖抽象类不需要知道具体子类是什么新增一种形状只需要新增一个子类无需改动其他代码。这里有个工程经验纯虚函数的析构函数也要给一个函数体的定义哪怕为空。因为析构函数在对象销毁时必然被调用而纯虚析构函数如果没有实现链接阶段会报错。这一点很多新手踩过坑。6.2 final 关键字锁死重写与override相对的是final。它可以修饰类也可以修饰虚函数。修饰函数时表示“这个虚函数不允许再被后代类重写”。class Circle : public Shape { public: double area() const override final { return 3.14159 * radius_ * radius_; } }; class SpecialCircle : public Circle { public: // double area() const override; // 编译错误final 函数不能重写 };final的价值在于给设计加上“契约边界”。某些核心算法你不想让后续维护者随意覆盖就标final防止继承体系失控。在大型团队协作时这算是一种“隐形的代码规范”。7. 现代 C 下的重写实践const、noexcept、default 与重写的组合艺术7.1 const 成员函数与重写的合作很多人在写类时对 const 修饰符不敏感但它在重写场景里特别关键。父类虚函数带不带const直接决定了子类重写函数的签名。如果你希望一个函数在 const 对象上也能调用并把这种特性延续到子类那么父类和子类的函数都必须带const。签名不统一就成了隐藏。class Base { public: virtual string name() const { return Base; } }; class Derived : public Base { public: string name() const override { // 这里的 const 不能少 return Derived; } };const的正确使用还能帮助你发现逻辑错误如果你在 const 成员函数里试图修改成员变量编译器会直接报错。这在多线程环境下尤其有价值——const 成员函数往往暗示“读操作”调用时更安全。7.2 noexcept 与重写什么场景下该加什么场景下不该加C11 引入noexcept后很多人在虚函数上随意乱加。这有一个隐患如果父类虚函数声明为noexcept子类重写时最好也保持一致。如果子类抛出了异常程序会直接调用std::terminate终止运行。反过来如果父类没有声明noexcept子类声明了运行期也不会有什么好处反而触发异常处理行为不一致的问题。我的建议是对于移动构造函数、移动赋值运算符、析构函数、swap 这类通常不会抛异常的操作明确标noexcept对于其他虚函数如果实现里确实不会抛异常可以加但要确保所有子类都不会破例。不能只看父类接口就贸然加noexcept否则后续子类实现想抛异常时只能违反契约。7.3 default 与虚析构看起来很怪但很实用注意下面这个模式class Base { public: virtual ~Base() default; };“default”的意思是“虽然我声明了析构函数但使用编译器生成的默认实现”。配合 virtual 使用既保证了多态删除的安全性又避免了手写空函数体可能带来的细节问题。手写virtual ~Base() {}和virtual ~Base() default;在这里行为基本等价但后者语义更明确也更符合现代 C 的审美。同理拷贝构造、拷贝赋值运算符也可以default但一旦基类有了virtual函数它的拷贝语义就需要格外小心通常建议把拷贝构造和赋值运算符删除 delete或显式声明。涉及多态对象的拷贝本来就是一件复杂的事默认的浅拷贝很容易在继承体系里埋下双重释放的隐患。8. 六个最容易踩的坑和对应的排查思路函数重写这块很多问题不是语法不会而是写对了表象、没躲过机制细节。我把平时工作里遇到的高频问题整理一下。8.1 构造函数里调用虚函数反直觉现象在基类构造函数里调用虚函数不会调到子类重写版本。class Base { public: Base() { init(); } virtual void init() { cout Base::init endl; } }; class Derived : public Base { public: Derived() : Base() {} void init() override { cout Derived::init endl; } }; int main() { Derived d; // 输出 Base::init }原因是对象构造时先执行基类构造函数此时子类部分还没初始化vptr 指向的是基类的 vtable所以虚函数调用被解析到基类版本。解决方案是把这种初始化逻辑放到子类构造函数末尾或者提供单独的init接口让调用方在构造完毕后显式调用。8.2 析构函数里调用虚函数析构函数里调用虚函数也不会调用子类重写版本因为子类析构函数已经先执行完毕vptr 已经切回基类 vtable。如果确实需要在析构时执行某些“多态”清理逻辑一个常见做法是让基类析构函数直接调用一个非虚的、内部包含具体清理逻辑的函数。或者换一种设计思路把释放资源的职责交给子类自己的析构函数。8.3 重写函数没有加 override这个前面反复强调了不加override的后果是当你把参数写错、把 const 漏掉、把返回类型搞错时编译器不报错只是把代码当作普通隐藏程序行为和你预期完全不一致。加了override这类错误会在编译期就被拦截。我在代码评审里有一条硬性要求凡是打算重写父类虚函数的子类函数一律加override。没有例外。8.4 父类析构函数没有加 virtual不写 virtual 的结果就是“只析构了一半”。特别是当基类还管理着动态分配的内存时这种泄漏是静默的。这不是理论问题我早期参与过一个数据采集系统就是因为一个基类析构函数漏写了 virtual导致每次切换采集器都泄漏一部分内存运行几个小时后内存占用翻倍。排查了很久才找到根因。现在很多静态检查工具比如 clang-tidy会把这个作为一条强警告遇到类似问题先看看工具输出。8.5 private 虚函数也能重写吗访问权限不影响重写。父类可以声明private virtual子类依然可以重写它只是父类外部调用时受访问权限限制。这种写法在某些框架设计里用于实现模板方法模式用 public 非虚函数调用 private 虚函数子类重写 private 虚函数来定制行为。它比纯虚函数更隐蔽调用方无法直接调用虚函数本身只能通过父类的 public 接口触发。8.6 重写和重载同时出现的时候如果子类同时写了void show(int)和void show(double) override前提是父类有virtual void show(int)这样的虚函数要注意show(double)可能构成隐藏而不是重载。因为父类作用域里的show被名字查找遮蔽了你在子类里写多个show这些函数之间是重载关系但它们和父类中同名的函数之间可能部分重写、部分隐藏。这种组合容易让人晕头转向。我的建议是如果父类存在同名虚函数子类尽量不要新增同名但不同参数的函数除非你明确知道自己在做什么。9. 面试与工程中绕不开的“函数重写”高频题结合热词里的“C八股文”“C面试题”函数重写几乎是大厂 C 岗位面试必考的一块。我把常见的考点整理成一组自测题先自己回答再看我的解答思路。1. 重写和重载、隐藏的区别是什么重载关注的是同一作用域内函数名相同、参数不同编译期确定。重写关注的是父子类之间函数签名相同、父类是虚函数、运行期动态绑定。隐藏则是父子类之间同名函数由于签名不同或父类非虚导致的父类函数被遮蔽现象。2. 为什么基类析构函数要用 virtual因为如果你的代码通过基类指针删除子类对象而析构函数不是虚的运行期只会调用基类析构函数子类析构函数被跳过导致资源泄漏。3. 构造函数和析构函数里调用虚函数会怎样不会发生动态绑定调用的是当前正在构造或析构的类的版本。因为 vptr 在构造和析构期间指向的是当前类的 vtable。4. 什么是纯虚函数什么是抽象类纯虚函数是用 0声明的虚函数。含有纯虚函数的类称为抽象类无法实例化。子类必须实现所有纯虚函数才能实例化。5. override 和 final 有什么区别override用于确认子类函数确实在重写父类虚函数如果不构成重写则报编译错误。final用于禁止进一步重写修饰虚函数或禁止类被继承修饰类。6. 虚函数表是什么虚函数调用过程是怎样的每个含虚函数的类拥有一张 vtable表里存有虚函数地址。对象的首个成员是 vptr指向所属类的 vtable。调用虚函数时运行期通过 vptr 找到 vtable再根据函数偏移取出地址并调用。7. 虚析构函数可以声明为纯虚函数吗可以。但纯虚析构函数必须提供函数体因为析构时必然要调用它。virtual ~Base() 0;后面还需要在外面写Base::~Base() {}。8. 静态成员函数可以是虚函数吗不能。静态成员函数属于类本身不依赖对象没有 this 指针也不参与动态绑定。9. 友元函数可以是虚函数吗不能。友元函数不属于类的成员继承体系对它没有意义。10. 为什么构造函数不能是虚函数构造对象时vptr 还没有初始化完成动态绑定无从谈起。构造函数只能通过对象类型显式调用谈不上多态。这些问题看似分散本质上考验的是你对“虚函数机制”和“对象模型”的掌握程度。搞懂 vtable 和 vptr 的底层逻辑几乎每一个问题的答案都能自然推导出来。10. 最后的实操建议函数重写不是一个孤立的语法点它是 C 面向对象设计和运行期多态的基石。把它学扎实了后面理解抽象基类、策略模式、观察者模式、插件化架构都会轻松很多。我从实际经验里总结几条硬建议子类重写函数一律加override让编译器做你的守门员。基类析构函数永远声明为virtual或使用final类锁定继承。不要在构造函数或析构函数里依赖虚函数调用实现多态行为。纯虚析构函数记得给函数体。如果你要给某个虚函数加noexcept先确认所有子类实现都不会抛异常。代码评审时可以专门跑一遍clang-tidy的相关规则自动排查虚函数相关问题。最后分享一个我一直在用的自查思路一个类如果包含 virtual 函数它就已经不是简单的数据容器了而是承担了“接口职责”。写它的子类时先问自己三个问题这个函数是否真的需要被重写重写后是否会影响其他调用方如果不重写默认行为是否合理想清楚这三个问题函数重写这关算是真正过了。