
1. 项目概述为什么我们需要区分左值与右值如果你写过一段时间的C尤其是接触过一些性能要求较高的项目那么你一定对“深拷贝”带来的性能开销深恶痛绝。想象一下你有一个包含大量数据的std::vector或自定义的复杂对象每次函数传参或返回时编译器都会默默地进行一次完整的内存拷贝。这种拷贝在数据量小的时候无关痛痒但当数据膨胀到MB甚至GB级别时它就成了性能的“隐形杀手”。C作为一门追求极致效率的语言自然不会对这种问题视而不见。在C11标准之前我们主要依靠“左值引用”来优化传参避免拷贝。比如void process(const BigObject obj)这样传参时就不需要拷贝整个BigObject。但是左值引用有一个致命的短板它无法高效地处理函数返回的“临时对象”。当一个函数返回一个局部对象时我们只能进行传值返回这必然伴随着一次拷贝构造。即使编译器有“返回值优化”RVO/NRVO它也不是在所有场景下都能生效的。于是C11引入了“右值引用”这一革命性的特性它背后的核心思想是“移动语义”。简单来说移动语义允许我们将一个即将销毁的临时对象右值的资源“偷”过来直接用于构造或赋值给新对象从而避免昂贵的深拷贝。这就像搬家时对于旧房子里那些还要用的家具左值我们选择小心翼翼地打包搬运拷贝而对于那些即将扔掉的废纸箱右值我们直接把里面的东西倒进新家的箱子移动省去了打包和解包的过程。理解左值和右值以及它们的引用是掌握现代C高效编程的基石。这不仅仅是语法知识更是一种资源管理思想的转变。接下来我将带你从最基础的概念开始一步步拆解左值、右值、左值引用、右值引用并深入探讨移动语义和完美转发的实现与应用让你不仅能写出更高效的代码更能理解编译器背后的运作逻辑。2. 左值与右值从内存地址的角度彻底理解很多初学者对左值右值的概念感到困惑各种教材和博客的定义也往往让人眼花缭乱。其实抓住最本质的一点就能轻松区分这个表达式是否拥有一个可被程序员获取的、持久的内存地址2.1 左值拥有“身份”的持久化对象左值lvalue locator value指的是那些有明确内存位置、可以取地址的表达式。你可以把它想象成一个有“身份证”的变量程序在它的整个生命周期内都能通过地址找到它。左值的核心特征可以取地址使用操作符。通常可以放在赋值运算符的左边但被const修饰的左值例外。通常有名字如变量名、函数名、解引用的指针等。让我们看一些具体的例子int main() { int a 10; // ‘a’是左值 a 是合法的 int* p a; // ‘p’是左值 p 是合法的 *p 20; // ‘*p’解引用是左值 (*p) 就是 a int arr[5] {0}; arr[2] 30; // ‘arr[2]’是左值可以取地址 const int c 100; // ‘c’是左值 c 是合法的 // c 200; // 错误c是const左值不能被赋值但它依然是左值。 std::string s hello; // ‘s’是左值 s[0] H; // ‘s[0]’返回字符的引用也是左值 return 0; }关键点在于const修饰的变量。const int c虽然不能被赋值但它仍然占据一块内存有明确的地址所以它依然是左值。左值关乎的是对象的“身份”内存位置而非其“可修改性”。2.2 右值短暂的“值”本身右值rvalue与左值相对它代表的是一个临时的、短暂的“数据值”本身而不是一个持久的存储位置。右值通常是“用完即扔”的。右值的核心特征不能取地址使用操作符会编译错误。不能放在赋值运算符的左边。通常是字面量、临时对象、表达式计算结果。int main() { int x 5, y 10; // 以下是典型的右值 42; // 字面常量纯右值 (prvalue) x y; // 算术表达式的结果纯右值 std::sqrt(2.0); // 返回临时double对象的函数调用纯右值 std::string(temp); // 临时构造的string对象将亡值 (xvalue) // 以下操作都会引发编译错误 // 42 x; // 错误右值不能作为赋值左操作数 // (x y); // 错误右值不能取地址 // std::sqrt(2.0); // 错误对函数返回的临时对象取地址 return 0; }在C11之后右值被进一步细分为纯右值和将亡值。纯右值如字面量、非引用返回的临时对象、lambda表达式等。它们是传统意义上的“临时值”。将亡值通过std::move强制转换得到的或者是在某些特定表达式如static_castT(x)中产生的值。它本质上是“生命周期即将结束的左值”我们被允许“移动”它的资源。实操心得一个快速判断技巧当你对一个表达式有疑问时尝试在它前面加上取地址符。如果编译器不报错那它大概率是左值如果编译器提示“error: cannot take address of rvalue”那它就是右值。这个方法在调试和理解复杂表达式时非常直观有效。2.3 左值右值判断的边界情况与陷阱有些表达式的情况不那么直观容易让人判断错误。情况一前置/后置自增运算符int i 0; i; // 前置返回的是i本身左值可以 (i) i; // 后置返回的是i递增前的一个临时副本右值不能 (i)情况二条件运算符三目运算符int a 1, b 2; (a b ? a : b) 100; // 合法因为a和b都是左值整个条件表达式的结果也是左值。 // (a b ? 1 : 2) 100; // 非法因为1和2是右值结果也是右值。条件表达式的结果类型取决于其两个操作数。如果两个操作数都是左值且类型相同结果就是左值否则通常是右值。情况三函数返回类型int getRef() { static int val 10; return val; } // 返回左值引用调用表达式是左值 int getVal() { return 42; } // 返回非引用类型调用表达式是右值 const int getCRef() { static int val 20; return val; } // 返回const左值引用调用表达式是左值但不能用于修改 getRef() 50; // 正确getRef()是左值 // getVal() 50; // 错误getVal()是右值 int* p1 getRef(); // 正确 // int* p2 getVal(); // 错误理解这些边界情况能帮助你在阅读和编写复杂表达式时准确预测其行为避免写出似是而非的代码。3. 左值引用与右值引用为对象取别名的艺术引用在C中就是给一个已存在的对象起一个别名。在C11之前只有“引用”这一种说法为了与新增的“右值引用”区分之前的引用被称为“左值引用”。3.1 左值引用避免拷贝的利器左值引用使用符号声明只能绑定到左值除了一个重要的例外。int main() { int var 10; int lref1 var; // 正确左值引用绑定到左值 // int lref2 20; // 错误不能将左值引用直接绑定到右值 const int clref1 var; // 正确const左值引用可以绑定到左值 const int clref2 20; // 正确这是关键例外const左值引用可以绑定到右值 std::string s1 hello; std::string rs1 s1; // 正确 // std::string rs2 world; // 错误 const std::string crs2 world; // 正确 return 0; }为什么const左值引用可以绑定右值这是C98/03时代为了函数能接受临时对象而设计的“权宜之计”。const承诺了不会通过这个引用来修改目标因此绑定一个临时的、即将销毁的右值是安全的。编译器会为这个右值延长其生命周期使其与这个const引用的生命周期一致。这使得像void print(const std::string str)这样的函数既能接受变量也能接受字符串字面量。左值引用的主要应用场景函数参数避免大型对象传参时的拷贝。void processLargeObject(const VeryLargeObject obj) { /* 只读操作 */ } void modifyObject(MyObject obj) { /* 需要修改obj */ }函数返回值用于返回函数作用域外仍然存在的对象例如返回类成员、静态变量或传入参数的引用。这可以避免返回时的拷贝。class Buffer { std::vectorchar data; public: std::vectorchar getData() { return data; } // 返回成员引用避免拷贝 };左值引用的短板它无法解决“返回局部对象”的拷贝问题。因为局部对象在函数结束时被销毁返回它的引用将导致“悬垂引用”访问它是未定义行为。因此对于返回局部对象的函数我们只能选择效率低下的传值返回。3.2 右值引用移动语义的载体右值引用使用符号声明主要用来绑定到右值无论是纯右值还是将亡值。它是C11引入移动语义的语法基础。int main() { int rref1 10; // 正确右值引用绑定到字面量右值 int rref2 5 8; // 正确绑定到表达式结果右值 int x 5, y 3; int rref3 x y; // 正确绑定到表达式结果右值 std::string sref std::string(hello); // 正确绑定到临时string对象 int var 42; // int rref4 var; // 错误不能将右值引用直接绑定到左值 int rref5 std::move(var); // 正确使用std::move将左值转换为将亡值右值 return 0; }一个至关重要的洞见右值引用变量本身是左值这是理解后续“完美转发”的关键。虽然rref1绑定了一个右值10但rref1这个标识符本身作为一个有名字的变量它有存储空间可以取地址(rref1)因此它是一个左值。int rref 10; int* p rref; // 合法rref本身是左值可以取地址 // *p 100; // 甚至可以修改它除非是const右值引用这意味着当你把一个右值绑定到一个右值引用后在这个引用变量的作用域内你可以像使用一个普通变量一样使用它。这带来了一个好处我们可以安全地“修改”这个临时对象移动其资源而不会影响其他逻辑。右值引用的核心价值延长临时对象的生命周期并允许我们“移动”其资源。当一个右值被绑定到一个右值引用上时这个临时对象的生命周期会被延长到与该引用相同。更重要的是我们获得了修改这个临时对象的许可从而可以将它的资源如动态内存、文件句柄等“转移”给新对象而不是复制。3.3 引用绑定规则总结为了更清晰地展示各种引用类型的绑定能力我整理了以下表格引用类型可绑定的值类型主要目的示例T(非const左值引用)非const左值修改对象避免拷贝int a; int ra a;const T(const左值引用)所有类型的值(左值、const左值、右值)只读访问万能常量引用const int cr1 a; const int cr2 5;T(非const右值引用)非const右值移动语义资源转移int rr1 10; int rr2 std::move(a);const T(const右值引用)const右值较少使用通常用于禁用移动const int crr std::move(const_int);注意事项关于std::movestd::move在编译期不做任何事情它只是一个强制类型转换static_castT(t)。它的作用是无条件地将一个左值或右值转换为一个右值引用类型从而允许移动语义的发生。它并不“移动”任何东西只是为移动操作铺平道路。调用std::move后原对象的状态是“被移动的”对于像std::vector这样的标准库类型它通常处于一个有效但未指定的状态例如变为空。最佳实践是除非你要放弃对原对象内容的所有权否则不要轻易对其使用std::move。4. 移动语义从理论到实践的效率革命移动语义是右值引用最重要的应用。它的核心思想是当拷贝一个对象时如果源对象是一个右值即将销毁的临时对象那么我们不需要深拷贝它的资源而是可以直接“偷”过来将源对象的资源指针“移动”到新对象然后将源对象的指针置空。这通常意味着只拷贝几个指针或整数时间复杂度是O(1)而非O(n)。4.1 移动构造函数与移动赋值运算符为了让一个类支持移动语义你需要为其定义移动构造函数和移动赋值运算符。一个简单的String类示例class MyString { private: char* m_data; size_t m_size; public: // 1. 普通构造函数 MyString(const char* str ) { std::cout 普通构造: str std::endl; m_size strlen(str); m_data new char[m_size 1]; strcpy(m_data, str); } // 2. 拷贝构造函数深拷贝 MyString(const MyString other) { std::cout 拷贝构造深拷贝: other.m_data std::endl; m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); } // 3. 移动构造函数资源转移 MyString(MyString other) noexcept { // noexcept 很重要标准库容器会利用它 std::cout 移动构造: other.m_data std::endl; // 直接“窃取”资源 m_data other.m_data; m_size other.m_size; // 将源对象置于有效但可析构的状态 other.m_data nullptr; other.m_size 0; } // 4. 拷贝赋值运算符深拷贝 MyString operator(const MyString other) { std::cout 拷贝赋值深拷贝: other.m_data std::endl; if (this ! other) { // 自赋值检查 delete[] m_data; // 释放原有资源 m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); } return *this; } // 5. 移动赋值运算符资源转移 MyString operator(MyString other) noexcept { std::cout 移动赋值: other.m_data std::endl; if (this ! other) { // 自赋值检查虽然移动自身不常见但安全第一 delete[] m_data; // 释放原有资源 // 直接“窃取”资源 m_data other.m_data; m_size other.m_size; // 将源对象置于有效但可析构的状态 other.m_data nullptr; other.m_size 0; } return *this; } // 析构函数 ~MyString() { delete[] m_data; } // 为了方便观察添加一个打印函数 void print() const { if (m_data) std::cout Data: m_data std::endl; else std::cout Data: (nullptr) std::endl; } };使用示例与效率对比int main() { std::cout 场景1从函数返回局部对象 std::endl; auto createString []() - MyString { MyString temp(Hello from function); return temp; // 编译器可能会进行RVO但移动构造是备选方案 }; MyString s1 createString(); // 期望调用移动构造或RVO s1.print(); std::cout \n 场景2容器操作 std::endl; std::vectorMyString vec; vec.reserve(3); // 预分配空间避免vector增长时的多次拷贝 MyString s2(World); vec.push_back(s2); // 传入左值调用拷贝构造 vec.push_back(MyString(C)); // 传入右值临时对象调用移动构造 vec.push_back(std::move(s2)); // 传入将亡值调用移动构造 // 此时s2已被移动处于空状态 std::cout After move, s2: ; s2.print(); // 输出: Data: (nullptr) std::cout \n 场景3赋值操作 std::endl; MyString s3(Old Data); MyString s4(New Data); s3 s4; // 左值赋值调用拷贝赋值 s3 MyString(Temporary); // 右值赋值调用移动赋值 s3 std::move(s4); // 将亡值赋值调用移动赋值 // 此时s4已被移动 std::cout After move, s4: ; s4.print(); return 0; }运行上述代码你可以清晰地看到拷贝构造/赋值与移动构造/赋值被调用的时机。当处理临时对象或显式使用std::move时移动语义得以触发避免了昂贵的深拷贝。4.2 移动语义在标准库中的应用C11后的标准库容器vector,string,map,unordered_map等都全面支持移动语义。这带来了巨大的性能提升。std::vector::push_back的重载void push_back(const value_type val); // (1) 拷贝版本 void push_back(value_type val); // (2) 移动版本当你向vector插入一个临时对象或使用std::move的对象时会自动调用移动版本效率极高。std::swap的优化在C11之前std::swap需要三次拷贝构造/赋值操作。支持移动语义后现代的std::swap实现利用移动操作通常只需要三次移动操作对于管理资源的对象来说快得多。template class T void swap(T a, T b) noexcept { T temp std::move(a); // 移动构造 a std::move(b); // 移动赋值 b std::move(temp); // 移动赋值 }实操心得何时定义移动操作规则一如果你定义了拷贝构造、拷贝赋值或析构函数中的任何一个编译器通常不会自动生成移动操作移动构造和移动赋值。这是“三五法则”在C11后的扩展“零/三/五法则”。此时如果你需要移动语义必须手动定义。规则二如果一个类没有定义任何拷贝控制成员拷贝构造、拷贝赋值、析构且所有非静态成员都可以移动那么编译器会生成默认的移动操作逐成员移动。规则三移动操作应标记为noexcept。特别是对于标准库容器如vector在重新分配内存时如果元素的移动构造函数是noexcept的容器会优先使用移动而非拷贝来转移元素这能保证异常安全并提升性能。对于简单的资源转移如指针交换移动操作通常不会抛出异常务必加上noexcept。规则四移动操作后必须使源对象处于一个可析构、可有效赋值的状态。通常是将源对象的资源句柄置为空如nullptr。不要破坏“可析构”这一基本保证。5. 完美转发保持值类别的“传送门”我们在3.2节提到过一个关键点右值引用变量本身是一个左值。这导致了一个问题当我们有一个参数是右值引用时如果把它传递给另一个函数它的“右值”属性就丢失了会变成左值传递。完美转发就是为了解决这个问题在参数传递过程中保持其原始的值类别左值还是右值。5.1 万能引用与引用折叠完美转发的实现依赖于两个概念万能引用和引用折叠。万能引用并不是一种新的引用类型而是指在模板推导或auto推导场景下形式为T的引用。它可以根据传入的实参是左值还是右值被推导为左值引用或右值引用。templatetypename T void foo(T param) { // 这里T是万能引用 // param的类型取决于传入的实参 } int x 10; const int cx 20; foo(x); // x是左值T被推导为int param的类型是int 引用折叠后 foo(cx); // cx是const左值T被推导为const int param的类型是const int foo(30); // 30是右值T被推导为int param的类型是int引用折叠是C中处理引用的引用时的一套规则。在模板推导或typedef/using别名中可能会间接产生引用的引用C不允许引用的引用因此需要通过折叠规则来确定最终类型T ,T ,T 都会折叠成TT 会折叠成T正是引用折叠规则使得万能引用T在接收到左值时最终能变成左值引用T。5.2std::forward的工作原理std::forward是一个条件转换。它的作用是如果传入的参数是一个右值引用那么它将其转换为右值保持右值属性如果传入的参数是一个左值引用那么它什么也不做保持左值属性。它通常与万能引用配合使用。// 一个简化的 forward 实现概念性 templatetypename T T forward(typename std::remove_referenceT::type arg) noexcept { return static_castT(arg); }没有完美转发的问题void process(int lval) { std::cout 处理左值\n; } void process(int rval) { std::cout 处理右值\n; } templatetypename T void relay(T arg) { // 万能引用 process(arg); // 错误arg本身是左值永远调用process(int) } int main() { int a 5; relay(a); // 期望调用process(int)实际也是但原因不对 relay(10); // 期望调用process(int)实际却调用了process(int) }在relay(10)中arg的类型被推导为int但arg这个变量名是一个左值。所以process(arg)调用的是process(int)右值属性丢失了。使用std::forward实现完美转发templatetypename T void relay_perfect(T arg) { // 万能引用 process(std::forwardT(arg)); // 关键使用forward保持值类别 } int main() { int a 5; relay_perfect(a); // 调用 process(int) relay_perfect(10); // 调用 process(int) 正确 relay_perfect(std::move(a)); // 调用 process(int) 正确 }std::forwardT(arg)会根据T的推导类型这隐含了原始实参的值类别决定是将arg转换为右值还是保持左值。5.3 完美转发的实际应用场景完美转发最常见的应用是在编写泛型包装函数、工厂函数或容器的emplace系列方法时。场景一实现一个泛型的包装器// 一个简单的日志包装函数 templatetypename Func, typename... Args auto log_and_call(Func func, Args... args) - decltype(func(std::forwardArgs(args)...)) { std::cout Calling function... std::endl; auto start std::chrono::high_resolution_clock::now(); // 使用完美转发将参数原封不动地传递给func auto result std::forwardFunc(func)(std::forwardArgs(args)...); auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble elapsed end - start; std::cout Function call took elapsed.count() seconds.\n; return result; } int add(int a, int b) { return a b; } std::string concat(const std::string a, std::string b) { return a b; } int main() { auto sum log_and_call(add, 10, 20); // 完美转发两个int std::string s1 Hello, ; auto str log_and_call(concat, s1, std::string(World!)); // 完美转发左值和右值 }场景二std::make_unique/std::make_shared的实现原理这些工厂函数使用完美转发将参数传递给new表达式从而直接构造对象避免了额外的拷贝或移动。templatetypename T, typename... Args std::unique_ptrT make_unique(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }场景三容器的emplace_back方法vector::emplace_back直接在容器尾部构造元素它使用完美转发将参数传递给元素的构造函数效率通常比push_back先构造临时对象再移动更高尤其是对于不可移动或移动成本高的类型。std::vectorstd::pairint, std::string vec; vec.emplace_back(42, Answer); // 直接在vector内存中构造pair无需临时对象 // 等价于 vec.push_back(std::pairint, std::string(42, Answer));避坑指南完美转发失败完美转发并非万能在某些情况下会失败导致编译错误或调用非预期的重载。常见失败情况包括传递了位域无法创建指向位域的指针或引用。传递了0或NULL作为空指针它们会被推导为整型而非指针类型。应使用nullptr。传递了重载函数的函数名编译器无法确定是哪个重载版本。需要先使用static_cast指定函数类型。传递了花括号初始化列表{1, 2, 3}这样的列表没有类型模板无法推导。可以先用auto推导出std::initializer_list再传递。传递了const或volatile修饰的整型静态常量成员某些编译器下需要先取地址或定义存储。 当遇到完美转发失败时通常需要显式指定模板参数或调整参数传递方式。6. 常见问题与实战排查技巧在实际项目中应用移动语义和完美转发时会遇到各种意想不到的问题。这里我总结了一些常见坑点和调试技巧。6.1 移动后对象的状态问题对象被移动后我还能使用它吗答案可以但必须谨慎。标准要求移动操作后的源对象必须处于一个“有效但未指定”的状态。这意味着你可以安全地对其调用析构函数。你可以安全地对其重新赋值。你不应该对其值做任何假设除非类有额外规定。例如一个被移动的std::string通常是空字符串但这不是强制保证。std::string str1 Hello; std::string str2 std::move(str1); std::cout str1: \ str1 \\n; // 输出可能是 也可能是其他值 std::cout str1.empty(): str1.empty() std::endl; // 这个通常是true但也不是绝对 str1 World; // 安全可以重新赋值 std::cout str1 after reassign: \ str1 \\n; // 输出 World最佳实践将被移动的对象视为“空壳”除了析构和重新赋值不要依赖其内容。如果需要继续使用请先赋予一个新值。6.2 编译器优化RVO与NRVO返回值优化和命名返回值优化是编译器为了消除不必要的拷贝/移动而进行的优化。它们有时会“掩盖”移动构造函数的调用。MyString createString() { MyString local(Local); return local; // 理论上这里应该调用移动构造或拷贝构造将local移到返回值位置。 // 但编译器可能会直接在外层调用者的栈帧上构造localRVO/NRVO省去任何拷贝/移动。 } MyString s createString(); // 可能直接调用普通构造函数没有拷贝或移动。如何观察在构造函数中加入打印语句。如果发现返回局部对象时没有调用移动构造很可能就是RVO/NRVO生效了。这是好事不要试图用std::move去“帮助”编译器。像return std::move(local);这样的代码反而可能阻止RVO因为std::move返回的是一个引用不符合RVO的条件。6.3 万能引用与重载的冲突问题万能引用模板的匹配优先级通常很高容易导致非预期的函数调用。templatetypename T void foo(T param) { std::cout 万能引用版本\n; } void foo(const std::string str) { std::cout const string 版本\n; } int main() { std::string s test; foo(s); // 输出什么你可能期望调用第二个但实际调用了第一个 // 因为 T 被推导为 std::string比 const std::string 更匹配。 foo(hello); // 同样调用第一个T被推导为 const char()[6] }解决方案使用std::enable_if或C20的concepts对万能引用模板进行约束或者调整重载顺序和参数类型例如使用非模板的重载版本。6.4 性能分析何时移动真的更快移动操作并非总是零成本。对于只包含基本类型int,double等或简单聚合的类移动和拷贝的成本是一样的都是逐成员拷贝。移动的优势主要体现在管理外部资源如堆内存、文件句柄、网络连接的类上。一个简单的性能测试思路#include chrono #include vector #include iostream class HeavyClass { std::vectorint data; // 假设这个vector很大 public: HeavyClass(size_t size) : data(size) {} // 提供拷贝和移动操作... }; void testCopyVsMove() { const size_t size 1000000; HeavyClass original(size); auto start std::chrono::high_resolution_clock::now(); HeavyClass copy original; // 拷贝构造 auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble copy_time end - start; start std::chrono::high_resolution_clock::now(); HeavyClass moved std::move(original); // 移动构造 end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble move_time end - start; std::cout Copy time: copy_time.count() s\n; std::cout Move time: move_time.count() s\n; }对于管理大量数据的类移动时间将是拷贝时间的零头。6.5 调试技巧如何确认移动发生了添加日志在移动构造函数和移动赋值运算符中加入打印语句这是最直接的方法。使用noexcept如果移动操作是noexcept的标准库容器如vector在扩容时会优先使用移动。你可以通过容器的capacity变化来观察。观察指针对于自定义资源管理的类移动后源对象的资源指针应为nullptr。在调试器中观察该指针值。利用编译器警告/工具一些静态分析工具或编译器扩展可以提示哪些地方可以用std::move优化但需谨慎判断。理解左值、右值、移动语义和完美转发是现代C高效编程不可或缺的一环。它不仅仅是语法糖更是一种资源管理哲学的体现。从理解“取地址”这一最本质的区别开始到熟练运用std::move和std::forward来编写高性能的泛型代码这条学习路径需要大量的实践和思考。我建议你在自己的项目中有意识地去审视那些可能发生拷贝的地方思考是否可以用移动来优化。同时也要警惕过度使用std::move尤其是在返回值上以免妨碍编译器的优化。多写、多测、多观察汇编输出如果可能是掌握这些高级特性的不二法门。