右值引用和移动语义
一、右值引用和移动语义
作用:C++11中引用了右值引用和移动语义,可以避免无谓的复制,提高了程序性能。
1. 什么是左值、右值
从2个角度判断:
左值可以取地址、位于等号左边;
而右值没法取地址,位于等号右边。
int a = 6;
a可以通过 & 取地址,位于等号左边,所以a是左值。
6位于等号右边,6没法通过 & 取地址,所以6是个右值
再举个复杂点的例子:
struct A {A(int a = 0) {a_ = a;}int a_;};A a = A();
同样的,a可以通过 & 取地址,位于等号左边,所以a是左值。
A()是个临时值,没法通过 & 取地址,位于等号右边,所以A()是个右值。
可见左右值的概念很清晰,有地址的变量就是左值,没有地址的字面值、临时值就是右值。
2. 什么是左值引用、右值引用
引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝。
2.1 左值引用
左值引用:能指向左值,不能指向右值的就是左值引用:
int a = 5;int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败
引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。 但是,const左值引用是可以指向右值的:
const int &ref_a = 5; // 编译通过
const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为参数的原因之一,如 std::vector 的 push_back :
void push_back (const value_type& val);
如果没有 const , vec.push_back(5) 这样的代码就无法编译通过。
2.2 右值引用
再看下右值引用,右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值:
int &&ref_a_right = 5; // okint a = 5;int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值
ref_a_right = 6; // 右值引用的用途:可以修改右值
2.3 对左右值引用本质的讨论
左右值引用的本质。
2.3.1 右值引用有办法指向左值吗?
有办法,使用 std::move :
int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向
cout << a; // 打印结果:5
在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值 了?并不是,打印出a的值仍然是5。
std::move 是一个非常有迷惑性的函数:
不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量;
但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左 值。其实现等同于一个类型转换: static_cast(lvalue) 。 所以,单纯的std::move(xxx) 不会有性能提升。
同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move 指向该左值:
int &&ref_a = 5;ref_a = 6;
//等同于以下代码:
int temp = 5;int &&ref_a = std::move(temp);ref_a = 6;
2.3.2 左值引用、右值引用本身是左值还是右值?
被声明出来的左、右值引用都是左值。 因为被声明出的左右值引用是有地址的,也位于等号左边。仔细看下边代码:
// 形参是个右值引用
void change(int&& right_value) {right_value = 8;}int main() {int a = 5; // a是个左值int &ref_a_left = a; // ref_a_left是个左值引用int &&ref_a_right = std::move(a); // ref_a_right是个右值引用change(a); // 编译不过,a是左值,change参数要求右值change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值change(std::move(a)); // 编译通过change(std::move(ref_a_right)); // 编译通过change(std::move(ref_a_left)); // 编译通过change(5); // 当然可以直接接右值,编译通过cout << &a << ' ';cout << &ref_a_left << ' ';cout << &ref_a_right;// 打印这三个左值的地址,都是一样的}
看完后你可能有个问题,std::move 会返回一个右值引用 int &&,它是左值还是右值呢?从表达式 int &&ref = std::move (a) 来看,右值引用 ref 指向的必须是右值,所以 move 返回的 int && 是个右值。所以右值引用既可能是左值,又可能是右值吗?确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值。int && 是个右值。
或者说:作为函数返回值的 && 是右值,直接声明出来的 && 是左值。这同样也符合前面章节对左值、右值的判定方式:其实引用和普通变量是一样的,int &&ref = std::move (a) 和 int a = 5 没有什么区别,等号左边就是左值,右边就是右值。
最后,从上述分析中我们得到如下结论:
- 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
- 右值引用可以直接指向右值,也可以通过 std::move 指向左值;而左值引用只能指向左值(const 左值引用也能指向右值)。
- 作为函数形参时,右值引用更灵活。虽然 const 左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
void f(const int& n) {n += 1; // 编译失败,const左值引用不能修改指向变量
}void f2(int && n) {n += 1; // ok}int main() {f(5);f2(5);}
3.右值引用和std::move使用场景
std::move 只是类型转换工具,不会对性能有好处;
3.1 右值引用优化性能,避免深拷贝
浅拷贝重复释放 对于含有堆内存的类,我们需要提供深拷贝的拷贝构造函数,如果使用默认构造函数,会导致堆内存的 重复删除/
一、基础概念:浅拷贝 vs 深拷贝
- 浅拷贝:默认拷贝构造函数(编译器自动生成)会直接复制对象的所有成员变量。对于指针类型(如
int* m_ptr
),浅拷贝仅复制指针的地址(即新对象和原对象共享同一块内存)。- 深拷贝:自定义拷贝构造函数,为指针成员重新分配内存,并复制原指针指向的内容(新对象和原对象拥有独立的内存)。
二、第一个代码示例(2-3-1-memory):浅拷贝导致重复释放
代码逻辑与执行流程
class A { public:A() :m_ptr(new int(0)) { // 构造函数:分配堆内存cout << "constructor A" << endl;}~A() { // 析构函数:释放堆内存cout << "destructor A, m_ptr:" << m_ptr << endl;delete m_ptr;m_ptr = nullptr;} private:int* m_ptr; // 指针成员,指向堆内存 };A Get(bool flag) {A a; // 构造a:m_ptr指向堆内存(如地址0xf87af8)A b; // 构造b:m_ptr指向堆内存(如地址0xf87ae8)cout << "ready return" << endl;return flag ? a : b; // 返回局部对象(假设返回b) }int main() {A a = Get(false); // 用Get返回的对象构造acout << "main finish" << endl; }
关键步骤与内存问题
构造局部对象:
Get
函数中构造a
和b
,各自分配堆内存(假设地址为0xf87af8
和0xf87ae8
)。返回局部对象:
Get
返回b
时,编译器调用默认拷贝构造函数(浅拷贝),将b
的m_ptr
(地址0xf87ae8
)复制给临时对象(假设为temp
)。此时temp.m_ptr
和b.m_ptr
指向同一块内存。局部对象析构:
Get
函数结束时,局部对象a
和b
析构,释放各自的m_ptr
(0xf87af8
和0xf87ae8
)。临时对象构造
main
中的a
:
main
中的a
由临时对象temp
构造(浅拷贝),a.m_ptr
指向0xf87ae8
(已被b
析构时释放)。
main
中a
析构:
a
析构时尝试释放m_ptr
(地址0xf87ae8
),但该内存已被b
释放,导致重复释放(运行报错)。输出结果分析(运行报错)
constructor A // 构造a(Get中的a) constructor A // 构造b(Get中的b) ready return // Get准备返回 destructor A, m_ptr:0xf87af8 // Get结束,a析构(释放0xf87af8) destructor A, m_ptr:0xf87ae8 // Get结束,b析构(释放0xf87ae8) destructor A, m_ptr:0xf87af8 // main中的a析构(尝试释放已被b释放的0xf87ae8,报错) main finish
三、第二个代码示例(2-3-1-memory2):深拷贝解决重复释放
代码修改:添加深拷贝构造函数
class A { public:A() :m_ptr(new int(0)) { // 构造函数:分配堆内存cout << "constructor A" << endl;}A(const A& a) :m_ptr(new int(*a.m_ptr)) { // 深拷贝构造函数:复制指针内容cout << "copy constructor A" << endl;}~A() { // 析构函数:释放堆内存cout << "destructor A, m_ptr:" << m_ptr << endl;delete m_ptr;m_ptr = nullptr;} private:int* m_ptr; };
关键步骤与内存行为
构造局部对象:
Get
函数中构造a
和b
,各自分配堆内存(地址0xea7af8
和0xea7ae8
)。返回局部对象:
Get
返回b
时,调用深拷贝构造函数:为临时对象temp
的m_ptr
分配新内存(地址0xea7b08
),并复制b.m_ptr
的值(*b.m_ptr
,即0
)。此时temp.m_ptr
指向独立内存(0xea7b08
),与b.m_ptr
(0xea7ae8
)无关。局部对象析构:
Get
函数结束时,局部对象a
和b
析构,释放各自的m_ptr
(0xea7af8
和0xea7ae8
)。临时对象构造
main
中的a
:
main
中的a
由临时对象temp
构造(深拷贝),a.m_ptr
指向新分配的内存(0xea7b08
)。
main
中a
析构:
a
析构时释放自己的m_ptr
(0xea7b08
),无重复释放问题。输出结果分析(正确运行)
constructor A // 构造a(Get中的a) constructor A // 构造b(Get中的b) ready return // Get准备返回 copy constructor A // 调用深拷贝构造函数,为临时对象分配新内存(0xea7b08) destructor A, m_ptr:0xea7af8 // Get结束,a析构(释放0xea7af8) destructor A, m_ptr:0xea7ae8 // Get结束,b析构(释放0xea7ae8) destructor A, m_ptr:0xea7b08 // main中的a析构(释放自己的0xea7b08) main finish
四、深拷贝的性能问题与右值引用的优化
虽然深拷贝解决了重复释放问题,但每次拷贝都需要重新分配内存并复制数据,对大对象(如存储大量数据的
std::vector
)来说效率较低。此时右值引用(移动语义)的作用就体现了:
- 移动构造函数:直接 “接管” 原对象的指针(无需分配内存),原对象指针置空(避免重复释放)。
- 性能优化:对于临时对象(右值),移动构造函数无需拷贝数据,仅转移指针,效率接近浅拷贝。
4.移动构造函数
一、问题背景:深拷贝的性能瓶颈
在之前的示例中,深拷贝构造函数通过为指针成员重新分配内存并复制数据(如
m_ptr = new int(*a.m_ptr)
),解决了浅拷贝导致的重复释放问题。但这种 “复制” 操作对大对象(如存储大量数据的std::vector
、字符串等)来说,时间和空间开销巨大。例如,若
m_ptr
指向一个包含百万个元素的数组,深拷贝需要:
- 分配新内存(与原数组等大);
- 将原数组的百万个元素逐一复制到新内存;
- 销毁原对象时释放原内存。
这一过程的时间复杂度为
O(n)
(n
是数据量),对性能影响显著。二、移动构造函数的核心思想:资源转移(浅拷贝 + 置空原对象)
移动构造函数的设计目标是:针对临时对象(右值),直接 “接管” 其资源(如堆内存指针),避免复制数据。其核心逻辑是:
- 浅拷贝指针:新对象直接复制原对象的指针(不分配新内存);
- 置空原对象的指针:原对象的指针被置为
nullptr
,避免其析构时释放已转移的资源。三、代码示例分析(2-3-1-memory3)
类
A
的移动构造函数定义class A { public:// 移动构造函数(参数是右值引用 A&&)A(A&& a) :m_ptr(a.m_ptr) { // 浅拷贝:直接复制原对象的指针a.m_ptr = nullptr; // 关键操作:原对象指针置空(避免析构时重复释放)cout << "move constructor A" << endl;}// 其他成员(构造、拷贝构造、析构)... };
关键行为说明
- 参数类型
A&&
:移动构造函数接收右值引用(临时对象或std::move
转换的左值)。- 资源转移:新对象的
m_ptr
直接指向原对象的堆内存(无需分配新内存)。- 原对象保护:原对象的
m_ptr
被置为nullptr
,其析构时delete m_ptr
不会执行(nullptr
释放无操作),避免重复释放。四、
Get
函数返回临时对象时的调用流程结合
Get(false)
的调用,分析移动构造函数的触发条件和执行过程:1.
Get
函数构造局部对象A Get(bool flag) {A a; // 构造a:m_ptr指向堆内存(地址0xfa7af8)A b; // 构造b:m_ptr指向堆内存(地址0xfa7ae8)cout << "ready return" << endl;return b; // 返回局部对象b(临时对象,右值) }
2. 返回临时对象时触发移动构造
Get
返回b
时,b
是局部变量(左值),但在返回时会被视为将亡值(xvalue)(一种右值)。编译器需要将b
的资源转移给调用者(main
中的a
),此时:
- 由于
b
是右值(将亡值),编译器优先调用移动构造函数(而非拷贝构造函数)。3. 移动构造函数执行资源转移
A a = Get(false); // 调用移动构造函数,用临时对象b构造a
移动构造函数执行以下操作:
a.m_ptr = b.m_ptr
:a
直接接管b
的堆内存(地址 0xfa7ae8);b.m_ptr = nullptr
:b
的指针被置空,避免其析构时释放已转移的资源。4. 局部对象析构,无重复释放
Get
函数结束时,局部对象a
和b
析构:
b.m_ptr
已被置为nullptr
,析构时delete m_ptr
无操作;a.m_ptr
指向原b
的堆内存(地址 0xfa7ae8),后续由a
析构时释放。五、移动构造函数的优势:性能与安全的平衡
操作类型 拷贝构造函数 移动构造函数 内存操作 分配新内存 + 复制数据(深拷贝) 直接转移指针(浅拷贝) 时间复杂度 O(n)
(n
是数据量)O(1)
(仅指针赋值)原对象状态 原对象资源保留(可继续使用) 原对象资源被转移(指针置空,不可再用) 适用场景 需保留原对象的场景(如复制数据) 原对象是临时对象(将被销毁)的场景 六、移动语义的意义:消除不必要的拷贝
移动语义(通过移动构造函数和右值引用实现)的核心价值是:仅当需要保留原对象时才进行深拷贝;当原对象是临时对象(即将销毁)时,通过移动构造直接转移资源,避免无意义的深拷贝。
例如,在
Get
函数返回临时对象的场景中:
- 临时对象
b
在Get
函数结束后会被销毁,无需保留其资源;- 通过移动构造将
b
的资源转移给a
,既避免了深拷贝的开销,又保证了a
能安全使用资源。
5. 移动(move)语义
move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转义,没有内存拷贝。要move语 义起作用,核心在于需要对应类型的构造函数支持。
#include <iostream>
#include <utility> // 需包含此头文件以使用 std::moveclass BigArray {
public:// 构造函数:分配大数组(模拟“昂贵资源”)BigArray(size_t size) : size_(size), data_(new int[size]) {std::cout << "构造函数:分配 " << size << " 个元素的数组" << std::endl;// 初始化数组(模拟实际数据填充)for (size_t i = 0; i < size; ++i) data_[i] = i;}// 析构函数:释放数组~BigArray() {if (data_ != nullptr) {delete[] data_;std::cout << "析构函数:释放数组(地址: " << data_ << ")" << std::endl;}}// 拷贝构造函数(深拷贝,代价高)BigArray(const BigArray& other) : size_(other.size_), data_(new int[other.size_]) {std::cout << "拷贝构造函数:深拷贝 " << other.size_ << " 个元素(地址: " << other.data_ << ")" << std::endl;for (size_t i = 0; i < size_; ++i) data_[i] = other.data_[i];}// 移动构造函数(浅拷贝,代价低)BigArray(BigArray&& other) noexcept : size_(other.size_), data_(other.data_) {other.data_ = nullptr; // 关键:原对象指针置空,避免重复释放std::cout << "移动构造函数:转移数组(原地址: " << other.data_ << " → 当前地址: " << data_ << ")" << std::endl;}private:size_t size_;int* data_; // 指向堆内存的指针(模拟“大资源”)
};int main() {// 情况1:直接拷贝(不使用 std::move)BigArray arr1(1000); // 构造一个大数组(1000个元素)BigArray arr2 = arr1; // 调用拷贝构造函数(深拷贝,代价高)// 情况2:使用 std::move 触发移动构造BigArray arr3 = std::move(arr1); // 调用移动构造函数(浅拷贝,代价低)return 0;
}
6. forward完美转发
forward 完美转发实现了参数在传递过程中保持其值属性的功能,即若是左值,则传递之后仍然是左值,若是右值,则传递之后仍然是右值。 现存在一个函数:
Template<class T>void func(T &&val);
根据前面所描述的,这种引用类型既可以对左值引用,亦可以对右值引用。 但要注意,引用以后,这个val值它本质上是一个左值!看下面例子:
int &&a = 10;int &&b = a; //错误
注意这里,a是一个右值引用,但其本身a也有内存名字,所以a本身是一个左值,再用右值引用引用a这 是不对的。 因此我们有了std::forward()完美转发,这种T &&val中的val是左值,但如果我们用std::forward (val), 就会按照参数原来的类型转发;
int &&a = 10;int &&b = std::forward<int>(a);
这样是正确的! 通过范例2-3-2-forward1巩固下知识:
// 2-3-2-forward1#include <iostream>using namespace std;template <class T>void Print(T &t){cout << "L" << t << endl;}template <class T>void Print(T &&t){cout << "R" << t << endl;}template <class T>void func(T &&t){Print(t);Print(std::move(t));Print(std::forward<T>(t));}int main(){cout << "-- func(1)" << endl;func(1);int x = 10;int y = 20;cout << "\n-- func(x)" << endl;func(x); // x本身是左值cout << "\n-- func(std::forward<int>(y))" << endl;func(std::forward<int>(y)); T为int,以右值方式转发ycout << "\n-- func(std::forward<int&>(y))" << endl;func(std::forward<int&>(y));cout << "\n-- func(std::forward<int&&>(y))" << endl;func(std::forward<int&&>(y));return 0;}
7.emplace_back减少内存拷贝和移动
对于STL容器,C++11后引入了emplace_back接口。 emplace_back是就地构造,不用构造后再次复制到容器中。因此效率更高。
考虑这样的语句:
vector<string> testVec;testVec.push_back(string(16, 'a'));
上述语句足够简单易懂,将一个string对象添加到testVec中。底层实现:
首先,string(16, ‘a’)会创建一个string类型的临时对象,这涉及到一次string构造过程。
其次,vector内会创建一个新的string对象,这是第二次构造。
最后在push_back结束时,最开始的临时对象会被析构。加在一起,这两行代码会涉及到两次 string构造和一次析构。
c++11可以用emplace_back代替push_back,emplace_back可以直接在vector中构建一个对象,而非 创建一个临时对象,再放进vector,再销毁。emplace_back可以省略一次构建和一次析构,从而达到优化的目的。
一、时间统计头文件
time_interval.h
#ifndef TIME_INTERVAL_H #define TIME_INTERVAL_H#include <iostream> #include <memory> #include <string>#ifdef GCC #include <sys/time.h> #else #include <ctime> #endif // GCCclass TimeInterval { public:TimeInterval(const std::string& d) : detail(d) {init();}TimeInterval() {init();}~TimeInterval() { #ifdef GCCgettimeofday(&end, NULL);std::cout << detail<< 1000 * (end.tv_sec - start.tv_sec) + (end.tv_usec - start.tv_usec) / 1000<< " ms" << std::endl; #elseend = clock();std::cout << detail<< (double)(end - start) << " ms" << std::endl; #endif // GCC}protected:void init() { #ifdef GCCgettimeofday(&start, NULL); #elsestart = clock(); #endif // GCC}private:std::string detail;#ifdef GCCtimeval start, end; #elseclock_t start, end; #endif // GCC };#define TIME_INTERVAL_SCOPE(d) \std::shared_ptr<TimeInterval> time_interval_scope_begin = std::make_shared<TimeInterval>(d)#endif // TIME_INTERVAL_H
二、测试代码(
2-3-4-emplace_back.cpp
)#include <vector> #include <string> #include "time_interval.h"int main() {std::vector<std::string> v;const int count = 10000000; // 测试次数:1000万次v.reserve(count); // 预分配内存,排除内存分配耗时// 测试1:push_back 左值引用(深拷贝){TIME_INTERVAL_SCOPE("push_back string:");for (int i = 0; i < count; i++) {std::string temp("ceshi");v.push_back(temp); // 调用 push_back(const string&),触发拷贝构造}}v.clear(); // 清空容器,准备下一次测试// 测试2:push_back 右值(移动构造){TIME_INTERVAL_SCOPE("push_back move(string):");for (int i = 0; i < count; i++) {std::string temp("ceshi");v.push_back(std::move(temp)); // 调用 push_back(string&&),触发移动构造}}v.clear();// 测试3:push_back 临时对象(右值,移动构造){TIME_INTERVAL_SCOPE("push_back(string):");for (int i = 0; i < count; i++) {v.push_back(std::string("ceshi")); // 临时对象直接构造,触发移动构造}}v.clear();// 测试4:push_back C风格字符串(构造+移动){TIME_INTERVAL_SCOPE("push_back(c string):");for (int i = 0; i < count; i++) {v.push_back("ceshi"); // 用C字符串构造string,触发移动构造}}v.clear();// 测试5:emplace_back 直接构造(无拷贝/移动){TIME_INTERVAL_SCOPE("emplace_back(c string):");for (int i = 0; i < count; i++) {v.emplace_back("ceshi"); // 直接在容器内构造string,无额外拷贝/移动}} }
三、测试结果及原因分析
测试结果(1000 万次操作耗时):
测试方法 耗时 push_back 左值引用(深拷贝) 335 ms push_back 右值(移动构造) 307 ms push_back 临时对象(移动构造) 285 ms push_back C 风格字符串 295 ms emplace_back 直接构造 234 ms 耗时原因分析:
push_back 左值引用(335ms)
调用push_back(const string&)
,需要将左值temp
通过拷贝构造函数复制到容器中。由于string
内部有动态内存(如字符数组),拷贝构造需重新分配内存并复制数据,耗时最长。push_back 右值 / 临时对象(307ms/285ms)
调用push_back(string&&)
,通过移动构造函数将右值的内存资源(如字符数组指针)直接转移到容器中,无需重新分配内存,耗时比拷贝构造少。push_back C 风格字符串(295ms)
本质是先用 C 字符串构造一个临时string
对象(触发构造函数),再通过移动构造放入容器,因此耗时与普通右值push_back
接近。emplace_back 直接构造(234ms)
emplace_back
直接在容器的内存空间中调用string
的构造函数(参数为 C 风格字符串),无需额外的拷贝或移动操作,因此耗时最少。
0voice · GitHub