C++11 ——— 右值引用和移动语义
右值引用和移动语义
左值引用和右值引用
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。
什么是左值?什么是左值引用?
左值(Lvalue)
左值是指可以取地址的表达式,通常是具名变量或对象。左值在赋值表达式中出现在等号的左边,表示一个持久的对象。左值可以通过取地址符(&) 获取其内存地址。
特征
- 可以取地址。
- 具有持久的生命周期,直到其作用域结束。
- 例如:变量名、数组元素、解引用的指针等。
左值引用就是给左值的引用,给左值取别名。
左值引用(Lvalue Reference)
左值引用是对左值的引用,使用符号 & 声明。左值引用可以绑定到左值,允许通过引用来访问和修改原始对象。
特征
- 只能绑定到左值。
- 可以通过左值引用修改原始对象的值。
- 例如:int a = 10; int& ref = a;,ref是a的左值引用。
示例:
int main()
{
// 以下的p、b、c、*p都是左值
int* p = new int(0); // p是指向动态分配的int对象的指针,是左值
int b = 1; // b是int变量,是左值
const int c = 2; // c是const int变量,是左值
// 以下几个是对上面左值的左值引用
int*& rp = p; // rp是对p的左值引用,是左值
int& rb = b; // rb是对b的左值引用,是左值
const int& rc = c; // rc是对c的const左值引用,是左值
int& pvalue = *p; // pvalue是对*p的左值引用,是左值
// *p是对动态分配的int对象的解引用,是左值
// new int(0)是动态分配int对象的右值表达式
// 1和2是int字面值,是右值
return 0;
}
右值(Rvalue)
右值是指不能取地址的表达式,通常是临时对象,字面常量,表达式返回值,函数返回值。右值在赋值表达式中出现在等号的右边,表示不持久的值。C++11将右值细分为纯右值(prvalue)和将亡值(xvalue)。
右值的分类:
- 纯右值(prvalue):
表示临时对象或不与任何对象关联的值。
例如:字面量(如1、true)、函数返回的非引用值(如int func() { return 42; })、表达式结果(如a + b)。 - 将亡值(xvalue):
表示即将被移动的对象,通常是一个临时对象的引用。
例如:通过std::move转换的对象、返回右值引用的函数。
#include <cmath> // 引入cmath库以使用fmin函数
int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10; // 10是一个字面值,属于右值
x + y; // x + y是一个表达式,其结果是一个右值
fmin(x, y); // fmin(x, y)的返回值是一个右值,表示x和y的最小值
// 以下几个都是对右值的右值引用
int&& rr1 = 10; // rr1是一个右值引用,绑定到右值10
double&& rr2 = x + y; // rr2是一个右值引用,绑定到表达式x + y的结果
double&& rr3 = fmin(x, y); // rr3是一个右值引用,绑定到fmin(x, y)的结果
// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
10 = 1; // 10是右值,不能作为赋值的左操作数
x + y = 1; // x + y是右值,不能作为赋值的左操作数
fmin(x, y) = 1; // fmin(x, y)是右值,不能作为赋值的左操作数
return 0;
}
需要注意的是,右值不能直接取地址。然而,当右值被绑定到一个右值引用时,它会被存储在特定的内存位置,这样就可以通过该引用获取这个位置的地址。
例如,虽然无法直接获取字面量10的地址,但在将其绑定到右值引用rr1后,可以获取rr1的地址,并且可以修改rr1的值。
如果不希望rr1的值被修改,可以使用const int&& rr1来引用,这样rr1将成为一个常量右值引用。
int main()
{
double x = 1.1, y = 2.2; // 定义两个double类型的变量x和y
int&& rr1 = 10; // rr1是一个右值引用,绑定到右值10
const double&& rr2 = x + y; // rr2是一个常量右值引用,绑定到表达式x + y的结果
rr1 = 20; // 将rr1的值修改为20,合法,因为rr1是非常量的右值引用
rr2 = 5.5; // 这里会报错:不能修改常量右值引用
// 解释:rr2是一个const double&&,这意味着它绑定的值不能被修改。尝试给rr2赋值会导致编译错误。
return 0; // 程序正常结束
}
左值引用与右值引用比较
左值引用总结:
- 左值引用只能引用左值,不能引用右值。
- 但是const左值引用既可引用左值,也可引用右值。
#include <iostream>
using namespace std;
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10; // 定义一个整型变量a,并初始化为10
// 创建一个左值引用ra1,引用变量a
int& ra1 = a; // ra1是a的别名,可以通过ra1访问和修改a的值
cout << "ra1: " << ra1 << endl; // 输出ra1的值,即10
// int& ra2 = 10; // 编译失败,因为10是右值,左值引用不能绑定到右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10; // ra3是一个常量左值引用,引用右值10
const int& ra4 = a; // ra4是一个常量左值引用,引用左值a
// 输出ra3和ra4的值
cout << "ra3: " << ra3 << endl; // 输出ra3的值,即10
cout << "ra4: " << ra4 << endl; // 输出ra4的值,即10
return 0; // 程序正常结束
}
右值引用总结:
- 右值引用只能右值,不能引用左值。
- 但是右值引用可以move以后的左值。
int main()
{
// 右值引用只能绑定到右值,不能绑定到左值。
int&& r1 = 10; // r1是一个右值引用,绑定到字面量10(右值)
// 这行代码是合法的,因为10是右值。
// 以下代码尝试将左值绑定到右值引用
int a = 10; // a是一个左值,初始化为10
// int&& r2 = a; // 这行代码会导致编译错误
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a); // std::move将左值a转换为右值
// r3现在是一个右值引用,绑定到a的右值版本
return 0; // 程序正常结束
}
右值引用使用场景和意义
前面我们可以看到左值引用既可以引用左值和又可以引用右值,那为什么C++11还要提出右值引用呢?是不是化蛇添足呢?下面我们来看看左值引用的短板,右值引用是如何补齐这个短板的!
右值引用肯定是要在某些场景比左值引用更加高效,所以才会引入右值引用的概念。
下面自己手动实现一个深拷贝的类,string类,观察其中的调用逻辑 :
namespace qq
{
class string
{
public:
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
//构造函数
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
_str = new char[_size + 1];
strcpy(_str, str);
cout << "string(const char* str = "") -- 构造函数" << endl;
}
// s1.swap(s2)
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
// 拷贝构造
//左值
string(const string& s)
:_str(nullptr)
{
cout << "string(const string& s) -- 深拷贝" << endl;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
// 赋值重载
//左值
string& operator=(const string& s)
{
cout << "string& operator=(string s) -- 深拷贝" << endl;
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
return *this;
}
~string()
{
delete[] _str;
_str = nullptr;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcapacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
const char* c_str()const
{
return _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
string to_string(int value)
{
bool flag = true;
if (value < 0)
{
flag = false;
value = 0 - value;
}
qq::string str;
while (value > 0)
{
int x = value % 10;
value /= 10;
str += ('0' + x);
}
if (flag == false)
{
str += '-';
}
std::reverse(str.begin(), str.end());
return str;
}
}
左值引用的使用场景:
做参数和做返回值都可以提高效率:
- 左值引用做参数,防止传参的时候进行拷贝操作。
- 左值引用做返回值时,防止返回临时对象的时候进行拷贝构造。
void func1(qq::string s)
{}
void func2(const qq::string& s)
{}
int main()
{
qq::string s1("hello world");
cout << "------" << endl;
// func1和func2的调用我们可以看到左值引用做参数减少了拷贝,提高效率的使用场景和价值
func1(s1);
cout << "------" << endl;
func2(s1);
// string operator+=(char ch) 传值返回存在深拷贝
// string& operator+=(char ch) 传左值引用没有拷贝提高了效率
s1 += '!';
return 0;
}
- 在上面的代码中,首先
fun1
和fun2
分别接受string
类型的参数,前者通过值传递,后者通过常量左值引用来传递。 - 首先在构造对象s1的时候首先调用构造函数进行构造。
- 其次在调用
func(s1)
的时候,会发生深拷贝,因为传入的是s1的一个副本,这就会发生深拷贝,因为传递的是一个副本。 - 而在调用
func(2)
的时候,由于使用了常量左值引用,避免了拷贝,提高了效率。
左值引用的短板:
但是当函数的返回对象是一个局部变量的时候,当出了函数的作用域,该变量就被销毁了,因为其声明周期只限于函数作用域。所以此时就不能使用左值返回了,不得不只能继续使用传值返回,但是传值返回至少都有一次拷贝构造,这就造成了效率的低下。
左值引用中编译器的优化
- 对于上面的
str
,其自身本是一个左值,传值返回,所以在销毁前会先创建一个临时对象,再用这个临时对象来赋值给s1
- 所以理论上,在声明str的时候会调用一次构造函数
- 其次在返回和赋值的时候会调用两次拷贝构造
但是实际上就只有一次拷贝构造,因为编译器会把连续的拷贝构造合为一个拷贝构造!
右值引用和移动语义
在qq::string
中增加移动构造,移动构造本质是将参数右值的资源窃取过来,占为已有,那么就不用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己。
//移动构造
//右值
string(string&& s)
:_str(nullptr),_size(0),_capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
我们添加了移动构造之后,继续执行上面的代码可以发现,此时直接调用了移动构造。
- 这里
to_string
的返回值是一个临时对象,然而此时这个临时对象不才会去调用const 修饰的左值引用,而是直接调用了右值引用,此时直接窃取这个将亡值。
并且这里编译器也对其做了相对于的优化,直接一步移动构造到位!
不仅仅有移动构造,还有移动赋值:
在qq::string类中增加移动赋值函数,再去调用qq::to_string(123),不过这次是将
qq::to_string(123)返回的右值对象赋值给ret1对象,这时调用的是移动构造。
//移动赋值
//右值
string& operator=(string&& s)
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
- 在这里,首先构造s1调用一次构造函数
- 其次在to_string函数的内部再次调用构造函数来创建str
- 在str返回的时候会调用移动构造创建出一个临时对象,最后再通过移动赋值向s1赋值。
- 这里的移动构造和移动赋值只是完成了资源的交换,并没有拷贝,所以整体的效率得到了提高。
右值引用引用左值
根据 C++ 的语法规则,右值引用只能引用右值。那么,右值引用是否绝对不能引用左值呢?
实际上,在某些场景下,我们可能确实需要使用右值引用来引用左值,以实现移动语义。当我们需要将一个左值转化为右值引用时,可以使用
std::move
函数。 在 C++11 中,std::move()
函数位于utility
头文件中。
这个函数的名称可能会引起误解,因为它并不实际“搬移”任何东西。它的唯一功能是将一个左值强制转换为右值引用,从而实现移动语义。
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
下面的代码实现了move对左值的强转:
int main()
{
qq::string s1("hello world");
// 这里s1是左值,调用的是拷贝构造
qq::string s2(s1);
// 这里我们把s1 move处理以后, 会被当成右值,调用移动构造
// 但是这里要注意,一般是不要这样用的,因为我们会发现s1的
// 资源被转移给了s3,s1被置空了。
qq::string s3(std::move(s1));
return 0;
}
右值引用的其他使用场景
C++11标准出来之后,STL中的容器除了增加移动构造和移动赋值之外,STL容器插入接口函数也增加了右值引用版本。
以list容器的push_back接口为例:
当使用std的list时,因为其已经实现了右值引用,所以当我们传入需要深拷贝的自定义string时,其会调用string的构造函数,左值的就调用左值引用版本的,右值就调用右值引用版本的。
- 第一个
lt.push_back(qq::string("11111"))
中,传入的是一个右值,故其直接调用移动构造即可完成插入。 - 第二个
qq::string s1("12345")
lt.push_back(s1);
其中s1是一个左值,所以调用左值引用版本的push_back
,故这是一次深拷贝 - 第三个
lt.push_back(move(s1));
对于s1进行了move
操作,强制其调用右值引用版本,即移动构造
完美转发
万能引用
模板中的&&
不代表右值引用,而是万能引用,其既能接收左值又能接收右值。比如
template<class T>
void PerfectForward(T&& t)
{
//...
}
但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,具体见下面的示例:
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
return 0;
}
我们一共实现了四种Fun函数,分别是左值引用,右值引用,const 左值引用 和 const 右值引用。
然后通过传入不同的左值右值以分别调用其函数,比如,传入右值10
,就应该调用右值引用函数,但是实际上的结果是:
这也就应证了之前说的:右值引用属性本身是个左值、,也就是说右值引用后续使用中都退化成了左值
所以这里就需要用到完美转发了!
完美转发
模板中的&& 万能引用
要想在参数传递过程中保持其原有的属性,需要在传参时调用forward函数。比如:
template<class T>
void PerfectForward(T&& t)
{
Func(std::forward<T>(t));
}
在使用完完美转发后,当PerfectForward
函数传入的是右值时,就不会退化为左值,而是匹配到右值引用的Func
函数中,传入左值时,类似。
完美转发的使用场景
下面实现了一个简化版本的list:
template<class T>
struct ListNode
{
ListNode* _next = nullptr;
ListNode* _prev = nullptr;
T _data;
};
template<class T>
class List
{
typedef ListNode<T> Node;
public:
List()
{
_head = new Node;
_head->_next = _head;
_head->_prev = _head;
}
void PushBack(T&& x)
{
//Insert(_head, x);
Insert(_head, std::forward<T>(x));
}
void PushFront(T&& x)
{
//Insert(_head->_next, x);
Insert(_head->_next, std::forward<T>(x));
}
void Insert(Node* pos, T&& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = std::forward<T>(x); // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
void Insert(Node* pos, const T& x)
{
Node* prev = pos->_prev;
Node* newnode = new Node;
newnode->_data = x; // 关键位置
// prev newnode pos
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = pos;
pos->_prev = newnode;
}
private:
Node* _head;
};
int main()
{
List<qq::string> lt;
lt.PushBack("1111");
lt.PushFront("2222");
return 0;
}
代码执行过程
在 main
函数中,调用 lt.PushBack("1111");
和 lt.PushFront("2222");
时,字符串字面量 "1111"
和 "2222"
被传递给 PushBack
和 PushFront
。
第一次调用 PushBack("1111")
-
构造
std::string
对象- 字符串字面量
"1111"
被隐式转换为std::string
对象,调用构造函数string(const char* str)
。 - 输出:
string(const char* str = ) -- 构造函数
- 字符串字面量
-
调用
Insert
方法- 在
Insert
中,std::forward<T>(x)
将x
作为右值传递给newnode->_data
。 - 由于
newnode->_data
是std::string
类型,右值绑定到newnode->_data
时,会调用移动构造函数(如果存在)。 - 输出:
string(const char* str = ) -- 构造函数
(用于创建newnode
的_data
)
- 在
-
移动赋值
- 在
Insert
方法中,newnode->_data
的赋值可能会触发移动赋值操作(如果std::string
的实现中有这样的逻辑)。 - 输出:
string& operator=(string&& s) -- 移动赋值
- 在
第二次调用 PushFront("2222")
-
构造
std::string
对象- 字符串字面量
"2222"
被隐式转换为std::string
对象,调用构造函数string(const char* str)
。 - 输出:
string(const char* str = ) -- 构造函数
- 字符串字面量
-
调用
Insert
方法- 同样,
std::forward<T>(x)
将x
作为右值传递给newnode->_data
。 - 由于
newnode->_data
是std::string
类型,右值绑定到newnode->_data
时,会调用移动构造函数(如果存在)。 - 输出:
string(const char* str = ) -- 构造函数
(用于创建newnode
的_data
)
- 同样,
-
移动赋值
- 移动赋值操作可能再次被触发。
- 输出:
string& operator=(string&& s) -- 移动赋值
引用折叠
这里需要介绍一下引用折叠:
引用折叠是C++
中的一种规则,涉及到如何处理引用的组合。具体来说,当我们在模板中使用引用时,可能会出现引用的引用。`C++ 规定了引用的折叠规则:左值引用 + 左值引用: 结果为左值引用(T& & 折叠为 T&)。
左值引用 + 右值引用: 结果为左值引用(T& && 折叠为 T&)。
右值引用 + 左值引用: 结果为左值引用(T&& & 折叠为 T&)。
右值引用 + 右值引用: 结果为右值引用(T&& && 折叠为 T&&)。完美转发的机制是通过结合万能引用、引用折叠和 std::forward 来实现的。std::forward 的工作原理依赖于引用折叠的规则,以确保在转发参数时能够正确处理左值和右值。