C++右值引用浅析

一直想试着把自己理解和学习到的右值引用相关的技术细节整理并分享出来,希望能够对感兴趣的朋友提供帮助。

右值引用是C++11标准中新增的一个特性。右值引用允许程序员可以忽略逻辑上不需要的拷贝;而且还可以用来支持实现完美转发的函数。它们都是实现更高效、更健壮的库。

move语义

先不展开具体右值引用定义。先说说move语义。右值引用是用来支持move语义的。move语义是指将一个同类型的对象A中的资源(可能是在堆上分配,也可能是一个文件句柄或者其他系统资源)搬移到另一个同类型的对象B中,解除对象A对该资源的所有权。这样可以减少不必要的临时对象的构造、拷贝以及析构等动作。比如我们经常使用的std::vector<T>,当两个相同的std::vector类型赋值时,一般的步骤如下:

  1. 内部的赋值构造函数一般是先分配指定大小的内存,
  2. 从源std::vector中拷贝到新申请的内存,
  3. 之后再把原有的对象实例析构掉,
  4. 最后接管新申请的数据。

这就是我们C++11之前使用的拷贝语义,也就是常说的深拷贝。move语义与拷贝语义相对,类似于浅拷贝,但是资源的所有权发生了转移。move语义的实现可以减少拷贝动作,大幅提高程序的性能。

而为了实现move语义的构造,就需要对应的语法来支持。原有的拷贝构造函数等不能够满足该需求。最典型的例子就是C++11废弃的std::auto_ptr,其构造函数会产生不明确的拥有权关系,很容易滋生BUG。这也是很多人不喜欢std::auto_ptr的原因。C++11为此增加了相应的构造函数。

 
class Foo {
public:
    Foo(Foo&& f) {}
    Foo& operator=(Foo&& f) { 
        return *this;
    }
};
 

这里可以明显看到两个函数中的参数类型是Foo&&。这就是右值引用的基本语法。这样做的目的是通过函数重载实现不同的功能处理。

强制move语义

C++11规定即可以在右值上使用move语义,也可以在左值上使用move语义。也就是说,可以把一个左值转为右值引用,然后使用move语义。比如在C++的经典函数swap中:

 
template<class T>
void swap(T& a, T& b) 
{ 
  T tmp(a);
  a = b; 
  b = tmp; 
} 

X a, b;
swap(a, b);
 

上面代码中没有右值,但是tmp变量只作用在本函数作用域中,只是用来承担数据的转移动作。C++11制定的上述规则在这里反而可以得到非常好的适用。C++11为了达到这个规则,实现了std::move函数,这个函数的就是把传入的参数转换为一个右值引用并返回。也就是说在C++11下,swap的实现如下:

 
template<class T> 
void swap(T& a, T& b) 
{ 
  T tmp(std::move(a));
  a = std::move(b); 
  b = std::move(tmp);
} 

X a, b;
swap(a, b);
 

我们在实际使用中,也可以尽量的多使用std::move。只要求我们自定义的类型实现转移构造函数。

右值引用

为了说清楚右值引用什么,就不得不说左值和右值。简单的说左值是一个指向某内存空间的表达式,并且我们可以用&操作符获得该内存空间的地址。右值就是非左值的表达式。可以阅读这篇《Lvalues and Rvalues》进行深入理解。

右值引用非常类似于C++的普通引用,也是一个复合类型。为了方便区分,普通引用就是左值引用。一个左值引用就是在类型后面加&操作符。而右值引用就是在类型后加&&操作符,就像上面的转移构造函数的参数一样。

右值引用的行为类似于左值引用,但是右值引用只能绑定临时对象,不能绑定一个左值引用。右值引用的出现还影响了函数重载决议。左值会优先适配左值引用参数的函数,右值会优先适配右值引用参数的函数:

 
void foo(X& x); // lvalue reference overload
void foo(X&& x); // rvalue reference overload

X x;
X foobar();

foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)
 

理论上,你可以用这种方式重载任何函数,但是绝大多数情况下这样的重载只出现在拷贝构造函数和赋值运算符中,也就是实现move语义。

如果你实现了void foo(X&);,但是没有实现void foo(X&&);,那么和以前一样foo的参数只能是左值。如果实现了void foo(X const &);,但是没有实现void foo(X&&);,仍和以前一样,foo的参数既可以是左值也可以是右值。唯一能够区分左值和右值的办法就是实现void foo(X&&);。最后,如果只实现了实现void foo(X&&);,但却没有实现void foo(X&);和void foo(X const &);,那么foo的参数将只能是右值。

右值引用是右值吗?

void foo(X&& x)
{
  X anotherX = x;
  // ...
}

在上面这个函数foo内,X的哪个构造函数会被调用?是拷贝构造还是转移构造?按照我们之前说的,这是个右值引用,应该是调用的X(X&&);函数。但是实际上,这里调用的是X(const X&);这里就是让人迷惑的地方:右值引用类型既可以被当做左值也可以被当做右值,判断的标准是该右值引用是否有名字。有名字就是左值,否则就是右值。如果要做到把带有名字的右值引用变为右值,就需要借助std::move函数。

void foo(X&& x)
{
  X anotherX = std::move(x);
  // ...
}

在实现自己的转移构造函数时,一些人没有理解这一点,导致在自己的转移构造函数内部的实现中实际是执行了拷贝构造函数。

move语义与返回值优化

了解了move语义和强制move以及右值引用的一些概念后,有些朋友在实现一些函数时,会在返回的地方进行强制move。认为这样可以减少一次拷贝。比如:

 
X foo()
{
  X x;
  // perhaps do something to x
  return std::move(x); // making it worse!
}
 

实际上这种是不需要的。因为编译器会做返回值优化(Return Value Optimization)。在C++11标准中有如下规定:

When the criteria for elision of a copy operation are met or would be met save for the fact that the source object is a function parameter, and the object to be copied is designated by an lvalue, overload resolution to select the constructor for the copy is first performed as if the object were designated by an rvalue.

直接return x;是NRVO支持的一种用例场景,可以做到多余的拷贝构造。编译器会自己选择使用拷贝构造还是move构造函数。

但是如果用std::move(x);那么可能会带来额外的影响:可能会阻止NRVO。也就是说可能需要额外的开销来执行move语义。

建议阅读Stackoverflow上的这两个问题:

《When should std::move be used on a function return value?》

《Why does std::move prevent RVO?》

完美转发

右值引用除了用来实现move语义之外,还就是为了解决完美转发的问题。我们有的时候会写工厂函数,比如如下代码:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg arg)
{ 
  return shared_ptr<T>(new T(arg));
}

这个实现非常简单,就是把参数arg传给类T进行构造。但是这里引入了额外的通过值的函数调用,不使用于那些以引用为参数的构造函数。

那么为��解决这个问题,就有人想到用引用,比如:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg& arg)
{ 
  return shared_ptr<T>(new T(arg));
}

但是这里又有问题,不能接收右值作为参数。

factory<X>(hoo()); // error if hoo returns by value
factory<X>(41); // error

对应的解决办法是继续引入const引用。如果有多个参数的情况下,这个函数的参数列表就变的比较恶心了。同时还有个问题就是不能实现move语义。

而右值引用可以解决这个问题,可以不用通过重载函数来实现真正的完美转发。但是它需要配合两个右值引用的规则:

  • 引用叠加规则
A& & => A&
A& && => A&
A&& & => A&
A&& && => A&&
  • 模板参数推导规则
template<typename T>
void foo(T&&);

当函数foo的实参是一个A类型的左值时,T的类型是A&。再根据引用叠加规则判断,最后参数的实际类型是A&。
当foo的实参是一个A类型的右值时,T的类型是A。根据引用叠加规则可以判断,最后的类型是A&&。

 

有了上面这些规则,我们可以用右值引用来解决前面的完美转发问题。下面是解决的办法:

template<typename T, typename Arg> 
shared_ptr<T> factory(Arg&& arg)
{ 
  return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}

而std::forward的实现如下:

template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept
{
  return static_cast<S&&>(a);
}

这里就不展开具体的例子来说明了,明白了上述的两个规则就可以明白了。建议阅读Scott Meyers的《Universal References in C++11》

参考文档

  1. 《C++ Rvalue References Explain》
  2. 《Universal References in C++11》
  3. 《A Brief Introduction to Rvalue References》 

总结

右值引用的出现虽然看似增加了额外的复杂度,但是它带来的收益还是非常明显的,能够帮助实现move语义,提升程序的性能;又可以实现完美转发,方便了库的设计。

C++就是这样,给你一个新增一个特性之后,也会带来额外的学习难度。但是这也是很多人喜欢C++的原因,它给了程序员太多的可能性。可以精准的控制对象的生命周期,是高性能程序必不可少的工具。

------------------------------分割线------------------------------

将C语言梳理一下,分布在以下10个章节中:

  1. Linux-C成长之路(一):Linux下C编程概要 http://www.linuxidc.com/Linux/2014-05/101242.htm
  2. Linux-C成长之路(二):基本数据类型 http://www.linuxidc.com/Linux/2014-05/101242p2.htm
  3. Linux-C成长之路(三):基本IO函数操作 http://www.linuxidc.com/Linux/2014-05/101242p3.htm
  4. Linux-C成长之路(四):运算符 http://www.linuxidc.com/Linux/2014-05/101242p4.htm
  5. Linux-C成长之路(五):控制流 http://www.linuxidc.com/Linux/2014-05/101242p5.htm
  6. Linux-C成长之路(六):函数要义 http://www.linuxidc.com/Linux/2014-05/101242p6.htm
  7. Linux-C成长之路(七):数组与指针 http://www.linuxidc.com/Linux/2014-05/101242p7.htm
  8. Linux-C成长之路(八):存储类,动态内存 http://www.linuxidc.com/Linux/2014-05/101242p8.htm
  9. Linux-C成长之路(九):复合数据类型 http://www.linuxidc.com/Linux/2014-05/101242p9.htm
  10. Linux-C成长之路(十):其他高级议题

相关推荐