左值右值的一点总结

再次来写左值右值相关的东西我的内心是十分惴惴不安的,一来这些相关的概念十分不好理解,二来网上相关的文章实在太多了,多少人一看这类题目便大摇其头,三来也怕说不清反而误导了别人,反复纠缠这些似乎无关大雅的语言细节实在也有成为 language lawyer 之嫌。但我还是决定再总结一次,因为这是我一直以来学习新东西的一种方式,只有把学到的东西真正写清楚说明白了才是真的理解了,再者也希望自己的经验总结能帮助到有同样困惑的人。

左值右值

我们一直说左值右值,从 c++ 的术语角度来看,这其实并不十分准确,确切地说应该是左值表达式,右值表达式:表达式是有值的,值是有类型的,值是动态的,类型是静态的,这是基本的概念。而我们说的左值右值,是对值的一种分类,这两个称呼也是从 c 时代遗传下来的:左值是指能出现在等号左边的值,右值是指只能出现在等号右边的值。简单来说,左值就是我们平时定义的变量,右值就是一些临时变量。但到了 c++11,这个分类被细化扩充了,如下所示[N3690,3.10.1]:

因此:

一个表达式要么是一个 glvalue(generalized lvalue),要么是一个 rvalue;一个 glvalue 要么是一个 lvalue,要么是一个 xvalue(expiring value);一个 rvalue 要么是一个 xvalue,要么是一个 prvalue (pure rvalue)。

乍一看好像情况变得好复杂,其实不是,图中所说 lvalue 与 c 时代的 lvalue 几乎表达一个意思(不妨称为纯左值),prvalue 与 c 时代的 rvalue 也几乎表达一个意思,所谓纯右值 (pure rvalue),只是多了一个 xvalue,一个介于纯左传与纯右值之间的奇怪物种。本质上来说,xvalue 是 c 时代的 lvalue,它不是中间变量临时变量之类的没有名字的纯右值,之所以再创造出这样一个新的值类型是因为有些时候,我们希望能够将一个纯左值当成临时变量(纯右值)一样来使用,这种被当成纯右值来使用的左值就是 xvalue,说起来很绕,本质上 xvalue 就是一些从程序逻辑上看要 "过时" 的变量(expiring value),它的名字也正是取义自这里。

xvalue 只能通过两种方式来获得,这两种方式都涉及到将一个左值赋给(转化为)一个右值引用[N3690,3.10.1]:

  1. 强制类型转化为右值引用,如 static_cast<T&&>(t); 该表达式得到一个 xvalue。
  2. 返回类型为右值引用的函数调用,如, T&& fun() { return t; };, 则调用 fun() 时, 返回一个 xvalue。

对于第 2 点,有一个与之类似的写法值得大家注意:如果一个函数的返回类型是左值引用,那么调用这个函数得到的返回值将是一个 lvalue[N3690,3.10.1],之所以特别地说这个事,是因为如果一个函数的返回值不是引用类型,那调用这个函数得到的结果将是一个临时变量,是个右值,而且是纯右值(prvalue),嗯,不要搞昏了。

值与引用

这是另外两个容易混为一谈的概念,引用在 c++ 里是一个很特别的东西,就我的理解,确切地说引用应是一种类型,和 int, float 等类似,比如说 T& t = v;, 则 t 是一个变量,它的类型是引用,指向一个左值,因此也称为左值引用,所以 t 本身是一个左值,类型是一个左值引用。与此相对应,我们也有右值引用: T&& t2 = fun();,同样的 t2 是一个左值,但它的类型是右值引用。所以,我们平时说的"引用"确切来说应该称作"引用变量"才准确,与"整型变量","字符变量"相对(N3690, 8.5.3.1)。当然引用变量这个说法比较牵强(甚至有些政治不正确),毕竟它和一般变量相比实在太不相似了,比如它一般不占内存,比如对它取地址,得到的不是变量本身的地址,比如定义时必须初始化,且不能再次被赋值等等特殊之处,怎么看都是异类。很多人倾向于把引用与指针并论来理解,它们其实也不一样,虽然实现上基本可以认为引用是一个由编译器自动帮你解引用(deference)的指针

需要注意的是,左值引用变量只能用左值(lvalue)来初始化,右值引用变量只能用右值(xvalue 及 prvalue)来初始化,唯一的例外是 const 类型的左值引用,它既能接受左值,也能接受右值。值得注意的是,引用对临时变量的生命周期是有影响的,如前面所说,临时变量是右值,当一个临时变量被一个引用(const 左值引用或右值引用)指向时,它的生命周期会被延长[N3690,12.2.5]。

关于右值引用,还有一个很容易让人迷惑的语义需要说一说,写法上定义一个右值引用变量的语法如右所示:some_type&& rv_ref = some_rvalue;,但这里要求 somt_type 必须是一个具体的完整的类型,而不能是模板参数,auto,或 decltype 等需要推导的类型,如果 T 是一个需要推导的类型,则 T&& u_t_ref 称为 universal reference 或 forwarding reference,根据 reference collapsing 原则及右值引用推导原则,u_t_ref 最后既可能是左值引用也可能是右值引用,具体可以参看这里[1]。

move 与 forward

接下来是很多人特别关心的 std::move()std::forward(),对这俩恰恰想总结的却不多,总的来说,以我的浅见,std::move() 的主要作用是将一个左值转为 xvalue, 它的实现,本质上就是一个 static_cast<>。而 std::forward() 则是用来配合 forwarding reference 实现完美转发,它主要的作用是将一个类型为引用(左值引用或右值引用)的左值(引用这个变量本身),转化为它的类型所对应的值类型(解引用?),这个说法实在是太无法理解太不知所云了,所以我放弃用自己的话来解释了,请参看 cppreference 上的例子

move 语义

move 语义是一个大家一定要注意,接受,掌握,并理解好的东西。过去使用 c++ 98/03 我们常说 rule of three,即是:如果一个类定义了析构函数,拷贝构造函数,赋值构造函数之一,那么这三个函数都应该要明确定义,目的是为了确保该类的拷贝语义被正确地处理。

到了 c++11,这个 rule of three 得改成 rule of five。我们知道,一个类如果定义了拷贝构造函数和赋值构造函数,则我们称它为 copyable 的类。同理,如果一个类定义了 move constructor 和 move assignment operator,那么我们称它为 movable 的类。从使用上来说,右值引用是一个很 tricky 的东西,它的正确使用场合应该只有两个:一个是为自定义类型实现 move 语义,一个是配合 forwarding reference 来实现完美转发。当你考虑写一个以右值引用作为参数类型的一般函数时,往往这是错误的开始,正确的作法是为相应的类型定义 move 语义,具有 move 语义的类型在作为参数传递时,要么直接传值(sink parameter),要么传 const 左值引用(read only),根本不需要右值引用这种 tricky 的东西.[2]

因此,能够在适当时候为自定义类型实现 move 语义是一个基本素质,就正如以前处理 copy 语义一样(会不会将一个类继承自 boost::noncopyable 也是基本素质)。STL 中所有的容器算法都妥善定义了对适用类型 move 语义的要求,如只适用于 copyable,或只适用于 movable 等,容器本身更都是 movable 的。一般来说,move 是一个更轻量的操作,对容器其实更友好(内部 copy 可以改为 move,效率更高),比如 vector<>,以前要求其所保存类型必须 copyable,现在 c++11 以后,只 movable(且 noexcept)的类型也被允许了(当然此时用户就不能调用那些需要 copy 的操作了)。所以,明确定义好自定义类型的 move 语义,意义是很大的。

引用

  1. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4164.pdf
  2. https://channel9.msdn.com/Events/GoingNative/2013/Cpp-Seasoning
posted on 2015-12-11 16:50  twoon  阅读(7009)  评论(4编辑  收藏  举报