最近在一个C++面试复盘里看到这么一条:"为什么常量成员函数可以赋值给引用对象而不能赋值给值对象?" 说实话,第一眼看到这句话我是有点愣住的,因为把"常量成员函数""赋值""引用对象""值对象"这几个词拼在一起,乍一听能对应上好几个完全不同的知识点。我当时在纸上写了几行代码验证,又翻了翻 cppreference 上的资格转换和引用限定符相关段落,才算把这句话背后的所有可能性都捋顺。
这个问题大概率是面试题里被转述岔了,但恰恰因为转述岔了,它反而很适合用来做一次 C++ 成员函数语义的集中梳理。如果你也正在准备 C++ 八股文,或者写代码时被"const 能不能调、值能不能调、引用能不能调"这类问题绕晕过,那这篇文章就是给你准备的。我不打算只给一个答案,而是把可能产生这个疑问的几个方向全部拆开讲清楚,顺便把编译器实际行为也跑给你看。
1. 先纠正一个概念:这句话里的"赋值"到底是哪种操作
在 C++ 里,"常量成员函数""赋值""引用对象""值对象"组合在一起,至少可以对应三种不同的代码场景:
第一种,成员函数指针赋值。比如 auto p = &Foo::bar; 这种写法,把 Foo::bar 这个成员函数取地址存进一个成员函数指针变量里。这个场景最容易碰到 const 相关报错,因为我发现很多初学者会把 &Foo::bar 的类型理解成 void (Foo::*)(),但实际情况要分 bar 是不是 const 成员函数。
第二种,对象拷贝赋值。比如 Foo a; Foo b; a = b; 这类操作,走的是拷贝赋值运算符。C++ 里的赋值运算符参数通常写成 const Foo&,而"能不能给一个临时值对象赋值"这个问题又牵扯到引用限定符,和题目里的"引用对象、值对象"确实能对上。
第三种,成员函数调用。比如 obj.func() 和 ref.func() 之间的差异,这里可能被误解成"引用对象上的调用和值对象上的调用规则不同"。
关键点在于:如果你真按字面去写代码,C++ 里并不存在"把一个常量成员函数赋值给引用对象"这种操作。 常量成员函数是类里的一个成员函数,你只能取它的地址得到成员函数指针,或者通过对象/指针/引用来调用它。所以这个问题的正确打开方式,是先搞清楚提问的人到底想说是哪一层语法,然后在对应的层面给出规则。
我实际验证下来,最接近"可以赋值给引用对象而不能赋值给值对象"这个说法的,是成员函数指针的类型转换规则,以及引用限定符的左右值限制。后面几节我把这两块分别展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. const 成员函数的真实底层:this 指针上的限定符
先把地平打好。一个非静态成员函数,比如:
cpp复制class Counter {
public:
int get() const { return value_; }
void add(int n) { value_ += n; }
private:
int value_ = 0;
};
get() 是 const 成员函数,add() 不是。它们在底层的差别,本质上不是返回值,也不是参数列表,而是隐式的 this 指针类型不同:
add()的 this 类型是Counter*get()的 this 类型是const Counter*
所以 const 成员函数内部,"通过 this 能干什么"这个问题就被编译器严格限制了。this 指向的是一个 const 对象,你自然不能通过它修改非 mutable 的成员变量,也不能调用非 const 成员函数。这不是什么运行时拦截,而是编译期的类型检查。
那"引用对象"和"值对象"在这件事上到底有没有区别?答案是没有。你写:
cpp复制Counter a;
Counter& ref = a;
const Counter& cref = a;
Counter b = a;
a.get(); // 可以
ref.get(); // 可以
cref.get(); // 可以
b.get(); // 可以
全都可以通过编译。因为无论是 Counter 值对象、Counter& 引用,还是 const Counter& 常量引用,只要这个对象能转换出 const Counter* 的 this 指针,就能调用 get()。值对象虽然没有显式加 const,但它和引用对象一样,在调用 const 成员函数时都会产生一个指向自身的 const 指针,编译器允许这种隐式加 const 的操作。
反过来,非 const 成员函数 add() 就只能在能产生 Counter* 的对象上调用:
cpp复制a.add(1); // 可以,a 本身非 const
ref.add(1); // 可以,ref 是非 const 左值引用
b.add(1); // 可以,b 是非 const 值对象
cref.add(1); // 编译错误,cref 是 const Counter&,this 只能是 const Counter*
这也就是很多人背的那句话:"非 const 成员函数不能被 const 对象调用,const 成员函数可以被所有对象调用。" 到这一步,问题里的"常量成员函数可以赋值给引用对象"其实应该改成"常量成员函数可以被任何对象通过合法 this 指针调用"更准确。
3. 成员函数指针的类型系统:为什么 const 版本不能隐式转成非 const 版本
现在进入真正容易让面试官兴奋的区域:成员函数指针。
看这段代码:
cpp复制class Sample {
public:
void normal() {}
void const_fn() const {}
};
int main() {
auto p1 = &Sample::normal; // 类型是 void (Sample::*)()
auto p2 = &Sample::const_fn; // 类型是 void (Sample::*)() const
}
p1 和 p2 的类型不是同一个类型。这一点是后续所有匪夷所思行为的根源。C++ 里成员函数指针的类型不仅携带类名和参数列表,还会携带这个成员函数是否带 const、volatile、ref-qualifier 等信息。
在 C++ 标准里,存在一条"成员函数指针资格转换"的规则。具体来说,void (Sample::*)() 可以隐式转换为 void (Sample::*)() const,但反过来 void (Sample::*)() const 不能隐式转换为 void (Sample::*)()。
我直接用代码验证:
cpp复制using NonConstFn = void (Sample::*)();
using ConstFn = void (Sample::*)() const;
void test() {
NonConstFn nc = &Sample::normal;
ConstFn c = nc; // 可以,非 const 成员函数指针可以转成 const 版本
ConstFn c2 = &Sample::const_fn;
// NonConstFn nc2 = c2; // 编译错误:不允许把 const 成员函数指针赋给非 const 成员函数指针
}
在 GCC 12 下,去掉注释那行会报类似这样的错误:
code复制error: invalid conversion from 'void (Sample::*)() const' to 'void (Sample::*)()'
为什么 C++ 要这么规定?道理和 T* 与 const T* 的关系完全一致。思考一个场景:你手上有一个成员函数指针,类型是 void (Sample::*)() const,它向外界承诺"通过这个指针调用函数时,this 是 const 的,函数不会修改对象状态"。现在我偏要把它当成 void (Sample::*)() 来用,就等于把一个"不修改对象"的承诺,变成了一个"可能修改对象"的调用入口。如果你允许这种转换,就会出现下面的情况:
cpp复制const Sample cs;
auto p = static_cast<void (Sample::*)()>(&Sample::const_fn);
(cs.*p)(); // 表面上 cs 是 const,但 p 的类型暗示可以修改对象,const 保护形同虚设
虽然实际被调用的函数体里可能什么也不改,但类型系统失去了一层保护。所以 C++ 直接堵住这条路,只允许从非 const 到 const 的收窄,不允许从 const 到非 const 的放大。
顺带一提,这个规则也解释了另一个常见面试题:"为什么成员函数指针不能直接 reinterpret_cast 成普通函数指针",因为成员函数指针要额外处理 this 参数和可能的虚函数调整,类型信息远比"一个普通函数地址"要复杂,强行转换在两个平台上都是未定义行为。
回到"引用对象"和"值对象"。如果你背着 void (Sample::*)() const 类型的指针,然后通过一个对象去调用它:
cpp复制Sample s;
Sample& ref = s;
Sample copy = s;
ConstFn cf = &Sample::const_fn;
(s.*cf)(); // 可以
(ref.*cf)(); // 可以
(copy.*cf)(); // 可以
const Sample cs;
(cs.*cf)(); // 也可以,const 成员函数指针可以在 const 对象上调用
照样是引用值都行。所以如果你在面试里听到"const 成员函数只能赋值给引用对象不能赋值给值对象",大概率是把对象调用和成员函数指针的隐式转换两件事揉在一起了。正确的说法应该是:const 成员函数指针不能隐式转换给非 const 成员函数指针,而这与对象是引用还是值没有直接关系。
4. 引用限定符与左值/右值对象:真正的"能引用不能值"发生在哪里
如果非要找一句和"引用对象"字面最接近的 C++ 语法,那就是引用限定符(ref-qualifier)。C++11 开始,成员函数声明后面可以加 &、&& 或 const &,限制这个函数允许在什么类别(value category)的对象上调用。
先看一个最基础的例子:
cpp复制#include <iostream>
struct Demo {
void lvalue_only() & { std::cout << "lvalue only\n"; }
void rvalue_only() && { std::cout << "rvalue only\n"; }
};
int main() {
Demo d;
d.lvalue_only(); // 可以,d 是左值
// Demo().lvalue_only(); // 编译错误,这是右值临时对象
Demo().rvalue_only(); // 可以,临时对象是右值
// d.rvalue_only(); // 编译错误,d 是左值
}
这里的 & 限定符表示"这个成员函数只能通过左值引用对象调用",&& 表示"只能通过右值临时对象(值对象)调用"。注意,Demo().lvalue_only() 报错的原因不是函数本身是不是 const,而是 void lvalue_only() & 要求调用对象的 this 是一个非 const 左值引用,而 Demo() 产生的是一个纯右值,没有任何办法绑定到非 const 左值引用上。
如果函数声明是 void lvalue_or_const() const &,情况就变了:
cpp复制struct Demo2 {
void f() const & { std::cout << "const&\n"; }
};
Demo2().f(); // 可以,const & 可以绑定右值
因为 const 左值引用是可以绑定到右值上的,所以"const 成员函数 + & 限定符"并不会阻止临时值对象调用。所以严格说起来,真正卡住值对象的是"非 const 左值引用限定"(&),不是 const 本身。
这跟"赋值"有什么关系?关系就在 operator= 上。C++ 里最经典的一个工程写法是:
cpp复制class BigObject {
public:
BigObject& operator=(const BigObject&) &; // 只允许左值被赋值
};
如果你加了 &,那么 BigObject() = other; 这种代码直接编译失败,因为左边是一个临时值对象,不满足"非 const 左值"这个条件。如果你不加 &,默认情况下 BigObject() = other; 是可以编译的,因为编译器会把临时量当成可修改的对象,调用赋值运算符给它赋值——但从语义上讲,给一个即将销毁的临时对象赋值,极大概率是代码逻辑写错了。
所以现在我们能拼出一张完整的对应表:
| 函数声明形式 | 左值引用对象 | 右值值对象(临时量) |
|---|---|---|
void f() |
可以 | 可以 |
void f() & |
可以 | 不行 |
void f() && |
不行 | 可以 |
void f() const & |
可以 | 可以 |
void f() const && |
不行 | 可以 |
从这张表就能看出,所谓"可以赋值给引用对象而不能赋值给值对象"如果指的是 operator=(const T&) & 这种写法,那么本质原因是引用限定符中的 & 在起作用,而不是"常量成员函数"这个概念在起作用。
5. 从面试题到工程实践:拷贝赋值、mutable、类型擦除里的相似场景
搞清楚了上面的规则,你会发现这类问题在真实工程里往往不是单独出现的,而是各种特性叠加在一起。我挑几个最常见的组合场景聊一聊。
第一个场景是"移动赋值与右值限定符"的配合。现代 C++ 里,移动构造函数和移动赋值运算符通常长这样:
cpp复制class Buffer {
public:
Buffer(Buffer&& other) noexcept { /* 转移资源 */ }
Buffer& operator=(Buffer&& other) noexcept { /* 转移资源 */ }
};
这里 operator=(Buffer&& other) 的右边参数本身是右值引用,但你有没有想过,如果左边是临时量会怎样?
cpp复制Buffer makeBuffer() { return Buffer{}; }
makeBuffer() = Buffer{}; // 语法上合法,但逻辑上没什么意义
要阻止这种写法,就可以给赋值运算符加 & 限定符:
cpp复制class Buffer {
public:
Buffer& operator=(Buffer&& other) noexcept & { /* ... */ }
};
加上之后,makeBuffer() = Buffer{}; 直接编译失败。makeBuffer() 的返回值是纯右值,虽然它可以被移动赋值运算符的右值参数接收,但左边的对象本身不满足 & 限定的左值要求。
第二个场景是 mutable 成员和 const 成员函数的纠缠。const 成员函数内部不能修改普通成员,但可以修改 mutable 成员,典型用途是缓存、引用计数、线程安全锁:
cpp复制class Cache {
public:
int get(int key) const {
std::lock_guard<std::mutex> lock(mutex_);
if (auto it = cache_.find(key); it != cache_.end()) {
return it->second;
}
int val = slow_compute(key);
cache_[key] = val; // cache_ 是 mutable,允许在 const 函数里修改
return val;
}
private:
mutable std::map<int, int> cache_;
mutable std::mutex mutex_;
};
很多面试官会把这个和"const 成员函数是不是一定不能修改成员"混淆。实际上,const 成员函数中的"const"只作用于 this 指针指向的对象,而 mutable 成员的类型本身就允许被 const 对象修改,所以这里没有矛盾。这和标题里的引用/值对象也没有关系,不管对象是 Cache 值、Cache& 引用还是 const Cache&,get() 内部的 mutable 逻辑都一样运转。
第三个场景是成员函数指针配合 std::function 做类型擦除。你把一个 const 成员函数指针塞进 std::function 时,std::function 的模板参数需要能容纳调用约定:
cpp复制struct Task {
void run() const {}
};
void func() {
std::function<void(const Task&)> f = &Task::run; // 可以
std::function<void(Task&)> f2 = &Task::run; // 也常常可以,但不建议
}
事实上,Task::run 是 const 成员函数,通过 std::function<void(const Task&)> 包装,表明调用方传入的是 const 引用,这是最自然匹配的写法;而 std::function<void(Task&)> 也能通过编译,因为非 const 引用对象可以隐式转换为 const this 来调用 const 成员函数。如果反过来,你用 std::function<void(const Task&)> 去包装一个非 const 成员函数,那就会和前面成员函数指针的 const 转换规则一样遭遇编译错误。
第四个场景是 C++20 之后的 consteval 和 const 成员函数。consteval 函数要求在编译期求值,这和对象是值还是引用没有直接关系,但如果你在 constexpr 上下文里调用一个 const 成员函数,很容易踩到"成员函数指针的 const 限定符不同导致无法在编译期解引用"的问题。遇到这类问题,我建议优先检查成员函数指针的类型是否完全一致,而不是怀疑 const 成员函数本身。
工程上的建议很简单:如果你的类里某个成员函数明确只允许在具名对象上调用(比如赋值、释放资源、注册回调),就明确加上 & 限定符,把误写在临时对象上的调用在编译期拦下来。如果你的成员函数承诺不修改对象,就老老实实加 const,这样它才能在 const 对象和 const 引用上正常工作。两者加起来就是 void foo() const &,表达的意思是"我既不修改对象,也只在具名对象上执行"。这个组合在代码审查里非常清晰,也最容易让维护的人理解设计意图。
我在实际项目里踩过的坑是:给一个赋值运算符漏写了 & 限定符,结果某个模块里出现了一段 GetConfig().SetGlobalLimit(...) 的代码,GetConfig() 返回临时配置对象,SetGlobalLimit 的修改在语句结束时就随临时对象销毁了,但代码能编译通过,数据却没有真正生效。后来定位了很长时间,最后加上 & 限定符,编译器在代码评审阶段就把这类问题全拦住了。所以这一节的内容不只是为了面试,它对减少线上 bug 是真的有效。
6. 排查问题时的实操笔记:怎么用编译器验证 const 成员函数的各种调用方式
如果你在看这篇文章之前就遇到了类似的报错,或者想通过实验亲眼确认这些规则,我建议你直接用下面这段最小示例,在 -std=c++17 或 -std=c++20 下编译:
cpp复制#include <iostream>
struct Sample {
void normal() { std::cout << "normal\n"; }
void const_fn() const { std::cout << "const_fn\n"; }
void lvalue_only() & { std::cout << "lvalue_only\n"; }
void rvalue_only() && { std::cout << "rvalue_only\n"; }
};
int main() {
Sample s;
Sample& ref = s;
const Sample& cref = s;
Sample copy = s;
// 1. const 成员函数在不同对象形态上的调用
ref.const_fn();
cref.const_fn();
copy.const_fn();
// 2. 成员函数指针的 const 资格转换
using NonConstFn = void (Sample::*)();
using ConstFn = void (Sample::*)() const;
ConstFn cf = &Sample::const_fn;
(ref.*cf)();
(copy.*cf)();
// 下面这行取消注释会报 invalid conversion 错误
// NonConstFn nf = &Sample::const_fn;
// 3. 引用限定符对值对象/引用对象的限制
ref.lvalue_only();
// Sample().lvalue_only(); // 取消注释会报错
Sample().rvalue_only();
// ref.rvalue_only(); // 取消注释会报错
return 0;
}
把这四个部分的注释位分别打开,你就能看到 GCC 和 Clang 的报错信息长什么样。我自己习惯在排查这类问题时,先编译一个"最小失败样例"把报错固定下来,再去 cppreference 上查对应的标准条款,这样比直接背结论印象深刻得多。
另外一个对排查有帮助的小技巧:用 static_assert 验证类型是否相同。
cpp复制static_assert(std::is_same_v<decltype(&Sample::normal), void (Sample::*)()>);
static_assert(std::is_same_v<decltype(&Sample::const_fn), void (Sample::*)() const>);
这样能快速确认成员函数指针的真实类型,尤其是在模板代码里出现一堆 std::mem_fn、std::bind 包裹的时候,类型到底是什么一目了然。
我这几年带项目的一个体会是,C++ 里很多面试题看起来像是文字游戏,但背后都对应着真实的工程边界。一个 const 成员函数能不能在某个对象上调用、一个成员函数指针能不能互相赋值,这类规则如果只是死记硬背,出了问题还是手忙脚乱。把 this 指针、类型系统、引用限定符这几条主线想通,就不需要背任何口诀了。
