0%

面试知识点详细解读之右值与移动语义

  1. 解释右值引用和左值引用的区别
  2. 移动构造函数和移动赋值运算符
  3. 理解std::movestd::forward

解释右值引用和左值引用的区别

所谓右值引用就是必须绑定到右值的引用,通过&&获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。左值引用,不能绑定到要转换的表达式、字面常量或返回右值的表达式。而右值引用恰好相反,可以绑定到这类表达式,但不能绑定到一个左值上。

返回左值的表达式包括返回左值引用的函数及赋值、下标、解引用和前置递增/递减运算符,返回右值的表达式包括返回非引用类型的函数及算术、关系、位和后置递增/递减运算符。可以看到,左值的特点是有持久的状态,而右值则是短暂的。

变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

函数参数和其它任何变量一样,都是左值表达式。

移动构造函数和移动赋值运算符

参见C++ primer 13.6

与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员。

移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。

如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。

类似拷贝构造函数和拷贝赋值运算符,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。

定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。

如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数:移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝(即,通过拷贝构造函数来“移动”)。

拷贝并交换赋值运算符和移动操作
HasPtr类定义了一个拷贝并交换赋值运算符,如果为此类添加一个移动构造函数,它实际上也会获得一个移动赋值运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class HasPtr
{
public:
// 添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i)
{
p.ps = 0;
}

// 赋值运算符既是移动赋值运算符, 也是拷贝赋值运算符
HasPtr& operator=(HasPtr& rhs)
{
HasPtr temp(rhs); // copy and
swap(*this, temp); // swap
return *this;
}
// 其他成员的定义
...
};

在这个版本中,我们为类添加了一个移动构造函数,它接管了给定实参的值。构造函数体将给定的HasPtr的指针置为0,从而确保销毁移后源对象是安全的。此函数不会抛出异常,因此我们将其标记为noexcept
现在让我们观察赋值运算符。此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
假定hphp2都是HasPtr对象:

1
2
hp = hp2;	// hp2 是一个左值; hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动 hp2

在第一个赋值中,右侧运算对象是一个左值,因此移动构造函数是不可行的。rhs将使用拷贝构造函数来初始化。拷贝构造函数将分配一个新string,并拷贝hp2指向的string
在第二个赋值中,我们调用std::move将一个右值引用绑定到hp2上。在此情况下,拷贝构造函数和移动构造函数都是可行的。但是,由于实参是一个右值引用,移动构造函数是精确匹配的。移动构造函数从hp2拷贝指针,而不会分配任何内存。
不管使用的是拷贝构造函数还是移动构造函数,赋值运算符的函数体都swap两个运算对象的状态。交换HasPtr会交换两个对象的指针成员。在swap之后,rhs中的指针将指向原来左侧运算对象所拥有的string。当rhs离开其作用域时,这个string将被销毁。

理解std::movestd::forward

参见C++ primer 16.2.6 & 16.2.7

调用std::move就意味着承诺:除了对移后源赋值或销毁外,我们将不再使用它。

标准库中std::move的实现
1
2
3
4
5
6
template<typename T>
typename remove_reference<T>::type&& move(T&& t)
{
// 从一个左值 static_cast 到一个右值引用是允许的
return static_cast<typename remove_reference<T>::type&&>(t);
}

其中,remove_reference模板有一个模板类型参数和一个名为type的(public)类型成员。如果我们用一个引用类型实例化remove_reference,则type将表示被引用的类型。例如,如果我们实例化remove_reference<string&>,则type成员将是string。更一般的,给定一个迭代器beg

1
remove_reference<decltype(*beg)>::type

将获得beg引用的元素的类型:decltype(*beg)返回元素类型的引用类型。remove_reference::type脱去引用,剩下元素类型本身。

std::move是如何工作的
1
2
3
string s1("hi!"), s2;
s2 = std::move(string("bye!")); // 正确: 从一个右值移动数据
s2 = std::move(s1); // 正确: 但在赋值之后, s1的值是不确定的

在第一个赋值中,传递给move的实参是string的构造函数的右值结果——string("bye!")。因此,在std::move(string("bye!"))中:

  • 推断出的T的类型为string
  • 因此,remove_referencestring进行实例化;
  • remove_reference<string>type成员是string
  • move的返回类型是string&&
  • move的函数参数t的类型为string&&

因此,这个调用实例化move<string>,即函数

1
string&& move(string&& t)

函数体返回static_cast<string&&>(t)t的类型已经是string&&,于是类型转换什么都不做。因此,此调用的结果就是它所接受的右值引用。

现在考虑第二个赋值,它调用了std::move。在此调用中,传递给move的实参是一个左值。这样:

  • 推断出的T的类型为string&
  • 因此,remove_referencestring&进行实例化;
  • remove_reference<string&>type成员是string
  • move的返回类型仍是string&&
  • move的函数参数t实例化为string&& &,会折叠为string&

因此,这个调用实例化move<string&>,即

1
string&& move(string& t)

这正是我们所寻求的——我们希望将一个右值引用绑定到一个左值。这个实例的函数体返回static_cast<string&&>(t)。在此情况下,t的类型为string&static_cast将其转换为string&&

标准库中std::forward的实现
1
2
3
4
5
6
7
8
9
10
11
template <class T>
T&& forward(typename std::remove_reference<T>::type& t)
{
return static_cast<T&&>(t);
}

template <class T>
T&& forward(typename std::remove_reference<T>::type&& t)
{
return static_cast<T&&>(t);
}

std::forward<T>的返回类型是T&&。通常情况下,使用forward传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward可以保持给定实参的左值/右值以及const属性:

1
2
3
4
5
template<typename T>
void func(T&& t)
{
// 在这里使用 std::forward<T>(t)
}

如果实参t是一个右值,则T被推断为一个普通(非引用)类型,std::forward<T>将返回T&&。如果实参是一个左值,则通过引用折叠,T被推断为一个左值引用类型。在此情况下,std::forward<T>返回类型是一个指向左值引用类型的右值引用T&& &,再次引用折叠,将返回一个左值引用类型T&

注意:不能使用一个左值实例化一个右值引用类型的函数参数。函数参数和其它任何变量一样,在函数体内都是左值表达式。