C++ 学习笔记之(13) - 拷贝控制
本文将学习类如何通过一组函数控制对象拷贝、赋值、移动和销毁,这组函数分别是拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符以及析构函数。若类没有显示定义这些拷贝控制成员,则编译器会自动定义。
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用(几乎总为const
引用),且任何额外参数都有默认值,则为拷贝构造函数,拷贝构造函数会被隐式使用,故不应为explicit
合成拷贝构造函数
若没有为类定义拷贝构造函数,则编译器会定义,即使定义了其他构造函数,编译器也会合成拷贝构造函数
- 合成拷贝构造函数:从给定对象中依次将每个非
static
成员拷贝到正在创建的对象中 - 对类类型,使用其拷贝构造函数来拷贝;对内置类型,直接拷贝;对数组,则逐元素拷贝
拷贝初始化
- 直接初始化:要求编译器使用普通的函数匹配来选择最匹配的构造函数,包括拷贝构造函数
- 拷贝初始化:要求编译器将右侧运算对象拷贝到正在创建的对象中,若需要可进行类型转换
- 拷贝初始化通常使用拷贝构造函数完成,有时也依靠移动构造函数完成
- 拷贝初始化发生情况
- 使用
=
定义变量时 - 将对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 使用
- 拷贝构造函数自身参数必须是引用类型,因为为了调用拷贝构造函数,必须拷贝实参,就又会调用拷贝构造函数,死循环
class Sales_data{
public:
Sales_data(const Sales_data& orig): bookNo(orig.bookNo), units_sold(orig.units_sold)) {} // 拷贝构造函数,第一个参数为引用,且通常为const
private:
std::string bookNo;
int units_sold = 0;
}
string dots(10, '.'); // 直接初始化
string s(dosts); // 直接初始化,因为是调用最匹配的构造函数,包括拷贝构造函数
string s2 = dots; // 拷贝初始化
参考
拷贝赋值运算符
与拷贝构造函数类似,若类未定义自己的拷贝赋值运算符,编译器会生成一个合成拷贝赋值运算符
- 重载运算符本质上是函数,其名字由
operator
关键字后接运算符符号 - 若一个运算符为成员函数,其左侧对象就绑定到隐式的
this
参数。若为二元运算符,其右侧运算对象作为显示参数传递 - 赋值运算符通常应该返回一个指向其左侧运算对象的引用
析构函数
析构函数释放对象使用的资源,并销毁对象的非static
数据成员
析构函数为成员函数,名字由波浪号组成,无返回值,不接受参数,不能被重载,在类中唯一
~Foo(); // 析构函数
析构函数中,首先执行函数体,然后销毁成员,按初始化顺序逆序销毁,且释放对象在生存期分配的所有资源
隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象,智能指针在析构阶段被自动销毁析构函数调用时间(对象被销毁时)
- 变量在离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针应用
delete
运算符时被销毁 - 对于临时对象,当创建它的完整表达式结束时被销毁
当指向一个对象的引用或指针离开作用域时,析构函数不会执行
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段被销毁的
三/五法则
- 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数(比如简单拷贝指针成员,导致多个类对象指向相同内存,
delete
会出错) - 如果一个类需要一个拷贝构造函数,几乎可以肯定也需要一个拷贝赋值运算符,反之亦然。但并不意味之需要页析构函数
使用=default
- 通过将拷贝控制成员定义为
=default
显示要求编译器生成合成版本,只能对具有合成版本的成员函数使用,即默认构造函数或拷贝控制成员 - 类内使用
`=default
修饰成员声明时,隐式表示为内联,若不希望合成成员为内联函数,可在类外定义使用
阻止拷贝
定义类时可以采取定义删除的函数来阻止拷贝或赋值,因为对于某些类,这些操作可能无意义
- 新标准定义可通过将拷贝构造函数和拷贝赋值运算符定义为 删除的函数来阻止拷贝,即函数参数列表后加
=delete
- 与
=default
不同,=delete
必须出现在函数第一次声明的时候 - 与
=default
不同,可以对任何函数指定=delete
- 不能删除析构函数,否则无法销毁对象。对于一个删除了析构函数的类型,编译器不允许定义该类型的变量或创建该类的临时对象,但可以动态分配这些类型的对象,但无法释放
- 新标准之前可将拷贝构造函数和拷贝赋值运算符声明为
private
熬阻止拷贝,但不推荐
拷贝控制和资源管理
拷贝操作可使类型的行为分为两种
- 看起来像一个值:即有自己的状态,拷贝后,副本和元对象完全独立,改变副本不会影响原对象,反之亦然
- 看起来像一个指针:会共享状态,当拷贝这种类的对象时,副本和原对象使用相同的底层数据。改变副本会改变原对象,反之亦然
行为像值的类
- 对于指针成员,应该拥有一份自己的拷贝,否则会与被拷贝对象中的指针指向相同的底层数据
class HasPtr{
public:
// 构造函数都动态分配自己的 string 副本,并将指向该 string 的指针保存到 ps 中
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; } // 对 ps 执行 delete, 释放分配的内存
private:
std::string *ps;
int i;
};
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // 拷贝底层 string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}
int main(void)
{
HasPtr hasPtr1("hao"); // 直接初始化,第一个构造函数
HasPtr hasPtr2(hasPtr1); // 直接初始化,调用拷贝构造函数
HasPtr hasPtr4;
hasPtr4 = hasPtr1; // 拷贝赋值运算符
}
- 对赋值运算符的编写,要注意两点
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作
定义行为像指针的类
令一个类展现类似指针的行为的最好方法是使用shared_ptr
管理类中的资源。若希望直接管理资源,可以使用 引用计数(reference count), 接下来重新定义HasPtr
,使用引用计数而不是shared_ptr
引用计数的工作方式
- 构造函数(除拷贝构造函数外)初始化对象,创建引用计数,记录共享状态对象数目,计数器初始化为1
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器,且计数器递增
- 析构函数递减计数器,若计算器变为
0
, 则析构函数释放状态 - 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。若左侧运算对象计数器为
0
,则销毁
class HasPtr{
public:
// 拷贝构造函数分配新的 string 和新的计数器, 将计数器置为 1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// 拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享 *ps 的成员
};
HasPtr::~HasPtr()
{
if (--*use == 0){ // 如果引用计数变为 0
delete ps; // 释放 string 内存
delete use; // 释放计数器内存
}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // 递增右侧运算对象的引用计数
if(--*use == 0) // 然后递减本对象的引用计数
{
delete ps; // 如果没有其他用户,则释放本对象分配的成员
delete use;
}
ps = rhs.ps; // 将数据从 rhs 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}
交换操作
管理资源的泪还会定义一个名为swap
的函数,对于那些重拍元素顺序的算法,在交换元素时会调用swap
- 交换对象需要进行一次拷贝和两次赋值
- 若类定义了
swap
,则算法会使用类自定义版本,否则,算法使用标准库定义的swap
swap
函数不是必须,但是是一种重要的优化手段- 定义了
swap
的类常用swap
定义他们的赋值运算符,使用了名为 拷贝并交换的技术,将左侧运算对象与右侧运算对象的一个副本进行交换 - 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值
class HasPtr{
friend void swap(HasPtr&, HasPtr&); // 定义为 friend 可访问私有成员
// ...
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap; // 若存在类型特定的 swap 版本,匹配程度会优于 std 定义版本
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs.i); // 交换 int 成员
}
// 参数是按值传递,故调用拷贝构造函数创建 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{ // 交换左侧运算对象和局部变量 rhs 的内容
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针
}
对象移动
新标准定义了移动对象的特性,比拷贝对象大幅度提升性能
- 标准库容器、
string
和shared_ptr
类型既支持移动也支持拷贝。IO
类和unique_ptr
类可以移动但不能拷贝
右值引用
右值引用就是必须绑定到右值的引用
右值引用只能绑定到一个而将要销毁的对象, 故可从绑定到右值引用的对象窃取状态
左值表达式表示的是对象的身份,而右值表达式表示的是对象的值
常规引用为左值引用,不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式
- 右值引用有着完全相反的绑定特性,不能将右值引用绑定到左值上
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 绑定到乘法结果上
变量是左值,故不能讲一个右值引用直接绑定到变量上,即使该变量为右值引用类型也不行。
标准库
move
函数可获得绑定到左值上的右值引用int &&rr1 = 42; // 字面值常量是右值 int &&rr3 = std:;move(rr1); // ok
移动构造函数和移动赋值运算符
为了让自定义类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符
移动构造函数第一个参数是右值引用,任何额外参数都要有默认实参
资源完成移动后,源对象必须不再指向被移动的资源,这些资源所有权已经归属新创建的对象
移动操作通常不分配任何资源,故通常不会抛出异常。新标准定义在函数参数列表后指定
noexcept
表示通知标准库此函数不会抛出异常。必须在声明和定义处都制定noexcept
标记了
noexcept
就会使用移动构造函数,否则会使用拷贝构造函数。比如
vector
的push_back
操作可能会要求vector
重新分配内存空间。若采用移动构造函数,且在移动了部分元素后抛出异常,就会产生问题,因为移动过的源元素已经被改变。而若采用拷贝构造函数则满足要求不同于拷贝操作,编译器不会为某些类合成移动操作
- 如果类定义了拷贝构造函数、拷贝赋值运算符或析构函数,就不会合成移动构造函数和移动赋值运算符
- 只有当类没有定义任何自己版本的拷贝控制成员,且类的每个非
static
数据成员都可移动时,编译器才会合成移动构造函数或移动赋值运算符
与拷贝操作不同,移动操作永远不会隐式i定义为删除的函数
若我们显示要求编译器生成
=default
的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,该类的合成拷贝构造函数和拷贝赋值运算符被定义为删除的
若一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来移动的。拷贝赋值运算符和移动赋值运算符的情况类似
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; }
// ... 同上
}
int main()
{
HasPtr hp, hp2;
hp = hp2; // hp2 是左值; hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动 hp2
}
移动迭代器(move iterator):解引用生成一个右值引用,通过标准库的
make_move_iterator
函数将普通迭代器转换为一个移动迭代器// 使用移动迭代器,原对象可能被销毁 unitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
右值引用和成员函数
区分移动和拷贝的重载函数通常有一个版本接受一个const T&
, 另一个版本接受一个T &&
通常不需要为函数定义接受一个
const X&&
或一个普通的X &
参数的版本。因为移动构造函数需要窃取数据,通常传递右值引用,故实参不能为const
。而拷贝构造函数的操作不应该改变该对象,故不需要普通的X &
参数的版本
// 定义了 push_back 的标准库容器提供了两个版本
void push_back(const X&); // 拷贝:绑定到任意类型的 X
void push_back(X&&); // 移动:只能绑定到类型 X 的可修改的左值
string s = "hao"
vector<string> vs;
vs.push_back(s); // 调用 push_back(const string&);
vs.push_back("happy"); // 调用 push_back(string &&); 精确匹配
引用限定符
与定义const
成员函数形同,通过在参数列表后指定引用限定符,指定this
的左右/右值属性,只能用于(非static
)成员函数,且必须同时出现在函数的声明和语义中
&
表示this
可以指向一个左值&&
表示this
指向一个右值- 引用限定符和
const
可以同时存在,const
在引用限定符前面 - 引用限定符也可区分重载
// 旧标准中会出现向右值赋值的情况
string s1 = "a value", s2 = "another";
s1 + s2 = "wow!";
// 新标准可通过引用限定符解决上述问题
class Foo{
public:
Foo &operator=(const Foo&) &; // 只能像可修改的左值赋值
// ... Foo 的其他参数
Foo someMem() & const; // 错误:const限定符必须在前
Foo anotherMem() const &; // 正确
Foo sorted() &&; // 用于可改变的右值,可以原址排序
Foor sorted() const &; // 对象为const 或左值,两种情况都不能进行原址排序
};
Foo &Foo::operator=(const Foo &rhs) &
{
// 其它工作
return *this;
}
结语
每个类都会通过拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数控制该类型对象拷贝、移动、赋值以及销毁操作。移动构造函数和移动赋值运算符接受一个(通常是非const
)的右值引用,而拷贝版本则接受一个(通常是const
)的普通左值引用
若类未声明这些操作,编译器会自动生成。若这些操作未定义成删除的,则会逐成员初始化、移动、赋值或销毁对象:合成的操作依次处理每个非static
数据成员,根据成员来兴确定如何移动、拷贝、赋值和销毁它
分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源,如果一个类需要析构函数,则它几乎也肯定需要定义移动和拷贝构造函数及移动和拷贝赋值运算符