说实话,我第一次认真看这个C++问题,是在给组里新人做代码评审复盘的时候。当时我问了个很常见的面试题:“空类编译器默认生成哪些函数?”对方回答得很流畅:默认构造、析构、拷贝构造、拷贝赋值。我又追了一句:“还有呢?”他愣了半天,最后才从记忆深处掏出一个答案——还有两个取地址函数。
这个答案本身不算错,但大多数人说的时候,只是把它当成需要硬背的“八股文”,很少去想最关键的那句话:为什么C++编译器要默认生成两个取地址函数?这两个operator&到底藏在哪里?它们和普通成员函数到底是不是一回事?
这篇文章我就把这个话题彻底拆开。会先讲清楚“两个取地址函数”在源码层面的样子,再把重载决议中的内建候选机制讲透,最后用几个实验证明编译器到底做了什么。内容不适合纯零基础,但只要碰到过指针、会写简单class,应该都能跟上。
1. 那道经典空类题的第六个答案:编译器在背后默认放好了两个取地址函数
1.1 常见的默认函数清单里,两个&总被忽略
很多C++教程在讲“编译器默认生成”时,通常列这几样:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。到了C++11之后还要补上移动构造和移动赋值。
但国内流传很广的另一版本面试整理里,还会在列表尾部接上两句:
cpp复制Demo* operator&() { return this; }
const Demo* operator&() const { return this; }
也就是说,编译器为每个类默认准备的非静态成员函数里,除了那几个特殊成员函数,还有两个一元取地址运算符:一个处理非const对象,另一个处理const对象。
你平时写代码时,并不需要手写这样的函数,但依然可以这样做:
cpp复制struct Demo {
int value;
};
int main() {
Demo d;
Demo* pd1 = &d;
const Demo cd{};
const Demo* pd2 = &cd;
}
这个例子编译完全没问题。&d得到的类型是Demo*,&cd得到的类型是const Demo*。如果不考虑编译器在背后兜底,这两行代码是没法解释的。
1.2 这两个函数在源码里的“标准长相”
为了让后面的讨论有一个明确的代码参照,我先把教科书或者面试题里常说的等价形式单独放出来:
cpp复制struct Demo {
Demo* operator&() {
return this;
}
const Demo* operator&() const {
return this;
}
};
注意看两个版本的区别,只在成员函数的const限定符,以及返回类型上。
非const版本的函数内部this类型是Demo*,返回this自然就是Demo*;const版本的内部this类型是const Demo*,返回this也就是const Demo*。除此之外,这两个函数都是空的,没有任何参数,也没有额外逻辑。
它俩和外界的联系只有一件事:当你对一个类对象使用&obj时,编译器如果走的是这种“默认版本”,结果就等于把该对象的起始地址原封不动地交出来的。不需要额外临时对象,不需要计算偏移,就是对象的地址本身。
1.3 先澄清一个容易抬杠的点:它们真的是“成员函数”吗
看到这里,有C++基础稍微深一点的朋友可能会产生一个疑问:C++标准真的规定了编译器要“生成”这两个成员函数吗?
严格来说,标准并没有把一元operator&列为隐式声明的特殊成员函数。真正会被“隐式声明”“按需定义”的特殊成员函数,只包括默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值这几类。operator&并不在这个官方清单里。
那为什么几乎所有资料都说编译器默认生成两个operator&?
原因是,从重载决议的效果看,当用户没有在类里声明任何operator&时,编译器会为一元&操作准备一组内建候选。这组内建候选参加重载决议,效果就像这个类默认多了两个operator&成员函数。
你可以把它理解成一套“操作符默认规则”,而不是真正的函数符号。类上不会像构造函数那样生成一堆代码符号,但行为上确实兜了底。这也是为什么我一直强调“默认候选”比“默认成员函数”更准确。只是市面上用“编译器默认生成两个取地址函数”来概括这个现象,写代码的人理解起来更直观,所以这个说法也就一直流传了下来。
想验证这个差别,最直接的方法是尝试取成员函数指针:
cpp复制struct Demo {};
// 错误:Demo里没有真正声明operator&这个成员
// auto pm = &Demo::operator&;
如果编译器真的为每个类都生成了成员函数,那么上面这行应该能编译过。但实际情况是,它编译失败。这个实验后面第5节我会专门演示。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么必须有它们:C++从C继承来的&语义靠什么兜底
2.1 取地址是内建运算,不是某个类的特权
C语言里,对一个变量取地址是天然操作。&x不需要任何函数支持,它就是一个表达式,直接得到变量所在内存地址。这个理念被C++完整继承了下来。
问题来了:C++有了class,有了struct,有了对象这个概念之后,用户自定义类型算不算“变量”?当然算。那对用户自定义类型的对象取地址,也应该是天然操作。总不能要求每个类都要手动重载operator&才能获得自己的地址,否则就不允许写&obj。
所以在语言设计最底层,编译器必须提供一套通用的规则,确保“任何类对象都能被取地址”这个从C语言一路继承下来的语义不丢失。这是内置候选存在的第一推动力。
可以这样类比:C++的&就像一个所有类型都能用的公共插座。int、double、指针可以用,class对象自然也应该可以用。自定义class让用户可以重载&,相当于让你可以改造这个插座,但不能因为某个类没改造它,就让它变得不可用。
2.2 operator&可以重载,所以语言需要一套默认候选
&在C++里并不是一个不能触碰的操作符。它既可以作为一元操作符取地址,也可以作为二元操作符做按位与。
一元取址的&允许被重载:
cpp复制struct Demo {
Demo* operator&() {
// 注意:这里完全可以把返回的东西改成别的
return this;
}
};
一旦你写了一个这样的重载,那么对Demo对象用&时,编译器优先调用这个自定义版本,而不是走默认的内建语义。
于是C++就有了一个非常现实的问题:用户不重载的时候,应该怎么处理?最直接的设计方法,就是让“内建取址行为”以候选函数的形式存在。哪个类没声明自己的operator&,默认候选就替补上;哪个类声明了自己的operator&,默认候选就被排挤掉。
这很像你手机里的默认浏览器和自定义浏览器。没有装第三方浏览器时,点链接用的是系统默认浏览器;你一旦装了第三方并设置为默认,原来的默认浏览器就不再处理打开链接这个动作了。
2.3 从重载决议看编译器如何选择:成员候选和内建候选的PK
当我们看到表达式&obj时,如果obj是类类型对象,编译器要经历一轮操作符重载决议:
第一步,生成候选集合。候选集里有两类来源:一类是类的operator&成员函数,另一类是编译器为取地址操作准备的内建候选函数。
第二步,删选可行函数。如果类里有operator&成员函数,它作为候选参与匹配。如果类里没有任何operator&成员函数,那内建候选就是唯一可行的选择。
第三步,选择最佳匹配并执行。选中的函数如果是内建候选,不会产生函数调用代码。编译器只是把取地址这个操作直接翻译成地址计算指令,可能在优化后连指令都不需要有,地址就在寄存器或栈帧里。
这些内建候选可以想象成有这样一个签名组合在等着被挑选:
cpp复制Demo* operator&(Demo&);
const Demo* operator&(const Demo&);
volatile Demo* operator&(volatile Demo&);
const volatile Demo* operator&(const volatile Demo&);
这些并不是真正能写出参数列表的自由函数,而是编译器内部的概念候选。它们的作用非常单纯:你在写&obj的时候,如果类没有自己声明operator&,内建候选就会保证这个表达式具备一个符合语义的返回类型。
从这里你也能看出来,“编译器默认生成了两个取地址函数”更严格的说法是“编译器在处理一元取地址时,给每个类内置了一套候选”。标题里的“两个”,对应平时最常见的非const对象和const对象两种场景。
2.4 一个总被省略的细节:volatile版本的四个候选
如果只看非const和const两种场景,标题说两个没有问题。但如果你去看标准上对内置候选的描述,会发现和volatile相关的候选也同时存在。非const、const、volatile、const volatile四个版本,一个都不少。
为什么平时没人说四个?
因为绝大多数C++代码里,volatile对象没有存在感。除了嵌入式寄存器映射、和硬件相关的底层访问、旧式多线程共享标志位等场景外,普通业务代码很少声明volatile对象。于是大家在默认函数清单这道题里,基本只答“一个普通版,一个const版”。
我自己在做嵌入式相关代码的时候遇到过类似的情况:对MMIO寄存器结构体取地址,或者对某些特殊地址区域的对象做访问,volatile限定会自然参与重载匹配。如果你只记住了“两个”,然后去写一个带有volatile重载的operator&,不会影响普通代码,但可能让硬件映射类的取址行为变得完全不可控。
所以我建议,理解默认取址候选的时候,脑内至少要建立“cv-qualified版本成对出现”的意识。真正常碰到的确实是两个,但更高维度的规则从来都是四个候选,而不是两个。
3. 为什么是两个不是一个是const语义决定的分工
3.1 const对象需要返回const指针,否则相当于打开修改漏洞
假设我们只有下面一个默认候选:
cpp复制Demo* operator&(Demo&);
它只能作用于非const的左值对象。如果你写:
cpp复制const Demo cd{};
const Demo* p = &cd;
会发生什么?&cd这个表达式需要一个能接收const Demo&的候选。但我们这里只有一个参数类型为Demo&的版本,const对象没法绑定到非const引用,编译必然失败。
你可能会想,那我把非const版本强行用在const对象上行不行?直接改成const对象取址时也返回Demo*行不行?那更危险。如果&cd能返回Demo*,就意味着通过这个指针可以修改原对象,即使原对象声明为const。你只要做出一个const_cast的行为,C++的const保护就形同虚设。
所以语言不能容忍用一个返回值是Demo*的候选去处理const对象。它需要一个返回值是const Demo*的版本,来保证const对象取地址后得到的是指向常量的指针。
这就清楚了:两个候选不是编译器闲着没事多生成一个,而是const语义下的必然需求。一个负责可变对象,一个负责不可变对象,互相之间不能替代。
3.2 this的类型在这里起决定性作用
再往底层一点看,这两个函数内部的实现为什么天然正确?因为它们都在成员函数内,返回值就是this。而this本身是编译器隐式传入的,它的类型已经随成员函数的const限定而改变:
- 非const成员函数内部,this类型是
Demo*。返回this,类型天然是Demo*。 - const成员函数内部,this类型是
