右值引用与TArray、TString
参考文献
引子
前文讲到一个点:
1 | // 差 - 返回常量数组 |
那为啥要这么写呢?这其中涉及到了右值引用这个知识点。
右值引用要解决啥问题
我们知道,C++中把值对象(包括栈上面分配的任何内存:基元类型或任何不是new出来的类实例)作为右值赋值给另外一个左值的时候,会触发复制。
比如:
1 | class Test |
在上面这段代码中,要注意两点:
- 实际上
Foo()
真正返回到main
函数中的对象和Foo
中的Ret
不是同一个,而是经过值复制得来的。 - 拿到
Foo()
的返回值之后,还需要执行拷贝构造函数,才能得到对象
这就造成了一个没有必要的消耗,也就是多了一次值复制的过程。
按照知乎帖子的神比喻来说:
如何将大象从一台冰箱转移到另一台冰箱?普通解答:打开冰箱门,取出大象,关上冰箱门,打开另一台冰箱门,放进大象,关上冰箱门。
2B解答:在第二个冰箱中启动量子复制系统,克隆一只完全相同的大象,然后启动高能激光将第一个冰箱内的大象气化消失。
等等,这个2B解答听起来很耳熟,这不就是C++中要移动一个对象时所做的事情吗?
其实这样子设计也可以理解,毕竟Ret
是分配于栈上的一个临时变量,当超出Foo()
的定义域的时候就应该消失,此时外界还想要获取到该值,就应该通过值复制拷贝一份出去。
所以我们要解决这个不必要的值复制的问题。
怎么解决呢?临时变量不要被回收就好了。
既然临时变量要被回收,那么就意味着它不能再被其他任何地方用到。那么,在法律上属于被遗弃品,我们拾取了它,转移它的所有权即可。
所谓的右值引用,实质上就是一个「转移所有权」的过程,它把一个右值的所有权赋予了一个左值。(相对的,左值引用的意义比较简单,就是相当于给一个左值起了别名。)
左值右值傻傻分不清
看了那么多博客,这里直接说个结论吧。
所谓左值右值,乍看一下就是等号的左右边的值。其实不然,左右值的本质区别在于:
- 对于左值,指的是变量的存储位置
- 对于右值,指的是变量的值;可以是一个常量,也可以是函数返回的变量
右值引用
前面简单地提到了右值引用。所谓右值引用,可以简单地理解为「控制权转移」。
有了右值引用,C++世界变得有效率得多了。当使用右值赋值给左值的时候,可以直接将右值的控制权移交给左值,而不是先把右值复制一遍,再交给左值。
在右值引用之前,如果将参数传进来,一般用的是const左值引用,如下:
1 | void Foo(const string& str); |
如果我们编写了右值引用,那就会直接采用右值引用的版本
1 | void Foo(string&& str); |
为了避免每次都要定义两个版本的函数,我们可以只定义一个右值引用的版本,然后采用std::move()
把左值引用都变成右值引用。
右值引用代表着原拥有者放弃了所有权,由于一般都是临时变量,所以放弃了所有权也就放弃了,随便别人拿到所有权后咋整都行。
再举个例子。在右值引用和左值引用函数同时存在(重写了)时,如果传进来的是右值,会优先使用右值引用版本的函数。
1 | class Test |
上面这个例子其实也没啥问题,传进去一个左值p,这个左值我们可能在main
函数的定义域内还要做其他修改,当然不能够简单地浅复制解决。
但是假如我们是传进一个临时变量,那么再把这个大数组进行完整地复制,那就是多此一举了。最好的就是浅复制,直接拿到这个int数组。
1 | void main() |
这个时候我们要多提供一个右值引用的构造函数:
1 | /** |
如果我们能够保证左值在传入右值引用函数之后,不再使用,那么我们也可以把左值赋予给右值引用(抛弃自己的所有权,但是只能我们自己保证,编译器不会禁止继续使用和更改该左值)。
1 | void main() |
禁止默认的const Class&构造函数
1 | Test(const Test& Another) = delete; |
如果我们delete了默认的拷贝构造函数,只实现移动构造函数,那么在使用拷贝构造函数的时候(不管隐式还是显示)就会编译报错,相当于强迫使用者使用右值引用。
1 | Person a; |
最后再举个例子
1 | void Func() |
上面这个过程总共发生了三次构造:
Func()
中使用默认构造函数构建了左值ret
Func()
返回了一个值,众所周知函数的传入与传出都是要进行一次值复制的,所以进行一次复制Test a
使用Func()
返回的结果作为参数传给构造函数
假设Test
类中含有很大的一个数组,光是使用Test(const Test& Another)
这种拷贝构造函数,我们没有办法区分清楚传进来的是左值还是右值,所以只能一律当做左值来处理。
如果是左值,那意味着传进来的这个引用,外界还会用到,并且有可能会对其内容做修改。所以我们需要进行深复制,把整个大数组拷贝一份。而如果传进来的是右值,那么亏了,明明我们知道这个右值被传进来使用过一次之后就会被外界销毁(右值都是一些会被销毁的临时变量),但是因为我们不知道究竟是不是右值,所以还是要把大数组整个拷贝一次。
而如果我们重写了一个Test(Test&& Another)
,当传进来的是一个右值的时候,会优先调用此构造函数。此时我们明确知道传进来的是一个右值,阅后即焚的那种,所以我们不必担心我们的修改会影响到这个变量。因此,我们直接把这个右值的内容拿过来用就行了。也就是说,直接把数组的地址拷贝过来,而不需要拷贝一整个数组。
拷贝构造函数&赋值运算符的区别
本质区别:拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。
换言之,一个会生成新的实例,而一个是对已存在实例进行数据更新。
1 | void Func(Person p){} |