15.3 移动构造和移动赋值¶
By Alex on February 26th, 2017 | last modified by Alex on January 23rd, 2020
翻译by dashjay 2020.07.17
在课程 15.1 智能指针和移动语义的介绍 中, 我们了解到了 std::auto ptr
,讨论了对移动语义的需求,并研究了为复制语义设计的函数(复制构造函数和复制赋值运算符),以及被重新定义为实现移动语义时出现的一些缺点。
在这堂课中,我们将深入了解 C++11
是如何通过 移动构造函数 和 移动赋值运算 来解决这些问题。
拷贝构造和拷贝赋值¶
首先,让我们复习一下拷贝语义(copy semantics
)。
拷贝构造函数 通过创建一份该类的拷贝来初始化一个类。拷贝赋值运算符 通过拷贝一个已存在类对象,创建一个新的对象。默认情况下,如果一个类没有显式的提供,C++ 将会默认提供拷贝构造函数和拷贝赋值函数。这些由编译器提供的函数仅实现了浅拷贝,在进行动态分配内存的类对象上使用可能造成问题。因此在涉及动态内存分配的类必须通过重写这些函数来进行深拷贝。
回到我们本章的前些课中提到的 Auto_ptr
智能指针类的例子,让我们看一眼实现深拷贝的拷贝构造函数和拷贝赋值函数,还有一个简单的程序来测试他们:
template<class T>
class Auto_ptr3
{
T* m_ptr;
public:
Auto_ptr3(T* ptr = nullptr)
:m_ptr(ptr)
{
}
~Auto_ptr3()
{
delete m_ptr;
}
// Copy constructor 拷贝构造函数
// Do deep copy of a.m_ptr to m_ptr
// 从 a.m_ptr 到 m_ptr 进行深拷贝
Auto_ptr3(const Auto_ptr3& a)
{
m_ptr = new T;
*m_ptr = *a.m_ptr;
}
// Copy assignment
// 拷贝赋值函数
// Do deep copy of a.m_ptr to m_ptr
// 从 a.m_ptr 到 m_ptr 进行深拷贝
Auto_ptr3& operator=(const Auto_ptr3& a)
{
// Self-assignment detection
// 自赋值检测
if (&a == this)
return *this;
// 释放所有持有的资源
// Release any resource we're holding
delete m_ptr;
// 拷贝资源
// Copy the resource
m_ptr = new T;
*m_ptr = *a.m_ptr;
return *this;
}
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
Auto_ptr3<Resource> generateResource()
{
Auto_ptr3<Resource> res(new Resource);
return res; // this return value will invoke the copy constructor
// 这个返回值将会触发拷贝构造函数
}
int main()
{
Auto_ptr3<Resource> mainres;
mainres = generateResource(); // this assignment will invoke the copy assignment
// 这个赋值将会触发赋值赋值函数
return 0;
}
在这个程序中,我们使用一个叫做 generateResource()
的函数来创建一个智能指针封装的Resource
,并且将它传回到 main
函数中。main
函数紧接着将其赋值给已经存在的 Auto_ptr3
对象 mainres
。
当这个程序运行的时候,打印:
Resource acquired
Resource acquired
Resource destroyed
Resource acquired
Resource destroyed
Resource destroyed
(注意:你可能只得到4个输出,如果你的编译器 省略\优化 了generateResource()
的返回值)
那会有很多次创建和销毁,仅仅为了这样一个简单的程序!发生了什么?
让我们仔细看看,程序里有 6 个关键步骤(每次打印都发生一件):
1; 在 generateResource()
中,局部变量 res
被创建并用初始化为一个动态分配的 Resource
。
2; Res
值返回到 main()
。通过值返回的原因是,res
是一个局部变量 —— 它不能被通过地址或引用返回,因为 res
将会在 generateResource()
函数结束前被销毁 结束。因为 res
是拷贝构造,做了一次深拷贝,一个新的 Resource
在这被分配,触发了第二次 "Resource acquired"。
3; Res 离开作用域,销毁之前创建的 Resource
,这触发了第一个 "Resource destroyed"。
4; 临时对象被赋值进 mainres
通过拷贝赋值。因为我们的拷贝赋值也做了一次深拷贝,一个新的 Reource
被分配,触发了另一个 "Resource acquired"。
5; 赋值表达式结尾,临时对象离开了表达式作用域并且被销毁,触发了一个 "Resource acquired"。
6; 在 main()
的末尾,mainres
离开了作用域,并且触发了最后一个 "Resource destroyed"。
因此,简而言之,因为我们调用了一次拷贝构造函数来拷贝构造 res
成一个临时变量,并调用了一次拷贝赋值函数来拷贝临时变量进入 mainres
,我们最终分配和销毁了3个独立的 objects
总计。
这是低效的,但是它至少不会崩溃。
然而如果用移动语义,我们可以做的更好。
移动构造和移动赋值¶
C++ 定义了两个新的函数来实现 “移动语义”:一个是 移动构造函数,一个是 移动赋值操作符 。拷贝构造函数的和拷贝赋值函数的目标是创建一份对象的拷贝到另一个对象,移动构造函数和移动赋值赋值运算符是来移动资源的 所有权(ownership) ,从一个对象到另一个对象(这样做消耗的资源少很多)。
定义一个移动构造函数和一个移动赋值函数和拷贝的同类函数所实现的目的相似。然而,这些复制函数的风格采用 常量左值引用 参数,移动函数的风格使用了 非常量右值引用 参数。
有一个和之前的 Auto_ptr3
相同的类,添加了移动构造函数和移动东赋值操作符。我们留下了进行 深拷贝 的拷贝构造函数和拷贝赋值操作符仅仅为了作比较。
# include <iostream>
template<class T>
class Auto_ptr4
{
T* m_ptr;
public:
Auto_ptr4(T* ptr = nullptr)
:m_ptr(ptr)
{
}
~Auto_ptr4()
{
delete m_ptr;
}
// Copy constructor
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr4(const Auto_ptr4& a)
{
m_ptr = new T;
*m_ptr = *a.m_ptr;
}
// Move constructor
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr4(Auto_ptr4&& a)
: m_ptr(a.m_ptr)
{
a.m_ptr = nullptr; // we'll talk more about this line below
}
// Copy assignment
// Do deep copy of a.m_ptr to m_ptr
Auto_ptr4& operator=(const Auto_ptr4& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Copy the resource
m_ptr = new T;
*m_ptr = *a.m_ptr;
return *this;
}
// Move assignment
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr4& operator=(Auto_ptr4&& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Transfer ownership of a.m_ptr to m_ptr
m_ptr = a.m_ptr;
a.m_ptr = nullptr; // we'll talk more about this line below
return *this;
}
T& operator*() const { return *m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};
class Resource
{
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
Auto_ptr4<Resource> generateResource()
{
Auto_ptr4<Resource> res(new Resource);
return res; // this return value will invoke the move constructor
}
int main()
{
Auto_ptr4<Resource> mainres;
mainres = generateResource(); // this assignment will invoke the move assignment
return 0;
}
移动构造函数 和 移动赋值操作符 非常简单。不再对原对象进行深拷贝到一个不同的对象,仅仅简单地从源对象 移动(偷) 到目标对象。其实就是涉及了从原指针到目对象的浅拷贝,然后原指针设定为 null
。
我们运行,这个程序将打印:
Resource acquired
Resource destroyed
那好得多:
程序的流程和以前相似,然而,不再调用拷贝构造函数和拷贝赋值函数,而这个程序调用了移动构造和移动赋值运算符。让我们深入来看:
1; 在 generateResource()
中,局部变量 res
被用动态分配创建和初始化。
2; Res
通过值返回到 main()
。Res
被移动构造进一个临时对象,转义这个在 res
中动态创建的对象,我们将会在下方讨论为何这样做。
3; Res
离开作用于。因为 res
不再管理指针(已经被移动到临时区),没什么其他的发生(delete nullptr 不会发生什么)。
4; 临时对象被移动赋值给 mainres
。这次转移了动态创建并储存在临时区的对象给 mainres
。
5; 在复制表达式结尾,临时的对象离开了表达式作用于,并且被销毁。然而因为临时区不再管理指针(被移动到了 mainres
上),因此这一步也什么都不会发生。
6; 在 main()
函数的末尾, mainres
离开了作用于,触发了最后一个 “Resource destroyed” 打印在屏幕上。
因此,不再拷贝 Resource
两次(一次为拷贝构造函数一次为拷贝赋值运算符),我们移动了它两次。这更高效,因为 Resource
仅仅被构造和销毁一次,而不是三次。
何时调用移动构造函数和移动赋值运算符¶
当那些函数被调用,并且构造函数或者赋值运算符的参数是一个右值时,会调用移动构造函数和移动赋值运算符。最典型的是,右值会是一个字面量(literal)或一个临时值。
在大多数情况下,移动构造函数 和移动赋值运算符不会被默认提供,除非该类没有定义任何 拷贝构造函数 ,拷贝赋值运算符, 移动赋值运算符 ,或者**析构函数** 。然而,默认的 移动构造函数 和 移动赋值运算符 只会做和拷贝构造函数和拷贝赋值运算符相似的事情(制作拷贝,不移动)。
规定:如果你想要一个移动构造函数和移动赋值运算符来实现移动语义,你需要自己实现一个。
移动语义背后的关进因素¶
你现在了解了足够多的上下文,理解移动语义背后的关键因素。
如果我们构造一个对象或者进行一次赋值时,当参数是一个左值,我们唯一能做的就是拷贝这个左值。我们不能假设修改它是安全的,因为它可能在之后的程序中被使用。就好像如果有一个表达式 a = b
,我们无论如何不会期待b被改变 。
然而,如果我们构造一个对象或者做一次赋值时,参数是一个右值,我们知道右值仅仅是一个某个类型的临时值。相比拷贝他(花费更多资源),我们可以简单的移动它的资源(花费非常少)给我们正在创建或者赋值的资源。这是实现起来是安全的,因为临时值将会被销毁在表达式的结尾,因此我们知道它将永远不会再被再次使用!
C++11,通过右值引用,给我们能力来提供一个不同的实现,当参数是一个右值或一个左值,有了这个能力使得我们可以更简单,更高效的决定我们编写的对象的行为。
移动函数应始终使两个对象处于定义良好的状态¶
在以上的例子中,移动构造和移动赋值函数设置 a.m_ptr
为 nullptr
。这是看起来似乎是没什么用————毕竟,如果 “a” 是一个临时右值,为什么阻止“清理”,如果 “a” 无论如何都会被销毁。
这个问题的答案很简单:当 “a” 离开作用域, "a" 的析构函数将会被调用,并且 a.m_ptr
将会被删除。如果在那时 a.m_ptr
仍然指着和m_ptr
相同的的资源,m_ptr
就会成为一个悬空指针。当对象对象包含的 m_ptr
最终被使用(或者销毁),将会发生未定义操作(undefiend behavior)。
另外,在下节课中我们将会看到一些例子当 a
可以是一个左值时。在这种情况下,a
将不会被立即销毁,我们可以在其声明周期结束前查询。
左值通过移动值返回,替代复制返回¶
在 之前的例子里,Auto_ptr4
中的generateResource()
,当变量 res
被通过值返回,它被移动而不是拷贝,即便 res
始终是一个左值。C++ 规范中有一个特例这样描述:通过值从函数返回的对象将自动使用移动语义,即便他们是一个左值。这很有意义,因为 res
无论如何即将被销毁在函数的末尾!我们可以也偷走它的资源而不是做没必要的拷贝。
尽管编译器可以移动左值作为函数的返回值(Although the compiler can move l-value return values),在一些例子中,可以做的更好,通过简单的淘汰完全拷贝(避免拷贝而或全部使用移动)。在这样的例子中,拷贝构造函数和移动构造函数都不会被调用。
禁用拷贝¶
在 上方的Auto_ptr4
类中,我们留下了 copy 构造函数和赋值操作符为了比较的目的。但在开启移动(mode-enabled)更愿意删除拷贝构造和拷贝赋值函数来确保拷贝不会发生。在接下来的例子中 Auto_ptr
类,我们想要拷贝我们的 templated
对象 T ———— 不仅因为它的开销很大,而且 T 类可能甚至不支持拷贝!
这是 Auto_ptr
支持移动语义,但是不支持拷贝语义的版本。
# include <iostream>
template<class T>
class Auto_ptr5
{
T* m_ptr;
public:
Auto_ptr5(T* ptr = nullptr)
:m_ptr(ptr)
{
}
~Auto_ptr5()
{
delete m_ptr;
}
// Copy constructor -- no copying allowed!
Auto_ptr5(const Auto_ptr5& a) = delete;
// Move constructor
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr5(Auto_ptr5&& a)
: m_ptr(a.m_ptr)
{
a.m_ptr = nullptr;
}
// Copy assignment -- no copying allowed!
Auto_ptr5& operator=(const Auto_ptr5& a) = delete;
// Move assignment
// Transfer ownership of a.m_ptr to m_ptr
Auto_ptr5& operator=(Auto_ptr5&& a)
{
// Self-assignment detection
if (&a == this)
return *this;
// Release any resource we're holding
delete m_ptr;
// Transfer ownership of a.m_ptr to m_ptr
m_ptr = a.m_ptr;
a.m_ptr = nullptr;
return *this;
}
T& operator*() const { return*m_ptr; }
T* operator->() const { return m_ptr; }
bool isNull() const { return m_ptr == nullptr; }
};
如果你尝试通过左值传一个 Auto_ptr5
到一个函数,编译器将会报错告诉你拷贝构造函数是必须的,用来初始化拷贝构造参数已经被删除。这就对了,因为我们应该通过常左值引用传递 Auto_ptr5
!
Auto_ptr5
是(终极)一个很好的只能指正类。并且事实上,标准库包含的类已经非常像这个(你应该用标准),被叫做 std::unique_ptr
。我们将讨论更多有关 std::unique_ptr
在稍后的章节中。
另一个例子¶
让我们看一眼另一个类使用了动态内存分配:一个简单的动态模板数组。这个类包含一个深拷贝的拷贝构造函数和拷贝赋值操作符。
# include <iostream>
template <class T>
class DynamicArray
{
private:
T* m_array;
int m_length;
public:
DynamicArray(int length)
: m_array(new T[length]), m_length(length)
{
}
~DynamicArray()
{
delete[] m_array;
}
// Copy constructor
DynamicArray(const DynamicArray &arr)
: m_length(arr.m_length)
{
m_array = new T[m_length];
for (int i = 0; i < m_length; ++i)
m_array[i] = arr.m_array[i];
}
// Copy assignment
DynamicArray& operator=(const DynamicArray &arr)
{
if (&arr == this)
return *this;
delete[] m_array;
m_length = arr.m_length;
m_array = new T[m_length];
for (int i = 0; i < m_length; ++i)
m_array[i] = arr.m_array[i];
return *this;
}
int getLength() const { return m_length; }
T& operator[](int index) { return m_array[index]; }
const T& operator[](int index) const { return m_array[index]; }
};
现在,让我们在程序中使用这个类。为了向你展示这个类的性能,我们在堆上分配了一百万个整型,我们将使用我们在 lesson 8.16 -- Timing your code 中开发的计时器类。我们将使用这个计时器类来展示我们的代码运行有多快,向你展示拷贝和移动之间的性能差距。
# include <iostream>
# include <chrono> // for std::chrono functions
// Uses the above DynamicArray class
class Timer
{
private:
// Type aliases to make accessing nested type easier
using clock_t = std::chrono::high_resolution_clock;
using second_t = std::chrono::duration<double, std::ratio<1> >;
std::chrono::time_point<clock_t> m_beg;
public:
Timer() : m_beg(clock_t::now())
{
}
void reset()
{
m_beg = clock_t::now();
}
double elapsed() const
{
return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
}
};
// Return a copy of arr with all of the values doubled
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
DynamicArray<int> dbl(arr.getLength());
for (int i = 0; i < arr.getLength(); ++i)
dbl[i] = arr[i] * 2;
return dbl;
}
int main()
{
Timer t;
DynamicArray<int> arr(1000000);
for (int i = 0; i < arr.getLength(); i++)
arr[i] = i;
arr = cloneArrayAndDouble(arr);
std::cout << t.elapsed();
}
在作者之一的机器上,在发布模式下,这段程序在 0.00825559
秒内执行完。
现在,让我们再次运行同样的程序,使用移动构造和移动赋值函数来替换拷贝构造函数和拷贝赋值函数。
template <class T>
class DynamicArray
{
private:
T* m_array;
int m_length;
public:
DynamicArray(int length)
: m_array(new T[length]), m_length(length)
{
}
~DynamicArray()
{
delete[] m_array;
}
// Copy constructor
DynamicArray(const DynamicArray &arr) = delete;
// Copy assignment
DynamicArray& operator=(const DynamicArray &arr) = delete;
// Move constructor
DynamicArray(DynamicArray &&arr)
: m_length(arr.m_length), m_array(arr.m_array)
{
arr.m_length = 0;
arr.m_array = nullptr;
}
// Move assignment
DynamicArray& operator=(DynamicArray &&arr)
{
if (&arr == this)
return *this;
delete[] m_array;
m_length = arr.m_length;
m_array = arr.m_array;
arr.m_length = 0;
arr.m_array = nullptr;
return *this;
}
int getLength() const { return m_length; }
T& operator[](int index) { return m_array[index]; }
const T& operator[](int index) const { return m_array[index]; }
};
# include <iostream>
# include <chrono> // for std::chrono functions
class Timer
{
private:
// Type aliases to make accessing nested type easier
using clock_t = std::chrono::high_resolution_clock;
using second_t = std::chrono::duration<double, std::ratio<1> >;
std::chrono::time_point<clock_t> m_beg;
public:
Timer() : m_beg(clock_t::now())
{
}
void reset()
{
m_beg = clock_t::now();
}
double elapsed() const
{
return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
}
};
// Return a copy of arr with all of the values doubled
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
DynamicArray<int> dbl(arr.getLength());
for (int i = 0; i < arr.getLength(); ++i)
dbl[i] = arr[i] * 2;
return dbl;
}
int main()
{
Timer t;
DynamicArray<int> arr(1000000);
for (int i = 0; i < arr.getLength(); i++)
arr[i] = i;
arr = cloneArrayAndDouble(arr);
std::cout << t.elapsed();
}
在同样的机器上,这个程序在 0.0056
秒内执行完毕。
比较两个程序的运行时间,0.0056 / 0.00825559 = 67.8%
。“移动语义”的版本几乎快了33%