std::move 概念
参考:《深入理解C++11:新特性解析与应用》
在 C++11 中,标准库在 <utility>
中提供了一个有用的函数 std::move
,这个函数的名字具有迷惑性,实际上 std::move 并不能移动任何东西,唯一的功能就是将一个左值强制转换为一个右值引用,继而可以通过右值引用使用该值,以便用于移动语义。
从实现上来看,std::move 基本等同于一个类型转换:
static_cast<T&&>(lvalue);
值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。如果期望 std::move 转换的左值变量 lvalue 能立即被析构,那么肯定会失望了。看以下例子:
#include <iostream>
using namespace std;
class Moveable
{
public:
Moveable() : i(new int(3)) {}
~Moveable() { delete i; }
Moveable(const Moveable& m) : i(new int(*m.i)) {}
Moveable(Moveable&& m) : i(m.i)
{
m.i = nullptr;
}
int *i;
};
int main()
{
Moveable a;
Moveable c(std::move(a)); //调用移动构造函数
cout << *a.i << endl; //运行时错误
}
我们为类型 Moveable 定义了移动构造函数。但在调用的时候使用了 Moveable c(std::move(a)); 这样的语句。将 a 转换右值。这样一来,a.i 就被 c 的移动构造函数设置为非空值。由于 a 等到 main 结束才能析构,随后对 *a.i 操作直接发生严重错误。这就是一个典型的误用 std::move 的例子。
要用该函数,必须清楚需要转换的时候。
我们要转换为右值引用的对象是一个确定生命即将结束的对象,看以下正确的例子:
#include <bits/stdc++.h>
using namespace std;
class HugeMen
{
public:
HugeMen(int size) : sz(size > 0 ? size : 1)
{
cout << "HugeMen Contruct." << endl;
c = new int[sz];
}
~HugeMen() { delete[] c; }
HugeMen(const HugeMen& hm) : sz(hm.sz), c(new int(*hm.c))
{
cout << "HugeMen Copy." << endl;
}
HugeMen(HugeMen&& hm) : sz(hm.sz), c(hm.c)
{
cout << "HugeMen Move." << endl;
hm.c = nullptr;
}
int *c;
int sz;
};
class Moveable
{
public:
Moveable() : i(new int(3)), h(1024)
{
cout << "Moveable Contruct." << endl;
}
~Moveable() { delete i; }
Moveable(const Moveable& m) : i(new int(*m.i)), h(m.h)
{
cout << "Moveable Copy." << endl;
}
Moveable(Moveable&& m) : i(m.i), h(std::move(m.h))
{
cout << "Moveable Move." << endl;
m.i = nullptr;
}
int *i;
HugeMen h;
};
Moveable GetTemp()
{
Moveable tmp = Moveable(); //构造 + 移动
return tmp; //移动
}
int main()
{
Moveable a(GetTemp()); //移动
}
这个例子的运行结果是:
$ ./test
HugeMen Contruct.
Moveable Contruct.
HugeMen Move.
Moveable Move.
HugeMen Move.
Moveable Move.
HugeMen Move.
Moveable Move.
在 Moveable 的移动构造函数中,看到了 std::move 的使用,将 m.h 强制转换为右值,迫使 Moveable 中的 h 能够实现移动构造。因为 m 将在表达式 Moveable a(GetTemp()); 结束后被析构掉,所以可以使用std::move。另外使用 std::move 使用是必要的。如果不适用 std::move(m.h) 这样的表达式,而直接使用 m.h 将会得到如下的运行结果:
$ ./test
HugeMen Contruct.
Moveable Contruct.
HugeMen Copy.
Moveable Move.
HugeMen Copy.
Moveable Move.
HugeMen Copy.
Moveable Move.
这是 C++ 有趣的地方:可以接受右值的右值引用本身却是个左值。这里的 m.h 引用了一个确定的对象,而且 m.h 也有名字,可以使用 &m.h 取到地址,因此是个不折不扣的左值。但是它确实会很快灰飞烟灭,在 Moveable a(GetTemp()); 构造完对象 a 后就结束了。这里使用 std::move 强制其为右值就不会有问题了。若不这样做,由于 m.h 是个左值,就会调用 HugeMem 的拷贝构造函数来构造 Moveable 的成员 h。移动语义就没有想类的成员传递下去,而且会有一定的性能损失。
为了保证移动语义的传递,在编写移动构造函数时,总应该记得使用 std::move 转换拥有形如堆内存、文件句柄等资源的成员为右值。这样一来,即使没有移动构造的话,也能接受常量左值的构造函数版本也会轻松实现拷贝,因此也不会引起大的问题。
移动语义的一些要点
如果这样声明移动构造函数:
Moveable(const Moveable&&)
或者这样声明函数:
const Moveable ReturnVal();
都会使得临时变量常量化,成为一个常量右值,临时变量的引用也就无法修改,导致无法实现移动语义。
因此,在实现移动语义时一定要排除掉不必要的 const 关键字。
C++11 中,拷贝/移动构造函数实际上有以下三个版本:
T Object(T& );
T Object(const T&); //拷贝构造
T Object(T&&); //移动构造
默认情况下,编译器会隐式生成一个默认移动构造函数(隐式表示不被使用则不生成)。但是如果程序员声明了自定义的拷贝构造函数、赋值构造函数、移动赋值函数、析构函数中的一个或多个,编译器都不会再生成默认版本。默认的移动构造和默认的拷贝构造一样,只能做简单的拷贝工作,对于一些简单的不包含资源的的类型而言,实现移动语义无关紧要,因为移动就是拷贝。
同样声明了移动构造函数、移动赋值函数、赋值构造函数、析构函数中的一个或多个,编译器也不会再生成默认的拷贝构造函数。
所以在 C++ 11 中,拷贝构造/赋值和移动构造/赋值必须同时提供,或者同时不提供,才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类仅能实现一种语义。
仅实现一种语义也比较常见。C++11 之前大多数类型的构造函数都是只是用拷贝语义。
而使用移动语义的类型很有趣,因为只有移动语义表明该类型的变量所拥有的资源只能被移动,而不能被拷贝,因此,移动语义的构造往往都是资源型的类型。比如说:智能指针,文件流等,都可以视为资源型类型。
在标准库中头文件 <type_traits>
里,可以通过一些辅助的模板类来判断一个类型是否是可移动的。比如:is_move_constructible
、is_trivially_move_constructible
、is_nothrow_move_constructible
,使用方法仍然是其成员 value。如:
cout << is_move_constructible<UnknownType>::value << endl;
就可以打印出 UnkbowType 是否可以移动,在一些情况下是非常有用的。
有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看下面这段 swap 模板函数代码:
template <class T>
void swap(T& a, T& b)
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
如果类型 T 是可以移动的,那么移动构造和移动赋值将会被用于这个置换。整个过程,代码都按照移动语义进行指针交换,不会有资源的释放与申请。如果 T 不可移动但可以拷贝,就会用拷贝语义来进行置换,但效率较低。这对于泛型编程来说,是很有意义的。
移动语义的异常处理
对于移动语义来说,抛出异常是危险的,可能移动语义没有完成,一个异常抛出来,导致一些指针成为悬挂指针。因此程序员编写时为其添加一个 noexcept 关键字,保证移动构造函数中抛出异常会直接调用 terminate 程序终止运行,而不是造成指针悬挂状态。
标准库中,可以用一个 std::move_if_noexcept 的模板函数代替 move 函数。该函数在类的移动构造函数没有 noexcept 关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有 noexcept 关键字时,返回一个右值引用,从而使变量可以使用移动语义。用法如下:
struct Nothrow
{
Nothrow() {}
//...
Nothrow(const Nothrow&&) noexcept
{
std::cout << "Nothrow move constructor." << endl;
}
};
int main()
{
Nothrow n;
Nothrow nt = move_if_noexcept(n);
return 0;
}
move_if_noexcept 是牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用 noexcept 进行描述,否则就会损失更多性能。这是库的开发者和使用者必须协同平衡考虑的。
RVO优化与移动语义
编译器中被称为 RVO/NRVO 的优化(返回值优化),事实上很多测试代码都使用了 -fno-elide-constructors
选项在 g++ 中关闭了这个优化,编译时如果不使用该选项的话,很多构造和移动都被省略了。比如如下代码:
A ReturnRvalue() { A a(); return a; }
A b = ReturnRvalue();
代开 g++ 的 RVO,对象 a 的拷贝和移动都没有了。b 变量实际使用了 ReturnRvalue 中 a 的地址,也就是 b 直接“霸占”了 a 变量。这是编译器中一个效果非常好的优化。不过 RVO 并不是对任何情况都有效。有些情况下,一些构造是无法省略的。还有一些情况下,即使 RVO 完成了,也不能达成最好的效果。但最终的结论是:移动语义可以解决编译器无法解决的优化问题。因为总是有用的。