拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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"; // 完整表达式结束后,a+b产生的临时 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::string:

void 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::vector<std::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&或者右值引用。在C++11以后,临时对象会被移动进容器;但如果你的类型没有移动构造函数,那就会退化成拷贝构造,临时对象和容器内部的对象各有一份数据。

而用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。

在C++17之前,如果没有拷贝省略的介入,这两次加法会产生两个临时对象,每个临时对象都可能涉及堆内存分配和释放。即便现代编译器优化能力强,你还是会看到至少一次或者两次的中间对象构造。更麻烦的是,如果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::vector<std::string>,分别用push_back和emplace_back。

在关闭优化和开启-O2的情况下,结果差异非常明显。未优化时,push_back版本比emplace_back版本慢了一倍多;开启-O2后差距缩小,但push_back版本依然存在更多的构造和析构调用。如果把一个自定义的、构造时打印日志的类型放进去,你会看到日志数量清清楚楚地告诉你:一次push_back(v)到底多执行了多少次构造。

这告诉我们一个道理:编译器优化能帮你“省掉”一部分临时对象,但并不是全部。特别是那些发生在循环里、无法被优化掉的临时对象,带来的性能损耗是实打实的。

4. 主流的解决方案与优化思路

4.1 拷贝省略:编译器帮你抹掉的临时对象

拷贝省略(copy elision)是编译器对临时对象的“蒸发术”。具体来说,当表达式产生一个临时对象用来初始化另一个对象时,编译器可以直接在目标对象的存储上构造这个临时对象,省掉中间那一次复制/移动。

C++17引入了一个重要变化:保证拷贝省略(guaranteed copy elision)。从C++17开始,当函数返回一个prvalue(纯右值)时,该返回值直接构造到调用者指定的存储中,不再产生临时对象。比如:

std::string makeString() { return std::string("hello"); // 返回prvalue,直接构造到目标位置 }

这个返回值不再需要“先构造临时对象,再拷贝/移动到目标对象”的过程。这是标准层面的保证,而不是编译器可选优化。所以,在C++17及以上版本中,写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 移动语义:把深拷贝变成“乾坤大挪移”

移动语义是C++11引入的杀手锏。它的核心思想是:当一个对象即将销毁时,与其深拷贝它的资源,不如把资源“偷”过来,然后把源对象置为空。移动构造函数和移动赋值运算符是实现这个能力的两个关键函数。

一个典型的移动构造函数长这样:

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)。这就是为什么在C++11以后,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::vector<std::string> v; v.reserve(10); v.emplace_back("hello"); // 直接在 vector 内部构造 string,no temporary

再看一个map的例子:

std::map<int, std::string> m; m.emplace(1, "value"); // 直接构造 pair<int, string> m.emplace(std::piecewise_construct, std::forward_as_tuple(2), std::forward_as_tuple(3, 'a')); // 分段构造,避免临时 pair

完美转发背后的关键是std::forward。它能把一个被声明为右值引用、但实际上是左值的函数参数,恢复成原来的值类别。比如:

template<typename T> void forwardToContainer(T&& arg) { container.emplace_back(std::forward<T>(arg)); }

如果调用方传入右值,T推导为X,std::forward<T>返回右值引用;如果传入左值,T推导为X&,std::forward<T>返回左值引用。这样转发过去时,参数的类型信息不会丢失,从源头上避免了“明明传入临时对象,却被当左值使用导致复制”的问题。

4.5 explicit、返回值优化等细节:从源头堵住临时对象

写构造函数时加上explicit,是防止隐式类型转换产生临时对象最有效的手段。特别是那些只有一个参数的构造函数,比如explicit Score(int value),如果不写explicit,add(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,不会隐式转换

编译失败虽然会让某些写法变得“麻烦”,但这是好事——它逼着你明确表达意图,避免在性能敏感路径上凭空创建临时对象。

返回值优化方面,除了前面提到的C++17保证拷贝省略和NRVO,还有一个常被忽略的细节:返回多个对象时,可以用结构化绑定配合pair/tuple。比如:

std::pair<std::string, int> getInfo() { std::string name = "hello"; int id = 42; return {name, id}; // 实际返回时,name 和 id 会被移动/拷贝进 pair 的临时对象 }

在C++17之后,如果你返回{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::vector:

void testPush() { std::vector<MinString> 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::vector<MinString> v; v.reserve(2); v.emplace_back("world"); std::cout << "--- push_back ---\n"; v.push_back(MinString("world2")); }

运行后,你会看到makeByValue在开启优化和不开启优化时的输出差异。如果编译器没有做NRVO,return 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++ -std=c++17 -O0 -fno-elide-constructors test.cpp -o test_noelide g++ -std=c++17 -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;就是最优解。编译器优化时优先执行RVO,RVO不可用时再调用移动构造。如果你用了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,这几招用熟练之后,大部分临时对象相关的性能坑都能提前避开。真正的性能优化不是靠炫技,而是靠把“哪里有临时对象产生”这件事搞清楚,然后有针对性地消灭它。希望这篇文章能帮你建立一套这样的直觉。

返回列表