在 C
语言中有两个概念,一个是“左值”,另外一个是“右值”。
赋值表达式语句的目的是把值存储到内存上,其中:
C
只会在提及左右值概念的时候才会提及这个术语)因此,沿着上述思路可以得到左右值得概念。
左值:是可以用于标识或定位存储位置的标签。
但是由于 C
的某次新标准加入了 const
关键字,导致有时会不满足第二条规则,因此左值又根据第二条规则分为了“可修改左值”和“不可修改左值”(后者只能放在赋值符号的右边)。
也就是说,根据第一条判断是否为左值,第二条判断左值是否可以被修改。
右值:是可以赋值给可修改左值的量,且本身不能是左值。右值只能放在右边,也就叫右值。右值无法被赋值,因此不可能在赋值符号的左边。
补充:我们举几个例子来判断左右值
int ex; //创建左值(可修改),已经关联上某个特定内存,可以用 ex 直接访问 int why; //创建左值(可修改),已经关联上某个特定内存,可以用 why 直接访问 int zee; //创建左值(可修改),已经关联上某个特定内存 const int TWO = 2; //创建出左值(不可修改),虽然可以用 TWO 来访问,但是不可以修改 why = 42; //将右值 42 赋值给左值(可修改),右值 42 自己没有办法直接找到 zee = why; //将左值赋给左值 ex = TWO * (why + zee); //(why + zee) 整体是一个右值,不指定某个特定内存,也不能直接给其赋值,该式只是程序计算出来的临时值,计算完后就会被丢弃,无法直接找到对应的地址进行访问
因此左值和右值不能根据其位置是左还是右来简单认定,实际上这两个术语很容易被人所误会。
左值引用可以给左值取别名。左值引用实际就是我们之前使用的普通引用,左值引用只需要是左值即可,可不可以修改无所谓。
补充:但是有一种特殊情况可以让左值给右值取别名,就是使用
const
来引用,也就是说左值引用可以说既可以给左值也可以给右值取别名。int a = 0; int b = 0; const int& c = a + b;//a + b 是右值,但是会先赋给临时变量,由 const int& c 引用,临时对象也是右值,因为没有标识指向临时变量的标识符
右值引用可以给右值取别名。和普通的引用有所区别,使用 &&
来引用。
补充
1
:右值引用对一切左值都无法直接引用,只能引用右值。但使用move()
可引用左值。#include
int main() { int a = 1; const int b = 2; int&& c = 2;//成功 //int&& d = a;//失败 //const int&& e = b;//失败 int&& f = std::move(a);//成功 return 0; } 补充
2
:有的书还会对右值进行区分
- 纯右值:指内置类型的右值
- 将亡值:自定义类型的右值
既然左值引用既可对左值也可对右值引用,那为何需要右值引用呢?还搞出了一个奇怪的 move()
库函数…我们来看下面这一场景您就可以明白了。
可以细化函数参数,区分左值调用和右值调用。
//左值引用缺点
#include
using namespace std;
void Func(const int& x)//使用左值引用
{
cout << "void Func(const int& x)" << x << '\n';
}
int main()
{
int a = 1;
int b = 2;
//下面两个函数是同一种调用,无法区分开来
Func(a);
Func(a + b);
return 0;
}
//右值引用优点
#include
using namespace std;
void Func(const int& x)
{
cout << "void Func(const int& x):" << x << '\n';
}
void Func(int&& x)
{
cout << "void Func(int&& x):" << x << '\n';
}
int main()
{
int a = 1;
int b = 2;
Func(a);//调用了 Func(int& x)
Func(a + b);//调用了 void Func(int&& x),优先走右值引用的接口
return 0;
}
但是为什么要区分细化左右值接口呢?下面我们结合某些场景来讲解。
我们先做一个简单的 string
轮子,演示资源转移的过程,下面代码中,我新增加了一个移动构造。
//资源转移
#define _CRT_SECURE_NO_WARNINGS 1
#include
#include
#include
using namespace std;
namespace limou
{
class string
{
//1.成员函数
public:
//构造函数
string(const char* str = "")
:_size(strlen(str))
, _capacity(_size)
{
cout << "begin: string(const char* str = \"\")" << '\n';
_str = new char[_capacity + 1];
strcpy(_str, str);
cout << "end: string(const char* str = \"\")" << '\n';
}
//拷贝构造
void swap(string& s)
{
::swap(_str, s._str);
::swap(_size, s._size);
::swap(_capacity, s._capacity);
}
string(const string& s)
:_str(nullptr)
{
cout << "begin: string(const string& s) -- 深拷贝" << endl;
string tmp(s._str);
swap(tmp);
cout << "end: string(const string& s) -- 深拷贝" << endl;
}
//移动构造
string(string&& s) noexcept
:_str(nullptr)
{
cout << "begin: string(string&& s) -- 移动拷贝" << endl;
swap(s);
cout << "end: string(string&& s) -- 移动拷贝" << endl;
}
//赋值重载
string& operator=(const string& s)
{
cout << "begin: string& operator=(string s) -- 深拷贝" << endl;
string tmp(s);
swap(tmp);
cout << "end: string& operator=(string s) -- 深拷贝" << endl;
return *this;
}
//析构函数
~string()
{
cout << "begin: ~string()" << endl;
delete[] _str;
_str = nullptr;
cout << "end: ~string()" << endl;
}
//其他函数
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;
}
string operator+(char ch)
{
string cache(*this);
cache += ch;
return cache;
}
//2.成员变量
private:
char* _str;
size_t _size;
size_t _capacity; //不包含最后做标识的\0
};
}
int main()
{
limou::string s1("hello word");//调用构造函数
limou::string ret1 = s1;
limou::string ret2 = move(s1 + '!');//将右值资源转移
//上述代码中 (str1 + '!') 整体是一个右值,
//整个拷贝过程中会出现一个临时变量接受右值,
//如果依旧使用深拷贝,新对象使用新地址
//并且复制这个临时变量的资源。
//这就有一些浪费,因为临时变量最后会死亡。
//为何不直接对这个临时变量做资源转移交给 ret2 呢?
//右值引用就可以做到这一事情。
//如果只用 const 的左值引用,
//则只能左右值都使用同一个深拷贝函数,
//无法区分开做各自的处理
limou::string ret3 = move(s1);//将左值资源转移,那么左值原有资源就会被窃取,转移到 ret3 上,move(s1) 的返回值是右值
return 0;
}
可以看到 move()
结合右值引用可以解决更多资源消耗问题,效率更高。在有些必须拷贝的场景下可以使用 move()
节省资源消耗,比如:函数的非引用返回值,函数返回的时候有临时变量的产生。
补充
1
:在C++ 11
的容器中,还新支持了:
- 支持右值引用相关的插入接口函数
- 移动构造和移动赋值,提高了拷贝的效率
补充
2
:如果接口返回一个自定义类型并且交给自定义变量初始化。
- 在
C++ 98
以前,原本是需要两次拷贝构造(中间有临时变量),如果编译器有优化,会把这种情况优化为一次拷贝构造。- 而如果是在
C++ 11
中,则需要两次移动构造(中间有临时变量),如果编译器有优化,会把这种情况优化为一次移动构造,这种情况编译器会尝试把返回值的左值识别为右值(相当于将返回变量move()
了一下),失败则不优化(失败是因为有些左值不能被轻易move()
)。
有了上述的优化例子,就无需担心函数返回值的大量深拷贝了,让我们来看看一个实际优化例子,之前我们做过一题关于杨辉三角的问题,返回的是一个vector
的vector
,直接返回就需要多次的拷贝构造,这个时候就可以使用资源移动优化。
#include
#include
using namespace std;
class Solution
{
public:
vector<vector<int>> generate(int numRows)
{
vector<vector<int>> vv(numRows);
for (int i = 0; i < numRows; i++)
{
vv[i].resize(i + 1, 1);
}
for (int i = 2; i < numRows; i++)
{
for (int j = 0; j < i; j++)
{
vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
}
}
return vv;
}
};
注意:不要轻易对左值进行转移,否则窃取后,原来的左值无法被正常使用。
另外,STL
的容器增加了支持右值引用的插入接口,比如:list
的push_back()
,就有push_back(const value_type& val);
和push_back(value_type&& val);
,那么为什么需要加入这个右值引用接口呢?
假设list
容器中存储了一些string
的字符串,在插入的时候,有左值插入,也有右值插入。前者是调用了深拷贝的拷贝构造,后者是调用了右值引用的移动构造,这样就可以减少一些深拷贝的消耗。
list<string> li;
li.push_back("hello word");//这样编译器会先构造一个临时对象,再去移动插入
li.push_back(string("hello word"));//这里是我们手动创建的一个匿名对象,也会调用移动插入
左值引用和右值引用都是为了减少拷贝的优化,前者是直接使用,后者是间接转移。
补充:右值是不可以直接取地址的,只是一个临时值,但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说会有下面代码的例子。
#include
using namespace std; int main() { int&& a = 0;//右值引用 int* pa = &a; cout << *pa << '\n'; *pa = 10; cout << *pa << '\n'; return 0; } 如果不希望右值被修改,可以使用
const
右值引用:#include
using namespace std; int main() { const int&& a = 0;//右值引用 const int* pa = &a;//取得到地址 cout << *pa << '\n'; //*pa = 10;//赋值失败了 return 0; } 所以右值引用后的别名成为左值(属性被修改了),这也就是上面我们造
string
“轮子”中,添加的移动构造却能使用接口void swap(string& str)
的原因。
在库中还会出现在移动赋值,也就是赋值运算符重载的移动赋值,也可以减少拷贝消耗。
总结:移动语义实际上就是指”移动构造“和”移动赋值“。
模板中的&&
不再代表右值引用,而是万能引用/引用折叠(内部会进行一系列的推导),既能接受左值,也能接受右值。
#include
using namespace std;
template<typename T>
void Func(T& t)
{
cout << "You can see me." << '\n';
}
template<typename T>
void Print(T&& t)//折叠的意思就是传左值的时候这里的 && 变成 &
{
Func(t);
}
int main()
{
int a = 0;
const int b = 10;
Print(a); //左值
Print(10); //右值
Print(b); //const 左值
Print(move(b)); //const 右值
return 0;
}
并且我们注意到,右值引用后的别名已经成为左值属性,也就是“属性丢失”,这是为了符合以往的语法特性:比如上面我们造string
“轮子”中,添加的移动构造却能使用接口void swap(string& str)
,导致C++
如此设计。
也就是说给右值取别名后会导致右值被存储到某一个位置,并且还有别名作为标识名,这就可以对别名进行取地址了,也就是具有左值的属性。但是这一特性有时会很坑,导致接受到右值后,属性被更改为左值属性,而我们在类内部原本想使用右值的接口,变成了调用了左值的接口。
补充:“折叠”的意思就是将
&&
变成&
,因此类可以根据情况选择折叠或者不折叠。
那怎么保持原有的属性呢?这个知识就叫完美转发forword
,该接口可以保持数据原有的左右值属性,从而解决错误调用接口的问题。
#include
using namespace std;
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(std::forward<T>(t));
}
int main()
{
PerfectForward(10);
int a;
PerfectForward(a);
PerfectForward(std::move(a));
const int b = 8;
PerfectForward(b);
PerfectForward(std::move(b));
return 0;
}
不可避免的情况下,会有很多层嵌套和复用,导致属性会在某一层发生改变,因此如果为了保证调用正确,每套一层就必须使用完美转化来正确调用接口。
这样,使用完美转发就可以控制变量的左右属性,等到该变量需要资源转移时,才调用右值引用接口,在调用内部更改属性为左值,方便资源转移。
后续补充…
由于C++ 11
中右值引用的加入,类也需要进行升级,新增了移动构造和移动赋值,但是形成默认的移动构造和移动赋值的条件不如前六个成员函数那么宽松,比较苛刻一些。
用户没有自己实现移动构造函数,且没有实现析构、拷贝构造、拷贝赋值重载中的任意一个,那么编译器就会自动生成一个默认的移动构造。
条件为什么这么苛刻呢?因为如果用户实现了上述三个接口,就证明很有可能需要对对象进行深拷贝,这种情况下就需要用户自己决定是否实现移动构造。
如果用户没有自己实现移动赋值,且没有实现析构、拷贝构造、拷贝赋值重载中的任意一个,那么编译器就会自动生成一个默认的移动赋值。
而条件苛刻的原因,和上面的移动构造是一样的。
注意:若明确某个类无需某个成员函数,则不要擅自书写该成员。