文章目录
- 一、C++中的左值与右值引用
- 左值与右值的区别
- 左值
- 右值
- 右值引用语法
- 二、左值引用与右值引用的使用
- 左值引用能引用右值吗?
- 右值引用能引用左值吗?
- 三、右值引用的底层原理
- 右值引用常量
- 右值引用绑定 move 后的左值
- 为什么我们需要右值引用?
- 左值引用解决的拷贝问题
- 右值引用解决的拷贝问题
- 四、移动语义
- 移动构造与移动赋值
- 为什么会调用移动构造?
- 移动赋值
- 右值引用的本质
- STL容器的移动构造与移动赋值
- 引用折叠
- 完美转发
- 完美转发
一、C++中的左值与右值引用
在传统的C++语法中,我们使用的是左值引用,而C++11引入了右值引用的概念。为便于理解,接下来我们会将之前学习的引用称为左值引用。无论是左值引用还是右值引用,它们本质上都是为对象取别名。
左值与右值的区别
在讲解右值引用之前,我们需要首先了解左值与右值的区别。
左值
左值是表示数据的表达式,我们可以对其取地址并且赋值。左值能够出现在赋值操作符(=
)的左边,而右值则不能。
例如,下面的代码片段中:
int i = 0;
int* p = &i;
double d = 3.14;
变量 i
、p
和 d
都是左值。首先,它们出现在赋值操作符 =
的左边;其次,我们可以获取它们的地址,并修改它们的值。
对于常量变量来说:
const int ci = 0;
int const* cp = &ci;
const double cd = 3.14;
ci
、cp
和 cd
也是左值,尽管它们具有 const
属性,使得它们的值不可修改,但它们仍然出现在 =
的左边并且可以取地址。
因此,左值最显著的特征是可以取地址,但不一定能被修改。
右值引用是C++11中引入的一种新语法,它使得程序能够更高效地处理临时对象。为了更好地理解右值引用,我们需要先了解什么是右值。
右值
右值也是一种表示数据的表达式,例如字面常量
、表达式的返回值
、函数的返回值
等。右值可以出现在赋值操作符(=
)的右边,但不能出现在左边。与左值不同,右值不能取地址。
以下是一个例子:
double func() {
return 3.14;
}
int x = 10;
int y = 20;
int z = x + y;
double d = func();
在这段代码中,10
、20
、x + y
和 func()
都是右值。具体来说,10
和 20
是字面常量,x + y
是表达式的返回值,而 func()
是函数的返回值。右值的显著特点是它们不能取地址。
通过对比,左值和右值的最大区别就是:左值可以取地址,而右值不能。
右值引用语法
首先,我们来回顾一下左值引用的语法:
int i = 0;
int* p = nullptr;
int& ri = i;
int*& rp = p;
在左值引用的语法中,我们只需在原变量类型后加一个 &
,便能创建一个左值引用。这时,新变量相当于原变量的别名,可以通过引用传递参数、返回值等,从而减少不必要的拷贝。
然而,左值引用不能引用右值。例如:
int& ri = 0; // 错误,右值不能绑定到左值引用
int*& rp = nullptr; // 错误
double& rd = 3.14; // 错误
以上代码尝试将右值绑定到左值引用,但编译时会报错。左值引用语法是 type&
,而右值引用的语法是 type&&
。
接下来,我们尝试使用右值引用来引用右值:
int&& ri = 0;
int*&& rp = nullptr;
double&& rd = 3.14;
在这种情况下,右值引用成功地引用了右值。
二、左值引用与右值引用的使用
现在我们已经了解了右值引用的语法,但接下来我们需要考虑以下两个问题:
- 左值引用能引用右值吗?
- 右值引用能引用左值吗?
虽然之前我们已经展示过左值引用不能直接引用右值,但这里我们要进一步深入讨论和澄清一些特殊情况。
左值引用能引用右值吗?
回顾之前的测试,我们知道左值引用不能直接引用右值。例如:
int& i = 5; // 错误
这段代码是非法的,因为右值(5
)不能绑定到左值引用。这正是引入右值引用语法的原因之一。
但是,const
左值引用可以引用右值:
const int& i = 5; // 合法
为什么会出现这种情况呢?因为常量引用具有常性,无法修改引用的对象。当我们使用 const
引用时,C++允许我们引用右值。这样,我们就能将一个右值绑定到一个常量左值引用上,避免了修改常量的风险。
右值引用能引用左值吗?
接下来讨论右值引用能否引用左值。右值引用通常用于绑定到右值,但它并不直接支持左值。然而,在某些特殊情况下,右值引用也能绑定到左值:
int x = 10;
int&& rx = std::move(x); // 合法
这里,我们使用 std::move(x)
将 x
转换为右值,从而允许将它绑定到右值引用。注意,std::move
并不真的是移动数据,它只是将对象转换为右值引用类型。因此,rx
实际上是绑定到左值 x
上的,但通过 std::move
强制它成为右值引用。
- 左值引用不能直接引用右值,但
const 左值引用
可以引用右值。 - 右值引用不能直接引用左值,但通过
std::move
可以将左值转换为右值,从而绑定到右值引用。
三、右值引用的底层原理
右值引用的引入为C++带来了更高效的资源管理和转移操作。接下来,我们将深入了解右值引用的底层工作原理,主要分为两种情况:右值引用常量和右值引用经过 move
转换后的左值。
右值引用常量
当右值引用绑定到一个常量时,引用并不会直接指向常量区的数据,而是将数据拷贝到栈区,然后让引用指向栈区中的数据。为什么这样做呢?因为如果右值引用直接指向常量区中的数据,修改该数据将会导致程序出现未定义行为。
看看这段代码:
int&& r = 5; // 右值引用常量
r = 10; // 修改数据
在这个过程中,我们使用右值引用 r
绑定到了常量 5
,然后通过右值引用将其值修改为 10
。但如果 r
直接指向常量区中的 5
,修改它就会导致常量区中的数据被不合法地改变,这是不允许的。
因此,右值引用常量时的真实操作是:将常量区中的数据拷贝到栈区,并且引用指向栈区的数据,这样就避免了对常量区数据的非法修改。通过这个方式,右值引用常量的值可以被修改,而不会影响原始常量区的数据。
同样,const
左值引用引用常量时也是类似的,数据会先被拷贝到栈区,然后引用指向栈区的数据,但无法修改这个数据。
总结:
- 右值引用常量:会把常量区的数据拷贝到栈区,然后引用指向栈区中的数据,且该数据可以修改。
const
左值引用常量:会把常量区的数据拷贝到栈区,然后引用指向栈区中的数据,但该数据是常量,不能修改。
右值引用绑定 move 后的左值
当右值引用绑定到经过 move
处理的左值时,它不会复制数据,而是直接指向原始的左值。这种情况下,右值引用实际上就成了左值的别名。
举个例子:
int i = 5;
int&& rri = std::move(i); // move 转换左值为右值
rri = 10;
std::cout << i << std::endl; // 输出 10
std::cout << rri << std::endl; // 输出 10
在这个例子中,rri
和 i
共享同一块内存,因此修改 rri
也会影响 i
的值。这里,rri
就相当于是 i
的别名。使用 std::move
将左值转换为右值,使得右值引用可以引用并操作该左值。
这是否与左值引用非常相似呢?的确如此。当右值引用绑定到经过 move
处理的左值时,它与直接使用左值引用没有任何区别。
为什么我们需要右值引用?
左值引用在C++中引入后,解决了许多拷贝的问题,比如传递参数时,参数的不必要拷贝。
来看以下代码:
string add_string(string& s1, string& s2)
{
string s = s1 + s2;
return s;
}
int main()
{
string str;
string hello = "Hello";
string world = "world";
str = add_string(hello, world);
return 0;
}
在这个例子中,add_string
函数使用引用传递两个 string
参数,从而避免了两个 string
对象的拷贝,提升了效率。然而,这并没有解决一些额外的性能问题,比如当返回值需要被拷贝时,仍然会消耗不必要的资源。
左值引用解决的拷贝问题
左值引用在传参和返回值中解决了一部分拷贝构造的问题。比如,当函数返回一个局部变量时,如果使用左值引用来返回,避免了不必要的拷贝构造。
考虑以下代码:
string& say_hello()
{
static string s = "hello world";
return s;
}
int main()
{
string str1;
str1 = say_hello();
return 0;
}
在这个例子中,函数 say_hello
返回一个 string
类型的引用,指向静态变量 s
。因为 s
是静态变量,它在函数调用结束后仍然存在,所以我们可以直接返回它的引用。这样,当 str1
接收返回值时,我们不需要创建临时变量进行拷贝构造,而是直接通过引用赋值,从而节省了拷贝构造的开销。
右值引用解决的拷贝问题
然而,当我们返回一个局部变量时,即使使用左值引用,也无法避免拷贝构造的问题。因为局部变量在函数结束时会被销毁,必须要返回一个临时值。来看以下代码:
string say_hello()
{
string s = "hello world";
return s;
}
int main()
{
string str;
str = say_hello();
return 0;
}
在这段代码中,say_hello
返回的是局部变量 s
。由于 s
是局部变量,它在函数结束时会被销毁,因此返回 s
时会先拷贝构造一个临时对象,然后临时对象再被拷贝构造到 str
。这导致了两次拷贝构造。
为了避免这种情况,C++引入了右值引用。右值引用允许我们将局部变量的资源"转移"到外部,从而避免不必要的拷贝。
我们可以使用 std::move
将 s
转换为右值引用,这样在返回时就不会进行拷贝构造,而是直接将资源转移给外部对象。看看这个改进的版本:
string say_hello()
{
string s = "hello world";
return std::move(s); // 转移资源
}
int main()
{
string str;
str = say_hello(); // 只进行一次资源转移
return 0;
}
这里,std::move(s)
会将 s
转换为右值引用,从而实现资源的转移,而不需要进行拷贝构造。通过右值引用,我们避免了拷贝的开销,并且提高了程序的性能。
右值引用不仅仅是一种语法,它本质上是一个“标记”,标识一个对象的资源可以被移动。它允许我们在返回值时,避免不必要的资源拷贝,直接转移对象的所有权。
这就是右值引用存在的意义。当我们需要返回一个对象时,使用右值引用可以避免不必要的拷贝,将对象的所有权直接转移到目标位置,从而提升性能。
- 右值引用常量:拷贝常量数据到栈区,引用指向栈区数据,且该数据可以修改。
- const 左值引用常量:拷贝常量数据到栈区,引用指向栈区数据,但数据不可修改。
- 右值引用move后的左值:右值引用直接指向原左值,修改右值引用也会影响原左值。
- 右值引用的意义:避免不必要的拷贝,提高程序效率,特别是在处理临时对象和返回值时。
我们先来探讨一下什么情况下会产生可以被右值引用的左值。
-
左值被
move
后
当一个左值被move
后,它就可以被右值引用。这表明程序员明确表示该左值的资源可以被迁移,从而赋予了它右值的特性。 -
将亡值
C++会把即将离开作用域的非引用类型的返回值视为右值,这种类型的右值也被称为将亡值。
回顾一个典型场景:函数内部的局部变量s
已经创建好了字符串"hello world"
,但s
马上就要离开函数作用域并被销毁。为了避免资源浪费,C++允许将s
的资源直接迁移给外部变量,而不是进行不必要的拷贝。
这种情况下,变量s
即将作用离开域并被销毁,但它内部的"hello world"
是我们需要的。
因此,C++将即将离开作用域的非引用类型返回值视为右值,这种右值的核心含义是:这个变量的资源可以被迁移走。这句话非常重要!
C++引入move
属性的原因是,有些变量的生命周期还很长,C++不敢擅自迁移它们的资源。但当程序员调用move
时,就明确表示了可以迁移该变量的资源。这相当于程序员亲自许可,将左值的资源迁移走。
四、移动语义
那么,右值是如何迁移资源的呢?这就涉及到右值引用的移动语义。
为了更好地理解移动语义,我们需要首先了解右值引用在实现资源转移时的重要性。右值引用不仅仅是为了优化性能,它还引入了一种新的语义,使得程序能够高效地管理资源,尤其是在避免不必要的拷贝构造时。
我们先定义一个简单的 mystring
类,模拟字符串的管理,并引入构造、拷贝构造、赋值操作等功能。
class mystring
{
public:
// 构造函数
mystring(const char* str = "")
{
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 析构函数
~mystring()
{
delete[] _str;
}
// 赋值重载
mystring& operator=(const mystring& s)
{
cout << "赋值重载" << endl;
return *this;
}
// 拷贝构造
mystring(const mystring& s)
{
cout << "拷贝构造" << endl;
}
private:
char* _str = nullptr;
};
在这个类中,_str
是一个指向字符数组的指针,负责存储字符串。当我们通过拷贝构造和赋值操作创建新对象时,资源会被复制到新对象中。
移动构造与移动赋值
在没有移动构造的情况下,返回一个 mystring
对象会触发多次拷贝构造。在下面的例子中,我们演示了这种情况:
mystring get_string()
{
mystring str("hello");
return str; // 发生拷贝构造
}
int main()
{
mystring s2 = get_string(); // 发生两次拷贝构造
return 0;
}
在这个过程中,str
是局部变量,当 get_string
函数返回时,str
会先被拷贝构造到一个临时变量,然后再拷贝构造到 s2
。如果字符串有大量字符,这种做法会非常低效,因为每次都需要进行拷贝。
为了避免这种效率问题,我们可以通过移动构造来实现资源的转移,避免不必要的拷贝:
class mystring
{
public:
// 移动构造
mystring(mystring&& s)
{
cout << "移动构造" << endl;
std::swap(_str, s._str); // 交换指针
}
// 拷贝构造
mystring(const mystring& s)
{
cout << "拷贝构造" << endl;
}
};
在这个新的移动构造函数中,我们使用 std::swap
交换 s
和当前对象 _str
的指针,实际上就是转移 s
所拥有的资源,而不是进行数据拷贝。这样,返回值 str
的资源就可以直接转移给临时对象。
为什么会调用移动构造?
当 get_string
函数返回时,str
是一个将亡值(临时变量),它具有右值属性。由于右值属性的存在,str
会调用移动构造,而不是拷贝构造。随后,s2
会通过移动构造直接获得 str
的资源,而不会再进行拷贝。
流程如下:
get_string
返回时,str
是一个右值,触发移动构造,返回临时变量。- 临时变量通过移动构造转移资源给
s2
。
通过这种方式,我们避免了两次拷贝构造的开销,只有一次资源转移。
移动赋值
除了移动构造,还有移动赋值操作,它允许我们在对象已经存在的情况下,将另一个对象的资源转移到当前对象中:
// 移动赋值重载
mystring& operator=(mystring&& s)
{
std::swap(_str, s._str);
return *this;
}
右值引用的本质
右值引用不仅仅是语法上的变化,它的引入实现了移动语义。移动指的是资源的转移,而语义则表示这是有意进行资源转移的行为。
- 移动构造:通过交换指针而不是复制数据,实现资源的转移。
- 移动赋值:通过交换指针而不是复制数据,将一个对象的资源转移给另一个对象。
右值引用和移动构造的出现,解决了对象返回时的拷贝问题。C++11之后,类的成员函数数量从6个增加到了8个,新增了移动构造和移动赋值重载,它们为C++带来了极大的性能提升,尤其是在处理大量数据或复杂对象时。
STL容器的移动构造与移动赋值
在C++11中,STL容器也更新了移动构造和移动赋值操作。这是为了能够利用移动语义提高性能,尤其是在处理临时对象时,避免不必要的深拷贝。
C++11的vector
构造函数:
vector(vector&& v); // 移动构造
C++11的vector
赋值操作符:
vector& operator=(vector&& v); // 移动赋值重载
这些移动构造和赋值操作使得STL容器能够直接转移数据的所有权,而不是进行昂贵的拷贝操作,从而显著提高了性能。
引用折叠
引用折叠是C++11引入的一个非常重要的概念,它与万能引用(T&&
)密切相关,能够使得代码更加简洁和高效。
在下面的代码中,我们定义了两个重载的func
函数,一个接受右值引用,另一个接受常量左值引用:
template <class T>
void func(T&& t)
{
cout << "T&& 右值引用" << endl;
}
template <class T>
void func(const T& t)
{
cout << "const T& const左值引用" << endl;
}
int main()
{
int a = 5;
func(a); // 左值
func(move(a)); // 右值
return 0;
}
程序输出:
T&& 右值引用
T&& 右值引用
为什么左值也会调用右值引用的版本?
这正是因为C++的引用折叠规则。T&&
在模板推导时会根据传入的参数类型推导为适当的类型,即便传入的是左值引用。C++11中的引用折叠规则为:
T& &&
会推导为T&
。
T&& &&
会推导为T&&
。
因此,T&&
可以“折叠”为左值或右值引用,而不需要写多个模板函数重载。
C++11引入的引用折叠特性,使得我们可以编写更简洁、统一的模板函数来处理左值引用和右值引用。通过一个模板函数,我们可以同时处理左值和右值,而无需显式编写多个重载版本。
以下是使用引用折叠的一种方式:
template <class T>
void func(T&& t)
{
// 处理t
}
int a = 5;
func(a); // 左值
func(move(a)); // 右值
当调用func(a)
时,a
是一个左值,而当调用func(move(a))
时,move(a)
是一个右值。在这两种情况下,C++通过引用折叠规则来决定如何处理T&&
参数。
-
第一次调用
func(a)
:
T
被推导为int&
,因此T&&
会折叠为int& &&
,最终类型为int&
,表示t
是左值引用。 -
第二次调用
func(move(a))
:
T
被推导为int&&
,因此T&&
会折叠为int&& &&
,最终类型为int&&
,表示t
是右值引用。
这种引用折叠机制能够统一处理左值和右值,从而避免我们需要显式为每个情况写出不同的函数重载。实际上,如果我们使用一个模板,就能生成原本需要四个重载的情况:
void func(int&); // 左值引用
void func(const int&); // 常量左值引用
void func(int&&); // 右值引用
void func(const int&&); // 常量右值引用
通过引用折叠规则,我们只需要一个模板函数就能统一处理这四种情况。这样大大减少了代码的冗余,并使得代码更加简洁和灵活。
完美转发
在调用其他函数时,我们希望传递的参数保持其原本的左值或右值属性。完美转发是通过std::forward
实现的,它确保了参数在传递时保持其原始的值类别(左值或右值)。std::forward
只有在需要转发一个万能引用时才会派上用场。
考虑以下代码:
void func2(int& x)
{
cout << "func2 左值引用" << endl;
}
void func2(int&& x)
{
cout << "func2 右值引用" << endl;
}
template <class T>
void fuc1(T&& t)
{
func2(t); // 这里会调用哪个函数呢?
}
int main()
{
int i = 5;
fuc1(i); // 传递左值
fuc1(move(i)); // 传递右值
return 0;
}
输出结果:
func2 左值引用
func2 右值引用
为什么第一次调用 fuc1(i)
时,调用了左值版本,而 fuc1(move(i))
调用时,调用了右值版本呢?
这是因为在调用fuc1
时,T&&
会根据传入的参数类型推导出对应的类型:
fuc1(i)
时,T
是int&
,所以T&&
会折叠成int& &
,根据折叠规则变为int&
,即左值引用类型。fuc1(move(i))
时,T
是int&&
,所以T&&
会折叠成int&& &&
,根据折叠规则变为int&&
,即右值引用类型。
这就是引用折叠的规则。
完美转发
为了确保在转发参数时保留原始的左值或右值属性,我们需要使用std::forward
,如下所示:
template <class T>
void fuc1(T&& t)
{
func2(std::forward<T>(t)); // 保留t的原始值属性
}
通过使用std::forward<T>(t)
,t
会保持其原始的值类别,确保func2
接受正确的左值或右值。
-
引用折叠:C++11中的引用折叠使得模板函数能够统一处理左值引用和右值引用,简化了代码。通过折叠规则,
T&&
可以变成不同类型的引用(左值引用或右值引用)。 -
完美转发:通过
std::forward
,我们可以将传入的参数保持其原始的值类别,避免不必要的拷贝或错误的类型转化。 -
移动语义与STL容器:STL容器也支持移动构造和移动赋值操作,从而使得容器能够高效地处理临时对象,避免不必要的拷贝,提高性能。
这两项功能(引用折叠和完美转发)使得C++在处理对象时更加灵活和高效,尤其是在涉及到泛型编程和资源管理时。