原文链接:Move Semantics and Perfect Forwarding in C++11
介绍
在本文中,我将探讨C++11的move相关的功能特性,重点是如何编写移动构造函数和移动赋值操作符,以及完美转发相关的问题。其中的一些功能在[1],[2]两处有很好的介绍,推荐读者们先去读这些材料。在[3]处,给出了非常接近C++11标准的文档。
背景
Move语义主要是设计用来优化对象间的数据传递,特别是对象拥有指向分配在堆上的大量内存的时候。这样的对象可以被表示成两部分:对象本身(shell)和其在堆上创建的部分(heap-allocated contents (HAC)。对象含有指向HAC的指针。比如说,如果我们想拷贝B的内容到A,多数情况下,它们堆上创建部分的大小是不同的。A,B示意图如下:
把B赋值给A的一般步骤是首先删除掉A的HAC部分:
然后,A再申请和B同样大小的空间:
最后再拷贝B的HAC部分的内容到A对应的地方:
如果我们后续需要同时适用A,B两个对象,这是将B赋给A的正常做法。但是,很多时候,我们后续不需要使用B对象,我们可以交换A,B两个对象的HAC部分:
对于交换A,B的HAC部分的方法,有两个主要的优点:
- 我们不再需要在堆上申请新空间,因而可以节省时间和空间;
- 我们不需要拷贝B的内容到A,因而节省了拷贝时间;
显然,对象的HAC部分的内容越大,优势越明显。
右值引用
为了标识哪些对象适合采用移动的方式来构造或拷贝,C++11中引入了右值引用的特性。右值引用通常作用到一个即将释放的对象(也就是说该对象后续不会被再次使用)。在C++11中,将&&放在类型名称后表示这是右值引用,例如T类型的右值引用写作T&&。
右值引用的主要用途是用来指定函数的形参(formal parameters)。如果没有右值引用,类型T的形参主要通过如下两种方式:
- T&
- const T&
第一类用来指定可以改变实参的情况;第二类用来不可变实参的情况。
在C++11中,现在有三种选择:
- T&
- const T&
- T&&
总的来说,我们可以仍然只用前面的两个选项,但是,为了让我们的代码更加高效,我们有必要使用第三个选项(右值引用)。显然,如果使用第三个选项,那么,前两个选项的使用将会减少。为什么呢?
这是因为第三个右值引用的选项对应于实参是即将销毁的临时变量。回想一下,什么参数可以对应于右值引用呢?就是那些返回类型的值而不是引用的函数或者表达式。它解决了const T&和T&之间差异的问题:
- const T&指向一般的变量和常量
- T&& 指向表达式(包括函数)的返回值,但不包括返回T&的情形
有时,某个变量(比如x)在某个点之后可能再也不需要使用了,该对象可以被当作一个右值引用,在这种情况下,我们可以用std::move(x)将其转化为右值。我们将在下面讨论这一点。
对于形式参数,既然有上面两种方案可以选择:使用右值引用和不使用右值引用的方案。那么对于传统的拷贝构造函数和拷贝赋值操作符,现在也有移动构造T(T&& t)和移动赋值操作符=(T&& t).
所以,对于一个具体的类,如果定义了移动构造函数和移动赋值操作符,对于上面所示的交换对象内容的情形,程序可以利用形参值来进行move而不是拷贝。
编写移动构造函数和移动赋值操作符有一点特别重要:对于被移动后的形参,其内部应该保持是有效的,这样它才能正常析构。对于这一点,一般有两种方案来实现:
- 交换大对象的内容,就像上面图片所示的那样。
- 设置被移动后的形参中的内容是一个合法的空值,例如将指向HAC部分的指针设为NULL。
对于移动构造函数,例如Foo b = std::move(a);也可以考虑方案1的交换方案,只需要交换前b的默认值是个空值就可以了。
示例
下面我们将从一个简单的例子开始:一个类,内部定义了一个double类型的数组,数组的大小是固定的,但是在赋值时这个大小也可以改变。这样的例子大家可能看到过很多,但是用这样的例子可以很好的展示移动构造相关的功能。首先,没有移动语义的数组定义是这样的:
class Array
{
int m_size;
double* m_array;
public:
Array():m_size(0),m_array(nullptr) {} // empty constructor
Array(int n):m_size(n),m_array(new double[n]) {}
Array(const Array& x):m_size(x.m_size),m_array(new double[m_size]) // copy constructor
{
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
virtual ~Array() // destructor
{
delete [] m_array;
}
auto Swap(Array& y) -> void
{
int n = m_size;
double* v = m_array;
m_size = y.m_size;
m_array = y.m_array;
y.m_size = n;
y.m_array = v;
}
auto operator=(const Array& x) -> Array& // copy assignment
{
if (x.m_size == m_size)
{
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
else
{
Array y(x);
Swap(y);
}
return *this;
}
auto operator[](int i) -> double&
{
return m_array[i];
}
auto operator[](int i) const -> double
{
return m_array[i];
}
auto size() const ->int { return m_size;}
friend auto operator+(const Array& x, const Array& y) -> Array // adding two vectors
{
int n = x.m_size;
Array z(n);
for (int i = 0; i < n; ++i)
{
z.m_array[i] = x.m_array[i]+y.m_array[i];
}
return z;
}
};
像上面,在赋值操作符中使用swap是非常方便的,也是非常安全的。下面是使用这个类的一个小程序:
int main()
{
Array v(3);
v[0] = 2.5;
v[1] = 3.1;
v[2] = 4.2;
const Array v2(v);
Array v3 = v+(v2+v);
std::cout << "v3:";
for (int i = 0; i < 3; ++i)
{
std::cout << " " << v3[i];
};
std::cout << std::endl;
Array v4(3);
v4[0] = 100.1;
v4[1] = 1000.2;
v4[2] = 10000.3;
v4 = v4 + v3;
std::cout << "v4:";
for (int i = 0; i < 3; ++i)
{
std::cout << " " << v4[i];
};
std::cout << std::endl;
return 0;
}
在这里可以看到这个程序的完整代码。程序输出:
v3: 7.5 9.3 12.6
v4: 107.6 1009.5 10012.9
接下来,我们添加一些Move语义相关的功能特性。首先,我们应该定义移动构造函数。形参是右值引用的形式:
Array(Array&& x) ...
正如之前提到的,形参对应的内容将会被移动到对应的部分:
Array(Array&& x):m_size(x.m_size),m_array(x.m_array) ...
同时,重要的是形参最后也要获得正确的值:其内容不能保持不变,因为它会很快析构。因此,在构造函数体内,我们应将形参的内容赋值为空。下面是完整的移动构造函数的完整定义:
Array(Array&& x):m_size(x.m_size),m_array(x.m_array)
{
x.m_size = 0;
x.m_array = nullptr;
}
接下来,我们编写移动赋值操作符。这很简单。形参是右值引用,正如之前说的那样,函数体可以简单的交换形参的内容和当前对象的内容。
auto operator=(Array&& x) -> Array&
{
Swap(x);
return *this;
}
这就是全部了。
接下来,对于+操作符号,有个问题需要解决:+操作符号也应该利用右值引用的优点。这里面有两个参数,每个参数都有两种选择:使用右值引用和不使用右值引用。那么应该有四种+操作符号的重载定义。如果写完一个,还必须写剩下的三个才算完整,下面是其中的一个:
friend auto operator+(Array&& x, const Array& y) -> Array
它的实现非常简单,我们只是简单的修改形参的内容。 下面是我们的实现:
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
for (int i = 0; i < n; ++i)
{
x.m_array[i] += y.m_array[i];
}
return x;
}
我们不需要使用右值引用形参了,我们需要对其进行move。最主要的事情是最后能够返回正确的内容。如果我们像上面那样简单的return x;那么x部分的内容就会原封不动的返回。尽管x的类型是右值引用,但是变量x本身是左值:我们没有将x的值move出来,这是右值引用该做的事情。也就是说,如果我们简单的return x, 将不会有移动操作。正确的做法是将x的内容和目标对象(相加得到的和对象)进行交换,下面是这个+操作符的完整定义:
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
for (int i = 0; i < n; ++i)
{
x.m_array[i] += y.m_array[i];
}
return std::move(x);
}
接下来,我们需要写出另外两个移动语义相关的+操作符,利用加法交换律,我们交换参数的顺序,因而可以重用上面写的第一个+操作符:
friend auto operator+(Array&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
friend auto operator+(const Array& x, Array&& y) -> Array
{
return std::move(y)+x;
}
就像你看到的那样,我们定义了4个+操作符函数,可以简单点,少写点吗?可以的,但是首先我们必须学下完美转发(Perfect Forwarding).
完美转发:减少成员函数的个数
完美转发主要是设计用来减小程序员编写代码的量的,当然它也有其它的用途。
首先,我们看看最后两个+操作符的实现。它们有非常多的共同点,唯一的不同是第一个形参有不同的定义:Array&& x 和const Array& x。这两个+操作符的定义可以采用下面的方式用一个定义替代:
- 定义一个模板 template;
- 将对应的形参定义为T&& x;
- 将用到std::move(x)的地方换成std::forward(x)
上面后两个+操作符的实现代码中没有用std::move(x),那么我们也不需要使用std::forward(x),整合后的代码如下:
template<class T>
friend auto operator+(T&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
为了将前两个操作符整合为一个,我们需要对将两个+操作符的实现代码做少许修改,这样它们的函数体就会看起来相似。
但是首先,我们需要考虑局部变量的一些额外信息。对于第一个+操作符的实现,你将会发现它有一个局部变量,z。根据C++11的语法规则,当返回局部变量时,局部变量将会自动采用move语义移动到新的对象。我们不需要显式的使用std::move(z)来使用move,事实上,只要定义了相关的移动构造函数和移动赋值操作符,move就会发生。
为了让这两个+操作符看起来相似,我们在两个的实现中都创建了一个局部变量,这样我们就能将第一个形参的内容通过拷贝或者move的方式转移到这个局部变量中,然后将第二个新参的值与这个局部变量相加,下面是前两个+操作符修改后的实现版本:
friend auto operator+(const Array& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(x);
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::move(x));
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
这就是完美转发需要的。如果我们采用之前完美转发的三条原则,我们可以将上面的两个+操作符的实现重写为下面的样子:
template<class T>
friend auto operator+(T&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::forward<T>(x));
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
也许你会认为我们的一些代码的改动会降低代码的执行效率。如果考虑到效率问题,对于前面两个+操作符的实现,我们最好不做修改。
测试
为了验证move功能的优点,我们在下面的代码中添加了额外的输出语句来看那个成员函数被调用了,这对我们测试代码的效率有帮助,下面是测试代码,也可以在这里运行它:
//MOVE SEMANTICS REVISED
#include <iostream>
#include <string>
#include <algorithm>
#include <cmath>
#define MOVE_FUNCTIONALITY
int count_copies = 0;
int count_allocations = 0;
int elem_access = 0;
class Array
{
int m_size;
double *m_array;
public:
Array():m_size(0),m_array(nullptr) {}
Array(int n):m_size(n),m_array(new double[n])
{
count_allocations += n;
}
Array(const Array& x):m_size(x.m_size),m_array(new double[m_size])
{
count_allocations += m_size;
count_copies += m_size;
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
#ifdef MOVE_FUNCTIONALITY
Array(Array&& x):m_size(x.m_size),m_array(x.m_array)
{
x.m_size = 0; // clearing the contents of x
x.m_array = nullptr;
}
#endif
virtual ~Array()
{
delete [] m_array;
}
auto Swap(Array& y) -> void
{
int n = m_size;
double* v = m_array;
m_size = y.m_size;
m_array = y.m_array;
y.m_size = n;
y.m_array = v;
}
#ifdef MOVE_FUNCTIONALITY
auto operator=(Array&& x) -> Array&
{
Swap(x);
return *this;
}
#endif
auto operator=(const Array& x) -> Array&
{
if (x.m_size == m_size)
{
count_copies += m_size;
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
else
{
Array y(x);
Swap(y);
}
return *this;
}
auto operator[](int i) -> double&
{
elem_access++;
return m_array[i];
}
auto operator[](int i) const -> double
{
elem_access++;
return m_array[i];
}
auto size() const ->int { return m_size;}
#ifdef MOVE_FUNCTIONALITY
template<class T>
friend auto operator+(T&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::forward<T>(x));
for (int i = 0; i < n; ++i)
{
elem_access+=2;
z.m_array[i] += y.m_array[i];
}
return z;
}
template<class T>
friend auto operator+(T&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
#else
friend auto operator+(const Array& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(n);
for (int i = 0; i < n; ++i)
{
elem_access += 3;
z.m_array[i] = x.m_array[i] + y.m_array[i];
}
return z;
}
#endif
void print(const std::string& title)
{
std::cout << title;
for (int i = 0; i < m_size; ++i)
{
elem_access++;
std::cout << " " << m_array[i];
};
std::cout << std::endl;
}
};
int main()
{
const int m = 100;
const int n = 50;
{
Array v(m);
for (int i = 0; i < m; ++i) v[i] = sin((double)i);
const Array v2(v);
Array v3 = v+(v2+v);
v3.print("v3:");
Array v4(m);
for (int i = 0; i < m; ++i) v4[i] = cos((double)i);
v4 = v4 + v3;
v4.print("v4:");
Array v5(n);
for (int i = 0; i < n; ++i) v5[i] = cos((double)i);
Array v6(n);
for (int i = 0; i < n; ++i) v6[i] = tan((double)i);
Array v7(n);
for (int i = 0; i < n; ++i) v7[i] = exp(0.001*(double)i);
Array v8(n);
for (int i = 0; i < n; ++i) v8[i] = 1/(1+0.001*(double)i);
v4 = (v5+v6)+(v7+v8);
v4.print("v4 new:");
}
std::cout << "total allocations (elements):" << count_allocations << std::endl;
int total_elem_access = count_copies*2 + elem_access;
std::cout << "total elem access (elements):" << total_elem_access << std::endl;
return 0;
}
当我做过一些测试后,我意识到不能仅仅考虑拷贝操作的次数,有必要将元素访问的次数也考虑进来,因为一个拷贝对应于两次的元素访问:源元素和目的元素的访问。下面是测试结果:
Counter | Copy | Move |
---|---|---|
Element Allocations | 1000 | 800 |
Element Access | 2500 | 2350 |
拷贝优化(Copy Elision)
在某些情况下,C++11标准中允许省略拷贝构造或者移动构造函数,即使这些构造或析构函数有副作用,这个特性被称为拷贝优化(省略不必要的拷贝)。拷贝优化被允许发生在下面几种情形中:
- 当函数的返回值是non-volatile的局部变量并且其类型和函数的返回值类型相同(在这里,类型比较时忽略const等类型修饰符),这通常叫作返回值优化(RVO);
- 在一个throw语句中,当某个操作符是non-volatile的局部变量并且其作用域不超过其最相邻的try语句块时;
- 当一个临时对象(没有被任何引用绑定)将要被拷贝或者移动到和它同类型的类对象时(和前面一样,类型比较时忽略const等类型修饰符);
- 在异常语句处理处,通常是try-catch语句块,当catch某个参数时,如果catch的参数和throw处抛出的参数类型相同;
在上面所列出的这些情况中, 对象可能会直接构造或者移动到目的对象中,不仅仅是拷贝,其它的一些操作也可能会省略。在所有的这些情况中,在拷贝优化发生前,通常先考虑右值引用(move),然后才会考虑左值引用(copy)。
下面是一个使用了前面讨论过的Array类的示例程序:
const Array f()
{
Array z(2);
z[0] = 2.1;
z[1] = 33.2;
return z;
}
Array f1()
{
Array z(2);
z[0] = 2.1;
z[1] = 33.2;
return z;
}
void g(Array&& a)
{
a.print("g(Array&&)");
}
void g(const Array& a)
{
a.print("g(const Array&)");
}
void pf(Array a)
{
a.print("pf(Array)");
}
int main()
{
{
Array p(f());
p.print("p");
g(f());
g(f1());
pf(f());
}
std::cout << "total allocations (elements):" << count_allocations << std::endl;
int total_elem_access = count_copies*2 + elem_access;
std::cout << "total elem access (elements):" << total_elem_access << std::endl;
return 0;
}
在定义了拷贝构造函数的情况下,程序输出如下(可以在这里允许该程序):
p 2.1 33.2
g(const Array&) 2.1 33.2
g(Array&&) 2.1 33.2
pf(Array) 2.1 33.2
total allocations (elements):8
total elem access (elements):16
首先,看一下函数调用,当用f()或者f1()时,不同选择的重载函数:g(f())选择g(const Array&),g(f1())选择g(Array&&)。但是这不会阻止编译器选择使用移动操作或者省略使用这些操作。
为了追踪哪个构造函数被调用了,你可以在函数里面额外添加一些语句来打印额外的信息。在删掉#define MOVE_FUNCTIONALITY这一行后,运行结果依然是相同的。
从程序的角度来看,即使你没有写带有右值相关的参数的函数,像移动构造函数或者移动操作符,你仍然享有move操作带来的好处:这里拷贝优化就相当于move。通常的方法是:
- 定义拷贝或者移动构造函数
- 定义可以利用移动语义优势的相关函数或操作符(比如Array class中的+操作符)
在其余的情况中,当没有编译器优化的时候,要像往常一样,编写带有右值引用 (T&& or const T&&)参数的函数。
- Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Notes in PDF. http://www.aristeia.com/TalkNotes/ACCU2011_MoveSemantics.pdf
- Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Presentation.Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Presentation. http://skillsmatter.com/podcast/home/move-semanticsperfect-forwarding-and-rvalue-references
- C++ Working Draft,C++ Working Draft, http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3376.pdf