本文不做正确性担保,仅仅作为作者学习C++路上的小总结。如果有不完备之处还请各位读者能够批评指正。
C++98的世界
这个时候的世界很简单,值类别只有lvalue
和rvalue
这么称呼的历史原因是因为lvalue
总是出现在等号左边,rvalue
只出现在等号右边
int a;
a=242;
//a is lvalue
//242 is rvalue
lvalue
左值
lvalue
的意思是能定位的表达式,人话讲就是可以取地址&
我们先只考虑基本类型
- 任何带名字的表达式
int a=42;
&a;//合法
int& b=a;
&b;//合法
const int& c=1245;
&c;//合法
struct D{int a};
D d;
&d.a;//合法
- 任何左值引用
int& add(int &a,int b){//return value is not named
//but it's lvalue reference
return a+=b;
}
int a=0;
add(a,25)*=2;//lvalue
- 解引用
int *a=new int(24);
&*a;//合法
- 任何存储期为静态的值
static int x=15;//lvalue
struct D{
static int c;//lvalue
};
int D::c=125;
然后考虑struct
的成员变量,数组,以及其他的各种东西,这里定义这种行为为对值的衍生
(非规范称呼)
- 有且只有左值的衍生是左值
struct D{int a};
&(D().a);//illegal
int a[23];
&(a[2]);//legal
vector<int>(10,1)[2]//legal
rvalue
右值
任何不能取地址的值都是rvalue
值的生命周期
rvalue
的生命周期
rvalue
通常是短暂的,除了绑定给const T&
可以确保延长rvalue
的生命周期。
{
const int& a=2145;//2145's lifetime is the same as lvalue a
std::cout << a << std::endl;//using a
}//destruct a
lvalue
的生命周期
非引用lvalue
的生命周期
非引用lvalue
的生命周期有两种
- RAII式
{
int a=124;//construct a
std::cout << a << std::endl;//using a
}//destruct a
- new/delete动态内存管理
int *a=new int;//construct a
/*
...lots of works
*/
delete a;//destruct a
引用lvalue
的生命周期
一般引用内部实现是指针(或者被优化掉),内部实现遵循RAII式,外部观察其不改变其绑定对象的生命周期,除非是rvalue
绑定到const T&
(见rvalue
的生命周期)。
函数内部对象的生命周期
这是一个经典的含有悬垂指针
问题的函数
vector<int>& max(int n){
vector<int> v;
for (int i=0;i<n;i++) v.push_back(i);
return v;
}
我们可以发现函数内部定义的对象生命周期均在函数内部,如果函数返回内部定义对象的引用,会导致引用对象不复存在。
接下来介绍另一个稍微复杂一点的悬垂引用
的例子
template<class T>
const T& max(const T& a, const T& b){
return (a < b) ? b : a;
}
int main(){
int n=24;
const int& x=max(n-1,n+1);
std::cout << x << std::endl;//Dangling reference
}
根据上文《rvalue
的生命周期》可知,n+1
和n-1
两个右值分别绑定到const int&
传入了max,此时他们的生命周期延长到max
结束,然后在将引用赋值x
完成后,两个右值当即销毁,x
此时成了悬垂引用。
C++11的世界
移动语义提出的背景
假定我们现在有一个vector
,如果现在构造了另一个vector
想要赋值给他
vector<int> a(100,0);
//...lot of works
a=vector<int>(100,1);
在C++98中,这样会先调用vector
构造函数,然后对a调用复制构造函数,然后再对右值调用析构函数。
我们发现这多出来的一个复制显然可以避免——我们只需要先让a析构,然后直接把右值的内存memcpy
到a
里就行了。
也就是说,我们需要一个变量的移动语义。
而C++11
的移动语义实现,就是以右值引用为代表的一套体系。
为什么移动语义只能是这种体系?
上文提到,我们需要表示一个值可移动
。但是在维持现在的类型体系下不可能做到这一点,我们必须要创建一类新的类型表达可以移动的语义。
也就是右值引用
。
vector<int> a(100,0);
//...lot of works
a=(vector<int>&&)vector<int>(100,1);//one move,one destruct
vector<int> a(100,0),b(100,1);
//...lot of works
a=(vector<int>&&)b;//one move
struct probe{
int a;
probe(){cout<< ("empty construct\n");}
probe(probe&& a){cout<<("move construct\n");}
probe(const probe& a){cout<<("copy construct\n");}
probe& operator=(probe&& a){cout<<("move =construct\n");return *this;}
probe& operator=(const probe& a){cout<<("copy =construct\n");return *this;}
~probe(){cout<<("deconstruct\n");}
};
probe a;
a=probe();//Expect call "probe& operator=(probe&& a)"
//rvalue bind to rvalue reference first
namespace std{
template< class T >
typename std::remove_reference<T>::type&& move( T&& t ) noexcept{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
}
vector<int> a(100,0),b(100,1);
//...lot of works
a=move(b);//is same as below
a=(vector<int>&&)b;
但我们发现,移动并不是万能的
string a;
a=(string&&)a;//WTF
为了避免这种情况,可被移动的值应该能够确认之间的等同性(能直接或间接得到地址)。
根据我们之前的定义,rvalue
都可被移动但是不能得到地址。什么时候rvalue
能得到地址呢?在他绑定为右值引用时
struct probe{
int a;
probe(){cout<< ("empty construct\n");}
probe(probe&& a){cout<<("move construct\n");}
probe(const probe& a){cout<<("copy construct\n");}
probe& operator=(probe&& a){cout<<"move =construct:"<<(&a)<<endl;;return *this;}//OK!
probe& operator=(const probe& a){cout<<"copy =construct:"<<(&a)<<endl;return *this;}
~probe(){cout<<("deconstruct\n");}
};
probe a;
a=probe();
我们解决了这个问题
我们让所有类型都可以转换为其对应的右值引用
,将右值引用赋值给左值的行为定义为移动...真的吗?
右值显然是可移动的,因此右值可以绑定到右值引用,如果将右值引用赋值给左值的行为定义为移动的话...
string&& a="12512";
string b=a;//move or copy?
我们期望应该是复制,因为此时的a
是一个能被取地址的左值
所以右值引用
!=可被移动
,但是我们又需要右值引用来表达可被移动,那我们用什么来表示move(x)
这种可被移动的标记呢。
xvalue
亡值的引入
我们发现,std::move(x)
和(T&&)x
是可以被移动的。
string a="245125";
string b=move(a);//move(a) is xvalue
string c=(string&&)a;//(string&&)a is xvalue
除此之外,其衍生值(数组,成员变量)也应该是可以动的
vector<string> s={"125","215"};
string b=move(s)[0];//call move
这些标记为可被移动的值,不同于C++98的rvalue
,暗示他们必须死亡,因此被称作xvalue
亡值。xvalue
一定是可移动的。C++98中的rvalue
一定也是可移动的。
而根据(2,他们应该不能取地址。这符合rvalue
的定义。因此xvalue
归属于rvalue
,我们将C++98
时的rvalue
称作prvalue
纯右值。
prvalue
和xvalue
之所以区别开还有个原因是因为prvalue
的动态类型严格等于其静态类型,而xvalue
则没有这个限制。
动态类型cppreference
若某个泛左值表达式指代某个多态对象(含有虚函数的类的对象),则其最终派生对象的类型被称为其动态类型。
// 给定 struct B { virtual ~B() {} }; // 多态类型 struct D: B {}; // 多态类型 D d; // 最终派生对象 B* ptr = &d; // (*ptr) 的静态类型为 B // (*ptr) 的动态类型为 D
对于纯右值表达式,动态类型始终与静态类型相同。
为什么prvalue
的动态类型一定与静态类型相同呢。如果不相同的话,当销毁prvalue
的时候,此时栈空间应该pop,pop多少呢?编译期无法确定。所以prvalue
的动态类型一定与静态类型相同。
那为什么xvalue
的动态类型可以与静态类型不同呢?
因为xvalue
是以右值引用及其衍生,不改变所指变量的原本生命周期
ostream os;
{
ofstream os1("a.cpp");//construct os1
os=move(os1);//os1 is in invalid status
}//destruct os1
//destruct os,close file "a.cpp"
这意味着在栈上顶多存一个指针,而不是整个完整对象,这样避免了对栈空间的破坏。
现在的C++值类别体系
xvalue
和prvalue
统称rvalue
lvalue
和xvalue
统称glvalue
C++98的值类型和C++11的值类型对应关系
lvalue
->lvalue
rvalue
->prvalue
xvalue
在C++98没有对应
lvalue
和rvalue
的定义并没有改变
具体细节参见
移动构造函数和复制构造函数
struct probe{
probe(){cout<< ("empty construct\n");}
probe(probe&& a){cout<<("move construct\n");}
probe(const probe& a){cout<<("copy construct\n");}
~probe(){cout<<("deconstruct\n");}
};
当rvalue
右值传入构造函数时,优先绑定/调用probe(probe&&)
当lvalue
左值传入构造函数时,优先绑定/调用probe(probe&)
cv限定不影响上面的规则
无法定义probe(probe)
构造函数
C++17的世界
在C++11
中,所有prvalue
都是可以移动的,但是可移动需要能获取地址,从prvalue
无法得到地址,只有可能在绑定到右值引用
时,从右值引用获取地址。
C++17
看这个弯道不爽,因此严格定义了这个过程,命名为临时量实质化
临时量实质化隐式转换
任何完整类型
T
的纯右值,可转换为同类型T
的亡值。此转换以该纯右值初始化一个 T 类型的临时对象(以临时对象作为求值该纯右值的结果对象),并产生一个代表该临时对象的亡值。 若T
是类类型或类类型的数组,则它必须有可访问且未被弃置的析构函数
简单来说,现在只有xvalue
可被移动了,prvalue
无法移动。
提出这个概念也是为了对复制消除
做完善,但是这超出了本文的范畴。
转发引用和引用折叠和完美转发
在漫长的实践中发现,我们经常需要调用函数时保持传入参数的值类别(lvalue
,rvalue
)
现有的体系下很难做到这一点,于是就有了标题所描述的一个方法
转发引用和引用折叠
忽略cv限定,在模板参数定义为T&&
时,此为转发引用
。
当转发引用
遇到右值时,T
会推导成无引用P
,参数类型表现为P&&
当转发引用
遇到左值时,T
会推导成左值引用P&
,参数类型表现为P& &&
,发生引用折叠,实际为P&
我们成功的区分了左值和右值
template<class T>
void func(T&& a){
}
int a=24;
func(124);//call func(T&& a)[T=int]
func(a);//call func(T&& a)[T=int&]
//same as func(int &a)
完美转发
我们直接来看std::forward
的实现与使用
//in namespace std
template <class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept{
return static_cast<T&&>(t);
}
//out namespace std
void add(int& a){cout<<"is lvalue"<<endl;}
void add(int&& a){cout<<"is rvalue"<<endl;}
template<class T>
void func(T&& a){
add(forward<T>(a));
}
int main(){
int a;
func(0);
func(a);
}
/*
when using lvalue call func,T is int&,forward will cast target lvalue of `int&& a` to `int&`,then will call `add(int& a)`
when using rvalue call func,T is int,forward will cast target lvalue of `T&& a` to `T&&`,then will call `add(int&& a)`
*/
std::forward
和std::move
只做了一个类型转换,所以开销可以忽略不计。