【c++工程实践】值语义与右值引用

一、值语义

值语义(value sematics)指的是对象的拷贝与原对象无关,C++ 的内置类型(bool/int/double/char)都是值语义,标准库里的 complex<> 、pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱离关系。对一个具有值语义的原始变量变量赋值可以转换成内存的bit-wise-copy

对于用户定义的类,如果一个type X 具有值语义, 则:

1)X 的size在编译时可以确定。

2)将X的变量x,赋值与另一个变量y,无须专门的 = operator,简单的bit-wise-copy 即可。

3)当上述赋值发生后,x和y脱离关系:x 和 y 可以独立销毁, 其内存也可以独立释放。

 1 了解第三点很重要,比如下面的class A就不具备值语义:
 2 class A
 3 {
 4      char * p;
 5      public:
 6           A() { p = new char[10]; }
 7           ~A() { delete [] p; }
 8 };
 9 A 满足1和2,但不满足3。因为下面的程序会出错误: 
10 Foo()
11 {
12     A a;
13     A b = a;
14 } // crash here

与值语义对应的是“对象语义/object sematics”,或者叫做引用语义(reference sematics),引用语义指的是面向对象意义下的对象,对象拷贝是禁止的。拷贝 TcpConnection 对象也没有意义,系统里边只有一个 TCP 连接,拷贝 TcpConnection  对象不会让我们拥有两个连接。

C++ 要求凡是能放入标准容器的类型必须具有值语义。准确地说:type 必须是 SGIAssignable concept 的 model。但是,由 于C++ 编译器会为 class 默认提供 copy constructor 和 assignment operator,因此除非明确禁止,否则 class 总是可以作为标准库的元素类型——尽管程序可以编译通过,但是隐藏了资源管理方面的 bug。因此,在写一个 class 的时候,先让它继承 boost::noncopyable,几乎总是正确的。

在现代 C++ 中,一般不需要自己编写 copy constructor 或 assignment operator,因为只要每个数据成员都具有值语义的话,编译器自动生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 为成员来持有其他对象,那么就能自动启用或禁用 copying&assigning

t2 = t1;  // calls assignment operator, same as "t2.operator=(t1);"
Test t3 = t1;  // calls copy constructor, same as "Test t3(t1);"

 值语义用于控制对象的生命期,而其具体的控制方式分为两种:

    • 生命期限于scope内:无需控制,到期自动调用析构函数。
    • 需要延长到scope外:移动语义。

因为右值引用的目的在于实现移动语义,所以右值引用 意义即是加强了值语义对对象生命期的控制能力。

二、左值、右值

左值对应变量的存储位置,而右值对应变量的值本身。左值与右值的根本区别在于是否允许取地址&运算符获得对应的内存地址.

作者:顾露
链接:https://www.zhihu.com/question/39846131/answer/85277628
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

来自 Scott Meyers 的方法

判断表达式是否是左值,有一个简单的办法,就是看看能否取它的地址,能取地址的就是左值。

A useful heuristic to determine whether an expression is an lvalue is to ask if you can take its address. If you can, it typically is. If you can’t, it’s usually an rvalue. A nice feature of this heuristic is that it helps you remember that the type of an expression is independent of whether the expression is an lvalue or an rvalue.
-- "Effective Modern C++", Introduction - Terminology and Conventions, Scott Meyers


来自 Joseph Mansfield 的方法

理解下面的几句话就可以了,顺带也清楚地表达了 std::move 和 std::forward 的区别和联系

  • 左值可看作是“对象”,右值可看作是“值” (Lvalues represent objects and rvalues represent values)
  • 左值到右值的转换可看做“读出对象的值” (Lvalue-to-rvalue conversion represents reading the value of an object)
  • std::move 允许把任何表达式以“值”的方式处理 (allows you to treat any expression as though it represents a value)
  • std::forward 允许在处理的同时,保留表达式为“对象”还是“值”的特性 (allows you to preserve whether an expression represented an object or a value)

那么这里的“对象” (object) 和 “值” (value) 是什么意思呢?任何一个有价值的 C++ 程序都是如此:a) 反复地操作各种类型的对象 b) 这些对象在运行时创建在明确的内存范围内 c) 这些对象内存储着值。 (Every useful C++ program revolves around the manipulation of objects, which are regions of memory created at runtime in which we store values.)

这一句话的解释实际上就指向了上面的第一条方法——只有特定的内存区域才可以被取地址。

三、右值引用

3.1 左值引用

引用底层是用const指针实现的,分配额外的内存空间。

准确地说

1 int b=0;
2 int &a=b;

这种情况等同于

1 int b=0;
2 int *const lambda=&b;
3 //此后 *lambda就完全等价于上面的a

通过简单的例子:

 1 int test_lvalue() {
 2   int b = 0;
 3   int& rb = b;
 4   rb = 1;
 5 
 6   return b;
 7 }
 8 
 9 int test_pointer() {
10   int b = 0;
11   int* pb = &b; 
12   *pb = 1;
13 
14   return b;
15 }

=> g++ -g -O0 test.cc

=> objdump -d -S a.out > test.i

 1 int test_pointer() {
 2   400876: 55                    push   %rbp
 3   400877: 48 89 e5              mov    %rsp,%rbp
 4   int b = 0;
 5   40087a: c7 45 f4 00 00 00 00  movl   $0x0,-0xc(%rbp)
 6   int* pb = &b; 
 7   400881: 48 8d 45 f4           lea    -0xc(%rbp),%rax
 8   400885: 48 89 45 f8           mov    %rax,-0x8(%rbp)
 9   *pb = 1;
10   400889: 48 8b 45 f8           mov    -0x8(%rbp),%rax
11   40088d: c7 00 01 00 00 00     movl   $0x1,(%rax)
12 
13   return b;
14   400893: 8b 45 f4              mov    -0xc(%rbp),%eax
15 }
16   400896: c9                    leaveq 
17   400897: c3                    retq  
 1 int test_lvalue() {
 2   400854: 55                    push   %rbp
 3   400855: 48 89 e5              mov    %rsp,%rbp
 4   int b = 0;
 5   400858: c7 45 f4 00 00 00 00  movl   $0x0,-0xc(%rbp)
 6   int& rb = b;
 7   40085f: 48 8d 45 f4           lea    -0xc(%rbp),%rax
 8   400863: 48 89 45 f8           mov    %rax,-0x8(%rbp)
 9   rb = 1;
10   400867: 48 8b 45 f8           mov    -0x8(%rbp),%rax
11   40086b: c7 00 01 00 00 00     movl   $0x1,(%rax)
12 
13   return b;
14   400871: 8b 45 f4              mov    -0xc(%rbp),%eax
15 }
16   400874: c9                    leaveq
17   400875: c3                    retq   

通过汇编指令,我们也可以看到,左值引用和指针在汇编层面是一致的。

3.2 右值引用

C++中右值可以被赋值给左值或者绑定到引用。类的右值是一个临时对象,如果没有被绑定到引用,在表达式结束时就会被废弃。于是我们可以在右值被废弃之前,移走它的资源进行废物利用,从而避免无意义的复制。被移走资源的右值在废弃时已经成为空壳,析构的开销也会降低。

右值中的数据可以被安全移走这一特性使得右值被用来表达移动语义。以同类型的右值构造对象时,需要以引用形式传入参数。右值引用顾名思义专门用来引用右值,左值引用和右值引用可以被分别重载,这样确保左值和右值分别调用到拷贝和移动的两种语义实现。对于左值,如果我们明确放弃对其资源的所有权,则可以通过std::move()来将其转为右值引用。

std::move

std::move()实际上是static_cast<T&&>()的简单封装。

1 template <typename T>
2 decltype(auto) move(T&& param)
3 {
4    using return_type = std::remove_reference<T>::type&&;
5    return static_cast<return_type>(param);
6 }

我们可以看见这里面的逻辑其实是无论你的param是何种类型,都会被强制转为右值引用类型。

这里需要注意的是模版这里的T&& 类型,我愿意欣赏与接受Meyers的叫法,他把这样的类型叫做Universal Reference。对于Universal Reference来说,若你传递的param是一个左值,那么T将会被deduce成Lvalue Reference(左值引用),其Param Type也是左值引用。若你传递进来的param是右值,那么T则是正常的param类型,如int等,其Param Type结果是T&&。

举一个简单的栗子

1 template<typename T>
2 void foo(T&& param);
3 
4 int i = 7;
5 foo(i);
6 foo(47);

i是一个左值,于是T被deduce成int&,于是变为了

1 foo(int& &&);

而整个参数的结果类型,即Param Type为int&,C++不允许reference to reference,会进行引用折叠,其规则为:

1.当类型推导时可能会间接地创建引用的引用,此时必须进行引用折叠。具体折叠规则如下:

    A. X& &、X& &&和X&& &都折叠成类型X&。即凡是有左值引用参与的情况下,最终的类型都会变成左值引用。

    B. 类型X&& &&折叠成X&&。即只有全部为右值引用的情况才会折叠为右值引用。

2.引用折叠规则暗示我们,可以将任意类型的实参传递给T&&类型的函数模板参数。

而对于foo(47),由于47是右值,那么T被正常的deduce成int,于是变为了

1 foo(int &&);

std::forward

对于forward,其boost的实现基本可以等价于这样的形式:

1 template <typename T>
2 T&& forward(typename remove_reference<T>::type& param)
3 {
4     return static_cast<T&&>(param);
5 }

那么这里面是如何达到完美转发的呢?

举一个栗子

1 template<typename T>
2 void foo(T&& fparam)
3 {
4     std::forward<T>(fparam);
5 }
6 
7 int i = 7;
8 foo(i);
9 foo(47);

如上文所述,这里的i是一个左值,于是,我们在void foo(T&& fparam)这里的话,T将会被deduce成int& 然后Param Type为int&。(注意,我这里使用的变量名字为fparam,以便与forward的param进行区分)

那么为什么Param Type会是int&呢?因为按照正常的deduce,我们将会得到

1 void foo(int& &&fparam);

先前我简单的提到了一句,C++不允许reference to reference,然而事实上,我们却会出现

Lvalue reference to Rvalue reference[1],

Lvalue reference to Lvalue reference[2],

Rvalue reference to Lvalue reference[3],

Rvalue reference to Rvalue reference[4]

等四种情况,那么针对这样的情况,编译器将会根据引用折叠规则变为一个Single Reference,那么是左值引用还是右值引用呢?其实这个规则很简单,只要有一个是左值引用,那么结果就是左值引用,其余的就是右值引用。于是我们知道了[1][2][3]的结果都是左值引用,只有[4]会是右值引用,而要从

1 void foo(T&& fparam)

这里T的Universal Reference让fparam拥有右值引用类型,那么则需要保证传递归来的参数为右值才可以,因为若是左值的话,T会deduce成左值引用,结合引用折叠规则,fparam的类型会是左值引用类型。

于是我们现在来看,int& &&这样的情况属于Lvalue reference to Rvalue reference,结果则为左值引用。那么,我们这个时候带入到forward函数来看看,首先是T变为了int&,经过了remove_reference变为了int,结合后面跟上的&,则变为了int&。然后我们再次替换 static_cast和return type的T为int&,都得到了int& &&

1 int& && forward(int& param)
2 {
3     return static_cast<int& &&>(param);
4 }

于是再应用引用折叠规则,int& &&都划归为了int&

1 int& forward(int& param)
2 {
3     return static_cast<int&>(param);
4 }

于是,我们可以发现我们fparam变量的左值引用类型被保留了下来。这里也需要注意,我们到达forward的时候就已经是左值引用了,所以forward并没有改变什么。

如我们这时候是47这样的右值,我们知道了T会被deduce成int,经过了remove_reference,变为了int,跟上后面的&,成为了int&,然后再次替换static_cast和返回类型的T为int&&

1 int && forward(int& param)
2 {
3     return static_cast<int&&)(param);
4 }

于是,我们也可以发现,我们fparam变量的右值引用类型也完美的保留了下来。

原文地址:https://www.cnblogs.com/ym65536/p/9201236.html