【C++】左值引用、右值引用
目录一、右值引用的意义二、基础理解左值与右值1. 左值LvalueLocator Value常见的左值场景2. 右值RvalueRead Value2.1 纯右值prvalue常见的纯右值场景2.2 将亡值xvalue常见的将亡值场景3. 左值与右值的核心区别三、右值引用的定义与语法规则1. 左值引用的回顾与局限2. 右值引用的语法与本质基础语法3. std::move ()做了什么4. 左值引用 vs 右值引用四、右值引用的两大核心应用场景1. 移动语义告别无意义的深拷贝实现资源高效转移1.1 无移动语义的痛点深拷贝的性能浪费1.2 基于右值引用实现移动语义1.3 移动语义的常见场景2. 完美转发2.1 完美转发的底层支撑2.1.1 万能引用转发引用2.1.2 引用折叠规则2.1.3 std::forward () 的作用2.2 完美转发的示例2.3 完美转发的常见场景五、高频踩坑点与实践建议1. 高频踩坑点坑 1std::move () 之后继续使用原对象坑 2对函数返回值使用 std::move ()坑 3误以为右值引用变量本身是右值坑 4给移动构造 / 移动赋值加 const 修饰坑 5把普通右值引用当成万能引用2. 实践建议一、右值引用的意义在 C 的编程世界中对象的拷贝与资源管理一直是性能优化的核心痛点。在 C11 标准之前我们编写的代码中隐藏着大量临时对象的深拷贝开销比如函数返回一个非引用类型的对象、容器插入临时对象时都会触发拷贝构造函数完成堆内存的重新分配与数据复制而临时对象在表达式结束后就会被销毁这就造成了完全无意义的内存操作与性能浪费。举个最简单的例子std::string str hello world;表达式会生成一个临时的 string 对象再通过拷贝构造把数据复制给 str随后临时对象被销毁。如果字符串很长这个深拷贝的开销是完全可以避免的。而 C11 引入的右值引用正是为了解决这个问题。它不仅重新定义了 C 的资源管理哲学让我们可以对即将销毁的临时对象的资源进行转移而非拷贝还支撑了泛型编程中的完美转发成为现代 C 不可或缺的核心特性。二、基础理解左值与右值1. 左值LvalueLocator Value左值的核心定义是可以通过取地址运算符获取其内存地址、拥有持久生命周期的表达式。简单来说左值就是一个 “有名字、有固定内存地址” 的对象你可以找到它的存储位置除了 const 修饰的左值外都可以修改其内容。左值既可以放在赋值号的左边也可以放在赋值号的右边作为右值使用。常见的左值场景普通变量、const 修饰的变量const 左值不可修改但依然可以取地址属于左值对指针解引用的结果*ptr数组的元素arr[index]结构体 / 类的非静态成员变量返回左值引用的函数调用前缀自增 / 自减表达式i、--iint main() { int i 0; i 1; // i是变量可修改左值能放在赋值号左侧 const int ci 5; // ci 6; // 报错const左值不可修改但ci可获取地址仍是左值 int* pi i; *pi 1; // *pi对指针解引用左值 int arr[5] {0}; arr[0] 1; // 数组元素左值 struct { int m_a; } st, *pst st; st.m_a 1; // 结构体成员左值 pst-m_a 2; return 0; }2. 右值RvalueRead ValueC11 之后右值被细分为纯右值prvaluePure Rvalue和将亡值xvalueeXpiring Value两类二者共同构成了右值。右值的核心定义是无法通过取地址、生命周期短暂的临时表达式结果它代表的是一个值而非值所在的存储位置。右值只能放在赋值号的右侧不能放在左侧。2.1 纯右值prvalue纯右值是 C98 标准中传统意义上的右值核心是 “字面量、临时计算结果”没有持久的存储地址。常见的纯右值场景除字符串字面量外的字面常量如42、a、true字符串字面量是 const char 数组类型属于左值算术、逻辑、关系运算符的求值结果如12、a10、ab非引用类型返回值的函数调用结果后置自增 / 自减表达式i、i--lambda 表达式2.2 将亡值xvalue将亡值是 C11 为了配合右值引用新增的概念核心是 “资源即将被转移、生命周期即将结束的对象”它是连接左值和右值的桥梁。将亡值虽然可以通过特殊方式获取地址但它的核心使命是完成资源转移使用后就会被销毁因此归属于右值范畴。常见的将亡值场景std::move()的返回结果返回右值引用类型的函数调用结果类型转换为右值引用的表达式结果int getNum() { return 100; } int main() { int i 0, c 0; i 42; // 字面值42纯右值 c a; // 字面值a纯右值 i 1 2; // 算术表达式结果纯右值 i (c ! a); // 逻辑表达式结果纯右值 i getNum(); // 非引用返回值纯右值 int rri std::move(i);// std::move(i)的结果将亡值右值 return 0; }3. 左值与右值的核心区别核心特性左值右值纯右值 将亡值内存地址可通过获取稳定的内存地址无法直接通过获取地址生命周期持久随变量 / 对象的作用域存在临时表达式结束后即销毁除非被引用绑定可修改性非 const 左值可修改const 左值不可修改不可直接修改赋值位置可放在赋值号左侧、右侧只能放在赋值号右侧引用绑定可被左值引用、const 左值引用绑定只能被 const 左值引用、右值引用绑定三、右值引用的定义与语法规则1. 左值引用的回顾与局限在 C11 之前我们使用的引用都是左值引用用符号声明本质是给变量起一个 “别名”底层通过指针实现必须在定义时初始化且初始化后无法重新绑定到其他对象。左值引用的核心局限非 const 左值引用只能绑定到左值无法绑定到右值。只有 const 左值引用是个例外它可以同时绑定左值和右值但 const 修饰决定了我们无法通过它修改绑定的对象。int main() { int a 10; int ra a; // 正确非const左值引用绑定左值 // int rb 10; // 报错非const左值引用无法绑定右值 const int rca a; // 正确const左值引用绑定左值 const int rcb 10; // 正确const左值引用绑定右值 // rcb 20; // 报错const引用无法修改绑定对象 return 0; }这个局限带来了两个核心问题无法对临时的右值对象进行修改和资源复用只能通过拷贝使用泛型编程中无法区分传入的参数是左值还是右值无法实现精准的参数转发而右值引用的出现完美解决了这两个问题。2. 右值引用的语法与本质右值引用是 C11 新增的引用类型用符号声明核心是专门绑定到右值临时对象、将亡值的引用。基础语法类型 引用名 右值表达式;右值引用的核心规则必须在定义时初始化且初始化后无法重新绑定到其他对象只能直接绑定到右值无法直接绑定到左值绑定到右值后会延长该临时右值的生命周期生命周期与右值引用变量一致可以通过右值引用修改绑定的右值对象右值引用变量本身是左值有名字、可通过取地址这是初学者最容易踩的坑#include iostream using namespace std; int getNum() { return 100; } void func(int rri) { rri 0; } int main() { // 1. 右值引用必须绑定右值 int ri0 42; // 正确绑定字面量纯右值 int ri1 1 2; // 正确绑定表达式结果纯右值 int ri2 getNum(); // 正确绑定函数返回值纯右值 // int ri3 ri0; // 报错ri0是右值引用变量本身是左值无法绑定 // 2. 可通过右值引用修改绑定的对象 ri0 100; cout ri0 ri0 endl; // 输出ri0 100 // 3. 可获取右值引用的地址证明它本身是左值 int* pi ri0; *pi 200; cout *pi *pi , ri0 ri0 endl; // 输出*pi 200, ri0 200 // 4. 右值引用作为函数参数接收右值 func(1 2); return 0; }3. std::move ()做了什么std::move()实现了资源的移动这是错误的。std::move()的本质不做任何资源移动、不生成任何机器码只是无条件地将一个左值强制转换为右值引用类型将亡值仅此而已。它的唯一作用就是让左值可以被右值引用绑定从而为后续的资源转移提供可能。真正的资源转移是在类的移动构造函数、移动赋值运算符中完成的std::move()只是打开了 “资源转移” 的入口。#include iostream #include string using namespace std; int main() { string str Hello World!; cout move前str str endl; // std::move将str左值转为右值触发string的移动构造函数 string tmp std::move(str); cout move后str str endl; // 结果未定义标准仅保证str可析构 cout move后tmp tmp endl; return 0; }注std::move()之后的原对象其资源已经被转移处于 “有效但未定义” 的状态只能对其进行析构或重新赋值绝对不能再访问其内部资源否则会出现未定义行为。4. 左值引用 vs 右值引用特性左值引用Type右值引用Type声明符号初始化要求必须初始化绑定后不可重新绑定必须初始化绑定后不可重新绑定直接绑定对象非 const 左值引用仅左值const 左值引用左值 右值仅右值纯右值 将亡值左值需通过 std::move 转换后绑定绑定对象的修改性非 const 左值引用可修改const 左值引用不可修改可修改绑定的右值对象const 右值引用除外本身属性左值有名字的右值引用变量是左值匿名右值引用是右值核心应用场景函数参数传递、避免拷贝、返回左值对象移动语义、完美转发、资源所有权转移引用折叠与任何引用折叠均为左值引用仅与右值引用折叠为右值引用其余均为左值引用能否为 nullptr不能必须绑定合法对象不能必须绑定合法对象四、右值引用的两大核心应用场景1. 移动语义告别无意义的深拷贝实现资源高效转移移动语义是右值引用最核心的价值它的核心思想是对于即将销毁的对象不做资源的深拷贝而是直接将其资源的所有权 “转移” 到新对象中避免了内存分配、数据复制和内存释放的巨大开销。移动语义的实现依赖于移动构造函数和移动赋值运算符二者的参数都是右值引用类型。1.1 无移动语义的痛点深拷贝的性能浪费先实现一个简单的字符串类看看没有移动语义时临时对象带来的性能开销#include iostream #include cstring using namespace std; class MyString { public: char* data; size_t len; // 普通构造函数 MyString(const char* str) { len strlen(str); data new char[len 1]; strcpy(data, str); cout 构造函数分配内存地址 (void*)data 内容 data endl; } // 拷贝构造函数深拷贝 MyString(const MyString other) { len other.len; data new char[len 1]; strcpy(data, other.data); cout 拷贝构造函数深拷贝内存新地址 (void*)data 内容 data endl; } // 拷贝赋值运算符深拷贝 MyString operator(const MyString other) { if (this other) { return *this; } // 释放自身原有资源 delete[] data; // 深拷贝源对象资源 len other.len; data new char[len 1]; strcpy(data, other.data); cout 拷贝赋值运算符深拷贝内存新地址 (void*)data 内容 data endl; return *this; } // 析构函数 ~MyString() { if (data) { cout 析构函数释放内存地址 (void*)data 内容 data endl; delete[] data; } else { cout 析构函数资源已转移无需释放 endl; } } }; MyString createString() { MyString str(Hello C); return str; // 返回临时对象触发拷贝构造 } int main() { MyString str1(Hello World); MyString str2 str1; // 触发拷贝构造 MyString str3 createString(); // 临时对象触发拷贝构造随后销毁 return 0; }此处每一次对象拷贝都会触发深拷贝重新分配内存、复制数据临时对象创建后很快被销毁造成了巨大的性能浪费。1.2 基于右值引用实现移动语义我们给 MyString 类添加移动构造函数和移动赋值运算符通过右值引用实现资源转移#include iostream #include cstring using namespace std; class MyString { public: char* data; size_t len; // 普通构造函数 MyString(const char* str) { len strlen(str); data new char[len 1]; strcpy(data, str); cout 构造函数分配内存地址 (void*)data 内容 data endl; } // 拷贝构造函数深拷贝 MyString(const MyString other) { len other.len; data new char[len 1]; strcpy(data, other.data); cout 拷贝构造函数深拷贝内存新地址 (void*)data 内容 data endl; } // 移动构造函数右值引用参数资源转移 // noexcept关键字告诉编译器该函数不会抛出异常STL容器会优先调用 MyString(MyString other) noexcept { // 直接转移源对象的资源无需深拷贝 data other.data; len other.len; // 源对象指针置空避免析构时释放资源 other.data nullptr; other.len 0; cout 移动构造函数转移资源目标地址 (void*)data endl; } // 拷贝赋值运算符深拷贝 MyString operator(const MyString other) { if (this other) { return *this; } delete[] data; len other.len; data new char[len 1]; strcpy(data, other.data); cout 拷贝赋值运算符深拷贝内存新地址 (void*)data 内容 data endl; return *this; } // 移动赋值运算符右值引用参数资源转移 MyString operator(MyString other) noexcept { if (this other) { return *this; } // 释放自身原有资源 delete[] data; // 转移源对象资源 data other.data; len other.len; // 源对象指针置空 other.data nullptr; other.len 0; cout 移动赋值运算符转移资源目标地址 (void*)data endl; return *this; } // 析构函数 ~MyString() { if (data) { cout 析构函数释放内存地址 (void*)data 内容 data endl; delete[] data; } else { cout 析构函数资源已转移无需释放 endl; } } }; MyString createString() { MyString str(Hello C); return str; } int main() { MyString str1(Hello World); MyString str2 std::move(str1); // 触发移动构造转移str1的资源 MyString str3 createString(); // 临时对象是右值触发移动构造 str3 MyString(Hello Move); // 临时对象触发移动赋值 return 0; }此处移动构造 / 移动赋值没有做任何深拷贝只是转移了指针的指向没有新的内存分配性能得到了提升。注移动构造 / 移动赋值的参数必须是非 const 右值引用因为我们需要修改源对象的指针将其置空强烈建议给移动构造 / 移动赋值添加noexcept关键字STL 容器如 vector在扩容时只有移动构造被声明为 noexcept才会优先调用移动构造否则会保守地调用拷贝构造函数当类没有自定义拷贝构造、拷贝赋值、析构函数时编译器会自动生成默认的移动构造和移动赋值函数如果自定义了上述函数编译器不会自动生成需要手动实现1.3 移动语义的常见场景STL 容器的优化C11 之后所有 STL 容器都实现了移动构造和移动赋值使用std::move()转移容器可以避免全量拷贝比如vectorint vec2 std::move(vec1);时间复杂度从 O (n) 降到 O (1)emplace 系列函数vector::emplace_back()、map::emplace()等函数通过完美转发直接在容器内存中构造对象避免了临时对象的拷贝和移动性能优于push_back()/insert()智能指针的资源转移std::unique_ptr无法拷贝只能通过移动语义转移所有权正是基于右值引用实现函数返回大对象返回大容器、大对象时编译器会优先进行 RVO/NRVO 返回值优化优化失效时会自动调用移动构造无需手动加std::move()2. 完美转发完美转发是右值引用的第二大核心应用它的核心目标是在模板函数中将参数原封不动地转发给另一个函数保留参数的左值 / 右值属性、const/volatile 修饰符。简单来说传入的是左值转发后还是左值传入的是右值转发后还是右值不会因为转发而改变参数的类型属性这就是 “完美” 的含义。2.1 完美转发的底层支撑完美转发的实现依赖三个 C11 的核心特性右值引用万能引用转发引用引用折叠规则std::forward()2.1.1 万能引用转发引用万能引用是指模板函数中形如T的参数它既可以绑定左值也可以绑定右值同时保留参数的所有类型属性。注意只有发生模板参数推导时T才是万能引用如果是具体类型的Type只是普通的右值引用只能绑定右值。// 模板参数推导T是万能引用 templatetypename T void func(T arg) { } // 具体类型int是普通右值引用 void func2(int arg) { } int main() { int a 10; func(a); // 正确万能引用绑定左值 func(10); // 正确万能引用绑定右值 // func2(a); // 报错普通右值引用无法绑定左值 func2(10); // 正确普通右值引用绑定右值 return 0; }2.1.2 引用折叠规则C 不允许引用的引用但在模板参数推导时会出现引用嵌套的情况此时编译器会执行引用折叠规则如下左值引用与 任何引用/折叠最终结果都是左值引用右值引用与 右值引用折叠最终结果才是右值引用嵌套类型折叠结果T TT TT TT T这个规则让万能引用可以同时适配左值和右值当传入左值int时T 被推导为intT int 折叠为int左值引用当传入右值int时T 被推导为intT int右值引用2.1.3 std::forward () 的作用std::forward()是实现完美转发的核心函数它的作用是有条件地进行类型转换当传入的是左值时不做任何转换依然返回左值当传入的是右值时转换为右值引用返回右值和std::move()的无条件转右值不同std::forward()是 “按需转换”完美保留参数的原始属性。2.2 完美转发的示例我们通过一个完整的例子看看完美转发的效果以及不使用完美转发的问题#include iostream #include utility using namespace std; // 重载函数区分左值和右值参数 void process(int x) { cout 左值引用版本被调用值 x endl; } void process(int x) { cout 右值引用版本被调用值 x endl; } // 不使用完美转发的模板函数 templatetypename T void noForward(T arg) { cout 无完美转发; process(arg); // arg是有名字的变量本身是左值永远调用左值版本 } // 使用完美转发的模板函数 templatetypename T void withForward(T arg) { cout 有完美转发; process(std::forwardT(arg)); // 完美转发保留原始左值/右值属性 } int main() { int a 5; cout 传入左值a endl; noForward(a); withForward(a); cout \n 传入右值10 endl; noForward(10); withForward(10); return 0; }运行结果 传入左值a 无完美转发左值引用版本被调用值5 有完美转发左值引用版本被调用值5 传入右值10 无完美转发左值引用版本被调用值10 有完美转发右值引用版本被调用值10可以看到不使用std::forward()时无论传入左值还是右值都会调用左值版本的 process 函数因为函数内部的 arg 是有名字的变量本身是左值。而使用完美转发后参数的左值 / 右值属性被完美保留正确调用了对应的重载函数。2.3 完美转发的常见场景STL 容器的 emplace 系列函数直接在容器内存中构造对象将用户传入的参数完美转发给对象的构造函数保留所有类型属性std::bind/std::function实现函数对象的封装和参数转发底层依赖完美转发线程库 std::thread将线程函数的参数完美转发到线程执行上下文泛型编程中的中间转发函数需要将参数透传给其他函数的模板场景五、高频踩坑点与实践建议1. 高频踩坑点坑 1std::move () 之后继续使用原对象std::move()之后原对象的资源已经被转移处于有效但未定义的状态此时访问其内部资源如 string 的字符、vector 的元素会触发未定义行为只能对其进行析构或重新赋值。坑 2对函数返回值使用 std::move ()很多人会写return std::move(obj);这是典型的错误写法。编译器默认会对返回的局部对象执行 RVO/NRVO 返回值优化直接在返回值地址构造对象零拷贝。而加了std::move()之后会阻止编译器的优化强制调用移动构造反而降低性能。坑 3误以为右值引用变量本身是右值有名字的右值引用变量本身是左值无法直接绑定到右值引用参数。如果需要在函数中转发右值引用形参必须使用std::move()或std::forward()。void func(int x) { } void test(int arg) { // func(arg); // 报错arg是右值引用变量本身是左值 func(std::move(arg)); // 正确 }坑 4给移动构造 / 移动赋值加 const 修饰移动构造 / 移动赋值需要修改源对象的指针将其置空加了 const 之后无法修改源对象只能做深拷贝完全失去了移动语义的意义。const 右值引用几乎没有使用场景不如直接用 const 左值引用。坑 5把普通右值引用当成万能引用只有在模板参数推导场景下的T才是万能引用类成员函数的模板参数如果没有推导过程也不是万能引用。class Test { public: // 这里的T不是万能引用因为类实例化时T已经确定没有参数推导 templatetypename T void func(T arg); };2. 实践建议只在需要转移资源的时候使用std::move()且确保 move 之后的对象不再使用实现移动构造 / 移动赋值时必须添加noexcept关键字确保 STL 容器能优先调用函数返回局部对象时不要加std::move()交给编译器做返回值优化泛型编程中需要转发参数时使用std::forward()实现完美转发不要用std::move()遵循 “三五法则”如果自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个就应该同时实现或禁用移动构造、移动赋值运算符对于不需要拷贝、只需要转移所有权的对象如 unique_ptr、文件句柄禁用拷贝构造和拷贝赋值只实现移动构造和移动赋值感谢阅读本文如有错漏之处烦请斧正。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2419571.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!