ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C++ const成员函数与operator重载:this指针的隐式陷阱

C++ const成员函数与operator重载:this指针的隐式陷阱 写这篇东西的起因是昨天群里一个朋友贴了段代码大致是一个const对象去调用某个普通成员函数编译器直接甩了一句error C2662。他在群里问我这个函数又没修改成员变量为什么const对象不能调用我看了下他的代码函数签名漏掉了尾部的const。就这么个三秒钟能解决的问题却带出了一连串讨论const成员函数到底对什么生效this指针的类型是什么还有人追问如果类里重载了取地址运算符operatorconst对象取地址会怎样我意识到这两个问题其实是一根藤上的瓜——它们都跟this这个隐式参数的类型有关。写这篇东西就是想把这条线从头到尾扯清楚顺便分享一次我被const成员函数和operator的组合拳坑到怀疑人生的调试经历。如果你是刚学 C或者写过一阵但遇到const、取地址重载就含糊这篇应该能帮你把底层逻辑理顺。1. 从一段编译错误开始为什么const对象调不动非const成员函数1.1 报错现场的还原与this指针的类型先还原一下报错现场。假设有这样一个类class Sample { public: void touch() { // 假设这里会对成员变量做写操作 } }; const Sample obj; obj.touch(); // error C2662: void Sample::touch(void): cannot convert this pointer from const Sample to Sample 报错信息说得很直白this指针无法从const Sample*转换成Sample*。这里的关键是要意识到成员函数不是真的“挂在”对象上的它只是编译器给你的一种语法糖。每次调用obj.touch()编译器都会做一个等价转换相当于调用了一个自由函数touch(obj)其中传入的第一个参数就是this指针。问题在于obj是const Sample类型的对象所以传入的地址是const Sample*。而touch()声明为普通成员函数它期望的this是Sample*。把一个const Sample*直接塞给Sample*属于去掉底层const限定符这在类型系统里是不安全的。编译器不知道这个函数未来会不会去写数据它只能通过函数签名来判断所以直接在编译期拦住你。很多初学者会觉得“我的函数体里没有修改操作为什么还要加const”。这其实搞反了因果关系。编译器不是看你函数体里写了什么而是看你有没有在签名上给出承诺。一旦你写了void touch()你就承诺了调用方这个函数会拿到一个可变的Sample*它可能修改对象。那么把一个const对象交给它就是对const的破坏。C 的类型系统就是这么保守它宁可拒绝也不愿留下未定义行为的隐患。1.2 把成员函数改写成自由函数一切都通透了如果上面那段还是有点绕你可以把成员函数“降维”成自由函数来看。假设Sample里有一个成员函数和一个const成员函数class Sample { public: void write(); void read() const; };它们在编译器眼里的真实面目约等于下面两个自由函数void write(Sample* this_ptr); void read(const Sample* this_ptr);write()的this是指向Sample的指针read()的this是指向const Sample的指针。这两个函数的参数类型不同所以它们可以重载。调用时如果对象是const Sample就只能匹配第二个如果对象是非const Sample两个都能匹配但第一个是更优的匹配因为不需要在实参上添加const。这也解释了一个常见的现象非const对象可以调用const成员函数。因为从Sample*到const Sample*的转换是一个“添加底层 const”的合法隐式转换类型系统允许。反过来从const Sample*到Sample*则被禁止它必须用const_cast才能实现而从语义上来说在真正的 const 对象上这么做是未定义行为。我们可以把四种情况整理成一张表方便记调用对象类型调用 const 成员函数调用非 const 成员函数const T对象允许不允许非const T对象允许允许所以面试里常问的“const对象能调用哪些成员函数”答案不只是“只能调用 const 成员函数”更准确的说法是能调用const成员函数和静态成员函数不能调用非const的普通成员函数。1.3 const成员函数里哪些能改哪些不能改加入const之后this变成了const T*于是编译器会限制你通过this访问成员。具体来说不能修改非static数据成员除非该成员被声明为mutable不能调用同一个类的非const成员函数不能返回指向类内非const成员的指针或引用可以读取所有数据成员可以调用其他const成员函数和static成员函数可以修改函数参数、局部变量、全局变量和static数据成员。这里有一个非常经典的误区指针成员在const成员函数里到底能不能改很多人以为指针成员本身被const修饰了所以不能通过它修改指向的内容。这实际上是错的。看下面这段代码class Widget { public: void inspect() const { buffer_[0] x; // 编译通过 char* local buffer_; // 编译通过 } private: char* buffer_; };inspect()是const成员函数此时this是const Widget*。成员buffer_的类型是char* const也就是说指针变量本身不能改变指向但指针指向的字符数组并不是const。编译器的规则是const限定的是通过this访问到的成员本身。对于指针成员const修饰的就是指针变量而不是指针所指的对象。所以buffer_[0] x合法。这点在使用旧式 C 风格字符串或缓冲区成员时特别容易踩坑。如果你希望const成员函数也不允许修改指向的数据应该把成员声明成const char* buffer_或者返回const引用/指针。C 的设计是信任程序员关键字const只是你用来表达意图的工具编译器只负责检查“是否通过这个特定的访问路径去修改”并不会替你判断“语义上算不算修改”。2. const版本与非const版本的重载编译器如何做选择2.1 一个类同时提供read()和read() const会发生什么很多实际类会同时提供const和非const两个版本的成员函数它们的名字、参数列表相同唯二的区别是函数尾部的const。典型的例子是容器的operator[]struct Buffer { int at(int index) { return data_[index]; } const int at(int index) const { return data_[index]; } int data_[64]; };调用规则取决于对象的const属性。const Buffer对象、const Buffer引用、const Buffer*指针在调用at时只能选择第二个版本得到const int外部无法通过它修改内部数据。而非const的Buffer对象则会优先选择第一个版本得到int可以直接读写。这里有个细节值得注意如果类里只有一个const版本非const对象也能调用它因为编译器可以把Buffer*隐式转换成const Buffer*来匹配参数。反过来如果只有一个非const版本const对象就无法调用。所以当你只写一个版本时要意识到你是在给哪类对象提供服务。标准库里像vector的operator[]、string的c_str()等接口都同时提供两个版本目的就是为了让可变对象和只读对象都能获得恰当的访问权限。2.2 为什么返回值设计也要跟着constconst成员函数里一个容易被忽略的问题是返回值的类型。看这个函数int at(int index) const { return data_[index]; }它会编译通过吗会。因为在const成员函数内部data_的类型是int* const下标表达式data_[index]产生一个int而这个int是data_指向的元素并不是data_这个指针成员本身。所以编译器不认为你修改了成员指针。但从调用方角度这等于在const对象上拿到了一个非const引用可以直接改内部数据完全破坏了const的语义。所以正确的做法是在const版本里返回const int让权限链保持完整对象是只读的拿到的引用也应该是只读的。这是一个很典型的设计原则const成员函数返回内部数据时要么返回值要么返回const引用绝不要返回非const的引用或指针。如果发现自己非要在const版本里返回可变引用那通常意味着这部分数据本来就应该设计成mutable或者你的类划分有问题而不是编译器不够聪明。2.3 一个常常被忽略的细节仅靠返回值不能重载关于重载还有一个细节const尾缀参与重载是因为它改变了this参数的类型返回值类型不参与重载。所以下面这两个版本不能共存struct Bad { int read() const { return 1; } void read() const { } // 错误重定义 };它们只是返回值不同参数和const限定相同编译器会认为这是同一个函数。不要试图用返回值区分重载那是 C 不支持的。另外在少数场景下你可能需要在const成员函数里调用同名的非const成员函数。比如你已经知道当前对象并不是真正只读的只是被const引用传递进来此时可以用const_cast去掉const再调用可变版本。但这是一个危险操作如果对象本身定义时就是constconst_cast之后写入数据会触发未定义行为。我见过有人把这种写法当“绕过麻烦”的技巧用结果线上偶发崩溃排了很久。用之前先问自己一句我到底为什么要骗编译器3. 取地址重载一旦触发整个对象的语义都可能被改写3.1 operator的语法与默认行为C 允许重载取地址运算符operator。它可以是成员函数也可以是非成员函数。成员函数的常见写法如下class X { public: X* operator() { return this; } const X* operator() const { return this; } };如果没有显式重载编译器会提供内置版本也就是直接返回this指针。对于const对象内置版本返回const X*对于非const对象返回X*。看起来一切正常直到有人开始“发挥创造力”。一旦你在类里重载了operator所有针对这个类对象的obj表达式都会调用你的实现。这意味着你改变了“取地址”这个最基本的语义。标准库中很多泛型代码都会依赖对象的地址来做拷贝、移动、哈希等操作而标准库为了保证在重载了operator的类上也能正常工作从 C11 开始专门提供了std::addressof这个工具它的作用就是绕过任何重载返回对象的真实地址。所以这里有一个基本判断重载operator是一件非常危险的事情千万不要因为“看起来很好玩”或者“想给面试官秀操作”就去写。它的危险不在于语法而在于它会静默改变所有使用者的行为。很多老代码在升级编译器后莫名其妙变慢或崩溃原因就是某个类引入了一个不返回this的operator而旧版容器和后端代码恰好还在用obj。3.2 什么样的类会重载operator代理对象、调试计数、安全封装既然这么危险为什么还会有人重载它确实有一些场景是合理的但都很窄。一种是代理对象。比如你写了一个包装类它的实例并不直接保存数据而是持有一个指向真实对象的指针。为了让外部代码对代理使用proxy时感觉就像对真实对象取地址可以在代理里重载operator返回内部目标指针。std::vectorbool的位引用在很多实现里就类似代理虽然它不一定重载operator但思路相同。这样做的代价是代理对象的生命周期必须严格掌握否则拿到指针后代理销毁地址立刻悬空。另一种是调试统计。比如你想知道对象什么时候被取地址、被谁取地址于是重载operator在返回前做计数。这类代码只在 debug 构建下存在release 构建会去掉。但问题也不少取地址本身成为一个有副作用的操作会影响性能也容易在并发环境下引入数据竞争。还有一种更极端的安全封装让operator返回nullptr以此防止外部通过obj拿到裸指针。这种思路听起来很“安全”实际却会破坏大量泛型算法和标准容器而且std::addressof已经可以绕过它所以“防止取到地址”的目的根本达不到。与其重载operator不如把裸指针封装在成员函数内部把创建限制在工厂函数里。3.3 这里有个隐藏的const陷阱当const成员函数和取地址重载碰在一起时还有一个很多人没意识到的陷阱。假设某个类同时重载了非const和const版本的operatorclass Weird { public: Weird* partner_; Weird* operator() { return partner_; } const Weird* operator() const { return partner_; } const Weird* self() const { return (*this); // 你以为拿到了this实际拿到了partner_ } };注意self()是const成员函数所以*this的类型是const Weird。当它执行(*this)时匹配到的重载是const Weird* operator() const返回值并不是当前对象地址而是partner_。如果你在self()里想通过(*this)获取this你得到的是一个完全不同的指针。这种组合拳非常隐蔽因为代码从语法和类型上都完全合法只有运行期你才会发现obj ! (*this)甚至不如说obj ! this。这也是为什么我在开篇说这两个主题是一条线上的瓜一个函数的const决定了你拿到的是哪种this而重载operator决定了当你对这个this取地址时会发生什么。两者一组合语义可能完全偏离你的直觉。4. 实战排障当 (*this) 不再等于 this我被坑了整整半天4.1 事故现场的还原这个坑发生在维护一个老项目的时候。项目里有一个链表节点类节点除了数据还保存了next_指针。源码历史很长某次提交里有人加了一个operator本意是为了调试时记录节点被取地址的次数。但实现写成了这样class Node { public: Node* next_; Node* operator() { return next_; } const Node* operator() const { return next_; } };这和我们前面说的Weird例子几乎一样operator返回的不是this而是next_。从语法的角度看没有问题类型也完全匹配所以编译期和静态检查都不会报错。但它带来了一系列奇怪的运行期现象某些算法在遍历链表时总是拿到错误的节点哈希表的地址计数完全不对程序偶尔还会在释放内存时崩溃。一开始我以为是内存越界或者某个指针被意外覆写于是各种加日志、开AddressSanitizer全部一无所获。因为问题根本不在内存写入而在于“取地址”这个动作本身被改写了。4.2 从“地址不对”到崩溃的排查链路真正让我意识到问题根源的是在一个const成员函数里打了一条日志。代码原本想记录当前节点this的地址于是写了const Node* Node::tracking() const { const Node* p (*this); log(tracking node at %p, p); return p; }我在断点里对比this和p发现两个值完全不同。this指向的是一个合法节点而p指向的却是它next_指向的下一个节点甚至可能是一个已经释放的节点。那一刻突然就明白了因为tracking()是const成员函数*this被推导为const Node所以(*this)调用的是const Node* operator() const而那个版本的返回值恰好是next_。这时候再回头看较早出现的问题就全对上了所有依赖node来生成唯一标识或者进行指针比较的代码拿到的其实都是node.next_。两个不同节点的地址可能相同因为它们的next_指向了同一个节点更可怕的是如果某个节点的next_为空node就返回nullptr随后对这个空指针解引用程序直接崩溃。修复分两步走。临时修复是把所有需要真实地址的地方改成std::addressof(*this)或std::addressof(node)这样能从重载的陷阱里绕出来。长期修复则是直接删除那个operator重载并且规定这类裸节点类不允许重载取地址运算符。如果确实需要调试计数就在调用的接口层统计不要在对象的底层语义上动刀。4.3 从这次坑里提炼的编码规范经历过这一次我给自己定了几条关于operator和const成员函数的规矩也分享给你第一类默认不要重载operator。如果你不是在设计一个代理类或者有一个明确且团队共识的需求就不要碰它。它带来的收益通常远小于它对泛型代码的破坏。第二如果确实要重载const版本和非const版本的语义必须完全一致。最好两个版本都返回真实的this并且保证在任何情况下obj和obj的地址都是一致的。如果有人想打破这个一致性必须让所有可能的调用方都知道。第三在任何泛型代码或模板代码里需要使用对象真实地址时统一用std::addressof(obj)而不是obj。这是 C 标准提供给你绕开重载的唯一正规途径。老的代码里如果没有用std::addressof需要警惕潜在问题。第四写const成员函数时函数体内尽量不要写(*this)。这个表达式的含义在普通类里是this但在重载了operator的类里就不可预测了。想要this就直接写this或者写std::addressof(*this)不要让取地址运算符的歧义混进来。第五code review 时看到有人重载operator一定要单独拎出来审。不要只看语法对不对要问清楚为什么需要重载有没有替代方案是否会影响标准库容器和算法。很多时候对方只是从某段代码里复制了一个模式自己都不知道后果。这些规则听起来有点像“教条”但它们都是踩坑踩出来的。我个人现在的态度很明确在没有强大理由时别和隐式的this玩花活。const成员函数是给你表达只读语义的operator是给你特殊需求用的两者都是工具但乱组合的话编译器不会拦你运行时却会狠狠咬你一口。
返回列表