C++ 临时对象生命周期:延长规则与悬空引用
04 临时对象的生命周期:什么时候出生,什么时候销毁
我们看下面这行代码在语法上看起来只是从一个字符串对象中取出了一个指针,但它实际上隐藏着一个严重的生命周期问题:
const char* p = std::string{"hello"}.c_str();
std::cout << p << "\n";
由于 std::string{"hello"} 是一个临时对象,当第一行语句执行完毕后这个临时字符串就会被销毁,而 p 所指向的恰恰是它内部的缓冲区。到了第二行再去读取 p 时,程序已经在访问失效的内存区域。在某些运行环境下它可能碰巧还能打印出 hello,在另一些情况下则会输出乱码,甚至可能直接导致崩溃。具体的表现形式不是重点,重点在于这条执行路径已经越过了对象的生命周期边界。
在上一章中我们讨论的是引用能否绑定的问题,而本章要追问的则是绑定之后的时间问题:临时对象在什么时候出现、什么时候销毁,哪些引用绑定能够延长它的生命周期,以及哪些看似合理的写法只是把悬空引用藏了起来。
临时对象从表达式结果里来
与拥有明确变量名的普通对象不同,临时对象通常没有名字,它们出现在按值返回、临时构造、类型转换和中间表达式结果等场景中。
std::string MakeName() {
return "cpp";
}
MakeName();
MakeName().size();
std::string{"cpp"};
在这段代码中,MakeName() 按值返回了一个 std::string。由于调用点没有给这个返回对象分配变量名,它就只是表达式求值过程中产生的临时结果。而 MakeName().size() 则是在这个临时字符串上调用成员函数,成员函数调用结束后,临时对象仍然要按照完整表达式的规则被销毁。
同样,std::string{"cpp"} 也是一个临时对象。它有真实的构造和析构过程,只是缺少变量名。不应因为它写在表达式中间就忽略它作为对象的实质。在 C++ 中,很多性能和生命周期方面的问题,正是来自这些没有名字但确实存在的对象。
没有名字还带来另一个问题:读者很难在后续代码中重新指向它。命名对象可以通过变量名在多处使用,而临时对象通常只能在当前表达式链条中被访问。MakeName().size() 不会出问题,因为成员调用发生在临时对象还存活的时候;但如果把 MakeName().c_str() 返回的指针拿到下一条语句去使用,就已经跨过了临时对象的生命边界。能否继续定位对象是值类别的问题,对象是否还存在则是生命周期的问题,而这两个问题在临时对象身上经常同时出现。
再看一个涉及中间结果的例子:
std::string a = "hello";
std::string b = a + " world";
表达式 a + " world" 会产生一个拼接结果,然后用这个结果来初始化变量 b。现代编译器可以做复制消除和移动优化,实际的构造路径可能很短,但从语言语义上仍然可以这样理解:表达式产生了一个结果,这个结果被用于初始化目标对象。
这就引出了关于临时对象的第一条直觉:它们的存在是为了服务于表达式求值。只要结果没有被命名对象接住,或者只是短暂地参与了一次调用,就需要关注它在哪个边界处被销毁。
临时对象最容易产生误导的地方,在于它们在源码中经常没有名字。但没有名字不代表没有生命周期,也不代表没有析构成本。std::string{"cpp"} 会构造字符串对象,std::vector<int>{1, 2, 3} 会管理一段元素存储,函数按值返回的对象也需要遵守构造、移动、复制消除和析构规则。只要对象管理着资源,生命周期边界就会影响程序的性能和安全性。
另一个常见的误区是把"编译器优化掉了某个临时副本"和"语言上没有生命周期问题"混为一谈。复制消除可以让返回值直接构造到目标位置,但它不会让保存临时对象内部指针这件事变得安全。优化改变的是对象如何被更高效地构造,而不改变引用、指针和视图必须指向有效对象的基本要求。在编写代码时,应当先按语义判断安全性,再让编译器去处理优化。
完整表达式结束是关键时间点
临时对象默认在完整表达式(full-expression)结束时被销毁。在入门阶段可以先把它理解成:最外层那条语句执行完毕后,临时对象就会被清理。
PrintName(MakeName());
MakeName() 产生的临时字符串至少要活到 PrintName 调用完成,否则函数参数中的引用就无法安全读取。等到整条调用语句结束后,这个临时对象才会被销毁。它不会在 MakeName() 子表达式刚返回时立刻销毁,也不会一直存活到作用域末尾。
通过使用一个带日志的类型,可以更清楚地观察这条时间线:
class Trace {
public:
explicit Trace(std::string name) : name_(std::move(name)) {
std::cout << "construct " << name_ << "\n";
}
~Trace() {
std::cout << "destroy " << name_ << "\n";
}
private:
std::string name_;
};
void Use(const Trace&) {
std::cout << "use\n";
}
Use(Trace{"temporary"});
std::cout << "after call\n";
其输出顺序的核心在于:
construct temporary
use
destroy temporary
after call
临时对象先被构造,函数能够安全地使用它;调用语句结束后临时对象被析构;然后下一条语句才开始执行。这个边界解释了为什么把临时对象传给 const T& 参数是安全的,但把函数参数中的引用保存到函数外部就会产生危险。
需要注意的是,"一行代码结束"只是入门阶段的直觉,并非严格定义。完整表达式也可能出现在控制语句条件、初始化语句、逗号表达式和其他语法环境中。在工程实践中最重要的不是背诵术语,而是理解一个关键事实:临时对象的生命通常很短,默认不会自动延长到你想使用它的任何位置。
完整表达式这个边界之所以重要,是因为它经常比作用域短得多。局部变量会存活到作用域结束,而临时对象通常只存活到承载它的完整表达式结束。以下两段代码存在本质区别:std::string text = MakeName(); 让结果成为一个命名对象,后续语句还能继续使用;而 MakeName().c_str() 只在当前表达式中产生一个字符串,表达式结束后内部缓冲区就会失效。虽然它们看起来都来自 MakeName(),但生命周期却完全不同。
在阅读代码时,可以把每条语句想成一条小的时间线:临时对象在表达式求值过程中出现,函数调用或成员访问使用它,完整表达式结束时清理它。如果某个引用、指针、迭代器或视图跨过了这条清理线,就需要立刻保持警惕。生命周期方面的问题很少在创建那一行暴露出来,通常是在后面某个看似无关的读取位置才表现出异常。
局部 const T& 可以延长临时对象生命周期
在 C++ 中有一种常见且安全的写法:
const std::string& name = MakeName();
std::cout << name << "\n";
MakeName() 产生一个临时字符串。如果按默认规则,它会在这条声明语句结束时被销毁。但语言专门规定:当临时对象直接绑定到局部引用变量时,临时对象的生命周期可以延长到该引用变量的生命周期结束。因此上面的 name 在当前作用域内是有效的。
这条规则也是 const T& 能够方便地接住临时对象的重要原因。没有这条规则,下面这种写法就很难使用:
const std::string& full_name = first_name + " " + last_name;
std::cout << full_name << "\n";
拼接结果是临时字符串,但由于它被局部 const std::string& 直接绑定,临时对象的生命周期被延长到 full_name 离开作用域为止。这不是悬空引用。
右值引用变量直接绑定临时对象时,也有类似的生命周期延长效果:
std::string&& temp = MakeName();
std::cout << temp << "\n";
这里的 temp 有名字,所以后续表达式 temp 是左值;但它引用的那个临时字符串会存活到 temp 离开作用域。右值引用变量常见于移动语义的内部实现,在日常代码中不应为了追求所谓的"更现代"而滥用它。
需要强调的是,生命周期延长并不意味着临时对象一旦接触到引用就能永久存活。它有明确的触发条件,最常用也最安全的理解是:局部引用变量直接绑定到临时对象时可以延长生命周期。而函数调用参数、返回引用、把引用存入长期对象等场景,都需要单独判断。
关于 const T& 的生命周期延长,还有一个容易产生误解的地方:延长的是临时对象本身的生命周期,并不意味着从该临时对象中取出的所有观察入口都自动变得安全。例如,把临时字符串绑定到局部 const std::string& 后,在该引用的作用域内可以安全使用;但如果再从它取出 c_str() 指针并保存到更外层,指针仍然受字符串对象生命周期和内部缓冲失效规则的约束。生命周期延长解决的是对象本身存活多久的问题,而不是所有派生引用都可以随意保存的问题。
这里的"直接绑定"值得多加注意。const std::string& name = MakeName(); 这种声明很清楚,临时对象和局部引用变量建立了直接关系,生命周期延长到引用变量离开作用域。但如果中间隔了一层函数返回引用,或者把引用交给构造函数保存为成员,规则就不再按照这个简单的直觉走。很多悬空引用正是藏在"看起来也绑定到了 const T&"这种相似的外形之下。
在工程实践中,不应把生命周期延长当成一种可以到处依赖的设计手段。它适合短小的局部场景,比如给一个复杂表达式的结果起个只读名字,方便后面几行代码阅读。它不适合用来支撑对象成员、异步任务、缓存或返回值。需要长期保存数据时,应当保存对象本身;需要短期观察时,应确保观察者的生命周期明显短于被观察对象。
函数参数绑定不会把临时对象延长到函数外
把临时对象传给 const T& 参数在函数调用期间是安全的,但安全范围仅限于这次调用结束之前:
void Print(const std::string& text) {
std::cout << text << "\n";
}
Print(MakeName());
MakeName() 的临时字符串存活到 Print(MakeName()) 这条完整表达式结束。在 Print 函数体内读取 text 没有问题。但如果函数试图把这个引用保存到外部,问题就出现了:
const std::string* saved = nullptr;
void SavePointer(const std::string& text) {
saved = &text;
}
SavePointer(std::string{"temporary"});
// 这条语句结束后,saved 指向的临时对象已经销毁
SavePointer 中拿到的引用只在调用期间安全。调用结束后,临时对象按照完整表达式规则被销毁,saved 就变成了悬空指针。函数参数是引用这一事实,并不代表它能将实参的生命周期延长到任意时间。
这也是很多 API 设计中的关键边界。如果函数只在调用期间读取对象,const T& 没有问题。但如果函数需要保存数据,就应该通过拷贝、移动或明确接收所有权来实现,而不是把引用或内部指针偷偷存起来。
class Store {
public:
void Set(std::string text) {
text_ = std::move(text);
}
private:
std::string text_;
};
这种按值接收再移动的写法意图很明确:调用者提供一个字符串,Store 自己保存一份。它不会依赖调用者对象的生命周期,也不会保存临时对象内部的地址。
函数参数绑定不延长到函数外这条规则,同样影响着回调和异步代码。假设一个函数接收 const std::string&,然后把 [&] { Use(text); } 这样的闭包存到队列中,在调用结束后再执行,其风险与保存指针是一样的。引用参数只是调用期间的借用,不是长期的所有权。把借来的对象地址、引用或视图放到晚于调用结束的地方,本质上都是把生命周期的责任推给了调用者。
如果函数确实需要延迟使用数据,接口应该显式拥有它。可以按值接收参数然后把值移动进任务对象,也可以要求调用者传 shared_ptr、unique_ptr 或其他表达所有权的类型。哪种设计更好取决于场景,但不能用 const T& 来完成长期保存。引用参数越轻量,越需要明确它的时间边界。
这个边界在多线程代码中尤其重要。一个引用参数在传进当前函数时看起来安全,但如果函数启动了线程并把引用交给后台任务,当前调用很快结束后,临时对象或调用者栈上的对象都可能已经不存在了。生命周期问题一旦跨线程,表现会更加随机和难以复现。要跨线程保存数据,默认应该复制、移动或使用明确的共享所有权机制,而不是捕获引用去赌调用者的对象还活着。
返回引用最容易制造悬空
返回局部对象的引用是 C++ 中最典型的生命周期错误之一:
const std::string& BadName() {
std::string local = "bad";
return local; // 错误:返回局部对象引用
}
local 是自动对象,函数返回时它的生命周期就结束了。调用者拿到的引用指向的是一个已经被销毁的对象。这个错误有时会被编译器以警告的形式提示,但不应依赖警告作为唯一的防线。
返回临时对象的引用同样危险:
const std::string& AlsoBad() {
return std::string{"bad"};
}
这里的临时字符串不会因为"返回的是 const std::string&"就延长到调用方。函数返回时临时对象已经结束,调用者拿到的仍然是悬空引用。
正确的做法通常是按值返回:
std::string MakeSafeName() {
return "cpp";
}
在现代 C++ 中,按值返回新对象通常既清晰又高效。编译器可以做复制消除;即便没有完全消除,也可以利用移动语义。为了避免拷贝而牺牲安全性,通常不是值得的选择。
在确实需要返回引用时,被引用的对象必须比调用者使用它的时间更长。常见的场景是返回对象自身的成员引用:
class User {
public:
const std::string& name() const { return name_; }
private:
std::string name_;
};
这段代码的安全前提是:调用者使用返回引用时,User 对象还存活着。如果在一个临时 User 上调用 name() 并保存返回引用,仍然可能出现问题。第 6 章会讲到成员函数引用限定,专门处理左值对象和右值对象上的不同返回策略。
返回引用的合理场景通常需要满足两个条件:被引用的对象不是函数中的局部临时物;调用者能够清楚地知道它依赖哪个对象的生命周期。容器的 front()、operator[] 返回元素引用就是典型的例子。引用指向容器内部的元素,只要容器还存活并且没有发生让引用失效的修改,就可以使用。这个前提虽然需要小心维护,但至少依赖关系是明确的。
不好的返回引用写法往往让人看不出依赖关系。函数签名 const std::string& GetName() 没有告诉你返回的是全局对象、成员对象、缓存对象还是局部临时对象。读者必须去查看实现才能判断安全性。公共接口如果返回引用,最好让所属对象和生命周期边界非常清楚;如果做不到,按值返回通常更稳妥。
成员函数返回引用时,还需要考虑调用对象本身是否是临时对象。user.name() 在一个长期存在的 user 上调用,返回成员引用通常可控;但 MakeUser().name() 在临时对象上调用,完整表达式结束后 User 被销毁,成员引用也随之失效。第 6 章会用成员函数引用限定来解决这类问题:左值对象可以返回引用,右值对象则更适合返回值。
保存临时对象内部地址也危险
开头的 c_str() 例子属于另一类常见的错误:没有直接保存对象引用,而是保存了临时对象内部资源的地址。
const char* p = std::string{"hello"}.c_str();
p 指向字符串内部的缓冲区。字符串对象一旦被销毁,内部缓冲区就会失效。p 本身只是一个指针变量,它的生命周期还在,但它指向的内容已经不存在了。
同类问题还可能出现在 data()、数组视图、迭代器和引用成员中:
auto it = std::vector<int>{1, 2, 3}.begin(); // 危险
std::string_view view = std::string{"hello"}; // 危险
这些写法的问题不在于语法本身有多复杂,而在于它们把临时对象内部的观察入口保存了下来。临时对象一结束,观察入口就悬空了。
安全的做法是先让对象本身拥有足够长的生命周期:
std::string text = "hello";
const char* p = text.c_str();
std::vector<int> values = {1, 2, 3};
auto it = values.begin();
现在 p 和 it 的安全性依赖于 text 和 values 的生命周期。只要对象还存活,并且没有发生会让指针或迭代器失效的修改操作,就可以按对应类型的规则来使用。
需要注意后半句:对象还存活只是第一层条件。很多标准库类型在修改时还会让内部地址、迭代器或视图失效。比如 std::string 重新分配缓冲区后,旧的 c_str() 指针可能失效;std::vector 扩容后,旧迭代器和元素指针通常会失效。生命周期没有结束,不等于所有观察入口都永久有效。
这就是保存内部地址比保存对象更脆弱的原因。对象变量本身有清楚的作用域,修改操作也集中在对象接口上;而内部指针、迭代器、string_view 则依赖对象内部存储保持不变。在编写教学示例时可以展示这些入口,但在工程代码中应该尽量缩短它们的使用范围。拿到、使用、丢弃,越紧凑越安全。
工程里优先让对象归属清楚
生命周期问题中最难排查的情况,往往不是来自复杂的语法,而是来自所有权边界不清晰的设计——看起来省了一点拷贝,实际上把对象的归属说模糊了。在工程代码中,可以遵循以下几条保守规则来编写。
当需要持有表达式结果时,用对象接住:
std::string name = MakeName();
这比保存 const char*、string_view 或引用更稳妥。对象本身拥有资源,生命周期跟随变量作用域。
当函数只读参数时,用 const T&:
void Print(const std::string& text);
函数只在调用期间读取,不保存引用,就不需要担心临时对象在调用中间被销毁。
当函数需要保存数据时,保存自己的对象:
class Logger {
public:
void SetPrefix(std::string prefix) {
prefix_ = std::move(prefix);
}
private:
std::string prefix_;
};
不论调用者传入的是左值还是临时对象,Logger 最终都拥有自己的 prefix_。接口不会把外部的生命周期依赖带进类内部。
当返回新结果时,优先按值返回:
std::string BuildMessage();
按值返回表达的是"我给你一个新对象"。这是清楚的所有权边界。除非能明确证明返回引用所指向的对象生命周期足够长,否则不要返回引用。
这些规则的共同点在于把"谁拥有对象"说清楚。生命周期问题之所以最难排查,往往不是因为语法复杂,而是因为所有权边界不清晰。一个函数到底只是看一下数据,还是要保存数据?一个类到底拥有字符串,还是只观察外部字符串?一个返回值到底是新对象,还是某个内部对象的别名?这些问题越早在接口上表达清楚,后续对注释和经验的依赖就越少。
性能和安全并不总是对立的。在很多情况下,按值接收再移动、按值返回、让对象自己拥有资源,既能让生命周期变得清楚,也能被现代编译器优化得很好。为了避免一次可能并不存在的拷贝而保存引用,结果把悬空风险引入系统,通常不是值得的选择。
在真正需要非拥有视图时,也应该把它限制在短路径中。string_view、span、迭代器、原始指针都可以很高效,但它们表达的是"我不拥有资源,只是在看一段别人管理的数据"。这种类型适合函数参数、局部计算和同步完成的算法,不适合默认存入长期对象中。越轻量的观察类型,越需要靠代码结构来保证被观察对象存活得更久。
生命周期和求值顺序是两条时间线
临时对象的生命周期回答的是"对象活到什么时候"这个问题。这条规则决定了引用、指针、迭代器和视图是否还指向有效对象。只要越过了对象的生命周期边界,再精巧的类型设计也无法挽救程序。
求值顺序回答的则是另一类问题:同一个表达式中的动作谁先发生。两者会同时出现在代码中,但不能互相替代。以 Use(MakeA(), MakeB()) 为例,两个临时对象都需要在函数调用期间有效,这是生命周期问题;而 MakeA() 和 MakeB() 谁先执行,则是求值顺序问题。一个对象存活得够久,不代表它一定先被构造;一个函数先被调用,也不代表它返回的引用可以存活到后面任何位置。
本章的内容可以收成四句话:
临时对象来自表达式求值。
默认在完整表达式结束时销毁。
局部引用变量直接绑定临时对象时,可以延长生命周期。
返回局部引用、保存临时内部地址,通常是在制造悬空。
下一章会看另一条时间线:表达式内部的动作谁先发生。生命周期告诉你对象什么时候还在,求值顺序告诉你副作用什么时候发生。把这两条线区分清楚,复杂表达式中的很多问题就能得到有效避免。
阅读导航




