在传统的 C 语言中,函数参数传递和对象赋值通常依赖于值传递,也就是说,当我们传递参数时,除非是使用指针,否则会对参数进行一份完整的复制,这在处理大型对象或容器时会导致较大的内存和性能开销。为了优化这种情况,C++ 引入了“引用”机制,允许在一定作用域内通过引用传递对象,避免不必要的拷贝。然而,引用只能绑定到左值,也就是那些在调用周期内仍然存在的对象。
但是在实际编程中,很多情况下我们只需要临时使用某个值,例如函数内创建的对象或表达式中的临时变量,这些对象的生命周期无法支持他们在后续代码中重复使用。这些将会被回收资源的对象也叫做 右值。由于右值通常不需要在调用周期外被保留,因此没有必要对其进行深度拷贝。为了更好地处理这些临时对象,C++11 引入了 右值引用,使得我们可以通过资源转移而非复制的方式来操作这些右值,从而防止进一步深拷贝浪费资源。
右值引用
右值与左值
在 C++ 中,左值 和 右值 是用来描述表达式结果的一种分类方式,主要区别在于对象是否存在在被创建的作用域里和是否有明确的内存地址。
左值是指那些在表达式结束后依然存在的对象,它们有明确的内存地址,能够被多次引用和操作。左值可以出现在赋值语句的左侧,因为它们能够持久保存值,并可以通过引用或指针操作。
int x = 5;
//x作为左值,在其作用域有明确的地址
int y = x;
//左值可以出现在表达式右侧
与之相对的,右值(rvalue)是短暂存在的值,通常是某个表达式的计算结果,例如字面常量或者临时创建的对象。右值没有明确的内存地址,无法被多次引用或者修改。它们只能用于一次性的计算,并且在表达式结束后就会被销毁。右值通常不能位于赋值语句的左侧。
int y = 5 + 3; // (5 + 3) 是右值
// (5+3) 无法被取地址
右值引用和移动语义
我们的引用不能对右值进行引用的原因是因为,引用之后,权限被放大了(右值不能被修改),但是被用于引用定义的左值并没有这个限制,如果我们使用引用加上const就可以对右值进行引用。
int& a = 5;//报错
const int& b = 5;
同时我们可以使用右值引用 && 对右值进行引用:
int&& a = 5;
同时我们可以使用 move函数,对一个左值转化到右值,转换到右值有什么好处呢,这就需要提到C++11为什么会创造出右值引用的概念了。
右值的引用返回
int&& add(int a, int b)
{
int temp = (a + b);
return std::move(temp); //temp在函数作用域中是左值
}
int main()
{
int c = add(1, 2);
return 0;
}
让我们注意看temp的地址与c的地址,在没有右值引用的时候,我们只能直接返回,而编译器的操作会是这样的
我们很清楚能看到,这两个变量的地址是一样的,还记得我们在学习c语言的时候,老师和教材上的特意嘱托,函数内创建的临时对象会随着函数调用结束返回后被销毁或者被其他数据覆盖,这片内存是不能被访问的,如果有指向该内存的指针就是野指针,那么我们也可以这样理解,函数正常的返回值是编译器对他的一份拷贝对象,返回的也就是这个拷贝对象,这一点我们也可以通过观察地址来得到
那样,我们就可以理解,右值引用返回,或者传参的主要目的就是告诉编译器,在赋值或者传值的时候这个变量占用的内存,可以直接被转移赋值给其他变量。
移动构造函数
移动构造,顾名思义,通常我们在对一个对象进行构造的时候,会采用拷贝构造,但是如果我们拷贝的这个对象会被销毁(临时对象),那么我们就可以使用移动构造进行,减少编译器开销和内存浪费,下面所有例子都用一个结构体进行:
namespace str
{
class string
{
public:
string(const char* str = "")//构造函数
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
private:
char* _str;
size_t _size;
size_t _capacity;
const static size_t npos = -1;
};
}
拷贝构造:
string(const string& str)
:_str(nullptr),
_size(0),
_capacity(0)
{
cout << "拷贝构造" << endl;//新开辟内存
string temp(str._str);
swap(temp); //因为temp是临时创建的,swap交换地址后,会随着函数结束被销毁
}
当我们使用拷贝构造的时候,就算我们传递的对象是临时对象,会被销毁,但是还是需要拷贝后才能进行赋值,增加编译器开销
string(string&& str)
{
cout << "移动赋值" << endl;//不新开辟
swap(str);
}
这时候,我们直接使用swap交换地址,函数结束后,编译器就直接会调用析构把传参的临时对象销毁,省时省力,这只是一个缩影,当我们容器嵌套容器后,临时对象进行过多的深拷贝进行的开销也是挺大的,我们直接把资源进行转移,减小开销。
移动赋值运算符
string& operator=(const string& str)
{
if (this != &str)
{
char* temp = new char[str._capacity + 1]; //开辟新内存
strcpy(temp, str._str);
delete[] _str;
_str = temp;
_size = str._size;
_capacity = str._capacity;
}
}
string& operator=(string&& str) //直接交换
{
swap(str);
return *this;
}
有同学不是会说,右值也可以传给 const 引用啊,这样写不会把右值传给第一个函数而开辟新内存吗,完全不必担心,在重载函数的匹配时,参数会匹配最符合的那一个,当我们传入右值的时候,最匹配的当然是第二个函数啊!!!
完美转发
还需要注意一点,右值被右值引用之后,会改变本身的右值属性:
void fun2(int&& x){
std::cout << "fun2(int&& x)" << std::endl;
}
void fun2(const int& x){
std::cout << "fun2(const int& x)" << std::endl;
}
void fun1(int&& x){
fun2(x); //重载函数,去调用最符合参数的那一个
return;
}
int main()
{
fun1(3);
return 0;
}
那是不是我们的临时对象被传入我们特意写的右值引用后,无法进行移动构造或者调用我们重载的移动赋值运算符,对的,那么如果我们想要继续让这个临时对象按照右值属性进行传递,C++11有两种解决方式
move:使用move,把左值属性改为右值属性,进行匹配
void fun1(int&& x){
fun2(std::move(x));
return;
}
forward:完美转发:
void fun1(int&& x)
{
fun2(std::forward<int>(x));
return;
}
这样都可以去调用 void fun2(int&& x)