C++11引入了move语义,弥补了C++的"值传递"带来的拷贝大量数据带来的性能问题的不足。那么,move语义的原理到底是什么呢?以及move语义有哪些新坑呢?
TLDR:
- move语义的核心概念是lvalue(左值)和rvalue(右值),本质上需要理解编译器认为rvalue的内存是暂时的因此其内存可以被重用以避免拷贝。
std::move
并不move;它只是无条件的将lvalue强制转换成rvalue,别的什么也不做。std::forward<T>
并不转发,它会在传递参数的时候保持lvalue为lvalue,保持rvalue为rvalue。一般情况下仅在定义模板函数时使用。
lvalue和rvalue
当我们谈起lvalue和rvalue的时候,我们首先应当意识到它们原始的定义:lvalue在赋值符号左边,而rvalue在右侧。最原始的含义,lvalue对应的lifetime要到对应的左值变量不在当前作用域才结束,而rvalue的lifetime一直是临时的。后续对lvalue和rvalue的演化非常复杂,并且不同的标准,如C++11,C++17,C++20均有差异。这种演化逐渐抛弃了"赋值符号左右边"的概念,转而以lifetime作为本质属性。
有标识符(glvalue) | 无标识符 | |
---|---|---|
可以由此move | lvalue | |
不可由此move | xvalue | prvalue |
在这里,我们需要牢记的一点是:
rvalue对应的lifetime是临时的;一旦一个变量标识符成为rvalue,后面任意对它的内容的引用都是未定义行为(UB)。
这里额外要注意的是,Data && D
中尽管D
是对rvalue的引用,但D本身是一个lvalue(它是一个变量标识符)。
std::move
std::move
并不move,它做的事情是把原先的lvalue/rvalue无条件转换(cast)成为rvalue。
这对于普通的表达式对应的rvalue而言是顺理成章的,它们本身就只能被使用一次;但这个行为对于变量标识符lvalue一旦转变成rvalue后,很容易会带来bug。
在libstdc++12中,std::move
的实现为:
c++
#if __cplusplus >= 201703L
# define _GLIBCXX_NODISCARD [[__nodiscard__]]
#else
# define _GLIBCXX_NODISCARD
#endif
template<typename _Tp>
struct remove_reference
{ typedef _Tp type; };
template<typename _Tp>
_GLIBCXX_NODISCARD
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
那为什么还使用std::move
这个看似具有迷惑性的名字呢?此时就要想起rvalue对应的lifetime是临时的
这个准则------------当我们讲lvalue转换成rvalue后,对rvalue的引用都将变得不靠谱。这是一种编程契约:一旦转化成rvalue,当前语句就可以随意处置这个原本还是lvalue的rvalue了。事实上,STL中对move相关的操作都假定了应用层面的代码遵循了这一契约。
move构造函数的正确形式
应当遵循下面的规约:
- 参数为
T&&
形式; - 不能抛异常;因此应当加上
noexcept
(但加上它并不能保证不抛异常)。
以class Tree
为例。
c++
class Tree {
Tree *parent;
int value;
std::string name;
};
move构造函数的正确签名形式为:
c++
Tree(Tree&&tree) noexcept;
对于move构造函数的实现,应当遵循下面的规约:
- move构造函数可以假定move的对象(source)的内存可以完全被自己所用;
- 构造函数应当保证在被调用后source处于合法状态;其中尤其要求source的指针被置为nullptr;
- 建议被调用后source的域值被置为该域值对应的类型的默认值。
对于Tree
,可以实现为
c++
Tree(Tree&&tree) noexcept:
parent(std::exchange(tree.parent, nullptr)), // 必须将tree.parent置为nullptr
value(std::exchange(tree.value), 0), // 可以为value(tree.value),或value(std::move(tree.value))
name(std::exchange(tree.name), std::string{}) // 或name(std::move(tree.name))
{}
注意事项:
std::exchange
会返回第一个参数的值,并且将第一个参数的结果修改为第二个参数的结果;此外,该函数的内部实现会move第一个参数,因此不需要对第一个参数使用std::move。- 指针必须置为nullptr,这是因为只有置为nullptr参能保证source在离开它的作用域调用析构函数是,对应的指针执行delete函数等,导致指针对应的地址被析构两次。
move构造函数蜕化为copy构造函数
如果一个class/struct/union仅定义了copy构造函数而没有move构造函数,则在实际调用时会fallback到copy构造函数。
c++
#include <iostream>
#include <string>
#include <utility>
struct Tree {
Tree* parent = nullptr;
int value;
std::string name;
Tree() = default;
Tree(Tree const& tree)
: parent(tree.parent), value(tree.value), name(tree.name) {
std::cout << "copy constructor\n";
}
Tree operator=(Tree const& tree) {
std::cout << "copy assignment\n";
if (this == &tree) {
return *this;
}
this->parent = tree.parent;
this->value = tree.value;
this->name = tree.name;
return *this;
}
// Tree(Tree&& tree) noexcept
// : parent(std::exchange(tree.parent, nullptr)),
// value(std::exchange(tree.value, 0)),
// name(std::move(tree.name)) {}
// Tree operator=(Tree&& tree) noexcept {
// std::cout << "move assignment\n";
// if (this == &tree) {
// return *this;
// }
// this->parent = std::exchange(tree.parent, nullptr);
// this->value = tree.value;
// this->name = std::exchange(tree.name, std::string{});
// return *this;
// }
};
int main(void) {
Tree aTree;
Tree bTree = std::move(aTree);
bTree = std::move(aTree);
}
自动生成move构造函数
在下面的情形下,编译器会自动为class/struct/union生成move构造函数
- 没有用户自定义的move构造函数
- 没有用户自定义的copy构造函数
- 没有用户自定义的move运算符
- 没有用户自定义的copy运算符
- 没有用户自定义的析构函数
编译器自动生成的move构造函数签名符合上述move构造函数签名要求。其实现是对source每个域变量调用std::move
初始化this域变量的方式;即其实现类似于上面Tree
的例子。
move赋值运算符的正确形式
move赋值运算符和move构造函数类似。但多了一种情形:可能会出现调用自赋值情形的move赋值运算符
(如Tree t; t = std::move(t);
)。
对于这种情形,C.65的建议是:
Make move assignment safe for self-assignment
以Tree为例。
move赋值运算符的正确签名为:
c++
Tree operator=(Tree&&tree) noexcept;
其实现为:
c++
Tree operator=(Tree&&tree) noexcept {
if (this == &tree) {
return *this;
}
this.parent = std::exchange(tree.parent, nullptr);
this.value = std::move(tree.value);
this.name = std::exchange(tree.name, std::string{});
return *this;
}
注意:理论上并不要求检查this
和&tree
,而只要保证该赋值不crash;这是因为std::move(t)
意味着t
的内存已经可以被move赋值运算符所用了。但是为了保证不造成惊奇,检查this
和&tree
是必要的。
自动生成move赋值运算
在下面的情形下,编译器会自动为class/struct/union生成move赋值运算符:
- 没有用户自定义的move运算符
- 没有用户自定义的move构造函数
- 没有用户自定义的copy构造函数
- 没有用户自定义的copy运算符
- 没有用户自定义的析构函数
同样,编译器会生成正确形式的通过调用域变量std::move
并会合理置nullptr地move赋值运算符。TODO:检验是否会考虑自赋值?
rule-of-five/six
由于
- copy构造函数和copy赋值运算符通常会被编译器自动生成(仅在显式delete情形下不会)
- 而move构造函数和move赋值运算符会在上面提及地情形下自动生成
- 默认构造函数会在没有用户自定义构造函数时自动生成
- 默认析构函数会在没有用户自定义的情形下自动生成
因此,优秀实践建议如果定义了copy构造函数、move构造函数、copy赋值运算符、move赋值运算符、析构函数其中之一,则应当把其余的也都显式定义(可以使用default
或delete
),这成为rule-of-five。
- 这里没有包含默认构造函数时因为它通常是no-op;如果包含了默认构造函数,则称为rule-of-six。
- 反之,如果没有打算实现当中之一的函数,那么就不应当手动实现其他函数;这被称为rule-of-zero。
匹配规则举例
编号 | 构造函数签名 |
---|---|
① | Tree(Tree&); |
② | Tree(const Tree&); |
③ | Tree(Tree&&); |
④ | template <typename T> T(const T&); |
⑤ | template <typename T> T(T&); |
⑥ | template <typename T> T(const T&&); |
c++
Tree getTree(); // 函数原型
auto w = getTree();
//
c++
Tree& getTree(); // 函数原型
auto w = getTree();
//
c++
const Tree& getTree(); // 函数原型
auto w = getTree();
//
std::unique_ptr<T>
的例子
对于函数的return变量,它是rvalue,因此可以return一个std::unique_ptr<T>
类型的变量。
remove const
TODO
std::forward<T>
之前的&形式 | 变为 | 之后的&形式 |
---|---|---|
& & |
➜ | & |
&& & |
➜ | & |
& && |
➜ | & |
&& && |
➜ | && |
可见,std::forward
是有条件地cast。其用法一般主要在写模板函数/类时需要用到,此处按下不表。