初探值与引用:学会正确地使用右值引用

C++11中引入了右值引用,一起 look、look,这到底是个啥。

右值引用是右值?

引用,就是为了避免复制而存在,而左值引用和右值引用是为了不同的对象存在:

  • 左值引用的对象是变量
  • 右值引用的对象是常量

最直观的使用如下:

1
2
3
int a =10;
int& lr = a; // a 是个变量
int&& rv = 10; // 10 是常量

可能老铁理不清:右值引用和右值到底是啥关系?

先说结论:无论左值引用还是右值引用,都是左值,即上面的lrrv是左值、是个变量,只是左值引用lr指向的是变量a,而右值引用rv指向常量10。

下面从demo中验证这一结论。假定有类Foo的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Foo { 
public:
Foo(int num=0) : num_(num) {
std::cout<<"default"<<std::endl;
}
Foo(const Foo& rhs) : num_(rhs.num_) {
std::cout<<"ctor"<<std::endl;
}
Foo(Foo&& rhs) : num_(rhs.num_) {
rhs.num_=0;
std::cout<<"mtor"<<std::endl;
}
private:
int num_;
};

以类Foo为基础,针对下面的代码进行案例分析:

1
2
3
4
5
6
7
8
Foo foo(10);                      // 仅在此调用构造函数

Foo& foo_lv_1 = foo; // (1) OK
// Foo&& foo_rv_1 = foo; // (2): error
Foo&& foo_rv_2 = std::move(foo); // (3) ok
Foo&& foo_rv_3 = static_cast<Foo&&>(foo); // (4) 和(3)等价
// Foo&& foo_rv_4 = foo_rv_2; // (5):error
Foo& foo_lv_2 = foo_rv_2; // (6) OK
  • (1)是最常用的左值引用,肯定不会发生复制行为
  • (2)会报错:error: cannot bind rvalue reference of type ‘Foo&&’ to lvalue of type ‘Foo’。正是因为foo_rv_1是个右值引用,只能指向常量,而foo是个变量、是个左值。
  • (2)报错是因为不能隐式地将左值转换为右值,但可以使用static_cast<Foo&&>强制转换,这是编译器所允许的。std::move函数底层即是如此实现,因此(3)和(4)等价。
  • (5)会和(2)有同样的error。因为foo_rv_2本质上是个左值,不能将foo_rv_4引用foo_rv_2
  • (6)正确。因为foo_rv_2本身就是个左值,foo_lv_2去引用一个左值变量并没有问题。

因此上面的demo,只会在构造foo时调用一次构造函数。

此外,由于foo_lv_1foo_lv_2及其foo_rv_2都指向foo,打印这三个变量地址会发现它们和foo的地址相同。说明不论左值引用还是右值引用都是左值这一事实。

1
2
3
4
// 打印地址
std::cout<<&foo <<" "<< &foo_lv <<" "<<&foo_rv_2<<" "<<&foo_lv_2 << std::endl;
// 输出
0x7fffceb73864 0x7fffceb73864 0x7fffceb73864 0x7fffceb73864

注释:上面的demo中出于演示,foo在(3)中移动到foo_rv_2后,仍继续在(4)中使用。实际上不应该继续使用,除非给它重新初始化。

std::move

假定存在 construct_foo_by 函数,能根据传入的Foo对象的引用类型调用不同的构造函数构造Foo对象foo,那么应该如下设计:

  • construct_foo_by 函数传入的对象是个右值时,construct_foo_by(Foo&& rhs) 会被调用。但是!!! 由上面的分析可知,尽管rhs是右值引用类型Foo&&,但却是左值,想调用Foo的移动构造函数,必须强制将rhs变成右值。因此,在construct_foo_by 函数内部,需要调用std::move函数来完成这一转换。

    注释:调用construct_foo_by(Foo&& rhs)函数前,入口函数rhs的初始化相当于是Foo&& rhs = foo;,因此rhs也是个变量。

  • construct_foo_by 函数传入的对象是非右值时,rhs就是个左值,construct_foo_by函数内部自然就会调用Foo的拷贝构造函数创建foo

整个demo实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void construct_foo_by(Foo&& rhs) { 
Foo foo(std::move(rhs));
}

void construct_foo_by(const Foo& rhs) {
Foo foo(rhs);
}

int main(int argc, char const *argv[]) {
Foo foo(10);
construct_foo_by(foo); // 调用 ctor
construct_foo_by(Foo{100}); // 调用 mtor
return 0;
}

因此,对于main函数中construct_foo_by的两次调用,输出如下

1
2
3
4
5
$ g++ reference.cc -o ref && ./ref
default
ctor
default
mtor

但是,如果将 construct_foo_by(Foo&& rhs)函数的实现修改为下面的版本,即内部不使用std::move函数对rhs进行转换:

1
2
3
void construct_foo_by(Foo&& rhs) { 
Foo foo(rhs);
}

输出如下,并不符合预期:本该调用Foo的移动构造函数来构造foo,却调用拷贝构造函数,就是因为rhs是左值,又没有经过std::move函数,最后触发了拷贝构造函数。

1
2
3
4
5
$ g++ reference.cc -o ref && ./ref
default
ctor
default
ctor

std::forward

在上面的实例中,知道了 右值引用是左值 这一事实,也正因为这个问题,导致 construct_foo_by(Foo&& rhs)函数内部再次调用std::move函数将rhs强制性转换为右值。

那么有没有办法可以使得不重载construct_foo_by函数,也依然能够根据传入参数的类型调用合适的构造函数?Of Course,答案就是std::forward函数。

但是std::forward必须配合模板使用,因为只有在模板参数下T&&才能触发引用折叠。T&&和具体的Foo&&不同,后者是具体类别的右值引用,而T&&可以是const Foo&Foo&,也可以是Foo

  • 当输入的rhs是左值类型时,T&&会被推断为Foo&,经过std::forward<T&&> 强制转换后是Foo&&&,触发引用折叠后还是Foo&,最后调用拷贝构造函数;
  • 当输入的rhs是右值类型时,T&&会被推断为Foo,经过std::forward<Foo&&>强制类型转换后变为Foo&&,触发移动构造函数。

注意:std::forward必须和模板搭配才能发挥完美转发的效果。
完整的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T> 
void create_foo_by(T&& rhs) {
Foo foo(std::forward<T&&>(rhs));
}

int main(int argc, char const *argv[]) {
Foo foo(10);

create_foo_by(foo); // ctor
create_foo_by(Foo{100}); // mtor

return 0;
}

输出如下:

1
2
3
4
5
$ g++ reference.cc -o ref && ./ref
default
ctor
default
mtor