ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++临时对象全解析:产生场景、性能代价与优化实战

C++临时对象全解析:产生场景、性能代价与优化实战 你有没有遇到过这种情况代码逻辑写得没什么问题可一旦容器里的元素变得复杂程序就莫名其妙地变慢或者你只是把一个对象按值传给某个函数内存占用立刻涨了一截。我刚开始排查这类问题的时候也挠过头后来才发现很多性能损耗和一个经常被忽略的东西有关——临时对象。C里的临时对象是老生常谈也是面试里最常被问到的“八股”之一但真正把它的产生场景、背后代价和优化手段搞清楚的人并不多。这篇文章我会从临时对象的本质讲起把最常见的产生场景一个个拆开再给出可以落地的解决方案。还会用一个自己实现的字符串类做一组对比实验让你直观看到临时对象到底消耗了多少构造、析构和内存分配。适合想把C基础打扎实的初学者也适合想系统梳理性能优化知识的已经入门开发者。看完之后你至少能回答出“为什么返回局部对象不需要std::move”“为什么vector扩容时可能走拷贝而不是移动”这类经典问题。1. 临时对象到底是什么为什么值得专门写一篇1.1 临时对象的定义与本质临时对象英文叫 temporary object指的是代码里没有名字、由表达式求值过程中产生的对象。最常见的形式就是std::string(hello)、a b的返回值、隐式类型转换产生的中间结果。它的本质来源是C的值语义。和Java、Python这类“默认操作引用”的语言不同C的变量名本身绑定着一块实实在在的对象存储传参、返回、赋值这些操作默认都是“把对象内容复制一份”。为了在表达式中保存中间结果编译器就必须在一些看不见的位置构造对象这些对象就是临时对象。打个比方你去餐厅吃饭厨师在后厨把菜炒好先放在传菜台上再由服务员端到你桌上。传菜台上那份菜就是“临时对象”——它不是最终摆在你餐桌上的那份但确实完整地存在过并且被后续步骤使用。等服务员端走之后传菜台上那个位置就清空了。对应到C里临时对象在完成它的使命后会被自动销毁。1.2 生命周期规则它什么时候“消失”临时对象的生命周期由C标准严格规定这里有两类情况需要区分。大多数情况下临时对象在创建它的完整表达式结束时销毁。比如std::string result std::string(hello) world; // 完整表达式结束后ab产生的临时 string 销毁但有一个例外当临时对象被绑定到const左值引用或者右值引用时它的生命周期会延长到该引用的生命周期结束。这就是为什么下面的代码是安全的{ const std::string s std::string(hello); // 临时对象生命周期延长到 s 离开作用域 std::cout s std::endl; }真正容易踩坑的是引用作为函数参数或成员的情况。如果临时对象绑定到函数形参的const引用生命周期会延长到整个函数调用结束这没问题但如果它被用来初始化一个成员引用情况就变了——标准规定临时对象只有在直接绑定到引用对象时才会延寿如果中间隔了一层“成员访问”生命周期不会延长。下面的代码就是典型的错误示范struct Holder { const std::string ref; Holder(const std::string s) : ref(s) {} // 临时对象绑定到成员引用不延寿 }; Holder h(std::string(temporary)); // h.ref 是悬垂引用注意这条规则在C标准里属于“陷阱中的陷阱”。如果你的类需要长期保存引用数据不要依赖临时对象延寿应该改成按值存储或使用std::string_view并保证源对象存活。2. 临时对象最常见的产生场景我踩过的几个坑2.1 按值传参和按值返回最容易被忽略的两处按值传参是临时对象最频繁的生产线。看这个函数void process(std::string s) { // do something } process(hello world); // hello world是const char*需要先构造一个临时std::string调用process(hello world)时实参类型是const char*而形参是std::string编译器会先构造一个临时std::string再用这个临时对象初始化形参s。如果有移动构造函数临时对象会被移动进s如果没有就是一次深拷贝。如果你传入的是一个已有的std::string左值变量那么更直接——按值传参一定会触发一次拷贝构造除非你显式std::move。按值返回同理。考虑下面这个函数std::string makeName() { std::string name hello; return name; // 需要从 name 构造一个返回值的临时对象 }这里return name时如果编译器不执行NRVO具名返回值优化就需要从name拷贝或移动构造一个临时对象这个临时对象再进一步初始化调用者那边的目标对象。虽然现代编译器在开启优化后大概率会做RVO但在未开启优化、或在某些复杂控制流下临时对象依然会出现。2.2 隐式类型转换与构造函数的“甜蜜陷阱”这是非常隐蔽的一种临时对象来源。当一个函数的参数类型是const std::string调用者传入的是一个const char*字符串时为了匹配参数类型编译器会隐式构造一个临时std::stringvoid print(const std::string s) { std::cout s std::endl; } print(hello); // 隐式构造临时 std::string(hello)这个临时对象绑定到const引用上生命周期没问题但构造、析构和堆内存分配的开销是实实在在的。如果你在循环里反复调用print(hello)或者这个函数的调用频率很高这部分的成本会被放大很多倍。更麻烦的是如果自定义类型没有把构造函数声明为explicit隐式类型转换会无处不在。比如你写了一个class Score构造函数接收int类型然后写出add(88)这样的调用编译器会静默地构造一个临时Score对象。这种代码在某些场景下是便利在另一些场景下就是性能黑洞。2.3 容器操作中的临时对象push_back 和 emplace_back 的差别容器操作是另一个重灾区。看这两行代码std::vectorstd::string v; v.push_back(std::string(hello)); // 先构造临时 string再移动进 vector v.push_back(hello); // 同样需要从 const char* 构造临时 string再移动进 vector第一行里std::string(hello)本身就是一个临时对象push_back接收const std::string或者右值引用。在C11以后临时对象会被移动进容器但如果你的类型没有移动构造函数那就会退化成拷贝构造临时对象和容器内部的对象各有一份数据。而用emplace_back就是另一番光景v.emplace_back(hello); // 直接在 vector 内部的存储上构造 std::string不产生临时对象emplace_back接收的是构造函数的参数包它会在容器已经分配好的内存上直接调用构造函数。整个过程只发生一次构造没有中间临时对象的搬运。2.4 表达式里的中间结果拼接字符串其实是“临时对象制造机”表达式中问结果产生的临时对象最典型的就是字符串拼接std::string result a b c;表达式a b会返回一个临时std::string然后这个临时对象再与c相加又产生一个临时std::string最后才赋值给result。在C17之前如果没有拷贝省略的介入这两次加法会产生两个临时对象每个临时对象都可能涉及堆内存分配和释放。即便现代编译器优化能力强你还是会看到至少一次或者两次的中间对象构造。更麻烦的是如果a b的结果比较大中间临时对象的堆内存分配和释放会让内存碎片增加。对于频繁拼接的场景合理做法是result.reserve(a.size() b.size() c.size())然后逐个append或者使用表达式模板库如std::string的实现通常已经做了小字符串优化和表达式优化但原则上要避免让大量中间临时对象堆积。2.5 运算符重载与“函数返回后立刻被使用”自定义类型如果重载了operator那么每写一次a b就会产生一个返回值临时对象。如果运算符重载内部还按值传递参数那临时对象会更多String operator(const String lhs, const String rhs) { String temp(lhs); temp rhs; return temp; } String c a b; // 运算符返回临时对象再拷贝/移动到 c一个简单的a b可能涉及临时对象temp、函数返回的临时对象、以及目标对象c的构造。虽然编译器有RVO和移动语义兜底但理解这个链条仍然很有价值。还有一种常见情况是“函数返回一个对象然后立刻给成员变量赋值”obj.setData(makeData()); // makeData() 的返回值是临时对象赋值给 obj.data如果setData的参数是一个按值接收的形参那么这里会发生“返回临时对象 - 移动构造形参 - 赋值给成员”这样一串操作。处理不好就是“临时对象接力赛”。3. 一次临时对象到底要付出多少代价3.1 从栈帧到深拷贝一次临时对象多次隐藏调用很多人以为临时对象的代价就是“多构造一次、多析构一次”其实远不止如此。一个临时对象的完整生命周期通常包括在栈上或寄存器中构造对象如果对象内部有指针构造函数需要分配堆内存如果临时对象由拷贝产生那么源对象的每个成员包括嵌套容器的每个元素都需要被复制临时对象被使用完后析构函数还得释放堆内存。对于std::string这样的类型一次拷贝意味着一次堆分配和一次memcpy如果字符串很长这个成本会线性增长。对于std::vector这种容器拷贝一个包含1万元素的vector意味着可能有一万次元素级别的拷贝操作如果元素本身还有堆分配那成本直接爆炸。更关键的是临时对象常常出现在循环或者热路径里。你写的是:for (int i 0; i 10000; i) { std::string str makeString(); // 每次循环产生/销毁临时对象 }这10000次调用如果每次临时对象都分配一次堆内存那等于10000次malloc和free。相比起直接复用同一个对象性能可能差一个数量级。3.2 实测对比一个简单 std::string 拼接能多出多少开销我自己做过一个简单的测试循环10万次把两个短字符串拼接起来存入一个std::vectorstd::string分别用push_back和emplace_back。在关闭优化和开启-O2的情况下结果差异非常明显。未优化时push_back版本比emplace_back版本慢了一倍多开启-O2后差距缩小但push_back版本依然存在更多的构造和析构调用。如果把一个自定义的、构造时打印日志的类型放进去你会看到日志数量清清楚楚地告诉你一次push_back(v)到底多执行了多少次构造。这告诉我们一个道理编译器优化能帮你“省掉”一部分临时对象但并不是全部。特别是那些发生在循环里、无法被优化掉的临时对象带来的性能损耗是实打实的。4. 主流的解决方案与优化思路4.1 拷贝省略编译器帮你抹掉的临时对象拷贝省略copy elision是编译器对临时对象的“蒸发术”。具体来说当表达式产生一个临时对象用来初始化另一个对象时编译器可以直接在目标对象的存储上构造这个临时对象省掉中间那一次复制/移动。C17引入了一个重要变化保证拷贝省略guaranteed copy elision。从C17开始当函数返回一个prvalue纯右值时该返回值直接构造到调用者指定的存储中不再产生临时对象。比如std::string makeString() { return std::string(hello); // 返回prvalue直接构造到目标位置 }这个返回值不再需要“先构造临时对象再拷贝/移动到目标对象”的过程。这是标准层面的保证而不是编译器可选优化。所以在C17及以上版本中写return std::string(hello);是很干净的。另一种情况是具名返回值优化NRVO针对返回局部命名对象std::string makeString() { std::string result hello; return result; // 编译器可能省略拷贝/移动直接构造 result 到目标位置 }NRVO不是标准强制要求但主流的GCC、Clang、MSVC在开启优化时都会做。所以一个重要的建议是返回局部变量时直接写return result;不要画蛇添足地写return std::move(result);。因为后者会把result当作右值抑制NRVO反而可能导致多一次移动。如果对象没有移动构造函数那甚至会退化成拷贝。4.2 移动语义把深拷贝变成“乾坤大挪移”移动语义是C11引入的杀手锏。它的核心思想是当一个对象即将销毁时与其深拷贝它的资源不如把资源“偷”过来然后把源对象置为空。移动构造函数和移动赋值运算符是实现这个能力的两个关键函数。一个典型的移动构造函数长这样class Buffer { public: Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } private: size_t size_; int* data_; };移动构造的成本通常是O(1)只是几个指针和整数赋值而深拷贝的成本是O(n)。这就是为什么在C11以后std::vector扩容的性能大幅提升——因为元素如果支持移动语义扩容时大部分场景只需要“搬指针”而不是“复制数据”。但要注意移动语义并不是自动发生的。函数参数是左值还是右值决定了走拷贝还是移动。很多新手写出std::move用得飞起但实际效果可能和想象的不一样。std::move本身什么也不做它只是一个static_cast把左值转换成右值引用。真正的移动发生在“接收方把它当作右值来操作”的时候。举个例子std::string a hello; std::string b std::move(a); // 调用移动构造a 可能变为空这里b的构造函数看到右值引用选择了移动构造把a内部的缓冲区指针偷走。之后a不再拥有这个缓冲区。4.3 引用传递与 const 引用能少建一个就少建一个想减少临时对象最简单粗暴也最有效的方式是尽量避免按值传参。如果函数不需要修改参数对象的内容就用const T接收如果函数需要修改可以考虑用T或者按值传入后再在函数内部修改。比如// 不推荐无论传左值还是右值都可能多一次拷贝/移动 void process(std::string s) { // ... } // 推荐传入 const 引用不产生额外拷贝 void process(const std::string s) { // ... }但传引用也不是万能。如果函数内部要保存参数的副本按值传参配合移动语义反而比按引用传参再复制一次更高效。看这个例子class MyClass { public: void setData(std::string d) { data_ std::move(d); // 如果 d 是临时对象传入这里就是一次移动赋值 } private: std::string data_; }; obj.setData(hello); // const char* 构造临时 string - 移动构造形参 d - 移动赋值给 data_调用方传入一个临时字符串时整个过程只发生一次真正的堆内存分配构造临时std::string后续都是指针搬运。但如果把参数改成const std::string那么函数内赋值就必须走深拷贝void setData(const std::string d) { data_ d; // 无论 d 是不是临时对象这里就是一次拷贝赋值 }所以参数选值传递还是引用传递取决于你后续如何使用这个参数只读不改就用const T需要保存副本就用按值传参加std::move。4.4 emplace 系列与完美转发容器原地构造省掉临时对象emplace_back、emplace、try_emplace这些接口是容器操作中消灭临时对象的利器。它们的原理是完美转发把传入的参数包直接转发给容器元素类型的构造函数在容器分配好的内存上原地构造对象。std::vectorstd::string v; v.reserve(10); v.emplace_back(hello); // 直接在 vector 内部构造 stringno temporary再看一个map的例子std::mapint, std::string m; m.emplace(1, value); // 直接构造 pairint, string m.emplace(std::piecewise_construct, std::forward_as_tuple(2), std::forward_as_tuple(3, a)); // 分段构造避免临时 pair完美转发背后的关键是std::forward。它能把一个被声明为右值引用、但实际上是左值的函数参数恢复成原来的值类别。比如templatetypename T void forwardToContainer(T arg) { container.emplace_back(std::forwardT(arg)); }如果调用方传入右值T推导为Xstd::forwardT返回右值引用如果传入左值T推导为Xstd::forwardT返回左值引用。这样转发过去时参数的类型信息不会丢失从源头上避免了“明明传入临时对象却被当左值使用导致复制”的问题。4.5 explicit、返回值优化等细节从源头堵住临时对象写构造函数时加上explicit是防止隐式类型转换产生临时对象最有效的手段。特别是那些只有一个参数的构造函数比如explicit Score(int value)如果不写explicitadd(88)这种代码就会隐式构造一个临时Score。explicit也能帮你发现误用。比如class Path { public: explicit Path(std::string_view p) : path_(p) {} private: std::string path_; }; void open(const Path p) {} open(data.txt); // 编译错误因为构造函数是 explicit不会隐式转换编译失败虽然会让某些写法变得“麻烦”但这是好事——它逼着你明确表达意图避免在性能敏感路径上凭空创建临时对象。返回值优化方面除了前面提到的C17保证拷贝省略和NRVO还有一个常被忽略的细节返回多个对象时可以用结构化绑定配合pair/tuple。比如std::pairstd::string, int getInfo() { std::string name hello; int id 42; return {name, id}; // 实际返回时name 和 id 会被移动/拷贝进 pair 的临时对象 }在C17之后如果你返回{std::move(name), id}或者构建一个std::tuple编译器通常会直接构造到调用者的结果对象中。这也是一种从源头减少临时对象的思路。5. 实操写一个 String 类从“频繁临时对象”到“接近零拷贝”下面我用一个自定义的MinString类做个实验。你可以自己动手跑一遍观察构造、移动、析构的调用次数直观地理解临时对象的开销。5.1 造一个用来观察的MinString类一个极简的字符串类包含一个char*指针、一个长度和一个容量。构造函数会分配堆内存析构函数会释放堆内存拷贝构造函数做深拷贝移动构造函数做指针转移。为了观察调用次数我在每次构造、移动、析构时打印一行日志。#include iostream #include cstring class MinString { public: MinString() : data_(nullptr), size_(0), cap_(0) { std::cout default ctor\n; } explicit MinString(const char* str) : data_(nullptr), size_(0), cap_(0) { size_ std::strlen(str); cap_ size_ 1; data_ new char[cap_]; std::memcpy(data_, str, cap_); std::cout ctor from const char*: str \n; } MinString(const MinString other) : data_(nullptr), size_(other.size_), cap_(other.cap_) { data_ new char[cap_]; std::memcpy(data_, other.data_, other.size_ 1); std::cout copy ctor\n; } MinString(MinString other) noexcept : data_(other.data_), size_(other.size_), cap_(other.cap_) { other.data_ nullptr; other.size_ 0; other.cap_ 0; std::cout move ctor\n; } MinString operator(const MinString other) { std::cout copy assignment\n; if (this ! other) { delete[] data_; size_ other.size_; cap_ other.cap_; data_ new char[cap_]; std::memcpy(data_, other.data_, other.size_ 1); } return *this; } MinString operator(MinString other) noexcept { std::cout move assignment\n; if (this ! other) { delete[] data_; data_ other.data_; size_ other.size_; cap_ other.cap_; other.data_ nullptr; other.size_ 0; other.cap_ 0; } return *this; } ~MinString() { delete[] data_; std::cout dtor\n; } private: char* data_; size_t size_; size_t cap_; };注意我用explicit标记了MinString(const char*)避免后面出现意外的隐式转换。5.2 第一次优化添加移动构造前后的对比先写一个测试函数把临时字符串传入std::vectorvoid testPush() { std::vectorMinString v; v.reserve(2); std::cout --- push_back(left value) ---\n; MinString a(first); v.push_back(a); // 传左值一定是拷贝构造一次 std::cout --- push_back(move) ---\n; v.push_back(std::move(a)); // 传右值移动构造一次 std::cout --- push_back(temp) ---\n; v.push_back(MinString(temp)); // 临时对象构造一次然后移动进容器 std::cout --- emplace_back ---\n; v.emplace_back(emplace); }留意输出里的构造/析构顺序和次数。如果你把类的移动构造函数注释掉再看一遍输出拷贝次数立马变多。这个实验能直观感受到移动语义对临时对象开销的削减。5.3 第二次优化函数返回与 emplace_back 的对比再写一个测试函数观察返回值优化MinString makeByValue(const char* s) { MinString result(s); return result; // NRVO 或移动 } void testReturn() { std::cout --- makeByValue ---\n; MinString obj makeByValue(hello); std::cout --- emplace_back ---\n; std::vectorMinString v; v.reserve(2); v.emplace_back(world); std::cout --- push_back ---\n; v.push_back(MinString(world2)); }运行后你会看到makeByValue在开启优化和不开启优化时的输出差异。如果编译器没有做NRVOreturn result;会调用移动构造如果做NRVO则可能连移动都没有直接在obj的存储上构造result。emplace_back(world)只调用了一次ctor from const char*没有额外的临时对象产生。而push_back(MinString(world2))则多了一次临时对象的构造和移动从日志里能数出来。5.4 编译选项带来的差异C编译器默认在优化级别较低时可能不会启用拷贝省略。GCC和Clang可以通过-fno-elide-constructors显式关闭拷贝省略也可以开启-O2让编译器尽量消除临时对象。我推荐你编译时加上这两个选项对比一下g -stdc17 -O0 -fno-elide-constructors test.cpp -o test_noelide g -stdc17 -O2 test.cpp -o test_o2运行两次观察输出的构造/移动/析构次数差异。你会发现关闭优化时临时对象数量非常多每次push_back(MinString(temp))都会经历“构造临时对象 - 移动进容器 - 析构临时对象”的完整流程开启-O2后临时对象数量明显减少甚至有些构造/移动会被省略emplace_back在任何优化级别下都只触发一次构造这是它结构上的优势。这组实验帮我形成了几个习惯容器插入优先用 emplace 系列返回局部对象直接 return 变量给有可能被复制的类加移动构造和移动赋值并保证 noexcept。6. 常见问题与排查技巧实录6.1 典型错误与排查思路场景一临时对象绑定成员引用程序崩溃或数据错乱。代码往往长这样struct User { const std::string name; User(const std::string n) : name(n) {} }; User u(std::string(Alice)); // name 悬垂排查思路不要纠结于为什么析构后数据还能“看”到标准就是这么规定的。直接改成按值成员std::string name;。如果担心拷贝开销可以用移动语义或shared_ptr。场景二return std::move(local)导致性能倒退。我在很多代码评审里见过这种写法作者以为加了std::move会更高效实际上抑制了NRVO。对于返回局部变量直接写return local;就是最优解。编译器优化时优先执行RVORVO不可用时再调用移动构造。如果你用了std::move(local)反而强制走了移动路径而且如果有异常抛出可能连移动的机会都没有直接走拷贝。场景三vector扩容时元素竟然被“拷贝”而非“移动”。这是因为标准库提供的强异常安全保证。std::vector扩容时如果元素的移动构造函数没有声明noexcept容器担心移动过程抛出异常导致原有数据丢失就会退化为拷贝构造。所以自定义类型的移动构造函数和移动赋值运算符务必加上noexcept。这也是很多“移动语义没生效”的根源。场景四临时对象在循环里高频创建性能惨不忍睹。比如反复用临时字符串拼接日志、反复把临时对象push_back进容器。排查时可以在编译器开启-Wpessimizing-move和-Wredundant-move警告看哪些地方移动用得不对也可以用perf或valgrind看热点函数。6.2 我总结的几个避免临时对象的“肌肉记忆”清单结合我多年写C的经验下面几条是我写代码时的默认习惯能传引用就不传值除非函数内部确实需要参数副本给自定义类型实现移动语义时构造函数和赋值运算符都要加noexcept返回局部对象直接return name;绝不画蛇添足加std::move容器插入新元素优先用emplace、emplace_back、try_emplace写只做一件小事的构造函数时尽量加explicit防止隐式转换制造临时对象std::move只用于“你明确知道要把这个左值转移掉”的场景不要随手到处加如果类需要长期保存传入的字符串按值传参再std::move进成员往往比按引用传参再拷贝更高效。这套清单在代码评审和面试里都挺实用。尤其是“传参选值还是引用”这个问题很多人纠结很久其实核心就一句后续怎么用它决定了参数怎么写。我个人在实际项目里优化临时对象时最深刻的体会是移动语义固然强大但真正立竿见影的往往是最朴素的“少建一次对象”的思路。把按值传参改成按引用传参、把push_back改成emplace_back、给构造函数加个explicit、返回值别乱加std::move这几招用熟练之后大部分临时对象相关的性能坑都能提前避开。真正的性能优化不是靠炫技而是靠把“哪里有临时对象产生”这件事搞清楚然后有针对性地消灭它。希望这篇文章能帮你建立一套这样的直觉。
返回列表