ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

一篇文章搞懂C++重载、隐藏与覆盖:编译器如何处理同名函数

一篇文章搞懂C++重载、隐藏与覆盖:编译器如何处理同名函数 C里如果有个最容易混淆概念排行榜重载、隐藏、覆盖这三个词绝对能上榜。我当年刚学C的时候先是把基类和派生类里的同名函数统统当成了重载结果在写一个带参数的重载调用时被编译器狠狠教育了一顿。后来啃完《Effective C》里关于名字隐藏的条款又自己写了十几个小例子验证才彻底搞明白这三个概念背后其实是三套完全不同的规则。这篇不打算做干巴巴的概念罗列而是从编译器处理函数调用的底层逻辑出发把重载、隐藏、覆盖的前因后果、判断方法、典型坑位一次性说清楚。适合正在学C基础的同学也适合那些在工作中突然被构造函数重载、虚函数覆盖问题难住的人。这三个概念是C继承知识的核心关卡搞懂它们虚函数、多态、动态绑定这一整条线都会顺起来。1. 先想清楚问题的本质编译器怎么处理一个函数调用很多人把重载、隐藏、覆盖混淆是因为只记住了同名函数这个表象却没有意识到这三种情况发生在编译器处理调用的不同阶段。所以第一步不是背定义而是先搞懂编译器的处理流程。1.1 三个概念分别回答的问题不同重载回答的是在同一个作用域里多个同名函数该如何共存典型的场景是同一个类里面写了多个add函数参数不同。隐藏回答的是当派生类和基类有同名成员时编译器究竟先看哪个认为同名即重载的人就是在这里栽的跟头。覆盖回答的是继承体系里同名虚函数在运行期如何被动态选择它依赖的是虚函数表和多态机制。一句话概括就是重载靠参数列表区分隐藏靠作用域遮蔽覆盖靠virtual 签名替换。1.2 函数调用要经过四步处理当你写下obj.print(10)这样的表达式时编译器严格按下面的顺序决策名字查找从当前作用域开始逐层向外查找直到找到名为print的实体。找到之后就不再往更外层找。重载决议把候选集里的函数按参数匹配程度排序选择最合适的那个。访问控制检查调用的位置是否有权限访问这个函数public、protected、private。绑定如果候选函数是虚函数采用动态绑定否则采用静态绑定也就是编译期就决定调谁。这个流程是理解一切的钥匙。隐藏发生在第一步重载发生在第二步覆盖发生在第四步。很多人混淆三者其实是把三个阶段的问题混在了一起。1.3 一个帮助理解的找人办事模型我把调用函数比作在大楼里找人办事。名字查找就是找张三这个人在哪个房间。只要你当前这一层有张三就不会去楼上找别的张三。派生类里有一个print编译器就认为这层已经有print了根本不会去基类那一层再看一眼——这就是隐藏。重载决议就是找到张三之后根据你要办的事选择他对应的一个能力。同一个张三既能写文档又能画图这是重载。覆盖则有点像公司指派的应急总机。你拨打的号码是固定的但总机背后实际接电话的人可能是张三也可能是李四取决于那一刻谁在值班——这就是虚函数的动态绑定。在这个模型里可以看得很清楚如果第一步连名字都没找到后面的重载决议和动态绑定都无从谈起。这也解释了为什么隐藏的优先级天然高于重载。2. 重载Overload同一作用域里的多面手重载是这三个概念里最好理解的一个但细究起来也有一些容易被忽略的规则。2.1 构成重载的条件重载针对的是同一作用域内的多个同名函数。构成条件有三个名字相同、作用域相同、参数列表不同。这里的参数列表包括参数的数量、类型和顺序。返回类型是不参与重载区分的。为什么返回类型不能作为重载依据因为调用f()的时候编译器很难判断你到底想要int版本还是double版本除非把每次调用都强行写进强制转换上下文那整个语言的表达代价就太大了。所以标准明确禁止只靠返回类型区分重载。#include iostream using namespace std; class Calculator { public: int add(int a, int b) { cout int version endl; return a b; } double add(double a, double b) { cout double version endl; return a b; } int add(int a, int b, int c) { cout int three params endl; return a b c; } }; int main() { Calculator calc; calc.add(1, 2); // 调用 int 版本 calc.add(1.0, 2.0); // 调用 double 版本 calc.add(1, 2, 3); // 调用三参版本 return 0; }2.2 重载决议的优先级当实参类型不能和某个重载函数声明的参数类型完全匹配时编译器会按照一组优先级规则挑选最合适的那个。简化版规则如下精确匹配完全一样或者只发生数组到指针、函数名到函数指针之类的退化匹配。提升匹配short到int、int到long、float到double这类标准提升。标准转换int到double、char到int、double到float这样的转换。用户定义转换需要调用构造函数或转换运算符。这个优先级在实际工作中会制造一些没想到的结果。比如void foo(char) {} void foo(int) {} foo(65); // 精确匹配 foo(int)不会转成 char 调用 foo(char)虽然65是字符A的ASCII码但字面量65是int精确匹配foo(int)绝不会有我以为它会转成char这种情况。设计重载集合时我是尽量避开整型和浮点型能靠隐式转换互相乱入的组合否则调用可读性很差。2.3 重载最常见的三个应用场景构造函数的重载是最典型的。类里可以定义无参构造、带参构造、拷贝构造等多个版本靠参数列表区分。顺便提一句热词里有人搜拷贝构造函数和重载其实拷贝构造函数可以理解为构造函数家族中一个特殊的重载形态只是它的第一个参数必须是自身类型的const引用并且有特殊的触发场景。运算符重载本质上也是函数重载。operator、operator这些名字在编译器看来都是一个普通函数名参数可以是int也可以是double照样可以重载出多个版本。还有一个容易被忽略的成员函数的const版本和非const版本可以构成重载。比如std::string的begin()就有const和非const两个版本它们的区别其实是this指针的const属性不同这也是重载。2.4 初学者最大的误解跨作用域不叫重载最大的坑来了。基类Base里有一个print(int)派生类Derived里也定义了一个print(double)。很多人潜意识里认为这组函数是重载参数不同嘛。但是C里这两个函数不在同一作用域根本不会组成重载候选集。编译器在处理d.print(1.2)时在Derived作用域找到print这个名字于是认为候选集里只有Derived::print(double)完全不知道基类还有一个print(int)。这其实就是下一节要讲的隐藏而不是重载。很多看着像是重载出了问题的编译错误根子都在这里。3. 隐藏Hide派生类对基类同名成员的一票否决隐藏是三个概念里最容易被误解的因为它和重载长得很像——都是同名函数参数可能不同。但两者的机制完全不同。3.1 隐藏是怎么发生的名字查找是自下而上的先在当前作用域找找不到才去外围作用域找。对继承体系来说派生类作用域就是最近的作用域基类作用域在外围。只要派生类里有同名成员基类的所有同名成员就全部不可见与参数无关只看名字。#include iostream using namespace std; class Base { public: void print() { cout Base::print() endl; } void print(int n) { cout Base::print(int) n endl; } void print(double d) { cout Base::print(double) d endl; } }; class Derived : public Base { public: void print() { cout Derived::print() endl; } }; int main() { Derived d; d.print(); // 输出 Derived::print() // d.print(10); // 编译错误no matching function for call to Derived::print(int) return 0; }注意d.print(10)是编译错误。这不是参数不匹配而是名字print在Derived作用域已经找到编译器根本不会把Base里的print(int)和print(double)加进候选集。基类的一整组重载因为派生类声明了一个同名函数瞬间集体消失。3.2 隐藏不只发生在函数身上隐藏看的是名字所以它不仅作用于函数也作用于数据成员。基类的value和派生类的value是两块完全不同的内存在派生类作用域里写value只会改派生类那一块。想访问基类的value必须写成Base::value。工程上这基本是个禁忌。我见过不止一个同事因为把成员变量和基类成员变量命名成同一个名字排查了半天才发现是遮蔽而不是什么逻辑错误。一定要避免自己的数据成员和基类数据成员同名。3.3 隐藏带来的三个经典坑位第一个就是上一节说的派生类里凭空消失的基类重载集合。Base有print()、print(int)、print(double)三个重载Derived只定义了一个print()另外两个全都不能通过Derived对象直接调用。第二个是虚函数的悄悄隐藏这是最阴险的坑。如果Derived里定义一个和基类虚函数同名但签名不同的函数它不是覆盖而是隐藏。调用d.g(1.2)时可能在Derived作用域只找到一个g(int)候选于是double被隐式转换为int程序完全没有走多态路线。这种代码编译能过、运行结果却莫名其妙是调试时最费神的。第三个是编译器警告。Clang 对这种情况会提示hides overloaded virtual functionGCC 的警告有时不够明显但也不是完全没有。我建议把-Wall -Wextra打开看到这种警告千万别忽略。3.4 解决隐藏的两个标准姿势需要访问被隐藏的版本时第一种是使用显式作用域解析d.Base::print(10); // 指名道姓调用基类版本第二种也是更推荐的方式是在派生类里用using声明把基类的名字集合引进来class Derived : public Base { public: using Base::print; // 把 Base 里全部的 print 重载引入 Derived void print() { ... } };有了using Base::printd.print(10)就能正常调用基类版本d.print()仍然调用派生类自己的版本。这两个作用域的同名函数在这时候才真正像一组重载那样共存。这是我在项目中处理派生类自定义同名函数、又要保留基类重载版本时的标准解法。3.5 为什么C故意保留隐藏机制很多初学者觉得隐藏反直觉甚至有毛病但C保留这个机制是有道理的它保证了当前作用域里声明优先的确定性。如果编译器允许自动把基类同名函数拉进来参与重载那么在一个局部作用域里新增一个函数就可能静默改变远处某个调用的行为这比隐藏更危险。C选择了可预期性——当前作用域里的声明就是老大。理解了这层设计哲学就不会总觉得是编译器在故意整人。4. 覆盖Override虚函数机制下的替换协议覆盖是三个概念里唯一依赖virtual关键字的机制也是多态的核心。4.1 覆盖做了什么覆盖指的是派生类重新实现一个基类的虚函数并且签名完全一致。它的核心价值是动态绑定——运行时不看指针的声明类型而是看它实际指向的对象是什么类型。通俗讲讲虚函数表的工作原理每个含有虚函数的类其对象内部会有一个指向虚函数表vtable的指针虚函数表里存放了该类各虚函数的实际地址。Base对象里对应表项指向Base版本Derived对象里对应表项指向Derived版本。当你用Base* p调用p-process()时编译器虽然按静态类型找到了Base的候选但真正跳转时是通过p所指向对象的 vtable 拿到函数地址。所以最终执行的是派生类覆盖后的版本。需要强调一点动态绑定也是建立在名字查找之上的。通过Base*调用时编译器先以Base的静态类型做名字查找找到那个虚函数然后才进入虚表分派。所以隐藏和覆盖并不矛盾它们是不同阶段的产物。4.2 构成覆盖的三个必要条件基类函数必须声明为virtual可以是多层继承下来的。函数签名完全一致参数类型、顺序、个数、const限定都必须相同。返回类型相同或者满足协变返回类型派生类版本返回的类型是基类版本返回类型的一个派生类类型。任何一条不满足结果都只是隐藏不构成覆盖。尤其要留意const限定符void f()和void f() const是两个不同的签名。基类是virtual void f()派生类写void f() const那不是覆盖而是隐藏。这种错误在编译期未必触发明显的报错却会让整个多态失效。4.3 override 和 final最好的保险丝从C11开始派生类的覆盖函数可以显式加上overrideclass Derived : public Base { public: void draw() override { ... } };如果Derived::draw并没有真正覆盖基类的任何虚函数编译器直接报错。这个特性就是为了防止签名变化导致你以为覆盖了其实是隐藏了的无声崩溃。final则用于禁止后续派生类再覆盖当前函数。如果一个虚函数被标记为final任何继承类都不能再去改动它这在设计某些基础类的时候能有效防止别人重写带来Bug。我在实际项目里有一条铁规矩凡是意图覆盖基类虚函数的地方必须写override绝不允许只靠我看签名没问题。4.4 覆盖与隐藏共存的经典场景覆盖和隐藏可以同时出现而且这个场景最能检验你是否真正理解了三者的关系#include iostream using namespace std; class Base { public: virtual void process(int x) { cout Base::process(int) endl; } void process(double d) { cout Base::process(double) endl; } }; class Derived : public Base { public: void process(int x) override { cout Derived::process(int) endl; } }; int main() { Derived d; d.process(42); // 输出 Derived::process(int) d.process(3.14); // 输出什么看下面 return 0; }这里有两件事同时发生Derived::process(int)覆盖了Base::process(int)这是动态绑定而Base::process(double)在Derived作用域里被隐藏了因为Derived里有名为process的函数。所以d.process(3.14)会怎样编译器在Derived作用域里只知道process(int)一个候选3.14被隐式转换为int调用的是Derived::process(int)。输出是Derived::process(int)不是你想当然的double 版本。这种覆盖和隐藏纠缠在一起的案例不知坑过多少人。如果你不清楚隐藏的规则看到这个输出完全无法解释。4.5 两个容易忽略的虚函数细节虚函数的默认参数有一个著名陷阱默认参数在编译期按指针的静态类型决定。比如Base::f(int x 10)是虚函数Derived::f(int x 20)覆盖它但通过Base* p new Derived; p-f()会使用默认参数10而不是20。所以不要在虚函数里使用不同的默认参数甚至最好避免在虚函数里使用默认参数否则调用方根本分不清。另一条是铁律基类析构函数必须是虚函数。否则通过Base*去delete一个Derived对象时只会调用Base的析构函数Derived内部持有的资源得不到清理。这个要求的底层逻辑就是覆盖和动态绑定。5. 一眼分清三者的判断流程和编译错误排查前面讲完了三个概念的机制这一节给一张可以直接套用的速查方法和排错清单。5.1 核心判断表概念作用域基类是否要求virtual函数名的匹配条件绑定时机重载同一作用域内无关名字相同参数列表不同编译期静态绑定隐藏派生类与基类无关名字相同即可签名随意编译期静态绑定覆盖派生类与基类必须为virtual名字相同签名完全一致运行期动态绑定遇到问题直接对照这张表基本上能解决90%的困惑。5.2 三步判断流程第一步两个函数是否在同一作用域如果是那就是重载候选看重参数列表如果不在同一作用域最典型的是基类与派生类就进入第二步。第二步基类中被同名对应的那个函数是virtual吗如果不是那无论签名是否完全一致都只是隐藏不是覆盖。第三步基类函数是virtual再看看签名是否完全一致。一致才是覆盖不一致仍然是隐藏尽管此时编译器可能弹出一个hides virtual function警告。很多人直接跳到是不是override这一步忽略了第二步里virtual的前提所以判断总是偏差。5.3 常见编译错误与解决方案错误一no matching function for call to Derived::print(int)。原因往往是基类重载函数被派生类同名函数隐藏了。解决要么在派生类里using Base::print;要么用d.Base::print(10)显式调用。错误二Derived::process marked override but does not override。说明签名不一致。最常见的是漏了const或者参数类型写成了float而不是int。解决改成和基类完全一致的签名。错误三Clang 的Derived::process hides overloaded virtual function警告。说明你定义了和基类虚函数同名但签名不同的函数。如果确实是想覆盖改签名如果只是想添加一个新函数建议直接换名字不要跟基类虚函数重名降低认知负担。5.4 调试中的实战建议现在的 IDE 普遍能在鼠标悬停时显示函数解析结果比如是Base::print(int)还是Derived::print(int)。如果显示结果和你的预期不符第一反应应该是这里是不是发生了隐藏。调试器里也可以直接看当前执行到的函数当你以为会进入派生类版本、结果进了基类版本90%的可能不是覆盖失效而是签名不一致导致的隐藏。6. 工程实践中的规则和经验概念讲完了最后说几条我实际写代码时一直遵守的规则每条都是从踩坑经验里提炼出来的。6.1 三条铁律覆盖必须写override。这个前面提过但值得再强调一次。有了这个关键字编译器能帮你检查签名而不是让你自己在报错和警告里猜。不要在派生类中定义和基类同名但不同参数的函数除非你非常清楚这是在制造隐藏。如果确实需要新增功能换个名字比靠重载安全得多。看一眼上面d.process(3.14)的例子就明白这种代码对后来维护的人极不友好。数据成员不与基类成员同名。同名value导致的内存遮蔽问题真的会让人浪费一整晚。6.2 合理使用 using 把基类重载带回家如果基类设计了一组合理的重载函数派生类想新增一个版本但又希望继续暴露基类版本那就在派生类定义函数之前写上using Base::类名。一行代码解决隐藏导致的家族消失也明确告诉读代码的人你是故意融合两个作用域的名字查找结果的。6.3 一点个人体会这三个概念我至少学了三遍才真正通透。第一遍只是背下了定义第二遍被no matching function逼着去查标准第三遍是看懂编译器处理顺序之后突然一通百通。如果你现在也感觉混乱最有效的办法不是反复看文章而是把本文这几个带坑的示例自己敲一遍亲眼看看编译错误和运行输出比背多少口诀都管用。重载、隐藏、覆盖是C继承知识的核心关卡这一关过了后面读多态、动态绑定、虚函数相关的代码都会轻松很多。
返回列表