本文不做正确性担保,仅仅作为作者学习C++路上的小总结。如果有不完备之处还请各位读者能够批评指正。

C++98的世界

这个时候的世界很简单,值类别只有lvaluervalue

这么称呼的历史原因是因为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+1n-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析构,然后直接把右值的内存memcpya里就行了。

也就是说,我们需要一个变量的移动语义。

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纯右值。

prvaluexvalue之所以区别开还有个原因是因为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++值类别体系

C++ | Value Categories

xvalueprvalue统称rvalue

lvaluexvalue统称glvalue

C++98的值类型和C++11的值类型对应关系

lvalue->lvalue

rvalue->prvalue

xvalue在C++98没有对应

lvaluervalue的定义并没有改变

具体细节参见

移动构造函数和复制构造函数

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::forwardstd::move只做了一个类型转换,所以开销可以忽略不计。