探究C++中的移动语义(左值、右值、引用、move)

引例

普通构造与移动语义的时间对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 创建一个大数据量的字符串
const int dataSize = 1000000; // 1,000,000 characters
char* largeString = new char[dataSize + 1];
for (int i = 0; i < dataSize; ++i) {
largeString[i] = 'A'; // 填充字符 'A'
}
largeString[dataSize] = '\0'; // 添加字符串结束符

// 测量使用移动构造的执行时间
auto startMoveConstructor = high_resolution_clock::now();
MyString temp(largeString);
MyString moved = std::move(temp);
auto endMoveConstructor = high_resolution_clock::now();
auto durationMoveConstructor = duration_cast<microseconds>(endMoveConstructor - startMoveConstructor);
cout << "Time taken by move constructor: " << durationMoveConstructor.count() << " microseconds" << endl;

// 测量使用拷贝构造的执行时间
auto startCopyConstructor = high_resolution_clock::now();
MyString tempCopy(largeString);
MyString copied(tempCopy);
auto endCopyConstructor = high_resolution_clock::now();
auto durationCopyConstructor = duration_cast<microseconds>(endCopyConstructor - startCopyConstructor);
cout << "Time taken by copy constructor: " << durationCopyConstructor.count() << " microseconds" << endl;

// 测量使用移动赋值的执行时间
auto startMoveAssignment = high_resolution_clock::now();
MyString anotherTemp(largeString);
MyString assigned;
assigned = std::move(anotherTemp);
auto endMoveAssignment = high_resolution_clock::now();
auto durationMoveAssignment = duration_cast<microseconds>(endMoveAssignment - startMoveAssignment);
cout << "Time taken by move assignment: " << durationMoveAssignment.count() << " microseconds" << endl;

// 测量使用拷贝赋值的执行时间
auto startCopyAssignment = high_resolution_clock::now();
MyString anotherTempCopy(largeString);
MyString assignedCopy;
assignedCopy = anotherTempCopy;
auto endCopyAssignment = high_resolution_clock::now();
auto durationCopyAssignment = duration_cast<microseconds>(endCopyAssignment - startCopyAssignment);
cout << "Time taken by copy assignment: " << durationCopyAssignment.count() << " microseconds" << endl;

delete[] largeString; // 释放动态分配的内存

// 测量返回值优化的执行时间(使用移动语义)
auto startReturnFromFunctionWithMove = high_resolution_clock::now();
MyString returnedFromFunction = getMyString(); // 假设有一个函数 getMyString() 返回 MyString
auto endReturnFromFunctionWithMove = high_resolution_clock::now();
auto durationReturnFromFunctionWithMove = duration_cast<microseconds>(endReturnFromFunctionWithMove - startReturnFromFunctionWithMove);
cout << "Time taken by return from function with move: " << durationReturnFromFunctionWithMove.count() << " microseconds" << endl;

// 测量返回值优化的执行时间(不使用移动语义)
auto startReturnFromFunctionWithoutMove = high_resolution_clock::now();
MyString returnedFromFunctionWithoutMove = getMyStringWithoutMove(); // 使用没有移动语义的返回
auto endReturnFromFunctionWithoutMove = high_resolution_clock::now();
auto durationReturnFromFunctionWithoutMove = duration_cast<microseconds>(endReturnFromFunctionWithoutMove - startReturnFromFunctionWithoutMove);
cout << "Time taken by return from function without move: " << durationReturnFromFunctionWithoutMove.count() << " microseconds" << endl;

得出的结果如下:

可以看出,使用移动语义后能提高我们程序的效率,这就是为什么C++11以后会有语义的概念,其实主要都是为了提升效率而不断引入的。接下来我们一步一步探究其中的奥秘,首先来看一些概念

一、左值与右值

C++中的表达式,要么是左值,要么是右值。左值是可寻址的变量,有持久性;而右值一般是不可寻址的常量,或在表达式求值过程中创建的无名临时对象,短暂性的。
通常情况讲,左值就是能放在等号左边的表达式,如

int i = 1;
i = 2;

这里的变量i就是左值,他是可修改的,但是加上const之后,他就具有常量属性,不可修改

const int i = 1;
i = 2;//错误。因为i具有常量属性,不可修改

能用到左值的运算符通常有:

  • 赋值运算符

    int a;
    a = 4;//整个赋值语句的结果仍然是左值

  • 取地址 &

    int a = 4;//变量就是左值
    &a;

  • 下标,如string, vector下标[]都需要左值

    string s = “I’m KK”;
    s[0];
    vector::iterator iter;
    iter++; iter–

  • 通过看运算符在字面量上的操作判断

    i++;//正确
    9++;//错误

而不是左值的,就是右值,右值也会被称为临时值

二、引用的分类

左值引用(绑定到左值上)带一个“&”

我们希望引用的对象可改变值,就会用到左值引用。左值引用只能绑定到左值上

1
2
3
4
int a = 1;
int& b{a}; //正确,a是左值,b可以绑定
int& c;//错误,引用必须要初始化
int& d = 1;//错误,左值引用不能绑右值

cosnt引用(常量引用)

常量引用也是左值引用,但是我们希望引用的对象是不改变的,const引用可以绑左值,右值

1
2
3
4
5
6
7
8
9
int t = 1;
const int& a = t;
a = 2;//错误,a具有const属性,不是可修改的左值
const int& b = 2;//正确,const引用可以绑定到右值上,这里就区别于普通左值引用了
/*
其实“const int& b = 10;”这句发生了这个事情:
int tmp = 2;//这里的tmp是一个临时变量
const int& b = tmp;
/*

右值引用(绑定到右值上)带”&&”

右值引用主要是来绑定到那些临时的或者即将销毁的对象,右值引用只能绑右值

1
2
3
4
int&& a = 1;//正确
int i = 2;
int&& b = i;//错误,右值引用不能绑左值
int&& c = i * 100//正确,i * 100 结果是右值

小结几点

  • (1)前置递增减运算符与后置递增减运算符的区别
    前置递增减运算法是左值表达式,因为++i是直接将i变量+1然后再返回i本身,而后置递增运算符是右值表达式,因为i++是先产生一个临时变量来保存i的值用于使用目的,再给i+1,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值;
    1
    2
    3
    4
    5
    6
    int i = 7;
    (++i) = 20;//正确,i被赋值成20
    (i++) = 10;//错误,表达式必须是可修改的左值
    int j = 1;
    int&& a = j++;//可以,成功绑定右值,但此后a的值和j没关系
    int& b = j++//不可以,左值引用不能绑右值表达式
  • (2) &&r1绑定到了右值,但r1是本身是左值(看成一个变量)
  • (3) 所有变量都要看成左值,因为他们是有地址的
  • (3)临时对象都是右值

三、探究临时对象

前面的例子中我们提到临时对象,临时对象的产生往往容易被我们忽略,而产生临时对象会消耗资源和空间,这对于我们的程序,应该是尽量去避免产生临时对象以达到提高、优化性能的目的。
以下是一些常见的会产生临时值的地方:

  • 函数传参
    1
    func("some temporary string");//这里虽然传的是常量,但是C++中大概率还是产生一个临时变量来复制
  • 初始化
    1
    v.push_back(x());//这里会初始化一个临时的x,然后被复制进vector
  • 类型转换产生
    1
    2
    TValue sum;
    sum = 100;//这里会产生一个临时的TValue的对象来进行调用一个拷贝赋值
  • 函数返回对象时
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    TValue doubled(TValue& t)
    {
    TValue tmp;
    tmp.x = t.x * 2;
    tmp.y = t.y * 2;
    return tmp;//这里会产生一个临时对象用于返回,tmp是左值,但优先移动,不支持移动时仍可复制。但要注意,现在的大多编译器会进行优化
    }
    ```
    * 表达式赋值
    ```cpp
    a = b + c; // b+c是一个临时值, 然后被赋值给了a
    a = b + c + d; //c+d是一个临时变量, b+(c+d)是另一个临时变量
  • 后置递增减运算符
    1
    x++; // 前面提到的,先产生一个临时变量来保存i的值用于使用目的,再给i+1,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值;

四、对象移动与move()的作用

对象移动

什么是对象移动?对象移动其实就是把一个不想用了的对象A(临时值那些)中的一些有用的数据提取出来,在构建新对象B时就不需要重新构建对象中的所有数据————而是直接从A中提取出来,这样就避免了拷贝复制浪费资源与效率

move()函数

move()函数的作用就是将一个左值强制转换成右值,这样就能使得一个右值引用能绑定到这个转换成的右值对象了。请注意:C++中的move函数只是做了类型转换,并不会真正的实现值的移动!!! 要实现真正的移动,得自己手动重载移动构造函数和移动复制函数。我们需要在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。
不过实际上,通常情况下C++编译器会默认在用户自定义的class和struct中生成移动语义函数。这样的前提是我们自己没有主动定义该类的拷贝构造等函数。

需要注意的点是:

  • 对象在被move后,并没有被立即析构,而是在其离开作用域后才会被析构,如果此时继续使用被析构的对象的一些变量,会发生一些意想不到的错误。因此一般需要手动将源对象的值置空,以防止同一片内存区域被多次释放!
  • 如果我们没有提供移动构造函数,只提供了拷贝构造函数,std::move()会失效但是不会发生错误,因为编译器找不到移动构造函数就去寻找拷贝构造函数,这也是拷贝构造函数的参数是const T&常量左值引用的原因!
  • c++11中的所有容器都实现了move语义
  • 一些基本类型使用move还是会被复制,因为它们没有对象的移动构造函数,所以move对于含有内存,文件句柄等资源对象更有意义

五、移动构造函数与移动赋值运算符

下面给出一个使用移动构造和移动赋值运算符的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>
#include <utility>
#include <vector>

class MyString {
public:
//constructor
explicit MyString(const char* data) {
if (data != nullptr) {
_data = new char[strlen(data) + 1];
strcpy(_data, data);
}
else {
_data = new char[1];
*_data = '\0';
}

std::cout << "built this object, address: " << this << std::endl;
}

//Destructor
virtual ~MyString() {
std::cout << "destruct this object, address: " << this << std::endl;
delete[] _data;
}

//Move constructor
MyString(MyString&& str) noexcept
: _data(str._data) {
std::cout << "move this object" << std::endl;
str._data = nullptr;//这一步很重要
}


//copy assignment
MyString& operator=(const MyString& str) {
if (this == &str)//避免自我赋值
return *this;

delete[] _data;
_data = new char[strlen(str._data) + 1];
strcpy(_data, str._data);
return *this;
}

//Move assignment
MyString& operator = (MyString&& str) noexcept {
if (this == &str)//避免自我赋值
return *this;

delete[] _data;
_data = str._data;
str._data = nullptr;//不再指向之前的资源
return *this;
}
public:
char* _data;
};

void f_move(MyString&& obj) {
MyString a_obj(std::move(obj));
std::cout << "move function, address: " << &a_obj << std::endl;
}

int main()
{
MyString obj{ "abc" };

f_move(std::move(obj));
std::cout << "==================== end ==================" << std::endl;
return 0;
}

输出结果如下:

观察输出结果,可以验证我们上诉所说的
这里我们需要注意:在移动构造函数和移动赋值函数中,我们将当前待移动对象的资源赋值为了空(str._data=nullptr),这里就是我们手动实现了资源的移动!
假如尝试修改两个地方,将导致报错:

  • 使用资源被move后的对象
    在main函数中添加如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    int main()
    {
    MyString obj{ "abc" };

    f_move(std::move(obj));
    std::cout << obj._data << std::endl; // danger!
    std::cout << "==================== end ==================" << std::endl;
    return 0;
    }
    会导致报错与程序崩溃:

image.png
因为此时obj中的内容已经为空了!

  • 在实现移动构造函数时不赋值为nullptr
    将这里注释掉:
    1
    2
    3
    4
    5
    MyString(MyString&& str) noexcept
    : _data(str._data) {
    std::cout << "move this object" << std::endl;
    //str._data = nullptr;//这一步很重要
    }
    程序崩溃:
    image.png

因为我们没将源对象指针置空,两个指针指向同一块资源,当他们生命周期结束后,都会释放同一块资源,导致两次释放!

参考资料:
《C++新经典》——王建伟
深入理解C++中的move和forward ——腾讯云开发者 张凯

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2022-2025 Capper
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信