浅谈C++11中的move和forward

含义

move和forward都是C++11中引入的,它们是移动语义和完美转发实现的基石。

  • move:不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义
    • 从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue)
  • forward: 不转发任何东西,也是执行左值到右值的强制类型转换,只是仅在特定条件满足时才执行该转换
    • 典型使用场景:某个函数模板取用了万能引用类型为形参,随后把它传递给了另一个函数

那么问题来了,既然move不移动而只是转换类型,为什么还把它命名为move,而不是一个类似rvalue_cast之类的名字?

  • 首先再重复一下,move做的是强制类型转换,把左值转换为右值,不做移动
  • 而右值是可以进行移动的,所以move一个对象,就是告诉编译器该对象具备可移动的条件,这样,move表述了这样一个事实:该对象可以移动了
move很牛逼,但可能并没有想象中那么牛逼

移动语义的支持,使得我们可以以较低的成本执行以前只能复制的操作。

对于标准容器操作,如下:

std::vector<Foo> vf1;

// 放入数据

// 移动
auto vf2 = std::move(vf1); // O(1),仅是包含在vf1和vf2中的指针修改了

以上操作得以实现的前提是,标准容器都是将内容放在堆上的,它们内持有一个指向内存的指针。

正是由于该指针的存在,移动操作在常数时间内完成得以实现:把指针从源容器复制到目标容器,然后把源容器包含的指针置空即可。

注意,并不是所有move操作都是如此低廉的:

std::array<Foo, 10000> af1;

// 放入数据

// 移动
auto af2 = std::move(af1); // O(n),需要把af1中所有元素移动到af2

这是因为array对象并没有内置的指针,其内容数据是直接存储在对象内的。

所以不要总是相信下面的说法:

移动容器和赋值一堆指针一样成本低廉。

总之,在下面的情况下,移动语义并不会带来什么好处:

  • 没有移动操作:待移动对象没有提供移动操作,这时移动请求就变成了复制请求
  • 移动不是更快:待移动对象有移动操作,但并不比复制操作更快,如采用了小型字符串优化(SSO)的小string(容器不超过15的字符串)
  • 移动不可用:在移动本可以发生的语境下,要求移动操作不可抛出异常,但该操作未加上noexcept声明
  • 源对象是个左值:除极少数例外,只有右值可以作为移动对象的源

如下面的代码:

class Foo {
 public:
  explicit Foo(const std::string text): value(std::move(text)) { // 为避免复制成本,对text进行move,产生右值
   // ... 
  }
  //...
  
 private:
  std::string value;
};

说明:

  • 代码能够顺利通过编译和运行
  • value的值被设置为text,表面看运行完美
  • 重点来了,text不是移动入value的,而是通过复制!!!
  • text已经被move强制转换成右值,但text是const的,所以转换后是个右值 const string,即常量性保留了下来
  • 当编译器决定调用哪一个string的构造函数时,有两个选项:
class string {
 public:
  ...
  string(const string& rhs); // 复制构造函数
  string(string&& rhs); // 移动构造函数
};
  • 可见,const右值无法传递给移动构造函数,因为移动构造只接受非常量的string右值引用形参
  • 但是该右值可以传递给复制构造函数,因此调用的是复制构造,即使text已经转换成了右值!!!
  • 这种行为对于维持常量正确性至关重要,因为如果const值能够被移动出去,就有可能被改变,所以语言不允许常量对象传递到有可能改变它们的函数(如移动构造)

这个示例说明了几个问题:

  • 针对常量对象执行移动操作将被偷偷地变换成复制操作,这可能会给性能调试带有困扰
  • 如果想取得对某个对象执行移动操作的能力,不要将其声明为常量
  • move不权不实际移动任何东西,甚至不保证经过其强制类型转换后的对象具备可移动的能力
  • 针对move的结果,唯一可确定的是,结果是个右值
forward很完美,但可能并没有想象中的那么完美

与move不同,forward仅在特定条件下将实参强制转换为右值。

典型使用场景如下:

void process(const Foo& lval);
void process(Foo && rval);

template<typename T>
void logAndProcess(T&& param)
{
	auto now = std::chrono::system_clock::now();
	makeLogEntry("Calling 'process'", now);
	process(std::forward<T> param);
}

其中:

  • 形参param被传递给函数process,而process依据形参是左值还是右值进行了重载,实现不同的操作
  • 我们期望,当调用logAndProcess时,传入的是左值,则以左值被process处理,右值也同样传给process处理
  • forward干的就是这个事情:
Foo f;

logAndProcess(f); // 传入的是左值,process以左值来处理
logAndProcess(std::move(f)); // 传入的是右值,process以右值处理

也就是说,使用forward可以实现完美转发,但完美转发有时候并不完美,甚至不能转发。

下面是一些需要格外注意的情形:

  • 大括号初始化物,下面两种情况会导致失败:

    • 编译器无法为函数形参推导出类型
    • 编译器为函数形参推导出了“错误”的类型
  • 0和NULL用作空指针:它们会被推导为int,而非传递实参的指针类型,应该使用nullptr

  • 仅有声明的整形static const 成员变量:能正常进行函数调用,它们没有内存,因此不能被引用,万能引用自然会失败

  • 重载的函数名字和模板名字:这能通过编译,但转发函数会执行失败,因为它无法决定使用哪个重载的版本

  • 位域用作函数实参:非const引用不得绑定到位域,位域是由机器字的若干任意部分组成的,无法对其直接取址。解决办法是传递位域的副本

小结

move和forward在作为c++11中引入的关键特性,使用得当可以达到很多以前很难实现的功效。

但也要注意避免使用上的陷阱,防止只是表面上使用这些特性,却没有收到任何实际的效果。

参考资料

《Effective Modern C++》

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值