声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。
摘要
本文整理了拷贝控制成员函数相关知识,即拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数和移动赋值运算符。具体包括函数定义、使用方式和场景、合成版本和删除的函数等内容。
目录
拷贝控制(copy control)操作,是类的对象在拷贝、移动(C++11)、赋值和销毁时所进行的操作。一个类通过五种特殊的成员函数来控制这些操作,包括:
- 拷贝构造函数、移动构造函数,定义了当用同类型的另一个对象初始化本对象时做什么;
- 拷贝赋值运算符、移动赋值运算符,定义了将一个对象赋予同类型的另一个对象时做什么;
- 析构函数,定义了当此类型对象销毁时做什么。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作。但是,对于一些类来说,依赖这些操作的默认定义会导致灾难。
1、拷贝、赋值与销毁
1.1 拷贝构造函数
拷贝构造函数(copy constructor),“第一个参数是自身类类型的引用,其它参数(如果有)都有默认值” 的构造函数。
1.1.1 合成拷贝构造函数
合成拷贝构造函数(synthesized copy constructor),如果我们没有为一个类定义拷贝构造函数,编译器会为我们合成一个。一般情况下(非删除的函数),合成的拷贝构造函数会将其参数的每个非 static
成员逐个拷贝到正在创建的对象中。
成员的类型决定了它的拷贝方式:
- 对于类类型的成员,使用其拷贝构造函数来拷贝;
- 对于内置类型的成员,直接拷贝;
- 对于数组类型的成员,逐元素地进行拷贝(元素拷贝方式同前)。
class Sales_data
{
public:
Sales_data(const Sales_data &); // 拷贝构造函数的声明
private:
string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// 拷贝构造函数的定义,当前定义与合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig) :
bookNo(orig.bookNo), // 使用 string 的拷贝构造函数
units_sold(orig.units_sold), // 直接拷贝 int 值
revenue(orig.revenue) // 直接拷贝 double 值
{} // 空函数体
1.1.2 拷贝初始化
直接初始化 vs 拷贝初始化:
// 直接初始化(direct initialization),不使用等号(=)初始化一个变量
// 编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数
string dots(10, '.');
string s(dots);
// 拷贝初始化(copy initialization),使用等号(=)初始化一个变量
// 编译器将右侧运算对象拷贝到左侧正在创建的对象中,如果需要的话还会进行类型转换
string s2 = dots;
string null_book = "9-999-99999-9";
string nines = string(100, '9');
拷贝初始化通常使用拷贝构造函数来完成,有时使用移动构造函数来完成(左值被拷贝,右值被移动)。
发生拷贝初始化的情况包括:
- 用
=
定义变量; - 将一个对象作为实参传递给一个非引用类型的形参;
- 拷贝构造函数自己的参数必须是引用类型,避免无限循环
- 从一个返回类型为非引用类型的函数返回一个对象;
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
- 某些类类型会对它们所分配的对象使用拷贝初始化,例如,在初始化标准库容器时,或调用其
insert
或push
成员时。
如果初始化值需要类型转换,对于使用 explicit
声明的构造函数,就不能够使用拷贝初始化:
vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10; // 错误:接受大小参数的构造函数是 explicit 的
void f(vector<int>); // f 的参数进行拷贝初始化
f(10); // 错误:不能用一个 explicit 的构造函数拷贝一个实参
f(vector<int>(10)); // 正确:从一个 int 直接构造一个临时 vector
1.2 拷贝赋值运算符
拷贝赋值运算符(copy-assignment operator),右侧运算对象是自身类类型的参数,左侧运算对象绑定到隐式的 this
参数,通常返回左侧运算对象的引用。
1.2.1 合成拷贝赋值运算符
合成拷贝赋值运算符(synthesized copy-assignment operator),如果我们没有为一个类定义拷贝赋值运算符,编译器会为我们合成一个。
- 一般情况下(非删除的函数),合成拷贝赋值运算符会将右侧运算对象的每个非
static
成员赋予左侧运算对象的对应成员。 - 每个成员的赋值通过成员类型的拷贝赋值运算符来完成,对于数组类型的成员,会逐个赋值数组元素。
- 合成拷贝赋值运算符返回一个指向其左侧运算对象的引用。
class Sales_data
{
public:
Sales_data &operator=(const Sales_data &rhs); // 拷贝赋值运算符的声明
private:
string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// 拷贝赋值运算符的定义,当前定义与合成拷贝赋值运算符等价
Sales_data &Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; // 调用 string::operator=
units_sold = rhs.units_sold; // 使用内置的 int 赋值
revenue = rhs.revenue; // 使用内置的 double 赋值
return *this; // 返回一个本对象的引用
}
1.3 析构函数
析构函数(destructor),释放对象使用的资源,销毁对象的非 static
数据成员。它没有返回值,也不接受参数,不能被重载。一个类只有一个析构函数。
构造函数 | 析构函数 |
---|---|
有一个初始化部分和一个函数体 | 有一个函数体和一个析构部分 |
先初始化成员,再执行函数体 | 先执行函数体,再销毁成员 |
按照成员在类中出现的顺序进行初始化 | 按照成员初始化顺序的逆序进行销毁 |
通过初始化列表显示初始化 | 析构部分是隐式的,类类型成员执行各自的析构函数 |
调用析构函数(对象被销毁)的情况包括:
- 变量在离开其作用域时被销毁;
- 当对象被销毁时,其成员被销毁;
- 当容器(标准库容器&数组)被销毁时,其元素被销毁;
- 对于动态分配的对象,在 “对指向它的指针应用
delete
运算符时” 被销毁; - 对于临时对象,在 “创建它的完整表达式结束时” 被销毁。
1.3.1 合成析构函数
合成析构函数(synthesized destructor),如果我们没有为一个类定义析构函数,编译器会为我们合成一个。一般情况下(非删除的函数),合成析构函数的函数体为空。
class Sales_data
{
public:
// 析构函数的定义,当前定义与合成析构函数等价
~Sales_data() {}
private:
string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
1.4 三/五法则
对于类的三个(拷贝构造函数、拷贝赋值运算符、析构函数),或五个(加上 C++11 移动构造函数、移动赋值运算符)拷贝控制操作,C++ 语言并不要求我们同时进行定义。但是,这些操作通常应该被看作一个整体,如果定义了其中的一个操作,一般也需要定义其他操作。
判断一个类是否需要自定义拷贝控制成员:
- 首先确定是否需要自定义析构函数(更容易判断);
- 如果一个类需要自定义析构函数,几乎可以肯定,它也需要自定义拷贝构造函数和拷贝赋值运算符;
- 如果一个类需要自定义拷贝构造函数,几乎可以肯定,它也需要自定义拷贝赋值运算符,反之亦然;
- 如果资源的拷贝是非必要的,定义移动操作可以避免这种额外的开销。
示例:需要析构函数的类也需要拷贝和赋值操作
class HasPtr
{
public:
// 在构造函数中分配动态内存
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
// 需要定义一个析构函数来释放构造函数分配的内存(合成析构函数不会 delete 一个指针数据成员)
~HasPtr() { delete ps; }
// 错误:HasPtr 需要一个拷贝构造函数和一个拷贝赋值运算符
// 使用合成的函数,会简单拷贝指针成员 ps,这意味着多个 HasPtr 对象可能指向同一块内存空间
// 这些对象在销毁时,就会通过析构函数多次 delete 同一块内存空间
private:
string *ps;
int i;
};
HasPtr f(HasPtr hp) // HasPtr 是传值参数,所以会被拷贝
{
HasPtr ret = hp; // 拷贝传入的 HasPtr
// process ret
return ret; // ret 和 hp 都被销毁,导致相同的指针被 delete 两次
}
示例:需要拷贝操作的类也需要赋值操作,反之亦然
class HasUniqueID
{
public:
HasUniqueID(int d = 0) : data(d), id(nextId++) {}
// 拷贝构造函数,为每个新创建的对象生成一个新的、独一无二的 id
HasUniqueID(const HasUniqueID &rhs) : data(rhs.data), id(nextId++) {}
// 拷贝赋值运算符,避免复制 id
HasUniqueID &operator=(const HasUniqueID &rhs)
{
data = rhs.data;
return *this;
}
private:
int data;
int id;
static int nextId;
};
int HasUniqueID::nextId = 0;
1.5 使用 =default(C++11)
将拷贝控制成员定义为 =default
,可以显式地要求编译器生成合成的版本。
只能对具有合成版本的成员函数(默认构造函数、拷贝控制成员)使用 =default
。
class Sales_data
{
public:
// 对默认构造函数、拷贝控制成员使用 =default
// 在类内使用 =default,合成的函数将隐式地声明为内联函数
Sales_data() = default;
Sales_data(const Sales_data &) = default;
Sales_data &operator=(const Sales_data &); //需要合成非内联函数
~Sales_data() = default;
};
// 如果不希望合成的成员是内联函数,那么只对成员的类外定义使用 =default
Sales_data &Sales_data::operator=(const Sales_data &) = default;
1.6 阻止拷贝
虽然,大多数类应该显式地或隐式地定义拷贝构造函数和拷贝赋值运算符。但是,对于某些类(如 iostream)来说,这些操作没有合理的意义。在定义类时,就需要采用某种机制(将其定义为删除的函数或私有的函数),来阻止拷贝或赋值。
Best Practice:
希望阻止拷贝的类,应该使用=delete
来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private
的。
1.6.1 使用 =delete(C++11)
删除的函数(deleted function),在函数的形参列表后面加上 =delete
将其定义为删除的。这样,虽然声明了函数,但是不能以任何方式使用它们。
struct NoCopy
{
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(const NoCopy &) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy &) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
};
对比 =default
:
=delete
必须出现在函数的第一次声明中;- 可以对任何函数指定
=delete
。
通常,析构函数不能定义为删除的成员,因为,对于具有删除的析构函数的类型:
- 无法销毁此类型的对象;
- 不能定义该类型的变量或创建该类型的临时对象;
- 如果是某个类的成员的类型,那么不能定义该类的变量或临时对象;
- 可以动态分配此类型的对象(但不能释放)。
struct NoDtor
{
NoDtor() = default; // 使用合成默认构造函数
~NoDtor() = delete; // 不能销毁 NoDtor 类型的对象
};
NoDtor nd; // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但是我们不能 delete p
delete p; // 错误:NoDtor 的析构函数是删除的
对于某些类,编译器将合成的拷贝控制成员、合成的默认构造函数定义为删除的函数。
情况1: 如果类的某个数据成员不能默认构造、拷贝、赋值或销毁,那么类对应的成员函数将被定义为删除的。
如果,类的某个成员 | 那么,类的 |
---|---|
析构函数是删除的或不可访问的(如 private) | 合成析构函数被定义为删除的 |
拷贝构造函数是删除的或不可访问的 | 合成拷贝构造函数被定义为删除的 |
析构函数是删除的或不可访问的 | … |
拷贝赋值运算符是删除的或不可访问的 | 合成拷贝赋值运算符被定义为删除的 |
是 const 或引用 | … |
析构函数是删除的或不可访问的 | 合成默认构造函数被定义为删除的 |
是 引用,但没有类内初始化器 | … |
是 const ,但没有类内初始化器,并且没有显式定义默认构造函数 | … |
情况2: 如果类定义了一个移动构造函数和/或一个移动赋值运算符,那么该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
情况3: 将移动操作定义为删除的函数。
情况4: 某些定义基类的方式可能导致有的派生类成员成为被删除的函数。
基类 | 派生类 |
---|---|
默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是删除的或不可访问的 | 对应的成员将是被删除的(因为不能执行基类部分的构造、赋值或销毁操作) |
析构函数是删除的或不可访问的 | 合成的默认和拷贝构造函数将是被删除的(因为无法销毁基类部分) |
移动构造函数或移动赋值运算符是删除的或不可访问的 | 对应的成员将是被删除的(因为基类部分不可移动) |
析构函数是删除的或不可访问的 | 移动构造函数也将是被删除的(因为无法销毁基类部分) |
情况5: 含有类类型成员的 union
(C++11)
- 当
union
包含的是内置类型的成员时,编译器将按照成员的次序依次合成默认构造函数或拷贝控制成员。 - 如果
union
含有类类型的成员,并且该类型自定义了默认构造函数或拷贝控制成员,那么编译器将为union
合成对应的版本并将其声明为删除的。- 例如,
string
类定义了五个拷贝控制成员以及一个默认构造函数。如果union
含有string
类型的成员,并且没有自定义默认构造函数或某个拷贝控制成员,那么编译器将合成缺少的成员并将其声明为删除的。
- 例如,
- 如果在某个类中含有一个
union
成员,并且该union
含有删除的拷贝控制成员,则该类与之对应的拷贝控制成员也将是删除的。
1.6.2 private 拷贝控制
在 C++11 之前,类通过将其拷贝构造函数和拷贝赋值运算符声明为 private
来阻止拷贝。
class PrivateCopy
{
// 拷贝控制成员是 private 的,普通用户代码无法访问(产生编译错误)
// 拷贝控制成员“仅声明不定义”,成员函数和友元也不能进行访问(产生链接时错误)
// 如果不使用某个函数,声明但不定义是合法的(虚函数除外)
PrivateCopy(const PrivateCopy &);
PrivateCopy &operator=(const PrivateCopy &);
public:
PrivateCopy() = default;
~PrivateCopy() = default; // 析构函数为 public,用户可以定义此类型的对象,但是无法拷贝它们
};
2、拷贝控制和资源管理
通常,管理类外资源的类都需要定义拷贝控制成员,因为它们需要定义析构函数来释放对象所分配的资源,同时也就会需要拷贝构造函数和拷贝赋值运算符。
一般来说,有两种选择来定义这些成员的拷贝操作:
- 使类的行为看起来像一个值:
- 在拷贝对象时,副本和原对象是完全独立的,改变副本不会改变原对象;
- 例如标准库容器和
strnig
类。
- 使类的行为看起来像一个指针:
- 在拷贝对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象;
- 例如
shared_ptr
类。
2.1 行为像值的类
对于类管理的资源,每个对象都应该有一份自己的拷贝。
class HasPtr
{
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
// 对于 ps 指向的 string,每个 HasPtr 对象都有自己的拷贝
HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
string *ps;
int i;
};
赋值运算符通常组合了析构函数和构造函数的操作:
- 类似析构函数,赋值操作会销毁左侧运算对象的资源;
- 类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
- 这些操作要以正确的顺序执行,确保即使将一个对象赋予它自身也能够正确工作;
- 如果可能,在异常发生时,要将左侧运算对象置于一个有意义的状态。
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
// 首先将右侧运算对象资源拷贝到一个局部临时对象中
// 这样可以处理自赋值情况,并能保证在异常发生时代码也是安全的
auto newp = new string(*rhs.ps);
delete ps; // 释放左侧运算对象(本对象)的资源
ps = newp; // 将资源从临时对象拷贝到左侧运算对象的成员中
i = rhs.i; // 内置类型(除指针)成员本身就是值,直接拷贝
return *this; // 返回左侧对象
}
2.2 行为像指针的类
令一个类展现“类似指针的行为”的最好方法是使用 shared_ptr
来管理类内的资源。
如果我们希望直接管理资源,可以使用引用计数(reference count),其工作方式如下:
- 每个构造函数(拷贝构造函数除外)都要创建一个共享的计数器(初始值为 1),用来记录有多少个用户使用本对象创建的共享资源;
- 拷贝构造函数递增计数器,指出共享资源的用户多了一个;
- 析构函数递减计数器,指出共享资源的用户少了一个,如果计数器变为 0,则释放资源;
- 拷贝赋值运算符
- 递增右侧运算对象的计数器;
- 递减左侧运算对象的计数器,如果计数器变为 0,则释放其资源。
class HasPtr
{
public:
// 构造函数,分配新的 string 和新的计数器,并将计数器初始化为 1,指出当前有一个用户使用本对象的 string 成员
HasPtr(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)) {}
// 拷贝构造函数,拷贝所有三个数据成员,包括 ps 指针本身,并递增计数器,指出 ps 和 p.ps 指向的 string 又多了一个用户
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr &operator=(const HasPtr &);
~HasPtr();
private:
string *ps;
int i;
size_t *use; // 计数器成员,用来记录有多少个用户共享 *ps
};
HasPtr::~HasPtr()
{
if (--*use == 0) // 递减引用计数,指出共享 string 的用户少了一个
{ // 如果引用计数变为 0
delete ps; // 释放 string 内存
delete use; // 释放计数器内存
}
}
HasPtr &HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // 递增右侧运算对象的引用计数
if (--*use == 0) // 递减左侧运算对象的引用计数(本对象)
{ // 如果引用计数变为 0,代表没有用户使用
delete ps; // 释放本对象的原有资源
delete use;
}
ps = rhs.ps; // 将数据从右侧运算对象拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
3、交换操作
除了定义拷贝控制成员,管理资源的类通常还会定义一个名为 swap
的函数,作为一种优化手段。
3.1 在重排算法中使用交换
如果一个类定义了自己的 swap
,算法将使用类自定义的版本,否则将使用标准库定义的 swap
。
class HasPtr
{
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps);
delete ps;
ps = newp;
i = rhs.i;
return *this;
}
~HasPtr() { delete ps; }
private:
string *ps;
int i;
friend void swap(HasPtr &, HasPtr &); // 声明为友元
};
inline void swap(HasPtr &lhs, HasPtr &rhs) // 声明为 inline 优化代码
{
#if 0 // 通常的 swap
HasPtr temp = lhs; // 一次拷贝
lhs = rhs; // 两次赋值
rhs = temp;
#else // 自定义的 swap
using std::swap; // 对于内置类型,swap 会调用 std::swap
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs.i); // 交换 int 成员
#endif
}
swap
函数应该调用 swap
,而不是 std::swap
。
struct Foo
{
HasPtr h;
// 其他成员
};
void swap(Foo &lhs, Foo &rhs)
{
// std::swap(lhs.h, rhs.h); // 错误:这个函数使用了标准库版本的 swap,而不是 HasPtr 版本
using std::swap;
swap(lhs.h, rhs.h); // 正确:使用 HasPtr 版本的 swap
// 交换类型 Foo 的其他成员
}
3.2 在赋值运算符中使用交换
定义了 swap
的类通常使用 swap
来定义其赋值运算符。
通过一种名为拷贝和交换(copy and swap)的技术,将左侧运算对象与右侧运算对象的一个副本进行交换。
// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数将右侧运算对象中的 string 拷贝到 rhs
HasPtr &HasPtr::operator=(HasPtr rhs)
{
// 交换左侧运算对象(本对象)和局部变量 rhs 的内容
// 本对象中的指针成员将指向新分配的 string,即右侧运算对象中 string 的一个副本
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针(左侧运算对象中原来的内存)
}
使用拷贝和交换的赋值运算符自动就是异常安全的,并且能够正确处理自赋值:
- 通过在改变左侧运算对象之前拷贝右侧运算对象,保证了自赋值的正确;
- 如果拷贝构造函数中的
new
表达式抛出异常,也并不会影响到左侧运算对象。
4、对象移动(C++11)
我们在 1.1.2 拷贝初始化 中提到,很多情况下会发生拷贝初始化,拷贝初始化通常使用拷贝构造函数来完成,有时使用移动构造函数来完成。
- 如果对象在拷贝后就立即被销毁,那么移动对象而非拷贝对象会大幅度提升性能;
- 另外,对于 IO 类或
unique_ptr
这样的类,它们都包含不能被共享的资源(如指针或 IO 缓冲),这些类型的对象不能拷贝但可以移动; - 而标准库容器、
string
和shared_ptr
类就既支持拷贝,也支持移动。
4.1 右值引用
右值引用(rvalue reference),为了支持移动操作,引入的新的引用类型。
- 仍然是某个对象的别名;
- 通过
&&
来获得; - 只能被绑定到右值(一个将要被销毁的、没有其他用户的临时对象),因此可以自由“移动”其资源。
4.1.1 左值和右值
C++ 的表达式要不然是右值(rvalue),要不然是左值(lvalue)。
- 右值表达式,表示的是对象的值(内容)
- 示例:返回非引用类型的函数,以及算术、关系、位、后置递增/递减运算符的返回值,要求转换的表达式,字面常量;
- 要么是字面常量,要么是在表达式求值过程中创建的临时对象。
- 左值表达式,表示的是对象的身份(存放内容的位置)
- 示例:返回左值引用的函数,以及赋值、下标、解引用、前置递增/递减运算符的返回值,变量;
- 有持久的状态(非临时)。
使用原则:
- 左值可以代替右值(使用"位置"中的"内容");
- 右值不能代替左值。
4.1.2 左值引用 vs 右值引用
常规引用,或称左值引用(lvalue reference)
- 可以绑定到左值表达式
- 不能绑定到右值表达式(const 左值引用可以)
右值引用
- 可以绑定到右值表达式
- 可以将一个左值显示转换成其对应的右值引用类型
- 可以通过
move
函数(谨慎使用),获取绑定到左值的右值引用
int i = 42;
int &r = i; // 正确:r 引用 i
int &&rr = i; // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; // 错误:i*42 是一个右值
const int &r3 = i * 42; // 正确:可以将一个 const 的引用绑定到一个右值上
int &&rr2 = i * 42; // 正确:将 rr2 绑定到乘法结果上
int &&rr3 = 42; // 正确:字面常量是右值
int &&rr4 = rr3; // 错误:变量 rr3 是左值
int &&rr5 = std::move(rr3); // 正确:move 返回给定对象的右值引用
// move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它
// 调用 move 意味着承诺:除了对 rr3 赋值或销毁它之外,我们将不再使用它
4.2 移动操作
为了让类支持移动操作,需要定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“移动”资源而不是拷贝资源。
4.2.1 移动构造函数
移动构造函数(move constructor),“第一个参数是自身类类型的右值引用,其它参数(如果有)都有默认值” 的构造函数。第一个参数是“右值引用”,是其不同于拷贝构造函数的地方。移动构造函数需要:
- 实现资源移动;
- 确保移后源对象是可析构的状态,不再指向被移动的资源;
- 确保移后源对象是有效的状态,可以安全地为其赋予新值,或者不依赖原值地被使用;
- 不需要对移后源对象中留下的值做保证(因此,用户不能对它的值进行任何假设)。
class StrVec
{
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
StrVec(const StrVec &); // 拷贝构造函数
StrVec &operator=(const StrVec &); // 拷贝赋值运算符
~StrVec() { free(); } // 析构函数
// 移动构造函数
// 由于移动操作“移动”资源,通常不分配任何资源,因此通常也不会抛出任何异常
// 使用 noexcept(C++11)指明函数不抛出任何异常,从而免去用户额外的处理异常的工作
// 如果函数定义在类外,必须在声明中和定义中都指定 noexcept
StrVec::StrVec(StrVec &&s) noexcept
// 成员初始化器接管 s 中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// 令 s 进入某种状态,使得对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
// ...
private:
void free() { /*...*/ } // 销毁元素并释放内存
string *elements;
string *first_free;
string *cap;
};
4.2.2 移动赋值运算符
移动赋值运算符(move-assignment operator),右侧运算对象是自身类类型的右值引用,左侧运算对象绑定到隐式的 this
参数,通常返回左侧运算对象的引用。
类似“移动构造函数与拷贝构造函数”的关系,相比于拷贝赋值运算符,移动赋值运算符的右侧运算对象是一个“右值引用”。
移动赋值运算符执行与“析构函数”和“移动构造函数”相同的工作。类似 拷贝赋值运算符,移动赋值运算符也必须正确处理自赋值。
class StrVec
{
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
~StrVec() { free(); } // 析构函数
StrVec &StrVec::operator=(StrVec &&rhs) noexcept; // 移动赋值运算符
// ...
private:
void free() { /*...*/ } // 销毁元素并释放内存
string *elements;
string *first_free;
string *cap;
};
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
// 直接检测自赋值,因为右侧运算对象的右值可能是 move 调用的返回结果,
// 这样右侧运算对象与左侧运算对象是同一份资源,不能够先释放再使用
if (this != &rhs)
{
free(); // 释放左侧运算对象所使用的内存
elements = rhs.elements; // 从右侧运算对象 rhs 接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 与移动构造函数一样,将 rhs 置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
Note: 定义了移动构造函数或移动赋值运算符的类,必须也定义它自己的拷贝操作。否则,这些成员会被默认定义为删除的。
4.2.3 合成的移动操作
与合成拷贝操作不同,只有在满足如下条件时,编译器才会合成移动构造函数或移动赋值运算符:
- 类没有定义任何自定义版本的拷贝控制成员;
- 类的每个非
static
数据成员都可以移动:- 对于内置类型成员,可以直接移动
- 对于类类型成员,需要有移动构造函数或移动赋值运算符,才可以移动
因此,有些类并没有移动构造函数和移动赋值运算符,这时,通过正常的函数匹配,会使用对应的拷贝操作(如果存在)来代替移动操作。
// 编译器会为 X 和 hasX 合成移动操作
struct X {
int i; // 内置类型可以移动
string s; // string 定义了自己的移动操作
};
struct hasX {
X mem; // X 有合成的移动操作
};
X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数
将移动操作定义为删除的函数:
- 与拷贝操作不同,移动操作永远不会被隐式地定义为删除的函数;
- 在“使用
=default
显示地要求编译器生成移动操作,而编译器又不能移动所有成员”时,编译器会将移动操作定义为删除的函数,并不会进行合成。
将合成的移动操作定义为删除的函数,遵循与 定义删除的合成拷贝操作 类似的原则
如果,类的某个成员 | 那么,类的 |
---|---|
定义了自己的拷贝构造函数,但没有定义移动构造函数 | 合成移动构造函数被定义为删除的 |
没有定义自己的拷贝构造函数,且编译器不能为它合成移动构造函数 | … |
定义了自己的拷贝赋值运算符,但没有定义移动赋值运算符 | 合成移动赋值运算符被定义为删除的 |
没有定义自己的拷贝赋值运算符,且编译器不能为它合成移动赋值运算符 | … |
移动构造函数是删除的或不可访问的 | 合成移动构造函数被定义为删除的 |
析构函数是删除的或不可访问的 | … |
移动赋值运算符是删除的或不可访问的 | 合成移动赋值运算符被定义为删除的 |
是 const 或引用 | … |
// Y 定义了自己的拷贝构造函数,未定义自己的移动构造函数,编译器不会为 Y 合成移动操作
// 编译器可以拷贝类型为 Y 的对象,但是不能移动它们
struct Y
{
Y() = default;
Y(const Y &) = default; // 定义拷贝构造函数
string s;
};
struct hasY
{
hasY() = default;
hasY(hasY &&) = default; // 显式要求一个移动构造函数,但编译器不能移动所有成员,无法生成
Y mem; // 成员 Y 不能被移动,hasY 将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); // 错误:移动构造函数是删除的
4.2.4 交换版移动赋值运算符
在 3.2 在赋值运算符中使用交换 部分,我们为 HasPtr
定义了一个拷贝并交换赋值运算符,如果我们再添加一个移动构造函数,那么就会同时获得一个移动赋值运算符。
class HasPtr
{
public:
// 添加移动构造函数,接管给定实参的值,并确保可以安全销毁移后源对象
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
// 赋值运算符,既是拷贝赋值运算符,也是移动赋值运算符
// 赋值运算符有一个非引用形参,意味着形参需要进行拷贝初始化
// 根据实参的类型,拷贝初始化要么使用拷贝构造函数(左值被拷贝),要么使用移动构造函数(右值被移动)
HasPtr &operator=(HasPtr rhs)
{
swap(*this, rhs);
return *this;
}
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i) {}
~HasPtr() { delete ps; }
private:
string *ps;
int i;
friend void swap(HasPtr &, HasPtr &);
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}
int main()
{
HasPtr hp, hp2;
hp = hp2; // hp2 是一个左值,通过拷贝构造函数来初始化
hp = std::move(hp2); // 将一个右值引用绑定到 hp2 上,通过移动构造函数来初始化
return 0;
}
4.3 右值引用和成员函数
4.3.1 成员函数的拷贝和移动版本
除了构造函数和赋值运算符,其他成员函数也可以同时提供拷贝和移动两个版本,并且通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式:
- 一个版本接受一个指向
const
的左值引用- 从传入对象拷贝数据时,不应该改变该对象,因此,通常不需要定义一个接受一个非
const
参数的版本。
- 从传入对象拷贝数据时,不应该改变该对象,因此,通常不需要定义一个接受一个非
- 一个版本接受一个指向非
const
的右值引用- 从传入对象移动数据时,就会改变该对象,因此,通常需要一个右值引用,并且不能是
const
的。
- 从传入对象移动数据时,就会改变该对象,因此,通常需要一个右值引用,并且不能是
例如,标准库容器定义 push_back
的两个版本(假定 X
是元素类型)
// 可以传递能转换为类型 X 的任何对象,此版本从其参数拷贝数据
void push_back(const X&); // 拷贝:绑定到任意类型的 X
// 只可以传递非 const 的右值,此版本从其参数移动数据
void push_back(X&&); // 移动:只能绑定到类型 X 的可修改的右值
4.3.2 左值和右值引用成员函数
在一个对象上调用成员函数时,有时需要对象是一个左值,有时需要对象是一个右值。
通过为成员函数添加引用限定符(reference qualifier),我们可以指定 this
指向对象的左值/右属值性,使用方式与定义 const
成员函数相同:
- 在形参列表后放置一个引用限定符
&
或&&
;- 对于
&
限定的函数,只能将它用于左值(this
指向左值) - 对于
&&
限定的函数,只能将它用于右值(this
指向右值)
- 对于
- 引用限定符只能用于(非
static
)成员函数,并且必须同时出现在函数的声明和定义中。
class Foo
{
public:
Foo &operator=(const Foo &) &; // 只能向可修改的左值赋值
// 如果同时是 const 和引用限定,引用限定符必须在 const 限定符后面
Foo someMem() const &;
// ...
};
Foo &Foo::operator=(const Foo &rhs) &
{
// 执行将 rhs 赋予本对象所需的工作
return *this;
}
Foo &retFoo(); // 返回一个引用;retFoo 调用是一个左值
Foo retVal(); // 返回一个值;retVal 调用是一个右值
Foo i, j; // i 和 j 是左值
i = j; // 正确:i是左值
retFoo() = j; // 正确:retFoo() 返回一个左值
retVal() = j; // 错误:retVal() 返回一个右值
i = retVal(); // 正确:可以将一个右值作为赋值操作的右侧运算对象
4.3.3 重载和引用函数
可以根据 const
和 引用,来区分成员函数的重载版本:
- 根据是否有
const
来区分- 定义
const
成员函数时,可以定义两个版本,唯一的差别是一个版本有const
限定,而另一个没有。
- 定义
- 根据是否有引用限定符来区分
- 定义两个或以上具有相同名字和相同参数列表的成员函数,只能要么都加引用限定符,要么都不加。
- 综合引用限定符和
const
来区分
class Foo
{
public:
Foo() = default;
Foo sorted() &&; // 用于可改变的右值
Foo sorted() const &; // 用于任何类型的 Foo
// Foo sorted() const; // 错误:引用限定符需保持一致(都有/都没有)
// ...
private:
vector<int> data;
};
// this 指向对象为右值,意味着没有其他用户,因此可以改变对象,进行原址排序
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
// this 指向对象是 const 和 左值,无论哪种情况,都不能进行原址排序
Foo Foo::sorted() const & {
Foo ret(*this); // 拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 对副本进行排序
return ret; // 返回副本
}
// 编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted 版本
retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&
retFoo().sorted(); // retFoo() 是一个左值, 调用 Foo::sorted() const &
参考
- [美] Stanley B.Lippman著.C++ Primer 中文版(第5版).电子工业出版社.2013.
- [美] Stephen Prata著.C++ Primer Plus(第6版)中文版.人民邮电出版社.2012.
宁静以致远,感谢 Vico 老师。