C++对象移动
对象移动介绍
对象移动是新标准的特性,对象移动注重的是某块内存的所有权“转移”,而非拷贝这样将旧内存中的内容转到新内存中。
使用场景:
- 拷贝代价高。
- 对象拷贝后就被销毁。
- 重新分配内存过程。
- 如IO或unique_ptr这样包含不可被共享的资源。
支持:
- 标准库容器、string和shared_ptr类,可移动和拷贝。现在,容器可保存那些只具备移动操作的类型。
- IO或unique_ptr只可以移动。
右值引用
背景:右值引用(&&)是为了支持移动操作而引入的新引用类型。
性质:右值引用是必须绑定到右值的的引用(当右值引用为转发引用时除外),其只能绑定到一个将要被销毁/没有其他用户使用的对象(字面常量、表达式求值得到的临时对象)。
模板中的右值引用
左值引用类型推断、右值引用类型推断、绑定规则↓
C++常规的绑定规则中,右值引用不能绑定到左值上,但为了move等功能的实现,额外定义了两个绑定规则。
- 规则一:在模板类型参数推导中,当右值引用参数绑定到左值上,且右值引用指向模板类型参数(T&&),模板类型参数为实参的左值引用类型(例如传int类型变量给T&&,推导出T类型为int&,则参数类型为int& &&,即对左值引用的右值引用,但直接定义引用的引用是不可能的例如 X& &&)。
- 规则二:也叫“引用折叠”。如果出现规则一中由类型参数推导出现了引用的引用(右值的右值引用是正常的),则将会形成“引用折叠”。除了对右值引用的右值引用(T&& &&)折叠成右值引用(T&&),其余都将折叠成普通的左值引用类型(T&)。该规则亦适用于类型别名。
有了引用折叠,就知道了为什么使用T&&作为函数模板的参数类型(该情况下无cv限定的右值引用叫转发引用)。
- 在C++中,变量表达式(引用或非引用类型)都是左值,所以传左值根据规则一推出T类型为X&,经过规则二后参数实例化类型仍为X&。
- 传右值,显然右值引用可以绑定到右值上,T类型就是X,实例化参数为X&&。
- 传右值引用(X&&)也就是规则二的特殊情况,推出函数实例化参数为X&&。
- 所以任意类型实参均可传递给T&&(转发引用)。
template<typename T>
void testValue(T&& arg) {
T a = arg;
a = a * 2;
cout << arg << a <<endl;
}
int main()
{
int a = 2;
int& b = a;
int&& c = 3;
testValue(c);
cout <<"c:" << c << endl;
testValue(b);
testValue(a);
}
output:
66
c:6
44
88
Reference:
https://www.msopopop.cn/cpp/language/reference.html#.E5.8F.B3.E5.80.BC.E5.BC.95.E7.94.A8
https://zh.cppreference.com/w/cpp/language/template_argument_deduction
由于参数实例化类型的不确定性,因此右值引用参数通常使用于两种情况
- 模板转发
- 模板重载
std::move
std::move的目的:前面提到C++绑定规则中右值引用无法绑定到一个左值,但有时需要这么做(转交资源所有权等目的)。使用函数模板、引用折叠、特殊的显式类型转换便可实现将右值引用绑定到一个左值,即std::move的实现
// FUNCTION TEMPLATE move
//std: c14
template<class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&&
move(_Ty&& _Arg) noexcept
{ // forward _Arg as movable
//特殊的显式类型转换
//显式将一个左值转换为一个右值引用
return (static_cast<remove_reference_t<_Ty>&&>(_Arg));
}
转发引用可以传递任意类型实参:
- 传递右值将会实例化X&& move(X &&_Arg),最终维持Arg的右值引用类型。
- 传递左值将会实例化X&& move(X &_Arg),最终得到一个绑定到左值的右值引用。
特殊的显式类型转换规则:
- 使用static_cast可以显式将一个左值转换为一个右值引用,也就是使用右值引用截断左值,令其变为xvalue使得其能被直接“移动”。这个特殊规则是std::move实现根本,我们通过直接的static_cast也能达到std::move效果,但后者更容易查错。
move后需要注意:
- 对某个对象使用std::move后,除了对其赋值或销毁,将承诺不再使用该对象,因为该对象的值已不再得到保证(即便他可能有一个正确的值)。随意使用move可能与想要借助其提升性能的目的相悖。
std::forward
std::forward的目的:保持被转发实参的所有性质(const属性、左值或右值)。考虑下面情况
- 模板函数参数为T,内部调用函数参数为引用类型。若直接将形参作为内部调用函数的实参,那么将不会影响模板函数外部实参。
- 模板函数参数为T&&,内部调用函数参数为右值引用类型。根据C++绑定规则,内部调用函数的右值引用类型形参无法绑定到模板函数形参,因为其为左值所以无法实例化。
- 总而言之,都是因为变量表达式是左值的缘故。
根据上述情况,模板类型参数定义为右值引用的函数参数,需要保持其左值/右值属性。std::forward通过返回显式实参类型的右值引用并通过引用折叠从而实现保持实参性质。
// FUNCTION TEMPLATE forward
template<class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{ // forward an lvalue as either an lvalue or an rvalue
return (static_cast<_Ty&&>(_Arg));
}
template<class _Ty>
_NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept
{ // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return (static_cast<_Ty&&>(_Arg));
}
std::forward是如何工作的:
template<typename T>
void func(T &&Arg)
{
fcn(std::forward<T>(Arg));
}
- 传入一个左值类型实参,T为X&,形参类型为X&,匹配第一个forward,转发返回类型为 X& &&折叠为X&。
- 传入一个右值类型实参,T为X,形参类型为X&&,匹配第二个forward,转发返回类型为 X&&。
- 前两点可以看出forward保持了实参的value category。
一个简单示例:
void fin_fcn(int& arg) {
cout << "仅左值" << endl;
}
void fin_fcn(const int &arg) {
cout << "左右值均可" << endl;
}
template<typename T>
void testValue1(T&& arg) {
fin_fcn(arg);
}
template<typename T>
void testValue2(T&& arg) {
fin_fcn(std::forward<T>(arg));
}
int main()
{
int a = 2;
testValue1(2); //仅左值
testValue1(a); //仅左值
testValue2(2); //左右值均可
testValue2(a); //仅左值
}
可以看到如果不使用转发,形参arg作为左值将只会调用左值版本,即便实参中有右值类型。使用转发后,会保持实参的value category,因此可以选择对应的版本,转发带来了优化的机会。
实际上,如果没有转发引用这一新特性,上述例子中的fin_fcn也可以作为转发的另一种实现:
- 引用类型参数优先匹配左值
- const限定的引用类型参数可以匹配左值或者右值
这种特性可以处理不同值型别实参,调用重载函数时可以选择针对型别的版本,但缺点是明显的——参数过多情况下,需要重载较多的版本。
void fin_fcn(int& argA,int& argB) {}
void fin_fcn(const int &argA,int& argB) {}
void fin_fcn(int &argA,const int &argB) {}
void fin_fcn(const int &argA,const int &argB) {}
为类增加移动操作
移动操作注意点:
- 移动操作最好标记成noexcept,以显式告知标准库可以安全使用移动操作。通常移动操作将会改变被移动对象的值,如果抛出异常那么不仅影响了移动源元素,也影响了待构造的元素(特别是在重新分配内存时)。
- 移动操作后,移后源对象必须保持有效(安全赋值或不依赖当前值使用)、可析构状态(移动后可能会被销毁)。
移动构造
同拷贝构造函数,但第一个参数是类的右值引用类型。移动构造将给定对象的资源进行转移。
移动赋值
同移动构造一般,不过需要注意自赋值检测,因为右值引用可能绑定的是本对象move后返回的右值,相同的资源不能先释放再获取。
class MoveTest
{
public:
explicit MoveTest(std::string s):str(s){}
MoveTest(MoveTest& _Right) :str(_Right.str)
{
cout << "正在使用non-move construct " << endl;
}
MoveTest(MoveTest&& _Right)noexcept :str(_Right.str)
{
cout << "正在使用move construct" << endl;
}
MoveTest& operator=(MoveTest&& _Right)noexcept
{
cout << "正在使用move operator" << endl;
if (&_Right == this) return *this;
str = _Right.str;
return *this;
}
private:
std::string str;
};
编译器合成
同前Rule of five 和Rule of three。
- 若定义移动操作的类不显式地定义拷贝操作,则拷贝操作会被默认delete,有移动操作最好就全部都定义上以帮助优化(函数匹配规则,针对实参类型优化)。
- 定义了拷贝操作和析构的类(通常你不定义编译器也会帮你隐式定义),编译器不会生成移动操作。若类没有拷贝操作且所有非static成员数据可移动,将会由编译器合成移动操作函数。
移动迭代器
C++11 中std::make_move_iterator可以将给定迭代器包装为一个move_iterator,其解引用(*)类型返回一个右值引用。
template<class _Iter>
class move_iterator
{
public:
...
using reference = conditional_t<is_reference_v<_Ref0>, remove_reference_t<_Ref0>&&, _Ref0>;
...
_NODISCARD _CONSTEXPR17 reference operator*() const
{ // return designated value
return (static_cast<reference>(*current));
}
...
};
通过这个包装器,我们能将普通迭代器包装后传入那些需要使用迭代器的算法中,在算法中通过解引用包装迭代器,将对象进行“移动”而非拷贝。