如果说新的语言特性使得过去的最佳实践不再成立的话,我想move构造函数和std::move所代表的move语义应该算其中一个。
在解释move引起的变化之前,这里先定义一个支持自定义move操作的类
class Foo { public: explicit Foo(int value) : value_{value} { std::cout << "Foo(int)\n"; } // copy Foo(const Foo &foo) : value_{foo.value_} { std::cout << "Foo(copy)\n"; } // copy assignment Foo &operator=(const Foo &foo) = delete; // move Foo(Foo &&foo) { std::cout << "Foo(move)\n"; value_ = foo.value_; foo.value_ = 0; } // move assignment Foo &operator=(Foo &&foo) { std::cout << "Foo(move assignment)\n"; value_ = foo.value_; foo.value_ = 0; return *this; } int value() const { return value_; } void set_value(int value) { value_ = value; } ~Foo() { std::cout << "~Foo(" << value_ << ")\n"; } friend std::ostream &operator<<(std::ostream &os, const Foo &foo) { os << "Foo(" << foo.value_ << ")"; return os; } private: int value_; };
注意构造函数 Foo(Foo&& foo) ,和一般的reference的标记 & 不同,这里有两个 & 符号。其次,参数没有加const。
move构造函数要做的事情,是把输入参数所拥有的内容移动到自己的实例中,类似数据所有权转移。具体来说
Foo f1{1}; Foo f2 = std::move(f1}; // f1 becomes Foo{0} // f2 becomes Foo{1}
上述代码中,f1的数据被转移到了f2中,f1中的数据不再可用(这里被置为0)。
这样做有什么用呢?第一个想到的就是代码中数据所有权的转移。
在给出所有权转移的例子之前,复习一下C++中的copy和borrow。
void with_operation1(Foo foo) { } void with_operation2(Foo &foo) { std::cout << foo.value() << std::endl; foo.set_value(2); } void with_operation3(const Foo &foo) { std::cout << foo.value() << std::endl; // foo.set_value(2); } int main() { Foo foo{1}; // copy with_operation1(foo); // borrow with_operation2(foo); // borrow with_operation3(foo); return 0; }
with_operation1中Foo会被复制,对于其他语言背景的人来说,这是必须了解和注意的。
with_operation2和with_operation3中Foo不会被复制,根据是否有const来决定是否可以修改参数中的Foo。
事情看起来很完美?似乎没有引入move语义的必要?
考虑一个FooWrapper
class FooWrapper { public: explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {} private: Foo foo_; }; int main() { Foo foo{1}; FooWrapper wrapper{std::move(foo)}; // wrapper.foo_ moved here return 0; }
在构造了Foo之后,需要把所有权转给FooWrapper。
在没有move之前,你可能会考虑指针。但是有了move之后,你可以通过std::move间接转移数据内容达到所有权转移的效果。转移之后main函数内栈上构造的Foo也可以安全销毁。
如果你执行上述程序,可以得到以下结果
Foo(int) Foo(move) ~Foo(1) ~Foo(0)
也就说,FooWrapper中的foo_和main函数中的foo是两个不同的变量,std::move触发了数据转移,间接达到了所有权转移。
这里为什么一直强调是数据所有权的转移呢?原因是变量本身没有被move,这点很重要。所以变量的析构函数仍旧会被调用。假如你的变量中包含指针的话,不能简单地复制一下就结束,需要置原变量的指针为nullptr。
举个例子
class IntPointerHolder { public: explicit IntPointerHolder(int *ip) : ip_{ip} {} // no copy IntPointerHolder(const IntPointerHolder &) = delete; // no copy assignment IntPointerHolder &operator=(const IntPointerHolder &) = delete; // move IntPointerHolder(IntPointerHolder &&holder) { ip_ = holder.ip_; holder.ip_ = nullptr; } // no move assignment IntPointerHolder &operator=(IntPointerHolder &&holder) = delete; ~IntPointerHolder() { delete ip_; } private: int *ip_; };
注意move构造函数里设置holder.ip_为nullptr的地方。这是必须做的,否则两个变量(原变量和目标变量)的析构函数都会被调用,造成double free问题。
上述代码其实在标准库中对应有一个unique_ptr(C++11引入),可以达到完全一样的效果。unique_ptr不支持copy,只支持move,可以帮助你写出所有权唯一的代码。比如说
class SimpleIntArray { public: explicit SimpleIntArray(size_t length): array_{new int[length]}, length_{length} { for(int i = 0; i < length; ++i) { array_[i] = 0; } } void debug() { std::cout << length_ << ' '; if(array_) { for(int i = 0; i < length_; i++) { std::cout << array_[i] << ' '; } } std::cout << std::endl; } private: std::unique_ptr<int[]> array_; size_t length_; }; int main() { SimpleIntArray array1{3}; array1.debug(); SimpleIntArray array2 = std::move(array1); array1.debug(); array2.debug(); return 0; }
输出结果为
3 0 0 0 3 3 0 0 0
注意代码中没有定义move构造函数,编译器默认生成的move构造函数中会逐个move成员变量,不支持move操作的成员变量会回退到copy操作。
可以想到,如果SimpleIntArray直接操作指针,并且要处理数据所有权转移的话会是一件比较麻烦的事情。相比之下这里通过unique_ptr非常简单地实现了数据转移和指针管理。
这里注意一点,从输出来看,默认生成的move构造函数在处理length_的转移时,并没有置为0。严格来说,move之后的原数据,内部状态如何是不确定的。所以理论上不应该去调用。硬要解决的话,这里可以自定义实现move构造函数,写一个直接move的length类型或者编码实践要求。
回到之前FooWrapper的代码,可以看到有两个std::move,如果问是否可以改成一个,结论是可以,不过个人不推荐。这里重要的是理解std::move做了什么,为什么需要std::move。
class FooWrapper { public: explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {} private: Foo foo_; }; int main() { Foo foo{1}; FooWrapper wrapper{std::move(foo)}; // wrapper.foo_ moved here return 0; }
FooWrapper wrapper{std::move(foo)}; 这行代码中,std::move是因为FooWrapper的构造函数的参数是 Foo&& ,换句话说要求是一个rvalue。这里不具体展开什么是rvalue。只要知道对于在栈上构造的foo来说,必须通过std::move转换为FooWrapper需要的Foo&&。
作为参考,临时的Foo可以不使用std::move
FooWrapper wrapper{Foo{1}};
可以看到,临时变量的情况下只有一个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带来的影响。本篇的最后,分析一下对于常规的函数输入和输出的影响。
首先是返回值
Foo makeFoo() { return Foo{1}; } int main() { // Foo(int) // copy/move is omitted Foo foo = makeFoo(); return 0; }
你可能没有看到std::move也没有看到move构造函数被调用,原因是编译器的“构造函数消除”优化启用了。如果你关闭了这个优化,可以看到move构造函数被调用。假如你禁用move构造函数的话,copy构造函数被调用。
顺便说一句,C++中的函数的返回值是否可以是一个对象?个人觉得,对于一个类似factory一样返回函数内栈上分配的对象的函数的话,由于“构造函数消除”优化的关系,和通过方法传入其实没有太大区别。即使没有“构造函数优化”,默认使用move而不是copy。但是如果你说的是异常处理,并且不想用C++默认的exception机制的话,那就是另外一回事情了。
回到move语义对函数的影响,之前的with_operation系列其实还有move版本
void with_operation4(Foo&& foo) { } void with_operation5(Foo&& foo) { foo.set_value(2); Foo foo2 = std::move(foo); std::cout << foo2 << std::endl; } int main() { Foo f1{1}; Foo f2 = std::move(f1); // f1 become Foo(0) now // with_operation4(f2); with_operation4(std::move(f2)); with_operation5(std::move(f2)); return 0; }
注意with_operation4虽然要求输入是Foo&&,但是函数内部没有通过std::move转移数据,所以main函数中的f2没有任何变化。相对的,with_operation5中转移了数据,所以f2数据不再有效。
考虑一个问题,假如你希望某个函数接管某个变量的数据所有权,该怎么定义函数?
答案其实很明显,上面的几段代码中都出现了,使用 T&& 这种形式
void some_function(Foo&& foo) { Foo bar = std::move(foo); } int main() { Foo foo{1}; some_function(std::move(foo)); some_function(Foo{2}); }
这里 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对于返回值的影响之后,具体可以怎么写呢?
class FooBuilder { public: explicit FooBuilder(int i): foo_{i} {} Foo build() { return std::move(foo_); } Foo& build2() { return foo_; } Foo build3() { return foo_; } Foo&& build4() { return std::move(foo_); } private: Foo foo_; }; int main() { FooBuilder builder1{1}; builder1.build().set_value(-1); // move, ok FooBuilder builder2{2}; Foo foo2 = builder2.build(); // move, ok FooBuilder builder3{3}; Foo foo3 = builder3.build2(); // copy FooBuilder builder4{4}; Foo foo4 = builder4.build3(); // copy FooBuilder builder5{5}; builder5.build4().set_value(-1); // not moved here Foo foo5 = builder5.build4(); return 0; }
个人在尝试了4种情况后,认为第一种即返回值是Foo,内部用std::move的方式最好。
build2结果是引用,除了赋值时会copy之外,还存在可以通过build2修改内部foo_的问题。
build3纯粹是copy。
build4在赋值时move,但是存在通过build4修改内部foo_的问题。
最后build在赋值时move,不存在通过build修改内部foo_的问题。
总结
C++11引入的move语义带来很多变化,个人认为理解move语义对于写好C++11的代码很重要。希望我的分析对各位有用。