前言
C++ 的类成员可以是引用,语法上写T& ref_;即可。它表达的是「这个对象从出生到销毁都绑定到同一个外部对象」,适合做代理、视图、计数器包装这类场景。但它带来一串连锁限制:必须在构造函数初始化列表里绑定、类不再有默认构造函数、拷贝赋值被隐式删除、移动赋值同样不可用。很多人第一次写引用成员,就是被这几条编译错误轮番挡住。
最常见的误解是「引用成员可以像指针一样在后续重新指向别的对象」。引用一旦绑定就不能重新绑定,赋值运算符对引用成员执行的是「给被引用的对象赋值」,而不是「换一个引用目标」。另一个误解是认为引用成员一定比指针省空间;标准并没有规定引用的布局,多数实现确实按指针存放,但这不是可以依赖的保证。
本文从初始化、拷贝与赋值、生命周期三个角度讲清引用成员的规则,并给出std::reference_wrapper这个可赋值替代方案的用法。示例以 C++17 为基准。
一、初始化:只能在构造时绑定
引用成员没有「默认值」,因为引用必须绑定到一个已存在的对象。因此它只能在构造函数的成员初始化列表(member initializer list)里初始化,不能在构造函数体内赋值:
// 适用于 C++17
class Counter {
public:
explicit Counter(int& target) : target_(target) {} // 正确
void increment() { ++target_; }
int value() const { return target_; }
private:
int& target_;
};如果写成Counter(int& target) { target_ = target; },编译器会报错,因为进入函数体时target_还没有绑定,而引用不能先声明后绑定。这一点和const成员相同:它们都必须在初始化列表里完成初始化。
初始化顺序由成员的声明顺序决定,而不是初始化列表里的书写顺序。如果类里同时有引用成员和依赖它的成员,声明顺序写反了就可能读到未初始化的值。编译器通常会对「初始化列表顺序与声明顺序不一致」给出警告,但不会报错。
引用成员还使类失去默认构造函数。只要没有显式提供无参构造,Counter c;就无法编译。如果确实需要默认构造,只能让引用指向某个全局占位对象,或者改用指针(允许为空)。
// 适用于 C++17
int global_placeholder = 0;
class OptionalCounter {
public:
OptionalCounter() : target_(global_placeholder) {} // 绑定到占位对象
explicit OptionalCounter(int& target) : target_(target) {}
void increment() { ++target_; }
private:
int& target_;
};这种做法能编译,但语义上要非常小心:默认构造出来的对象会悄悄修改全局变量。多数情况下,改用指针并把「未绑定」表示为nullptr更诚实。
二、拷贝、赋值与移动的特殊规则
引用成员的拷贝构造是「让新对象绑定到同一个对象」,而不是「复制一份被引用的值」。这一点与指针成员的浅拷贝类似,但更隐蔽,因为语法上看不出指针的存在:
// 适用于 C++17
int main() {
int a = 1;
Counter c1(a);
Counter c2 = c1; // c2.target_ 也绑定到 a
c2.increment(); // a 变成 2,c1.value() 也读到 2
return 0;
}拷贝赋值则被隐式删除。原因很直接:赋值运算符的语义是「修改左操作数的成员」,但引用成员不能被重新绑定,标准因此把这种类的拷贝赋值定义为 deleted。任何需要「赋值」的容器操作都会因此失败,例如std::vector<Counter>的erase、insert中间位置、std::sort,它们都要求元素可移动赋值。
移动构造的情况稍微不同:隐式移动构造函数会「拷贝」引用成员(绑定到同一个对象),因此只要类没有用户声明的拷贝构造、拷贝赋值、移动赋值或析构,移动构造仍然可用。但移动赋值同样被删除,因为赋值无法重新绑定引用。若类里还有const引用成员,连隐式移动构造也可能被抑制,具体取决于其他特殊成员函数的声明情况。
| 特殊成员函数 | 含引用成员时的状态 | 原因 |
|---|
| 默认构造 | 不生成(除非显式绑定) | 引用必须初始化 |
| 拷贝构造 | 生成,绑定同一对象 | 引用按引用语义复制 |
| 拷贝赋值 | deleted | 不能重新绑定 |
| 移动构造 | 通常生成,绑定同一对象 | 引用被复制而非移动 |
| 移动赋值 | deleted | 不能重新绑定 |
| 析构 | 正常生成 | 引用不拥有对象 |
如果确实需要「可赋值的引用语义」,标准库提供了std::reference_wrapper<T>。它是一个可拷贝、可赋值的类模板,内部保存指向T的指针,并提供到T&的隐式转换。用它做成员,类就恢复了赋值能力,赋值操作改变的是「包装器指向谁」,而不是被引用对象的值。
// 适用于 C++17
#include <functional>
#include <iostream>
class MeterRef {
public:
explicit MeterRef(std::reference_wrapper<double> value) : value_(value) {}
void add(double delta) { value_.get() += delta; }
double read() const { return value_.get(); }
private:
std::reference_wrapper<double> value_;
};
int main() {
double x = 1.0;
double y = 10.0;
MeterRef m(x);
m.add(2.0); // x == 3.0
m = MeterRef(y); // 重新指向 y,这是引用成员做不到的
m.add(1.0); // y == 11.0
std::cout << x << ' ' << y << '\n';
return 0;
}std::reference_wrapper通常由std::ref或std::cref创建。它不能绑定到右值(std::ref(42)编译失败),这正好避免了一部分悬垂问题,但并不能防止被引用对象提前销毁。
三、生命周期:引用成员最大的风险
引用成员不拥有被引用对象,因此对象必须比引用成员活得更久。这是所有悬垂问题的根源,而引用成员的语法又特别容易让人忘记这一点。
最危险的一种写法是在构造函数的初始化列表里绑定临时对象:
// 适用于 C++17
class Bad {
public:
explicit Bad(int v) : ref_(v) {} // v 是临时对象
int get() const { return ref_; }
private:
const int& ref_;
};按标准语义,绑定到构造函数初始化列表中的临时对象的生命周期只延续到构造函数结束,而不是延续到Bad对象销毁。因此Bad b(42); b.get();读取的是一个已经销毁的对象,属于未定义行为(undefined behavior, UB)。有些编译器会给出警告,有些不会,不能依赖警告来判断代码是否正确。
正确的做法是让引用绑定到调用方保证生命周期的对象:
// 适用于 C++17
int stored = 42;
Bad ok(stored); // stored 比 ok 活得久类的成员之间也可能出现生命周期顺序问题。成员按声明顺序构造、按逆序销毁,如果引用成员绑定的是同一个类里声明在它后面的成员,那么构造时那个成员还没初始化;反过来,如果引用成员绑定的是前面的成员,销毁时引用先失效但不会自动置空,之后再去读就是悬垂。这类问题在含有多个引用成员的类里尤其难查。
| 场景 | 是否安全 | 说明 |
|---|
| 绑定到调用方传入的左值 | 安全 | 前提是该对象活得更久 |
| 绑定到构造函数参数中的临时量 | 不安全 | 构造结束即悬垂 |
| 绑定到同类的后声明成员 | 不安全 | 构造顺序在前 |
| 绑定到函数返回的引用 | 需确认 | 返回的引用本身可能悬垂 |
| 绑定到静态/全局对象 | 通常安全 | 生命周期贯穿程序 |
如果无法保证生命周期,就应该改用值成员(复制一份)、指针成员(显式表达可能为空、可能失效),或者std::shared_ptr(共享所有权)。引用成员适合「确定不会为空、且生命周期由外部严格保证」的场景,例如一个函数对象持有对某个配置对象的引用,而配置对象的生命周期覆盖整个调用期。
常见坑点
坑点一:在构造函数体内给引用成员赋值。
❌Counter(int& t) { target_ = t; }编译失败,引用成员未绑定。
✅ 写进初始化列表:Counter(int& t) : target_(t) {}。
坑点二:让引用成员绑定构造函数参数中的临时对象。
❌Bad(int v) : ref_(v) {},构造结束后引用悬垂。
✅ 绑定到调用方传入的左值,或改用值成员保存一份副本。
坑点三:以为赋值会重新绑定引用。
❌c1 = c2;期望c1改指向c2的目标,实际编译失败或(对引用包装器)语义不同。
✅ 需要重新指向时用std::reference_wrapper或指针。
坑点四:把含引用成员的对象放进std::vector并调用erase。
❌v.erase(v.begin());要求元素可移动赋值,而引用成员使移动赋值被删除。
✅ 改用std::reference_wrapper成员,或把容器换成std::list/std::deque(仍需确认具体操作的要求),更好的做法是存指针或索引。
坑点五:引用成员与const混用后困惑于移动构造消失。
❌ 类里写const std::string& name_;,随后发现连移动构造都不可用。
✅ 明确是否需要所有权;需要可移动就用std::string值成员,需要观察就用指针或reference_wrapper。
坑点六:初始化列表顺序与声明顺序不一致。
❌ 引用成员声明在后,却在初始化列表里先写,依赖它初始化另一个成员。
✅ 按声明顺序书写初始化列表,并让有依赖关系的成员声明顺序正确。
坑点七:默认构造绑定到全局占位对象,误以为对象处于「空」状态。
❌OptionalCounter c; c.increment();悄悄修改全局变量。
✅ 用指针表示可空状态,或提供明确的hasTarget()查询。
总结
| 主题 | 规则 |
|---|
| 初始化 | 必须用成员初始化列表绑定 |
| 默认构造 | 通常不可用 |
| 拷贝构造 | 新对象绑定同一目标 |
| 拷贝/移动赋值 | 被隐式删除 |
| 重新绑定 | 不支持,需改用reference_wrapper或指针 |
| 临时对象 | 构造结束即悬垂,禁止绑定 |
| 生命周期 | 被引用对象必须比本对象活得久 |
| 适用场景 | 确定的、长期存在的依赖注入 |
类中的引用成员是一把双刃剑:它把「必须存在」这一约束写进了类型,省掉了空指针检查,但也把对象的可赋值性、可默认构造性和生命周期责任一并改变。写之前先问三个问题:目标是否一定存在、目标是否一定活得更久、这个类是否需要被赋值或放进容器。三个答案都是「是」时,引用成员才是合适的选择;否则std::reference_wrapper、指针或值成员通常更省事。