ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++临时对象深度解析:来源、代价与消除策略

2026/9/30 6:05:40 拓冰建站 浏览量
C++临时对象深度解析:来源、代价与消除策略 几年前我在重构一个内部服务时最耗时的不是算法本身而是一段看起来人畜无害的字符串拼接逻辑。随手一测一个请求周期里居然创建了上千个临时对象堆分配、拷贝、析构层层叠叠性能自然被拖垮。从那时候起只要聊到C性能优化我第一个问的问题就是你的临时对象都藏在哪里这篇内容想和你系统聊透C临时对象的常见来源、底层代价以及现代C给出的解决思路包括复制省略、移动语义、引用限定符、接口设计等多个层面。无论你是刚接触C不久、还在被值语义绕得头疼还是已经写了好几年C、想系统梳理性能优化手段这篇文章都值得耐心看完。理解临时对象本质上是理解C值语义、生命周期和现代编译器优化机制的交汇点。1. 先搞清楚C里的“临时对象”到底指什么很多人一说临时对象第一反应是“函数返回的那个对象”或者“运算产生的中间值”。这没错但在C标准层面临时对象有一个更精确的画像理解它才能明白为什么有些代码会产生临时对象、有些代码不会。1.1 从prvalue到临时对象的物化过程C17之后的标准里表达式被分成三类lvalue、xvalue和prvalue。其中prvalue纯右值就是用来初始化对象的“值”比如字面量42、std::string(hello)、算术表达式a b的结果。当这个prvalue真正被用来创建一个具体的内存实体时发生了所谓的“临时物化”temporary materialization。举个例子int func() { return 42; } int x func();从语义上看func()返回的42是一个prvalue它在初始化x之前会被物化成一个临时整数对象再用这个临时对象去初始化x。当然现代编译器几乎都会直接把这个过程优化掉但你要知道在标准语义里临时对象确实存在过。临时对象最大的特征就是“无名”。你没有办法在代码里直接写它的名字只能通过引用短暂地接触它。也正是因为无名它的生命周期和普通变量完全不同。1.2 临时对象的生命体终结规则一个裸的临时对象生命周期结束点是所在完整表达式结束时。看这段代码const char* cstr() { return hello; } void test() { const std::string ref std::string(cstr()); // 绑定临时对象 // 这里还可以安全使用ref }这里的std::string(cstr())创建了一个临时std::string它被绑定到const std::string上生命周期会延长到ref的生命周期结束。这是引用绑定带来的特殊规则也是很多初学者容易踩坑的地方如果ref是一个函数的参数情况就完全不同了。void consume(const std::string s) { // s引用的临时对象生命周期延续到函数结束 } void caller() { consume(cstr()); // 安全因为临时对象活得比函数调用时间长 }这里能安全不是因为临时对象被延长而是因为整个函数调用过程中临时对象还没到析构点。但有一种经典误用是std::string make() { return hello; } void broken() { const char* p make().c_str(); // make()返回的临时对象在此表达式结束后析构 // 此时p已经悬垂访问它是未定义行为 }这类问题在C新手代码里出现的频率非常高。理解了临时对象的生命体终结规则你才可能真正安全地使用引用。1.3 区分“临时对象”和“局部对象”还有一层容易混淆函数里的局部变量不是临时对象比如std::vectorint generate() { std::vectorint v{1, 2, 3}; return v; // v是具名局部对象不是临时对象 }虽然返回时经常会被“移动构造”或“复制构造”到一个外部临时载体里但v本身作为局部变量生命周期在函数返回时就结束了。很多编译器会做NRVO具名返回值优化直接把v构造到调用者的空间里连一次拷贝/移动都省了。之所以要明确区分主要是因为C17之后标准对“临时对象的复制省略”做了强制保证而具名局部对象返回时的NRVO仍然只是“允许优化”两者地位完全不同。这个差异直接决定了你写返回代码时该怎么安排。2. 生产临时对象的“高危场景”逐个拆给你看下面这些场景是我日常代码评审里见到最多的临时对象来源。每个看起来都平平无奇但叠加到一个中型项目里就是实实在在的性能损耗。2.1 函数传参时的隐式类型转换这是最容易被忽略的一类。我知道很多人的习惯是给函数传const std::string就是零拷贝。但前提是你传入的必须是一个真正的std::string左值。void handle(const std::string name); handle(alice); // 传入了const char[6]这一行代码会创建一个临时std::string然后再通过引用传入函数。因为const char*到std::string存在隐式转换而引用绑定到右值临时对象是允许的。整个过程看起来就像handle直接接收了字符串字面量实际上底层做了一次字符串的堆分配和字符拷贝。类似的问题在数值类型上也会出现void draw_point(const Point p); draw_point({1, 2}); // 如果Point有接受两个int的构造一样的道理换个方式理解const T参数解决的是“生命周期延长”问题它从来不解决“临时对象是否产生”的问题。要真正规避这类临时对象需要让调用方显式构造具名变量或者干脆让函数接受std::string_view之类的轻量视图类型。2.2 函数按值返回对象时产生的临时中转再看返回值std::mapint, std::string build_map(); auto m build_map();在没有复制省略、也没有移动语义的时代build_map()返回的临时std::map会先被构造出来再复制给m然后临时对象析构一次“无谓”的深拷贝。有了移动语义后如果std::map的移动构造函数可用这个临时对象的内容会被“搬”进m代价大幅降低。有了C17的强制复制省略后临时对象甚至可能压根不会出现build_map()的结果直接在m的内存上构造。但注意这里说的顺畅只是针对“返回一个prvalue”的场景。如果你在函数里写的是std::mapint, std::string build_map() { std::mapint, std::string m; // ... 填充m return m; }编译器是否做NRVO是可选的尤其是当函数里有多个分支返回同一个名字时NRVO往往就会失效此时会退回到移动构造或复制构造。所以“按值返回”不等于“一定没有临时中转”具体情况要看编译器和代码结构。2.3 表达式链中的中间结果这是临时对象的“批发市场”。最典型的莫过于字符串拼接std::string a Hello, ; std::string b world; std::string c !; std::string result a b c;这段代码里a b会先构造一个临时std::string接着这个临时对象再和c拼出第二个临时对象最后才赋给result。如果字符串内容较长每一步都可能触发std::string内部的动态内存分配和拷贝结果就是一次简单的拼接背后做了好几次堆分配。数学运算、容器操作、用户自定义运算符重载都有类似问题Matrix m m1 m2 m3; // 每个都产生临时Matrix std::vectorint v v1 v2; // 假设你重载过vector的这里我想说明不是所有临时对象都要第一时间消灭。关键是你要意识到表达式越复杂可能的临时对象数量就越多。如果这段代码在核心热路径上优化是完全值得的如果只跑一次那根本不用管。2.4 以值方式存储到容器的过程中容器是另一个重灾区。很多人知道push_back可能会复制但没意识到如果传入的是一个刚构造的临时对象或者是可以隐式转换的另一种类型临时对象还会再多一个。std::vectorstd::string items; items.push_back(std::string(apple)); // 构造临时string再move进容器 items.push_back(banana); // 先隐式构造临时string再move进容器 std::string real cherry; items.push_back(real); // 复制real进容器real本身还在每次push_back传入临时对象就是在“先在函数调用现场创建一个临时对象再把临时对象移动进容器”之间走一遍。虽然移动成本比复制低但如果临时对象是std::string底层那块堆内存还是发生了所有权转移总比“复制一份完整数据”好不过对性能敏感的场景最好的选择是从源头上直接少构造一次。2.5 构造函数参数和隐式转换带来的临时对象还有一种隐蔽情况类构造函数接受单参数时如果没加explicit就容易被隐式转换“偷袭”。比如struct UserId { UserId(int id) : id_(id) {} int id_; }; void show_profile(const UserId uid); show_profile(123); // 产生一个临时UserId也许有些场景你觉得“用123直接传挺方便”但代价是多构造一次临时对象。对于像UserId这样轻量的类几乎无感。可如果构造函数里面做了资源分配这就是性能陷阱了。正确的做法是凡是构造函数参数可以通过一个参数完成类型转换的默认加上explicit。这不是风格洁癖而是从根源上杜绝隐式临时对象。3. 一分钟看懂临时对象带来的真实开销以及优化器的“救火”能力临时对象本身未必是问题真正的问题是它背后叠加的构造、析构、内存分配和拷贝。这一节我想把开销模型讲清楚顺便说明编译器到底能帮你擦掉多少屁股。3.1 临时对象开销的三层解析临时对象的开销可以拆成三层来看栈空间的分配与回收这块在现代ABI里几乎是零成本跳栈时一次性偏移就行。构造函数和析构函数本身如果一个类型几乎不做事比如Point{int,int}那开销可以忽略。但如果构造函数里有new、文件句柄、锁、网络连接代价就大了。资源管理引发的连带成本最典型的就是std::string/std::vector的堆分配、拷贝、释放。一次堆分配可能就是几百纳秒一个请求里多出几十个临时对象就可能在毫秒级延迟上体现出来。简单估算假设一个std::string构造需要一次堆分配析构需要一次释放再加上发生拷贝时的二次分配和数据复制。三个临时对象就可能带来六次以上堆相关操作。而堆操作在并发场景下还可能触发锁竞争、内存碎片化实际影响比理论模型更糟糕。3.2 编译器的复制省略能在多大程度上“救火”很多人觉得自己写了很多临时对象编译器应该能优化掉。这个想法对了一半。RVO返回值优化和NRVO确实很强大。对于std::string build() { return std::string(hello); // RVO直接在调用点构造 }C17之前绝大多数编译器就能把这个临时对象优化掉。到了C17这种对纯右值返回的复制省略已经是标准强制的。也就是说如果你返回一个临时表达式编译器不能“复制”一份再初始化目标对象只能直接在目标内存里构造。但NRVO面对具名局部对象时标准仍只允许未要求。编译器可以优化不一定必须优化。典型无法应用NRVO的情况包括std::string build(bool flag) { std::string a aaa; std::string b bbb; if (flag) return a; // 分支返回 return b; }两个不同的具名对象在运行时决定返回哪一个这种情况下编译器没有足够空间把某个变量直接构造到返回地址处移动构造就成了兜底方案。还有一个很容易被忽略的点复制省略只适用于返回值和对象初始化不适用于函数参数。所以handle(alice)里那个临时std::string无论编译器怎么优化都很难靠复制省略消除因为它已经绑定到形参的引用上了必须在调用现场真实构造一个对象。3.3 怎样量化和观测临时对象的产生讲优化之前先说说怎么用工具和代码去“看见”临时对象。有一个非常朴素但有效的办法就是写一个带静态计数的类型#include iostream struct Probe { static inline int ctor_count 0; static inline int dtor_count 0; static inline int copy_count 0; static inline int move_count 0; Probe() { ctor_count; } Probe(const Probe) { copy_count; } Probe(Probe) noexcept { move_count; } ~Probe() { dtor_count; } }; Probe make() { Probe p; return p; } int main() { { Probe p make(); } std::cout ctor Probe::ctor_count copy Probe::copy_count move Probe::move_count dtor Probe::dtor_count \n; }在不同编译选项、不同C标准下运行你会看到计数完全不同的组合。在C17且开启了优化的情况下大概率是ctor1, copy0, move0, dtor1这就是NRVO生效的结果在C14下可能出现一次move。另外编译器生成的汇编也是很好的观察手段。打开godbolt.org把目标函数编译到汇编层面你可以直接看到是否调用了std::string的构造函数、有没有插入拷贝调用。4. 方案一靠标准——复制省略RVO/NRVO的现代规则与利用思路是先吃透标准里的“硬承诺”然后再讨论怎样在代码里主动创造适合优化的形态。4.1 C17之后的强制复制省略到底省略了什么C17标准里强制复制省略直接写进了规范当return语句或初始化表达式是prvalue时不再将结果物化成临时对象并拷贝而是直接将结果构造到目标对象所在内存中。std::string getString() { return std::string(hello); } auto str getString();在C17之前即使编译器做了RVO从语法语义上仍认为发生了“先构造临时、再拷贝初始化”的过程所以说拷贝构造函数必须是可访问的。C17之后则完全不需要如果类型删除了拷贝构造函数这依然合法——因为这里根本没有发生拷贝。这个变化最直接的影响就是你可以放心地返回临时对象而不必担心拷贝开销。甚至很多库的API可以写得更简洁class Config { public: Config(std::string path) : path_(std::move(path)) {} private: std::string path_; }; Config makeConfig() { return Config(/etc/app/config.ini); }这里Config的构造就发生在makeConfig()返回值的存储空间里没有额外的临时中转。4.2 善用分支与条件表达式为优化留空间对于NRVO我们虽然在标准上不能强求但可以通过代码形态提高优化概率。一个常见技巧是尽量让函数只有一个返回点或者让返回值直接来自同一个“名字”。比如可以先把结果集中的局部变量再用简单return返回// 不推荐的写法分支returnNRVO容易失败 std::string build(int level) { if (level 10) { std::string s high; return s; } else { std::string s low; return s; } } // 推荐的写法结果统一走一个return点 std::string build(int level) { std::string s; if (level 10) { s high; } else { s low; } return s; }当然如果两个分支里构造的字符串大小差异巨大把结果统一到std::string s中可能会让s预留的堆内存不够还是要分别判断。优化不能死记硬套要结合类型特征。在返回临时对象时如果用条件表达式直接返回prvalueC17下基本无忧std::string build(int level) { return level 10 ? std::string(high) : std::string(low); }两个分支都是prvalue返回类型和表达式类型一致这属于强制复制省略范畴。4.3 反面案例return std::move(local) 是反模式这是我在Code Review里经常看到的一个误区std::string build() { std::string local hello; return std::move(local); // 本意反正要返回先move了再说 }直觉上std::move(local)把local转换成右值可能能触发移动构造减少一次拷贝。但问题在于当local是具名局部变量时编译器本来有资格做NRVO直接省略掉移动。一旦你写了std::move(local)返回的表达式类型变成了std::string不再是prvalueNRVO直接失效。编译器的执行路径变成了“把local移动构造到返回值中”反而多了一次移动操作。在C11/14时代这条规则的把握还不够严因为那时不写move可能触发拷贝导致更糟的结果。但在C17以及std::string已经自带移动构造的今天return local;就足够了。这背后有一个通用原则不要替编译器做它本来能做得更好的决定。提前std::move往往切断了编译器的高级优化路径。5. 方案二靠语义——移动构造、std::move与引用限定符的正确姿势复制省略能解决一部分问题但不是万能的。当RVO/NRVO无法应用时移动语义就是我们减少临时对象拷贝成本的核心武器。5.1 移动语义把“拷贝数据”变成“转移所有权”移动构造的实质是用一个已有对象来“偷取”另一个对象内部资源。以std::string为例移动构造通常会把源字符串内部的指针直接指向目标字符串然后把源字符串的指针置空。这样省去了堆内存重新分配和字符数组拷贝。来看真实的调用路径std::vectorstd::string v; v.push_back(std::string(hello)); // 临时对象作为右值触发移动构造后面的std::string(hello)临时对象被移动进v的缓冲区如果有空间的话临时对象在表达式结束时析构但它内部的堆内存已经被“搬”到容器中所以析构只是释放一个空指针。成本比深拷贝低得多。不过移动不是免费午餐。移动后被移动的对象处于“有效但未指定”的状态你在代码里不应再假设它仍然保存原来的值。如果在一次生命周期不长的临时传递中使用完全没问题如果被移动的是某些关键缓存、全局状态你得仔细确认是否真的希望清空它。5.2 std::move的应用边界什么场景真正受益有些场景用std::move是合理的比如class DataBuffer { public: void setPayload(std::string payload) { payload_ std::move(payload); // 把形参的堆内存转给成员 } private: std::string payload_; };这里payload是按值传入的如果调用方传入左值会先复制一次传入右值或临时对象会移动一次。然后函数内部再进行一次移动把字符串资源挂到成员变量上。整体路径是“一次拷贝/移动 一次移动”相比直接传常量引用再深拷贝还是有一定优势。但以下场景里std::move几乎没意义移动一个内置类型比如int只是拷贝数值而已写std::move(i)只是增加阅读噪声。移动一个const对象。const T无法绑定到Tstd::move成功把它转成const T后实际能调用的是拷贝构造函数而不是移动构造函数白费功夫。移动一个马上要销毁的局部变量比如在函数末尾return std::move(local)如前面所说反而可能抑制NRVO。5.3 引用限定符限制临时对象上的操作C11起成员函数可以用引用限定符区分“左值对象”和“右值对象”的调用场景class StringBuffer { public: void append(const std::string s) { data_ s; } void append(std::string s) { data_ std::move(s); } void print() const { std::cout data_ \n; } private: std::string data_; };这里的表示该成员函数只能被左值对象调用则表示只能被右值对象临时对象或经过std::move的对象调用。这样做有什么价值它能在编译期就拦住一些“对临时对象做低效操作”的代码。比如StringBuffer makeBuffer() { StringBuffer buf; buf.append(a); return buf; } makeBuffer().append(b); // 如果append只有版本这一行会编译报错如果没有引用限定符开发者可以在一个临时对象上链式调用成员函数每次都要考虑“这个临时对象的生命周期够不够长”。用引用限定符之后想在临时上执行频繁修改就必须把它先存储到一个具名变量中这无形中推动了更健康的代码风格。当然引用限定符也可以用来做性能优化。比如对右值对象重载一个sink函数直接转移资源避免深拷贝class Container { public: void addData(const std::string s) { store_.push_back(s); } // 左值拷贝 void addData(std::string s) { store_.push_back(std::move(s)); } // 右值移动 };5.4 完美转发让中间层不引入新临时对象写模板代码时中间层函数最容易无意引入临时对象。比如templatetypename T void forwardToStore(T value) { store.emplace(std::forwardT(value)); }使用T作为模板参数时它不一定是右值引用而是“转发引用”也叫万能引用。配合std::forward如果传入的是左值就按左值转发触发拷贝如果传入的是右值或临时对象就按右值转发触发移动。这样中间层不会主动“多造”一个临时对象。这里有一个常见坑如果用了T但忘了std::forward而是直接再store.emplace(value)那么即使调用方传入临时对象value在函数内部也表现为左值最终走的是拷贝构造。所以模板转发时std::forward不是可选项而是必需品。6. 方案三靠接口设计——把临时对象扼杀在入口之外有些时候优化并不需要等到写实现时再想在接口设计阶段就可以把大量临时对象“劝退”。这一节分享几个我项目里反复用到的思路。6.1 优先用emplace系列代替push_back/insert用std::vector、std::map这类容器时push_back接收的是一个已构造好的对象所以调用方经常得先构造一个临时对象再塞进去。emplace_back则直接接收构造参数在容器内部原地构造元素完全省去临时对象。struct User { std::string name; int age; }; std::vectorUser users; users.push_back(User{Alice, 30}); // 构造临时User再移动/拷贝进容器 users.emplace_back(Alice, 30); // 直接在容器内构造User第二种写法的优势非常直观没有多余的临时User对象User内部的std::string也在容器内存中直接构造省了一次可能发生的字符串移动/拷贝。std::map::emplace同理std::mapstd::string, int scores; scores.emplace(Alice, 90);顺便提一嘴try_emplace是C17里更精细的版本尤其适合std::map和std::unordered_map即使键已经存在也不会发生多余的资源构造。如果代码里还在用emplace做“存在才插入”的判断建议换成try_emplace。6.2 参数类型选择string_view和const T的正确分工字符串参数是一个特别容易产生临时对象的场景。很多人第一反应是const std::string因为它能绑到临时对象上看起来“很高效”。但实际上如果你接收的是字面量或者char*传参时已经产生了一次std::string临时构造。更好的选择是std::string_viewvoid log_info(std::string_view message); log_info(this is a literal); // 不产生std::string临时对象 std::string msg hello; log_info(msg); // 直接视图绑定也不产生临时对象std::string_view本质上就是一个指向字符数组的指针长度不拥有数据、不分配内存传入一个字面量时它只是记录下字符串地址和长度成本可以忽略。但要注意string_view不延长字符串数据的生命周期底层字符数组被销毁后视图就成了悬垂指针。所以它适合短期传递、只读场景不适合把string_view长期存储起来。对于非字符串的轻量对象比如Point、Rect、Timestamp这类可能是几个整数/浮点数的类型我建议直接按值传参就好。按值传参时如果调用方传的是临时对象编译器会直接在其位置构造形参如果是左值可能发生拷贝但小对象拷贝也极便宜。反倒是const T失去引用折叠、可能带来间接寻址成本。6.3 运算符重载时的两条设计策略先说最常用的优先重载复合赋值运算符而不是二元运算符。比如自定义一个高精度整数类BigIntBigInt operator(const BigInt lhs, const BigInt rhs) { BigInt result(lhs); result rhs; return result; } BigInt operator(const BigInt rhs) { // 直接修改this的内存、进位等 return *this; }这样写a a b;会先构造一个临时BigInt然后移动/拷贝给a但如果直接写a b;就完全不会产生临时对象。在需要频繁累加的循环里差异会非常明显。另一种策略是针对返回右值引用的重载把临时对象“吃掉”。有些库会这样设计class StringAccumulator { public: StringAccumulator append(const char* s) { data_ s; return std::move(*this); } };通过限制函数在右值上调用并返回右值引用确保链式调用过程中不会复制整个对象。这种写法在一些零拷贝解析器、日志库中很常见。6.4 链式调用的生命周期与代价控制我再多聊一个链式调用的场景。很多时候为了可读性我们会写这种代码auto res loadConfig() .parse(config.toml) .validate() .finalize();每一步成员函数如果返回T按值返回新对象就会产生连续临时对象。如果返回类型是T左值引用调用的就是同一个对象成本反而低。如果返回类型是T说明调用者转移了所有权后续操作在同一个对象上进行也很高效。这里的关键就是当你设计链式API时明确每个调用返回的是新对象、左值引用还是右值引用。返回值类型直接影响临时对象产生的数量。一个实用的做法是对于只读的查询类链式调用返回const T对于修改类链式调用返回T对于想要转移资源的链式调用返回T。6.5 一个综合案例优化前的临时对象密度 vs 优化后我把这些策略应用到一个模拟场景里可以直观看到差别。假设有一个处理订单的函数需要接收参数、构造字符串、存进容器// 优化前 void processOrder(const std::string user_name, const char* product_id) { std::vectorstd::string log; std::string msg std::string(User: ) user_name , product: product_id; log.push_back(msg); // 其他业务逻辑... }这段代码产生的临时对象包括std::string(User: )、user_name , product: 产生的临时字符串、product_id隐式转换出的临时std::string、push_back(msg)时的拷贝等。优化后void processOrder(std::string_view user_name, std::string_view product_id) { std::vectorstd::string log; std::string msg; msg.reserve(user_name.size() product_id.size() 16); msg User: ; msg user_name; msg , product: ; msg product_id; log.emplace_back(msg); // 或者log.push_back(std::move(msg))如果msg后不再使用 // 其他业务逻辑... }新增的临时对象数量几乎为零。std::string_view避免了从字面量构造字符串避免构造中间结果emplace_back避免从msg再拷贝一次。当然处于可读性考虑我不会要求每处字符串拼接都写成这种形态。但对于高频路径这种“临时对象密度管理”非常有效。7. 我的实战建议该优化时优化别和编译器较劲最后聊点更贴近工程实际的东西。写了这么多年C我在临时对象上的心态经历了好几个阶段一开始无所谓后来看见临时对象就浑身难受再后来学会了在“性能”和“代码可读性”之间做取舍。7.1 先测量再决定要不要优化临时对象的绝对数量和性能影响之间并不总是正相关。如果函数构造的临时对象生命周期极短且对象很小可能连L1缓存的边都碰不到优化了也没有可感知的效果。所以我的第一原则永远是先用profiler测量或者至少写出带计数的探针确认瓶颈确实在临时对象上再动手。热路径上常见的高成本临时对象通常集中在std::string、std::vector、std::map这些带有堆分配语义的类型上。自定义的轻量类型反而不用过度优化因为一次栈对象的构造析构在热点循环里也可能被乱序执行隐藏掉。7.2 写好代码的“默认姿势”我总结了一套“默认姿势”不太会出错的习惯构造函数与转换函数单参数构造函数习惯性加explicit。函数返回值能直接返回prvalue就直接返回不要std::move局部变量。容器插入优先emplace系列已经持有对象且不再使用时用std::move搬家。字符串参数只读场景用std::string_view或const char*需要长期保存时考虑按值传参再std::move给成员。运算符重载复合赋值运算符优先实现二元运算符基于复合赋值实现。这些习惯不需要每次都经过严密思考它们会自动减少代码里的临时对象密度同时保留足够的可读性。7.3 和编译器协作而不是对抗有一类优化是编译器永远做不好的跨函数的临时对象传播。比如你在一个函数里构造字符串传给另一个函数另一个函数再存到容器里编译器很难跨越大段业务代码去判断“这个临时对象是否可以直接在容器里构造”。这时候人为地在接口层用emplace和string_view去调整效果远比依赖优化器好。相反有些优化你不需要替编译器操心。比如return TempObject();C17已经强制复制省略局部对象的NRVO现代编译器的实现质量也已经很高。别为了手动优化写出绕来绕去的代码反而破坏了编译器本来能做的优化。7.4 一点扩展这些思路不止于“性能优化”深入理解临时对象的另一个好处是能帮你写出更安全的并发和异步代码。很多并发bug的根源是某个对象在一段异步任务里被引用但它的生命随临时对象结束而提前终结。当你习惯了关注每个对象的生命周期自然会更容易在设计阶段发现这类隐患。比如async_task([str std::string(view)]() { // 捕获到的是一个拥有数据的std::string安全 });这类写法避免了对string_view或临时对象的悬垂引用。用“生命周期”的思维看代码很多临时对象相关的问题都不攻自破。我自己在写代码时已经形成了条件反射看到一个函数签名先问一句“这里会构造临时对象吗”看到一个链式调用会看看每一步返回的是对象、左值引用还是右值引用看到容器插入第一反应是emplace。这些习惯养成之后很多性能问题会在写代码的当下就被拦下来而不是等到上线后靠告警和性能分析转储去拆弹。希望这篇文章也能帮你建立这套反射。