技术: C++11 右值相关问题探讨

这里涉及到了至少两个问题, 移动语义和完美转发; 最后还说了RVO问题

本文集中说说和右值相关的所有问题, 右值&左值, 移动语义, 完美转发, 非const引用; 以及引入移动语义的利弊.

基本概念

这里要总结一下, 左值, 右值, 左值引用等.

左值(lvalue)表示一个可被标识的(变量或对象的)内存位置,并且允许使用&操作符来获取这块内存的地址。

如果一个表达式不是左值,那它就被定义为右值。

左值转换为右值:

一般上讲,对象之间的运算,对象是以右值的形式参与的;那些表示数组、函数和非完整类型的左值是不能转换为右值的,因为无法对那些类型进行求值

例如下面的代码:

1
2
3
int a = 1;     // a 是左值
int b = 2; // b 是左值
int c = a + b; // a和b自动转换为右值求和

左值转换右值, 还涉及到CV限定问题:

  • 非对象类型的左值, 转换为右值计算或者求值, 会去掉其CV限定
  • 类对象类型的左值, 转换为右值, 则保留其原有类型修饰(const or volatile)

也就是说,如果是类类型,从左值转为右值时,它的CV限定符会被保留

例如下面的例子:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

class A {
public:
void foo() const { std::cout << "A::foo() const\n"; }
void foo() { std::cout << "A::foo()\n"; }
};

A bar() { return A(); } //返回临时对象,为右值
const A cbar() { return A(); } //返回带const的右值(带CV限定符)

由于C语言不涉及类, 所以也就不存在右值可以保留CV限定这一说.

btw: 什么是CV限定符?如果变量声明时类型前带有const或volatile,就说此变量类型具有CV限定符。

左值引用

如果一个左值同时是引用,就称为左值引用.

例如:

1
2
std::string s;
std::string& sref = s; //sref为左值引用

非const引用

最开始, 碰到右值问题的时候是, 临时对象不能赋值给非const类型的引用 (编译器从安全检查的角度禁止你这么做).

非const引用, 一般也可以称为左值引用.

例如有一个函数:

1
2
3
4
void test_move(A& a)
{
//do nothing
}

如果你这么调用test_move(A(1));, 即使用临时对象进行调用, 然后直接就报错了:

1
invalid initialization of non-const reference of type ‘A&’ from an rvalue of type ‘A’

然后, 没办法, 为了通过编译, 改进一下引入了一个A& setInt(int i);方法让其返回A&, 通过编译.
完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class A
{
public:
A()=default;
A(int i): i_(i){}

A& setInt(int i){ //注意该函数返回值类型 A&
i_ = i;
return *this;
}

~A(){}

private:
int i_;
};

void test_move(A& a)
{
//do nothing
}


int main(void)
{
/*invalid initialization of non-const reference of type ‘A&’
from an rvalue of type ‘A’ */
//test_move(A(1));
test_move(A().setInt(1));

return 0;
}

那么有没有一种机制, 可以直接接收临时对象呢? 而不再受限于test_move(A&)这种形式? (当然你改写成const A&是可以解决问题的)

这样就由非const引用, 引出了右值引用问题(右值专用的引用)

右值引用

上面顺利成章的说道了 C++11的右值引用问题, 符号表示就是T &&, 表示上不同于以前的左值引用`, 即一般引用.

但是右值引用的存在, 不仅仅只是因为非const引用问题, 它的主要用途:

利用临时对象, 即右值

利用临时对象, 以前我们用临时对象是利用const T &来做, 例如string的交换:

1
2
3
4
5
6
7
8
9
string& operator=(const string& other)
{
log("copy assignment operator");
string tmp(other); //构造一个临时对象,因为other为const,不能被修改
std::swap(m_size, tmp.m_size);
std::swap(m_data, tmp.m_data);
//跟临时对象交换值,临时对象晰构时会delete [] m_data
return *this;
}

实际上, 我们还是拿右值创建了一个临时对象, 然后进行的交换, 那么为何不直接利用好临时对象呢?
比如, 这样利用:

1
2
3
4
5
6
7
string& operator=(string&& other)
{
log("move assignment operator");
std::swap(m_size, other.m_size);
std::swap(m_data, other.m_data);
return *this;
}

比如v = string("33");, 赋值交换完毕, 外部的临时对象也是会被销毁的, 何苦在内部创建一个?

&&语法声明右值引用,表示一个指向右值的引用,通过这个引用,可以直接操作右值.

实际上右值引用专门用于引用右值(临时对象、匿名对象), 但是也可以接收左值.

但是除了可以引用临时对象, 很多时候, 也需要减少对象的构建代价, 直接利用已经存在的临时对象生成新对象(比如上面的例子),
甚至把别的对象挖空, 然后转移到本对象. 这方面广泛的应用是拷贝构造拷贝赋值.

但是归根揭底, 右值引用只是为临时对象, 匿名对象设置了机制

如果拿到临时对象, 或者具体的说, 从原有对象中拿到临时对象? (比如说就是要利用现有对象构造新的对象, 避免拷贝, 要偷)

于是引出了移动语义, 移动语义是利用右值引用引用机制.

移动语义

说先强调一下, 移动语义应该分成两个方面理解:

  • 我们提供 move 版本的函数, 比如构造和拷贝构造
  • 系统提供的 std::move 作用方面(仅仅为了产生临时对象, 方便调用移动版本-避免拷贝)

(其中移动语义, 很小一部分程度是指std::move, 更多的是指完成偷的任务, 下面详细说)

先说第一点:

我们提供 move 版函数, 实际上就是为了挖空对象或者说利用已知对象, 更高效的创建对象; 避免拷贝(只是搬动指针指向).

对于挖空(偷)别的对象的资源, 不管是不是临时对象, 移动语义写法可谓是得心应手, 很多时候, 你甚至可以简单的认为:

移动语义就是浅拷贝, 安全的浅拷贝.

因为它避免了隐式的转移操作 (这也是我们舍弃auto_ptr, 改用unique_ptr的原因之一), 改成了显式的语义, 被偷的对象不再拥有相关资源的所有权.

你可以理解成原来资源的指针已经被置为null.

但是C++是有规则的, 什么对象可以偷?

没有主人的, 即非左值的, that is 非具名的.

也就是你给我一个右值最好, 我心安理得地偷; 但是, 如果你给我左值, 我会先把它转换为右值(&&可以接收左值), 再偷, 于是引出了move的第二个方面, std::move. 如下所示:

1
2
3
4
5
6
template<class _Ty> inline
constexpr typename remove_reference<_Ty>::type&&
move(_Ty&& _Arg) _NOEXCEPT
{
return (static_cast<typename remove_reference<_Ty>::type&&>(_Arg));
}

注意区别于算法的 std::move, 这个std::move定义在<utility>头文件中.

凡是经过 move 这一家伙动作, 原来的对象已经不再控制相关内部资源了.

也就是说 std::move 已经是提供好的 move 版本的函数, 它会把原对象的资源转移到临时对象上. 注意其返回值&&.

准确来说, std::move(x)只是产生临时对象, 方便调用移动版本的函数, 而这个函数才是真正的转移资源.

最简单的例子, 你可以试试:(str合法, 但是没有任何内部值)

1
2
3
4
std::string str = "Hello";
std::vector<std::string> v;
v.push_back(std::move(str));
std::cout << "After move, str is \"" << str << "\"\n"; //output : After move, str is ""

此时, 移动语义和右值引用的关系就是:

移动语义为右值引用产生临时对象, 副作用是把原来对象的资源偷到临时对象(右值)中了, 而这部分工作是由vector::push_back(string &&)版本的函数实现的.

std::movej就像个助手, 安全的转换工具; 不借助 std::move, 很难完成具名对象的转移任务, 除非你自己提供移动版本的函数, 就像移动构造和移动赋值函数一样.

How does std::move work

You might be wondering, how does one write a function like std::move? How do you get this magical property of transforming an lvalue into an rvalue reference? The answer, as you might guess, is typecasting.
The actual declaration for std::move is somewhat more involved, but at its heart, it’s just a static_cast to an rvalue reference. This means, actually, that you don’t really need to use move–but you should, since it’s much more clear what you mean. The fact that a cast is required is, by the way, a very good thing! It means that you cannot accidentally convert an lvalue into an rvalue, which would be dangerous since it might allow an accidental move to take place. You must explicitly use std::move (or a cast) to convert an lvalue into an rvalue reference, and an rvalue reference will never bind to an lvalue on its own.

一句话总结, 移动语义就是偷, 明目张胆的偷, 而 std::move 帮你偷, 它是帮助你调用到你移动版本函数的助手.

容器的影响

临时对象放入容器, 对比一下临时对象带有移动语义或者不带有移动语义的情况, 发现, 对于vector的影响最大.
其他的比如list, deque(只往末端插入), map, unordered容器(底层实际为hashtable)影响不是太大.

实际拷贝容器的时候, vector直接拷贝构造和使用std::move();差别也很大:

  • 实际拷贝的, 一个元素一个元素的拷贝(从头到尾), 所以慢
  • std::move 版本, vector(vector&& __x) noexcept, 内部实现其实是交换地址指针(begin, end, capacity), 所以快.

使用std::move是为了调用移动版本的重载函数, 副作用才是产生临时对象(右值), 例如:

1
2
3
vector<int> a = {1,2,3,4,5};
vector<int> b(std::move(a)); //这里调用的是b的移动构造函数
//a在重新赋值之前绝不使用

vector自己提供的移动版本才是避免拷贝的真正所在, 即移动的真理.

一旦std::move()之后, 就不能再使用原来的资源了, 不管原来的是否是具名的变量.

右值引用引发的问题

是的, 不管是移动语义本身, 还是系统帮我们实现的简化偷动作的 std::move 都是有代价的,
可以回想一下, 以前我们在说拷贝问题时, 涉及的问题:

  • 返回值问题 (引出了返回值优化问题, 即RVO)
  • 函数参数问题 (完美转发问题)

先说比较简单的, 完美转发问题.

完美转发

已经知道了右值引用, 可以直接说这个问题, 例如: (参考<C++ primer> page162)

1
2
3
4
5
template<typename T1, typename T2>
void functionA(T1&& t1, T2&& t2)
{
functionB(t1, t2);
}

你先不管 functionB, 只看functionA, 我传递给你的是右值, 然后在functionA内部使用这些值, 其实会把它弄为临时变量(拷贝), 即编译器会给它命名.
那么如果functionB有非右值重载函数, 直接调用非右值版本了;

此时, 这样的转发就不是完美的, 只能说是正确的; 因为经过中途调用, 我还是想保留调用的是右值版本.

标准库给了一种方案, 语言层面的实现, 头文件 move.hforward()部分, 源码实现也简单, 例如:

1
2
3
4
template<typename _Tp>
constexpr _t&& //注意其返回值
forward(typename std::remove_reference<_Tp>::type& _t) noexcept
{return static_cast<_Tp &&>(_t);}

上面代码中typename std::remove_reference<_Tp>::type& _t, typename std::remove_reference<_Tp>看做类型转换的模板,
实际上可以简单认为就是T& &&, 即T & (具体可以参考折叠规则) 大部分去掉左值名字关联的动作就在这个模板里面
::type&则拿到了模板中内部参数的类型, _t则是调用时传递的实参对应的形参. 之后内部实现, 由于已经拿掉命名了, 直接static_cast即可.

简单说, 摘掉名字, 强制转换一下.

(为什么这里不用std::move, 其实可以; 但是由于各自用途不同, 以及未来的扩展, 这里还是规范的采用forward)

于是就变成了这样:

1
2
3
4
5
template<typename T1, typename T2>
void functionA(T1&& t1, T2&& t2)
{
functionB(std::forward<T1>(t1), std::forward<T2>(t2));
}

其实和 std::move工具一样, std::forward也就是为了调用右值版本的函数, 从而完成移动语义, 避免拷贝, 提高效率.

最常见的例子:

1
2
3
//vector容器中
template< class... Args >
void emplace_back( Args&&... args );

c++11中大部分容器都加了一个emplace_back成员函数, 它的内部也是调用了std::forward实现完美转发的。

因此如果我们需要往容器中添加右值、临时变量时,用emplace_back版本最终可以调用移动版本的添加函数, 从而可以提高性能。

甚至, 有人为不带移动语义的函数提供了转发语义包装函数:

1
2
3
4
5
template<class Function, class... Args>
inline auto FuncWrapper(Function && f, Args && ... args) -> decltype(f(std::forward<Args>(args)...))
{
return f(std::forward<Args>(args)...);
}

转发之后, 优先调用右值引用版本(如果没有则调用值拷贝版本, 即普通右值版本)

具体实际的例子如下, 可以加深一下理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>

using namespace std;

//对不同类型的参数进行转发测试
void RunCode(int && m) {cout << "rv ref" << endl;}
void RunCode(int & m) {cout << "lv ref" << endl;}
void RunCode(const int && m) {cout << "const rv ref" << endl;}
void RunCode(const int & m) {cout << "const lv ref" << endl;}


template<typename T>
void PerfectForward(T &&t) { RunCode(forward<T>(t));}


int main(void)
{
int a, b;
const int c =1;
const int d = 0;

PerfectForward(a);
PerfectForward(move(b));
PerfectForward(c);
PerfectForward(move(d));

/*
lv ref
rv ref
const lv ref
const rv ref
*/

return 0;
}

全部转发成了对目标函数的调用了. 其他复杂的例子, 例如:

1
2
3
4
5
template<typename T, typename U>
void PerfectForward(T &&t, U& func)
{
Func(forward<T>(t));
}

大同小异.

完美转发其实就是一个包装函数的作用, 只不过它增加了对右值引用的支持.

没有必要去钻那么引用推导规则, 只要明白, 一旦你的外层函数参数涉及了右值, 你就可能要进行一波完美转发, 才能调用到正确的函数版本

RVO

虽然利用右值引用可以写出移动语义的高效代码, 但是也已经引发了, 完美转发问题, 以及现在要说的返回右值引用问题.

但是这方面问题一直很奇怪. 以一个简单的案例解释一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int x;

int getInt ()
{
return x;
}

int && getRvalueInt ()
{
// notice that it's fine to move a primitive type--remember, std::move is just a cast
return std::move( x );
}

void printAddress (const int& v) // const ref to allow binding to rvalues
{
cout << reinterpret_cast<const void*>( & v ) << endl;
}

调用代码如下:

1
2
3
printAddress( x );  //1
printAddress( getInt() ); //2
printAddress( getRvalueInt() ); //3

明确返回右值引用的 31是一样的, 而2则不同.

So returning an rvalue reference is a different thing than not returning an rvalue reference, but this difference manifests itself most noticeably if you have a pre-existing object you are returning instead of a temporary object created in the function (where the compiler is likely to eliminate the copy for you).

(也就是说, 但你外部有接收对象接收临时对象, 编译器是会进行具名返回值的优化; 并且明确返回原右值引用的确实返回的是同一地址的右值, 返回右值的则是值拷贝)

具名优化, 和编译器实现有关, 不再多谈这些边角料;

g++ 使用选项 -fno-elide-constructors 关闭该选项

总之, 明确返回右值引用的, 应该是想利用原右值做进一步的操作, 比如再调用右值版本的函数.

其他

string类参考

自己实现move版本的函数, 尤其注意把原来参数中的指针置为null.
代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
class MyString
{
private:
char *_data;
size_t _len;

//only deal with data part
void _init_data(const char* s)
{
_data = new char[_len+1];
memcpy(_data, s, _len);
_data[_len] = '\0';
}

public:
//default
MyString() : _data(NULL), _len(0) {}

//constructor
MyString(const char *p) : _len(strlen(p)) {
_init_data(p);
}

//copy constructor
MyString(const MyString& str) : _len(str._len)
{
_init_data(str._data);
}

//copy assigment
MyString& operator=(const MyString&str)
{
if(this != &str) {
if(_data) {
delete _data;
}
_len = str._len;
_init_data(str._data);
}
return *this;
}


//move constructor
MyString(MyString&& str) noexcept :
_data(str._data), _len(str._len) {

str._len = 0;
str._data = nullptr; //watchout double delete
}

//move assigment
MyString& operator=(MyString&& str) noexcept
{
if(this != & str) {
if(_data) {
delete _data;
}
_len = str._len;
_data = str._data; //MOVE

str._len = 0;
str._data = nullptr;
}

return *this;
}

//deconstructor
virtual ~MyString()
{
if(_data) {
delete _data;
}
}

//为了放入一些有序容器中, 需要下面的函数
//compare
bool operator<(const MyString& str) const
{
//借用std::string的比较函数
return std::string(this->_data) < string(str._data);
}

//==
bool operator==(const MyString& str)
{()
return std::string(this->_data) == std::string(str._data);
}

//get , c_str()
char *get() const {return _data;}
};
//一般还要提供一个 MyStringHashCodeFunctor函数对象用于hash容器, 或者借助标准库的`std::hash`仿函数模板特化一个属于MyString的特化模板仿函数.
//例如下面
namespace std
{//一定要放在std下
template<>
struct hash<MyString>
{
size_t operator(const MyString& s) const noexcept()
{
return hash<string>()(string(s.get()));
}
}

};

参考

  1. http://thbecker.net/articles/rvalue_references/section_01.html
  2. https://eli.thegreenplace.net/2011/12/15/understanding-lvalues-and-rvalues-in-c-and-c
  3. https://www.cprogramming.com/c++11/rvalue-references-and-move-semantics-in-c++11.html
  4. https://en.wikipedia.org/wiki/Return_value_optimization
  5. https://www.ibm.com/developerworks/community/blogs/5894415f-be62-4bc0-81c5-3956e82276f3/entry/RVO_V_S_std_move?lang=en
  6. 《深入理解C++11新特性与应用》 C++标准委员会 & IBM XL编译器中国开发团队
文章目录
  1. 1. 基本概念
  2. 2. 非const引用
  3. 3. 右值引用
  4. 4. 移动语义
    1. 4.1. 容器的影响
  5. 5. 右值引用引发的问题
    1. 5.1. 完美转发
    2. 5.2. RVO
  6. 6. 其他
    1. 6.1. string类参考
  7. 7. 参考
|