万维网游活动资讯门户

C++常量成员函数与引用/值对象:面试题背后的类型系统与引用限定符
2026-09-05 04:59:05

最近在一个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(&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

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 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 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 f = &Task::run; // 可以

std::function f2 = &Task::run; // 也常常可以,但不建议

}

事实上,Task::run 是 const 成员函数,通过 std::function 包装,表明调用方传入的是 const 引用,这是最自然匹配的写法;而 std::function 也能通过编译,因为非 const 引用对象可以隐式转换为 const this 来调用 const 成员函数。如果反过来,你用 std::function 去包装一个非 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

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);

static_assert(std::is_same_v);

这样能快速确认成员函数指针的真实类型,尤其是在模板代码里出现一堆 std::mem_fn、std::bind 包裹的时候,类型到底是什么一目了然。

我这几年带项目的一个体会是,C++ 里很多面试题看起来像是文字游戏,但背后都对应着真实的工程边界。一个 const 成员函数能不能在某个对象上调用、一个成员函数指针能不能互相赋值,这类规则如果只是死记硬背,出了问题还是手忙脚乱。把 this 指针、类型系统、引用限定符这几条主线想通,就不需要背任何口诀了。