移动语义与转发
C++11 之后,很多代码会出现 &&、std::move、std::forward、emplace_back。它们看起来零散,实际上都围绕同一个问题——当一个对象不再需要保留原有资源时,能不能把资源直接交给新对象,而不是重新复制一份?
例如,一个 std::string 内部可能在堆上分配了字符数组。复制字符串时,需要重新申请内存并复制每一个字符;但如果原字符串马上就要销毁,那么新对象完全可以直接接管那块内存。这就是移动语义的核心。
左值和右值
一种判断方式是,左值为有名字、可重复的对象,右值为即将消亡的临时值。比如 int calt = n + 1,calt 有变量名,还可以继续访问,而 n+1 只是临时的计算结果,表达式结束后通常就没有意义了。
传统的 C++ 的引用为左值引用:
int value = 2;
int& ref = value;在这个例子中, int& 只可以绑定左值,不能绑定临时值,比如 int& ref = 42;。而在 C++11 中,新增加了右值引用:
int&& ref_r = 42;右值引用的目的是告知编译器这个对象可以当作临时资源被处理,允许他的资源被转移。函数 std::move() 操作后的对象就是一个右值引用。比如下面的例子:
std::string a = "a long text";
std::string b = std::move(a);在这段代码中,std::move(a) 将 a 转化为 std::string&& 类型的对象,所以编译器就可以优先匹配 std:string(std::string&& other),即移动构造而不是拷贝构造,提高性能。
右值引用最常见的用途是对于大资源对象,可以用其来选择移动构造/赋值的重载函数。同时,其标记了允许后续代码把 a 当作可被消耗的对象,比如可以让 b 拥有原先 a 的内存,而 a 清空。
昂贵的拷贝
先给出一个没有移动语义简化的字符串类:
class String {
public:
String(const char* text) {
size_ = std::strlen(text);
data_ = new char[size_ + 1];
std::strcpy(data_, text);
}
~String() {
delete[] data_;
}
String(const String& other) {
size_ = other.size_;
data_ = new char[size_ + 1];
std::strcpy(data_, other.data_);
}
private:
char* data_ = nullptr;
std::size_t size_ = 0;
};调用:
String first("Modern C++");
String second = first;当复制字符串时,程序会为 second 申请一块新的内存,将 first 的字节逐个复制到新内存,然后两个对象分别管理自己的数据。这当然是安全的,但是成本取决于字符串的大小。这被称为深拷贝。对于大得多的字符串,这样做其实非常昂贵。
移动构造
现在,我们为这个 String 类添加移动构造函数:
class String {
public:
String(const char* text) {
size_ = std::strlen(text);
data_ = new char[size_ + 1];
std::strcpy(data_, text);
}
~String() {
delete[] data_;
}
String(const String& other) {
size_ = other.size_;
data_ = new char[size_ + 1];
std::strcpy(data_, other.data_);
}
String(String&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
private:
char* data_ = nullptr;
std::size_t size_ = 0;
};调用:
String first("Modern C++");
String second = std::move(first);其中,移动构造函数的类型为 String&&。他在函数中干了两件事:让新的对象接管 other 的数据和大小;将原先的对象设置为空。此时,first 的资源被转移到 second 对象下,但是 first 依旧是一个合法的 String 对象。
移动赋值
构造对象和给已有对象赋值是两件事,因此还需要移动赋值运算符:
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}调用:
String first("first");
String second("second");
second = std::move(first);在这个重载函数中,second 先释放自己原先的 "second",然后 second 接管 first 的字符数组,first 被置为空状态, second 最终保存 "first"。这里的自赋值检查也很重要。虽然实际中 object = std::move(object) 不常见,但没有这个判断时,先释放自己的资源再接管资源,可能导致错误。
Rule of Five
当一个类手动管理资源,例如堆内存、文件句柄或互斥锁时,我们一般称其为资源类。在资源类的设计中,通常要一起考虑五个特殊的成员函数,这被称为 Rule of Five。具体如下:
~String();析构函数String(const String& other);拷贝构造String& operator=(const String&);拷贝赋值String(String&& other);移动构造String& operator=(const String&&);移动赋值
不过,更为现代的建议是优先使用 std::string、std::vector 等 RAII 类型,让编译器自动生成这些操作。
关于 std::move()
std::move() 很容易被认为是执行移动操作,但是其本身并不移动任何资源,仅仅是将一个左值转化为右值。真正发生资源转移的是 String 的移动构造函数。这一点在 std::move() 函数声明可以看出来:
template <class _Tp>
[[__nodiscard__]] inline _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR __libcpp_remove_reference_t<_Tp>&&
move(_LIBCPP_LIFETIMEBOUND _Tp&& __t) _NOEXCEPT {
using _Up _LIBCPP_NODEBUG = __libcpp_remove_reference_t<_Tp>;
return static_cast<_Up&&>(__t);
}但是,针对 const 对象使用 move 函数有例外的情况。如果对于一个 const String 对象使用 move 函数,那么大概率其会调用拷贝构造,而不是移动构造。如下代码所示。
const String text("hello");
String copy = std::move(text);这是因为,我们调用移动构造函数时,需要修改原来的对象,将其内容清空(参见上文子实现的 String 类)。但是 text 为 const 对象,无法被修改。所以他最终会被匹配到拷贝构造函数。
所以还可以注意到,移动构造函数往往喜欢添加 noexcept 属性。如果遇到上述的情况,其不应该抛出异常,而是转而尝试改用其他的函数进行匹配。
完美转发
加入我们写一个通用函数用于将对象加入容器:
template <typename T>
void add(std::vector<T>& values, T value) {
values.push_back(value);
}这样写看似没问题,但 value 是函数内部的具名变量,因此它永远是左值。即使调用者传入右值,也会通过按左值被传给 push_back,会发生额外的拷贝。所以可以写成以下的形式。
template <typename T>
void add(std::vector<T>& values, T value) {
values.push_back(std::move(value));
}这样虽然保证了加入到容器中的是右值,但是参数传入函数时仍然会创建中间对象,发生拷贝。所以更加通用的方法是转发引用。
template <typename T>
void process(T&& value);上面的 T&& 在模板类型推导场景中称为转发引用。他可以同时接受左值和右值,这是通过 C++ 语言的引用折叠规则实现的:
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&可以发现,当我们传入左值时,其最终参数是左值引用;传入右值时,最终参数是右值引用。
虽然转发引用能够接收左值和右值,但在函数内部,变量 value 有名字,表达式依然是左值。如果调用者传入的是右值,右值属性在这里就丢失了,如下所示。
template <typename T>
void wrapper(T&& value) {
target(value); // value 在这里是左值
}新的函数 std::forward<T> 的作用是根据 T 的推导结果恢复原始值类别:
void target(const String&) {
std::cout << "接收左值\n";
}
void target(String&&) {
std::cout << "接收右值\n";
}
template <typename T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}调用:
String text("hello");
wrapper(text); // 接收左值
wrapper(String("temporary")); // 接收右值这就是完美转发:让包装函数像“透明通道”一样,把参数的类型和值类别尽量原样传给下一层函数。
返回值优化(拷贝消除)
参考以下案例:
String create_message() {
String message("Hello");
return message;
}
// Call
String result = create_message();按照上文的解释,程序会构造一个 String 对象,然后移动这个返回值到临时变量,然后将临时变量移动到 result。实际上,现代编译器往往会直接在 result 的位置上构造 message,这被称为返回值优化(RVO/NRVO),即拷贝消除。从 C++17 开始,许多这种场景的拷贝消除是语言规则保证的,而不是可有可无的编译器优化。所以,返回局部对象时,要直接写:
String create() {
String result("Hello");
return result;
}而不要 return std::move(result)。这可能阻止 NRVO,反而让一次本可消除的构造变成实际移动。