右值引用和移动语义
Last updated on
前言
在你写C++代码的时候,你可能曾经写过这样的代码 至少我干过
struct Graph
{
std::vector<std::pair<int, int>> graph;
int n;
Graph(std::vector<std::pair<int, int>> g)
{
graph = g;
n = graph.size();
}
};
Graph gp(g);
大概像这样的对象,你创建了很多次,然后对着飞上天的运行时间开始怀疑人生:这到底是为什么?
可能你已经知道了,这是由于对一个超大型对象进行了拷贝构造,计算机不得不一点一点地从你传入的g里面把数据拿出来,然后塞进你的graph,这样的时间复杂度无疑是 的,性能开销极大,你的电脑在哀嚎。
你可能发现,其实我们在使用g构造完成graph之后,就已经不再需要g了,对于这样的情况,我们原有的拷贝构造显然是低效且没有必要的。那我们要采用什么方法来优化呢?这就是我们今天要提到的:右值引用和移动语义
左值和右值
在此之前,我们先需要搞清楚两个概念:左值和右值。
- 左值(lvalue):指向某个内存地址,可以持久存在。
- 右值(rvalue):临时值,即将被销毁,不能持久存在。
我们看这段代码示例:
int a = 10;
/* 这里的a就是左值,它指向了一个确定的内存地址;
而10就是右值,它是被临时写出来的字面量,这行代码结束后就会被销毁,你找不到它的地址。 */
bool flag = false;
/* flag是左值,false是右值 */
std::string s = "Abel is so handsome";
/* 注意这里,在C++中字符串字面量是左值,它有内存地址,被存储在静态区 */
在C++中,我们使用&&来表示右值引用,表示此时我指向的这个东西是一个右值,帮助编译器识别出哪些是临时对象,比如
void func(Object&& obj)
{
// 函数逻辑...
}
这里的意思是,这个函数func只接受右值,如果往里面传入左值,则会直接error
但是比较有意思的是,你在这个函数内部来看,这个obj其实还是一个左值!因为它有名字,叫做obj,值就是你传进来的右值。
那么我们传右值进去有什么用呢?作用就是告诉编译器:我有权把这个obj里的东西拆了,把它们拿给别人。
我们常用的场景就是在对象的构造函数中,实现函数重载:
class SmartBuffer
{
private:
size_t size;
char* data;
public:
SmartBuffer(size_t s) : size(s), data(new char[s]) {
std::cout << "[构造]申请了" << size << "字节内存\n";
}
SmartBuffer(const SmartBuffer& other) : size(other.size), data(new char[other.size]) {
memcpy(data, other.data, size);
std::cout << "[拷贝构造]分配内存并复制数据\n";
}
SmartBuffer(SmartBuffer&& other) : size(other.size), data(other.data) { // 直接接管指针
other.data = nullptr;
other.size = 0;
std::cout << "[移动构造]接管对方地址\n";
}
};
我们注意到,这里出现了移动构造的概念,这就是我们将要提到的移动语义。
移动语义
移动语义的核心本质就是管理权的转移。
如果说拷贝语义是复制,好比说你想要获取朋友的一本书里的内容,如果使用拷贝语义,你得用笔把它抄下来,或者说PDF扫描等等。拷贝语义的结果是你得到了一个和原来对象的值一模一样的对象,但是地址不同。
那么移动语义就是,朋友说我这本书直接给你了,书的归属权由你的朋友转移到了你身上,你现在同样拥有了一个新对象,但是实际上那还是同一本书,地址是相同的。
显然我们可以看出,在我们无需保留原件或者说创造新拷贝,只需要把那份数据拿过来的时候,移动语义的效率要远高于拷贝语义。
在C++中,我们使用下面的方法来实现移动语义
std::move()
移动语义实现的经典代表,它将括号内的对象参数强制转换为右值引用并返回。
正如我们前面所说的,当一个对象是右值引用时,编译器就知道你可以对这个对象做任何事,所以此时可以直接将这个对象所指向的数据的内存地址赋给你想要持有数据的新对象。
仍然以上面的SmartBuffer为例:
int main()
{
SmartBuffer b1(1024); // 这里是普通构造,传入了一个右值作为内存大小
SmartBuffer b2 = b1; // 这里触发了拷贝构造,因为向构造函数中传入了左值
SmartBuffer b3 = std::move(b1); // 这里触发了移动构造,因为向构造函数中传入了右值引用
return 0;
}
在这里,b1被std::move()强制转为右值引用并传递给构造函数,使用移动构造将b1的内存地址拿出来并给予了b3
那么b1现在变成了什么?它现在是一个有效但是未指定的状态,你仍然可以给它赋值,但是如果直接进行访问,就像访问空指针或者一个空的std::vector一样,它原有的资源已经被b3拿走了。
std::forward<T>()
我们考虑下面的场景:
void process(int& x) {
std::cout << "左值处理\n";
}
void process(int&& x) {
std::cout << "右值处理\n";
}
void relay(int&& arg) {
process(arg);
}
int main() {
int a = 10;
relay(std::move(a));
}
当你尝试运行的时候,你会惊喜地发现我们最终调用的是左值处理的函数!为什么?
我们仔细看看relay这个函数,它虽然接收的是一个右值引用的参数,但是正如我们先前所说,在这个函数内部,由于它被赋予了名字arg,它也相当于在栈内存中占据了一块地址,此时我们再调用process(arg),传递的自然就是左值形式。
在实际应用中,我们会面临很多这种情况,在想要使用移动语义带来的高效率构造的同时,又不得不通过多次的函数传参,怎么处理呢?
有人可能会想,那我直接再在函数内部对需要传入右值引用的对象调用std::move()处理不就好了?但在实际情况中,你可能不知道当前需要传入的是左值还是右值引用,比如模板编程。如果不分青红皂白地就把所有对象都转成了右值引用,原来的对象直接在构造完新对象之后被销毁,造成我们可能不太想看见的后果。
这时候我们就要使用std::forward<T>()来解决这个问题,在此之前,我们需要先理解万能引用。
万能引用T&&
当&&出现在模板参数里时,它不叫右值引用,它叫做万能引用。此时它不再像平常那样严苛,要求传入参数必须为右值,而是随着传入类型的变化而变化。
当你往里面传入左值时,对应的就变为左值引用;传入右值时,就变为右值引用。
就像下面这样:
template<typename T>
void relay(T&& arg)
{
process(std::forward<T>(arg));
}
对于万能引用的本质,它实际上是C++的引用折叠:
| 原型 | 声明方式 | 折叠结果 |
|---|---|---|
& | & | & |
&& | & | & |
& | && | & |
&& | && | && |
也就是说,当且仅当传入参数的原型和在模板中的声明方式都为&&时,所得出的结果才为&&
回到std::forward<T>()
此时我们就已经很清楚了,std::forward<T>()的作用只有一个:根据T的类型,决定把arg转成左值还是右值。
它的核心就是:原样搬运,不增不减。
这在模板编程中具有重要的作用,用于在不确定传入参数类型的情况下,保持对应的左值引用或者是右值引用类型并继续传参。但是在非模板编程中就没什么必要使用了,因为此时传入类型已经确定,再用这个传参属于是脱裤子放屁多此一举。
小结
- 左值&右值:有名字、能取地址的是左值;没名字、不能取地址的是右值。
- 移动的本质:指针所有权的交换。
std::move:它并没有移动任何东西,只是一个强制类型转换,将对象转换为右值引用。std::forward:它是在模板编程中让参数保持本质的关键工具。