前言
先澄清术语。中文语境里的「匿名对象」通常指临时对象(temporary object),也就是没有名字、由编译器在表达式求值过程中创建的类类型对象,比如Widget("x")或者a + b的结果。它和「命名对象」(named object,有标识符的变量)相对。需要说明的是,C++ 标准里并没有「匿名对象」这个术语,标准用的是「临时对象」;另一个容易被混进来的概念是匿名命名空间(anonymous namespace),那是限制链接性的机制,和本文讲的不是一回事。
围绕临时对象的误解主要有三个。第一是以为Widget w = Widget();一定会构造两次、拷贝一次,于是在 C++11 之前拼命优化返回值;实际上 C++17 起这种写法保证只有一次构造。第二是以为临时对象的生命周期总是到分号结束,忽略了绑定到引用时的延长规则。第三是反过来,以为只要用const&接住就万事大吉,结果在「引用成员」「函数返回值」「成员访问」这几种场景下拿到悬垂引用。
本文按「什么时候产生临时对象、它活多久、拷贝是否真的发生、几种初始化写法有什么区别」的顺序展开,最后给出可以照着检查的规则表。代码以 C++17 为基准。
一、临时对象在什么时候产生
临时对象主要由以下几类表达式产生:
第一,显式调用构造函数但不给它名字,如Widget("x")。这是一个纯右值(prvalue),表示「一个尚未具现化的对象」。
第二,函数按值返回。Widget make();的调用表达式本身是 prvalue;如果函数返回引用,则不产生临时对象(但可能悬垂)。
第三,类型转换。把一个int传给接受Widget的参数时,编译器会用转换构造函数创建一个临时对象。
第四,运算符的结果。a + b返回Widget时,结果是一个 prvalue;内置类型的a + b不产生类对象,但同样属于右值。
第五,绑定引用时的中间对象。const Widget& r = Widget("x");中的Widget("x")是临时对象,但它的生命周期被延长了。
理解这些来源之后,一个重要的推论是:临时对象是「值」而不是「变量」。不能对纯右值表达式取地址,也不能给它赋值,它只能被读取、被绑定到引用、被移动,或者作为实参传递。
二、生命周期:全表达式与引用延长
默认规则是:临时对象在完整表达式(full-expression)结束时销毁。所谓完整表达式,通常就是一条以分号结尾的语句。所以std::cout << make().name << '\n';里的临时对象活到分号,安全;而const char* p = make().name.c_str();里的临时对象也在分号处销毁,p立刻悬垂。
例外是引用延长:把一个临时对象绑定到const左值引用或右值引用时,临时对象的生命周期被延长到该引用的生命周期结束。
// 适用于 C++17
const Widget& a = make(); // 延长,a 有效期内临时对象一直存在
Widget&& b = make(); // 同样延长延长规则有几个明确的例外,必须记住:
第一,绑定到函数参数的引用不延长。void f(const Widget&);调用f(make());时,临时对象只活到f返回之后的那条完整表达式结束,f内部使用它是安全的,但f不能把它存起来。
第二,return语句中的引用不延长。函数返回const Widget&而实际返回的是一个临时对象时,临时对象在函数返回时就销毁,调用方拿到的是悬垂引用;编译器通常会给出警告,但这属于未定义行为(undefined behavior, UB),不是「一定崩溃」。
第三,绑定到类的引用成员不延长。在构造函数的成员初始化列表里写ref_(make()),临时对象在构造函数结束时就销毁,成员引用立刻悬垂。这是最隐蔽的一种。
第四,通过成员访问取得的引用不延长。const auto& x = make().name;中,被延长的是name这个成员所绑定的对象吗?不是——延长只作用于「直接绑定到临时对象」的引用,而这里引用绑定的是临时对象的成员,临时对象本身仍然在分号处销毁,x悬垂。正确写法是先auto tmp = make();再取tmp.name。
三、拷贝消除:C++17 的保证
在 C++17 之前,Widget w = Widget("x");在语义上要求「先构造临时对象,再拷贝构造w」,编译器可以优化掉这次拷贝,但这不是强制的,而且被优化的拷贝构造仍然必须是可访问的。C++17 引入了「保证的拷贝消除」(guaranteed copy elision):当用同类型的 prvalue 初始化对象时,直接在该对象的存储上构造,根本不存在临时对象,也就不存在拷贝。
这条规则的直接后果是:即使一个类删除了拷贝构造和移动构造,Widget w = Widget("x");依然合法。同理,函数返回 prvalue 时也不需要移动构造。
// 适用于 C++17
struct NonCopyable {
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable(NonCopyable&&) = delete;
};
NonCopyable make_nc() {
return NonCopyable{}; // C++17 起合法,无需拷贝或移动
}
int main() {
NonCopyable a = NonCopyable{}; // 合法
NonCopyable b = make_nc(); // 合法
// NonCopyable c = std::move(a); // 错误:移动构造被删除
return 0;
}注意最后一行的对比:拷贝消除只适用于「prvalue 直接初始化同类型对象」,一旦写成std::move(a),表达式变成将亡值(xvalue),必须调用移动构造,而移动构造被删除了,于是编译失败。这条边界经常被误解为「C++17 之后移动构造没用了」,实际上移动构造在容器扩容、std::vector::push_back(std::move(x))这些场景里仍然不可替代。
四、几种初始化写法的区别
| 写法 | 语义 | 备注 |
|---|
Widget w; | 默认初始化 | 对内置类型成员不初始化,类类型调用默认构造 |
Widget w{}; | 列表初始化 | 内置类型成员被零初始化,避免未定义值 |
Widget w("x"); | 直接初始化 | 一次构造,无临时对象 |
Widget w = Widget("x"); | 拷贝初始化 | C++17 起保证消除,等价于直接初始化 |
Widget w = "x"; | 隐式转换 | 构造函数不能是explicit,否则编译失败 |
auto w = Widget("x"); | 值推导 | 推导出Widget,同样消除 |
最容易被忽略的是第一行与第二行的差别。Widget w;对类类型会调用默认构造函数,如果类里有int成员且构造函数没有初始化它,这个成员的值是不确定的,读取它是 UB。Widget w{};会做值初始化,把内置类型成员置零。对于聚合类型,Widget w;甚至不会初始化任何成员。
Widget w = "x";依赖隐式转换,如果构造函数声明为explicit(这几乎是所有单参数构造函数的推荐做法),这行就会编译失败,必须写成Widget w("x");或Widget w{"x"};。
常见坑点
坑点一:把c_str()的指针存起来。
❌const char* p = make_string().c_str();临时std::string在分号处销毁,p悬垂。
✅ 先std::string s = make_string();,再在s的生命周期内使用s.c_str()。
坑点二:认为成员访问也能延长临时对象生命周期。
❌const auto& name = make_widget().name;被延长的是成员绑定的对象,不是外层临时对象。
✅ 先具名保存对象:auto w = make_widget(); const auto& name = w.name;。
坑点三:在构造函数初始化列表里绑定引用成员。
❌Holder() : ref_(make()) {},构造结束临时对象销毁,ref_立刻悬垂。
✅ 成员按值保存,或者要求调用方传入生命周期足够长的对象。
坑点四:返回局部对象的引用。
❌const Widget& make() { Widget w; return w; }返回后w已销毁。
✅ 按值返回,依赖 C++17 的拷贝消除,不产生额外开销。
坑点五:以为 C++17 之后移动构造可以删掉。
❌ 删除移动构造后仍在容器里push_back(std::move(x)),编译失败。
✅ 拷贝消除只覆盖 prvalue 初始化;需要转移所有权的地方仍然依赖移动构造。
坑点六:用Widget w;得到未初始化的内置成员。
❌ 类里有int count;且构造函数未初始化,随后++count;,读取未初始化值是 UB。
✅ 用Widget w{};,或在类内给成员默认初始化int count = 0;。
坑点七:以为临时对象可以作为非 const 引用参数传递。
❌void f(Widget&); f(Widget("x"));编译失败,非 const 左值引用不能绑定临时对象。
✅ 参数改成const Widget&或Widget&&,或者先具名再传。
坑点八:在 range-for 中遍历临时对象的成员。
❌for (auto& x : make_holder().items)绑定的是临时对象的成员引用,外层临时对象在循环开始前就可能销毁,C++20 及以前的规则下这是悬垂。
✅ 先把对象具名保存:auto h = make_holder(); for (auto& x : h.items)。C++23 起范围初始化中的所有临时对象都被延长到循环结束,但不要依赖这个较新的规则。
总结
| 要点 | 结论 |
|---|
| 术语 | 标准称「临时对象」,不叫匿名对象 |
| 销毁时机 | 默认在完整表达式结束时 |
| 引用延长 | 绑定到const&或&&时延长 |
| 不延长的情况 | 函数参数、return、引用成员、成员访问 |
| 拷贝消除 | C++17 起 prvalue 初始化同类型对象保证无拷贝 |
| 移动构造 | 仍然必要,处理 xvalue 与容器操作 |
| 初始化写法 | 优先T x{};与直接初始化 |
| 悬垂高发点 | c_str()、成员引用、返回引用 |
临时对象的全部难点可以压缩成一句话:它活在表达式里,不在语句之外。凡是想让它的生命周期超过当前语句,就必须用引用直接绑定或者先具名保存;凡是间接拿到它的内部指针或成员引用,都要先问一句「外层对象还在吗」。把这条规则记牢,绝大多数悬垂问题都能在写代码时就避开。