【C++11】move构造函数和std::move

如果说新的语言特性使得过去的最佳实践不再成立的话,我想move构造函数和std::move所代表的move语义应该算其中一个。

在解释move引起的变化之前,这里先定义一个支持自定义move操作的类

注意构造函数 Foo(Foo&& foo) ,和一般的reference的标记 & 不同,这里有两个 & 符号。其次,参数没有加const。

move构造函数要做的事情,是把输入参数所拥有的内容移动到自己的实例中,类似数据所有权转移。具体来说

上述代码中,f1的数据被转移到了f2中,f1中的数据不再可用(这里被置为0)。

这样做有什么用呢?第一个想到的就是代码中数据所有权的转移。

在给出所有权转移的例子之前,复习一下C++中的copy和borrow。

with_operation1中Foo会被复制,对于其他语言背景的人来说,这是必须了解和注意的。

with_operation2和with_operation3中Foo不会被复制,根据是否有const来决定是否可以修改参数中的Foo。

事情看起来很完美?似乎没有引入move语义的必要?

考虑一个FooWrapper

在构造了Foo之后,需要把所有权转给FooWrapper。

在没有move之前,你可能会考虑指针。但是有了move之后,你可以通过std::move间接转移数据内容达到所有权转移的效果。转移之后main函数内栈上构造的Foo也可以安全销毁。

如果你执行上述程序,可以得到以下结果

也就说,FooWrapper中的foo_和main函数中的foo是两个不同的变量,std::move触发了数据转移,间接达到了所有权转移。

这里为什么一直强调是数据所有权的转移呢?原因是变量本身没有被move,这点很重要。所以变量的析构函数仍旧会被调用。假如你的变量中包含指针的话,不能简单地复制一下就结束,需要置原变量的指针为nullptr。

举个例子

注意move构造函数里设置holder.ip_为nullptr的地方。这是必须做的,否则两个变量(原变量和目标变量)的析构函数都会被调用,造成double free问题。

上述代码其实在标准库中对应有一个unique_ptr(C++11引入),可以达到完全一样的效果。unique_ptr不支持copy,只支持move,可以帮助你写出所有权唯一的代码。比如说

输出结果为

注意代码中没有定义move构造函数,编译器默认生成的move构造函数中会逐个move成员变量,不支持move操作的成员变量会回退到copy操作。

可以想到,如果SimpleIntArray直接操作指针,并且要处理数据所有权转移的话会是一件比较麻烦的事情。相比之下这里通过unique_ptr非常简单地实现了数据转移和指针管理。

这里注意一点,从输出来看,默认生成的move构造函数在处理length_的转移时,并没有置为0。严格来说,move之后的原数据,内部状态如何是不确定的。所以理论上不应该去调用。硬要解决的话,这里可以自定义实现move构造函数,写一个直接move的length类型或者编码实践要求。

回到之前FooWrapper的代码,可以看到有两个std::move,如果问是否可以改成一个,结论是可以,不过个人不推荐。这里重要的是理解std::move做了什么,为什么需要std::move。

FooWrapper wrapper{std::move(foo)}; 这行代码中,std::move是因为FooWrapper的构造函数的参数是 Foo&& ,换句话说要求是一个rvalue。这里不具体展开什么是rvalue。只要知道对于在栈上构造的foo来说,必须通过std::move转换为FooWrapper需要的Foo&&。

作为参考,临时的Foo可以不使用std::move

可以看到,临时变量的情况下只有一个FooWrapper内部的std::move(具体编码中,如果不确定是否需要std::move,可以先不加,看编译器是否报错)。

因为外层的std::move只是起到转换为rvalue的作用,所以理论上不会触发move构造函数。事实上也是这样的,实际触发move构造函数的是 foo_{std::move(foo)} 这句。注意,这里如果不加 std::move,调用的会是copy构造函数。

小结一下

  • Wrapper的构造函数中使用std::move
  • 调用Wrapper构造函数的地方看情况使用std::move,比如栈上分配的变量

老实说,要完全讲清楚什么时候用std::move必须完全理解rvalue,但是看 cppreference 上的定义一头雾水。所以个人觉得,常见pattern+自己试错可能是最好的。

C++11引入的move语义很重要的一个原因,个人认为是标准库增加了对于move的支持。你想利用好新版本的功能,而不是固守旧版本的最佳实践的话,有必要了解move带来的影响。本篇的最后,分析一下对于常规的函数输入和输出的影响。

首先是返回值

你可能没有看到std::move也没有看到move构造函数被调用,原因是编译器的“构造函数消除”优化启用了。如果你关闭了这个优化,可以看到move构造函数被调用。假如你禁用move构造函数的话,copy构造函数被调用。

顺便说一句,C++中的函数的返回值是否可以是一个对象?个人觉得,对于一个类似factory一样返回函数内栈上分配的对象的函数的话,由于“构造函数消除”优化的关系,和通过方法传入其实没有太大区别。即使没有“构造函数优化”,默认使用move而不是copy。但是如果你说的是异常处理,并且不想用C++默认的exception机制的话,那就是另外一回事情了。

回到move语义对函数的影响,之前的with_operation系列其实还有move版本

注意with_operation4虽然要求输入是Foo&&,但是函数内部没有通过std::move转移数据,所以main函数中的f2没有任何变化。相对的,with_operation5中转移了数据,所以f2数据不再有效。

考虑一个问题,假如你希望某个函数接管某个变量的数据所有权,该怎么定义函数?

答案其实很明显,上面的几段代码中都出现了,使用 T&& 这种形式

这里 some_function(Foo&) 肯定不行,对于 some_function(Foo{2}) 是无法编译通过的。

some_function(Foo)可以编译通过,但是 some_function(foo) 是复制, some_function(std::move(foo)) 调用时触发一次move,some_function中又move,结果有两次move。

综上所述,对于有数据转移要求的函数,使用 T&& 这种形式。

最后一个问题,对于builder这种类(Builder设计模式),如何定义build方法?

builder这种类,很典型的从类中向外数据转移。在了解了move对于返回值的影响之后,具体可以怎么写呢?

个人在尝试了4种情况后,认为第一种即返回值是Foo,内部用std::move的方式最好。

build2结果是引用,除了赋值时会copy之外,还存在可以通过build2修改内部foo_的问题。

build3纯粹是copy。

build4在赋值时move,但是存在通过build4修改内部foo_的问题。

最后build在赋值时move,不存在通过build修改内部foo_的问题。

总结

C++11引入的move语义带来很多变化,个人认为理解move语义对于写好C++11的代码很重要。希望我的分析对各位有用。