引例
普通构造与移动语义的时间对比
cpp
// 创建一个大数据量的字符串
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++;//错误
而不是左值的,就是右值,右值也会被称为临时值
二、引用的分类
左值引用(绑定到左值上)带一个"&"
我们希望引用的对象可改变值,就会用到左值引用。左值引用只能绑定到左值上
cpp
int a = 1;
int& b{a}; //正确,a是左值,b可以绑定
int& c;//错误,引用必须要初始化
int& d = 1;//错误,左值引用不能绑右值
cosnt引用(常量引用)
常量引用也是左值引用,但是我们希望引用的对象是不改变的,const引用可以绑左值,右值
cpp
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;
/*
右值引用(绑定到右值上)带"&&"
右值引用主要是来绑定到那些临时的或者即将销毁的对象,右值引用只能绑右值
cpp
int&& a = 1;//正确
int i = 2;
int&& b = i;//错误,右值引用不能绑左值
int&& c = i * 100//正确,i * 100 结果是右值
小结几点
- (1)前置递增减运算符与后置递增减运算符的区别 前置递增减运算法是左值表达式,因为++i是直接将i变量+1然后再返回i本身,而后置递增运算符是右值表达式,因为i++是先产生一个临时变量来保存i的值用于使用目的,再给i+1,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值;
cpp
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)临时对象都是右值
三、探究临时对象
前面的例子中我们提到临时对象,临时对象的产生往往容易被我们忽略,而产生临时对象会消耗资源和空间,这对于我们的程序,应该是尽量去避免产生临时对象以达到提高、优化性能的目的。 以下是一些常见的会产生临时值的地方:
- 函数传参
cpp
func("some temporary string");//这里虽然传的是常量,但是C++中大概率还是产生一个临时变量来复制
- 初始化
cpp
v.push_back(x());//这里会初始化一个临时的x,然后被复制进vector
- 类型转换产生
cpp
TValue sum;
sum = 100;//这里会产生一个临时的TValue的对象来进行调用一个拷贝赋值
- 函数返回对象时
cpp
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)是另一个临时变量
- 后置递增减运算符
cpp
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对于含有内存,文件句柄等资源对象更有意义
五、移动构造函数与移动赋值运算符
下面给出一个使用移动构造和移动赋值运算符的地址:
cpp
#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函数中添加如下:
cpp
int main()
{
MyString obj{ "abc" };
f_move(std::move(obj));
std::cout << obj._data << std::endl; // danger!
std::cout << "==================== end ==================" << std::endl;
return 0;
}
会导致报错:
因为此时obj中的内容已经为空了!
- 在实现移动构造函数时不赋值为nullptr 将这里注释掉:
cpp
MyString(MyString&& str) noexcept
: _data(str._data) {
std::cout << "move this object" << std::endl;
//str._data = nullptr;//这一步很重要
}
程序崩溃:
因为我们没将源对象指针置空,两个指针指向同一块资源,当他们生命周期结束后,都会释放同一块资源,导致两次释放!
参考资料:
- 《C++新经典》------王建伟
- 深入理解C++中的move和forward ------腾讯云开发者 张凯