右值引用和完美转发

  • 参考
  • 右值引用和完美转发
  • move语义与右值引用
  • move简介
  • move构造、赋值函数与函数调用
  • move构造函数
  • 右值引用和左值引用的重载规则
  • 使用move的注意事项
  • move与编译器优化
  • 关于编译器优化
  • 完美转发
  • 滥用完美的副作用

move语义与右值引用

C++标准库中有很多资源占有(resource-owning)类型,比如std::ifstream,std::unique_ptr还有std::thread都是可移动,但不可拷贝。move的好处是可以避免不必要的拷贝和临时变量的产生。因为move,C++11标准库的性能得到了不错的提升,具有深远的影响。std::move并不做任何移动,而是将左值转换为右值引用,然后调用接收右值引用的重载函数,具体的事情由该函数来完成。

move简介

以下为cppreference.com对std::move的简介。

std::move is used to indicate that an object t may be "moved from", i.e. allowing the efficient transfer of resources from t to another ​​object.In​​ particular, std::move produces an xvalue expression that identifies its argument t. It is exactly equivalent to a static_cast to an rvalue reference type.

翻译:std::move被用来指明一个对象可以被移动,即允许高效的将t的资源转移到另外一个对象中。一般,std::move产生一个右值表达式来代表参数t。该动作完全等效于对右值引用进行static_cast。 说明:

  • 右值:一般就是临时变量(将亡值或者纯右值),在表达式结束的时候其生命周期就结束了。比如常量(123,"abc"等),或者函数进行值返回时产生的临时变量、接收非引用(指针)类型的函数进行实参传递时等都会产生临时变量
  • std::move:它啥都没有干,只是把一个对象转换成了右值。那它的作用是什么呢?单单看去除一个对象的static属性好像没啥用,但是如果配合右值拷贝构造、右值赋值运算符则用处大大滴有。
  • std::move一般作用于即将离开作用域的对象,比如stack.top()时会产生临时对象,或者在返回一个局部变量时,或者在函数调用接收右值引用作为参数时(比如vector.emplace_push就接收右值引用)

move构造、赋值函数与函数调用

一个左值变量如果要传递给接收右值引用的重载函数进行调用,则必须将左值转换为xvalues,也就是要调用std::move进行static_cast,这就是move构造函数和move赋值运算符通常使用std::move的原因。如果你希望进行move减少临时对象的创建与销毁,那么你就要提供接收右值引用参数的重载函数。最普遍的接收右值引用参数的重载函数就是构造函数和赋值函数。

move构造函数

class_name ( class_name && )    (1)    (since C++11)
class_name ( class_name && ) = default; (2) (since C++11)
class_name ( class_name && ) = delete; (3) (since C++11)

在C++11以后,你可以通过以上三种方式自定义move构造或者使用编译器提供的move构造,也可以将move构造禁用(避免隐士的调用move)。如果你除了缺省构造函数之外没有指定任何构造、析构函数,那么编译器会为你指定一个default的move构造函数,如果你提供了任何拷贝构造或者赋值运算符或者析构函数,那么编译器就会将move constructor设置为delete。

一般情况下,所有的标准库对象在进行move之后其值都是合法的但是他们的状态是不确定。值是合法的指的是它确实有一个值,但是状态不确定是因为有一些函数在调用前都有一个前置条件,只有满足该条件的时候调用才是合法的。因此,一般情况下如果左值被move之后,就不要再使用它了。 比如:

std::vector<std::string> v;
std::string str = "example";
v.push_back(std::move(str)); // str is now valid but unspecified
str.back(); // undefined behavior if size() == 0: back() has a precondition !empty()
str.clear(); // OK, clear() has no preconditions

上面的str在move之后其值已经变成empty了,该值确实是合法的。但是要调用str.back有一个前提条件——str不能为空。

右值引用构造函数和右值引用赋值运算符在以下3种情况被调用:

  • 初始化:使用一个move(lvalue)来初始化一个对象的时候,右值引用构造函数将被调用
  • 函数调用:在函数接收一个左值的情况下,使用move(lvalue)来调用该函数,f(std::move(a));, where a is of type T and f is void f(T t);
  • 函数返回:如果a是一个拥有move构造函数的对象,那么在return a的时候就会调用a的move构造函数

Trivial move constructor The move constructor for class T is trivial if all of the following is true:

  • it is not user-provided (meaning, it is implicitly-defined or defaulted);
  • T has no virtual base classes;
  • T has no virtual member functions;
  • the move constructor selected for every direct base of T is trivial;
  • the move constructor selected for every non-static class type (or array of class type) member of T is trivial.
  • A trivial move constructor is a constructor that performs the same action as the trivial copy constructor, that is, makes a copy of the object representation as if by std::memmove. All - data types compatible with the C language (POD types) are trivially movable.

右值引用和左值引用的重载规则

  • 提供void foo(X&):只能使用左值进行调用
  • 提供void foo(const X&):能被右值和左值进行调用
  • 提供void foo(X&&):能被右值引用所调用,如果没有提供f(X&)和f(const X&)那么使用左值进行调用的时候会出现编译错误

使用move的注意事项

  • 不要在return中使用move:因为编译器在该情形下会进行最佳优化——比如直接使用return对象的原始地址给接收参数,但是如果你显示的调用了move那么还会造成一次右值拷贝构造的开销。另外,在return对象的时候,如果有右值拷贝则优先进行move,如果有拷贝构造则会进行copy,否则可能会报错。[C++标准库,edition2,page23]
  • 不能将本地的非静态对象作为右值引用类型返回。比如: X &&foo(){X x;return x;},但是如果使用move则不会有问题。

move与编译器优化

#include <iostream>
#include <string>

class compiler_optimization
{
public:
compiler_optimization(const std::string &name,const int32_t age):m_name(name),m_age(age)
{
std::cout << "constructor " << std::endl;
}

compiler_optimization(const compiler_optimization &c):compiler_optimization(c.m_name,c.m_age)
{
std::cout << "constructor & " << std::endl;
}

// 为避免使用委托构造带来的误解,这里不使用该特性
// compiler_optimization(compiler_optimization &&c):compiler_optimization(std::move(c.m_name),c.m_age)
// {
// std::cout << "constructor && " << std::endl;
// }
compiler_optimization(compiler_optimization &&c):m_name(std::move(c.m_name)),m_age(c.m_age)
{
std::cout << "constructor && " << std::endl;
}
private:
std::string m_name;
int32_t m_age;
};

compiler_optimization create_by_return_value(const std::string &name,const int32_t age)
{
compiler_optimization c(name,age);
return c;
}

compiler_optimization create_by_move(const std::string &name,const int32_t age)
{
compiler_optimization c(name,age);
return std::move(c);
}

int main()
{
// 测试提供右值拷贝构造的类
{
auto xiaohong = compiler_optimization("xiaohong",27);// 调用1次 constructor
auto xiaoli = create_by_return_value("xiaoli",18);// 调用1次 constructor
auto xiaoma = create_by_move("xiaoma",19);// 调用1次 constructor + 1次 constructor &&
}
}

上面的代码编译的时候只指定了-std=c++11选项,可以看出如果是在实际的工程中,使用std::move的性能将是最差的,相比于直接返回局部变量,显示的对局部变量进行move反而多了一次右值拷贝构造函数调用的开销。再看下面的代码:

class no_rvalue_constructor
{
public:
no_rvalue_constructor(const std::string &name,const int32_t age):m_name(name),m_age(age)
{
std::cout << "constructor " << std::endl;
}

no_rvalue_constructor(const no_rvalue_constructor &c):no_rvalue_constructor(c.m_name,c.m_age)
{
std::cout << "constructor & " << std::endl;
}

//no_rvalue_constructor(no_rvalue_constructor &&c) = delete;
private:
std::string m_name;
int32_t m_age;
};

no_rvalue_constructor create_by_return_value_v2(const std::string &name,const int32_t age)
{
no_rvalue_constructor c(name,age);
return c;
}

no_rvalue_constructor create_by_move_v2(const std::string &name,const int32_t age)
{
no_rvalue_constructor c(name,age);
return std::move(c);
}

int main()
{
{
auto xiaohong = no_rvalue_constructor("xiaohong",27);// 调用1次 constructor
auto xiaoli = create_by_return_value_v2("xiaoli",18);// 调用1次 constructor
auto xiaoma = create_by_return_value_v2("xiaoma",19);// 调用1次 constructor
}
}

当我们不提供右值拷贝构造函数时,貌似程序运行的也很好,效率也很高,但实际上这是因为编译器为我们提供了一个缺省的右值拷贝构造函数。缺省的右值拷贝构造函数将该类的成员变量递归的进行move,所以我们看不到输出,其行为等价于将右值拷贝构造函数声明为default。如果显示的将右值拷贝构造函数声明为delete,那么上面调用std::move的地方将会编译出错。

综上:

  • 提供有效的右值拷贝构造函数,如果不提供请确保缺省的右值拷贝构造也能满足该类的需求
  • 不要在return语句中显示的对局部变量进行move,最好的做法是提供右值拷贝构造并且保证所有路径的返回值是一致的,这样方便编译器进行优化
  • 将局部变量作为实参进行传递的时候可以进行move

关于编译器优化

一般情况下,编译器会自动的将临时变量、return的局部变量进行move,但是却不调用其右值拷贝函数。因此,如果编译器做了上面的优化,而你却在代码中显示的进行了move,则性能反而会降低。最佳的做法是提供相应类的右值拷贝和右值赋值操作,怎么优化由编译器来决定。除了return以外,其他的地方都可以进显示的move。

完美转发

通过将函数模板的形参类型设置为 T&&,我们可以很好地解决接收左、右值的问题。但除此之外,还需要解决一个问题,即无论传入的形参是左值还是右值,对于函数模板内部来说,形参既有名称又能寻址,因此它都是左值。那么如何才能将函数模板接收到的形参连同其左、右值属性,一起传递给被调用的函数呢?C++11 标准的开发者已经帮我们想好的解决方案,该新标准还引入了一个模板函数 forword(),我们只需要调用该函数,就可以很方便地解决此问题。

#include <iostream>
using namespace std;

//重载被调用函数,查看完美转发的效果
void otherdef(int & t) {
cout << "lvalue\n";
}
void otherdef(const int & t) {
cout << "rvalue\n";
}

void otherdef(int && t) {
cout << "rrvalue\n";
}

//实现完美转发的函数模板
template <typename T>
void function(T&& t) {
otherdef(forward<T>(t));
}

int main()
{
function(5);// 输出:如果有otherdef(int && t)则输出rrvalue,否则输出rvalue,可以看到编译器优先调用的是
int x = 1;
function(x);// 输出:lvalue
return 0;
}

滥用完美的副作用

完美转发的本意是将一个对象通过右值模板类型以及forward,以其本来的类型被调用。完美转发存在的原因是由于一个右值引用在作为参数传递给某个调用者之后,再次将该实参作为参数调用的时候,它变成了一个左值——因为它不再是一个匿名的对象或者临时对象了。而完美转发的滥用的问题主要存在于右值引用,见下面例子:

#include <iostream>
#include <memory>
#include <vector>

class Object
{
public:
Object(const std::string &name,const size_t height,const size_t weight):m_name(name),m_height(height),m_weight(weight)
{
std::cout << "Constructor &" << std::endl;
}

Object(std::string &&name,const size_t height,const size_t weight):m_name(std::move(name)),m_height(height),m_weight(weight)
{
std::cout << "Constructor &&" << std::endl;
}

void print() const
{
std::cout << "m_name:" << m_name << ",m_height:" << m_height << ",m_weight:" << m_weight << std::endl;
}
private:
std::string m_name;
size_t m_height;
size_t m_weight;
};

template <typename T>
class ObjectPool
{
public:
template <typename ...Args>
std::vector<std::shared_ptr<T>> create_objects(const size_t count,Args ...args)
{
for (size_t i = 0; i < count;++i)
{
m_pool.emplace_back(std::make_shared<T>(T(std::forward<Args>(args)...)));
}
return m_pool;
}

private:
std::vector<std::shared_ptr<T>> m_pool;
};

// 测试代码
int main()
{
ObjectPool<Object> pool;

// 本意是创建18个高为100,重25kg的desk。但是实际上创建出来的18个对象,只有第一个的m_name值是正确的
// 原因是 new T(...) 的时候使用了 std::forward 进行完美转发
// 由于 "desk" 是一个右值,进行完美转发的时候,因此调用的是 Object 的带右值引用的构造函数,而右值引用的构造函数往往需要将资源进行move
// 因此,只有第一个对象创建的时候name是真确的,其他对象的name都为空。
auto objects = pool.create_objects<std::string,size_t,size_t>(18,std::string("desk"),100,25);
for (const auto&o:objects)
{
o->print();
}


// test string
std::string name_1{"xiaohong"};
std::string name_2{std::move(name_1)};
// 输出:name_1:,name_2:xiaohong
std::cout << "name_1:" << name_1 << ",name_2:" << name_2 << std::endl;
return 0;
}