拷贝控制
1 拷贝、赋值与销毁
拷贝控制操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。
1.1 拷贝构造函数
若一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。示例如下:
class Foo
{
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
};
拷贝初始化
string dots(10, '.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string nines = string(100, '9'); //拷贝初始化
当使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。
当使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中。
拷贝初始化不仅在用=
定义变量时发生,在以下情况也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
例如:初始化标准库容器或调用其insert
或push
成员时,容器会对其元素进行拷贝初始化,而用emplace成员创建的元素都进行直接初始化。
1.3 析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,而析构函数则是释放对象使用的资源,并销毁对象的非static数据成员。其示例如下:
class Foo
{
public:
~Foo(); //析构函数
};
析构函数完成的工作: 成员的初始化是在函数体执行之前完成的,按照在类中出现的顺序进行初始化,而在析构函数中,首先执行函数体,然后销毁成员,==成员按初始化顺序的逆序销毁。==通常析构函数释放对象在生存期分配分配的所有资源。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用于时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向其指针应用delete运算符时被销毁
- 对于临时对象,当创建它的完整表达式结束时被销毁
1.4 三/五法则
三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
需要析构函数的类也需要拷贝和赋值操作
当决定一个类是否要定义其拷贝控制成员时,首先应该确定这个类是否需要一个析构函数,若这个类需要一个析构函数,则其也需要一个拷贝构造函数和一个拷贝赋值运算符。
合成析构函数不会delete一个指针数据成员,此时则需要定义一个析构函数来释放构造函数分配的内存。
需要拷贝操作的类也需要赋值操作,反之亦然
第二个基本原则:如果一个类需要一个拷贝构造函数,可以肯定也需要一个拷贝赋值运算符;反之亦然,如果一个类需要一个拷贝赋值运算符,可以肯定也需要一个拷贝构造函数。
1.5 使用=default
通过将拷贝控制成员定义为=default来显式要求编译器生成合成的版本,示例如下:
class Sales_data{
public:
//拷贝控制成员;使用default
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
};
当在类内用=default修饰成员的声明时,合成的函数将隐式第声明为内联的,若不希望合成的成员是内联函数,则只对成员的类外定义使用=default
只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)
1.6 阻止拷贝
定义删除的函数:声明了该函数,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出定义其为删除的,示例如下:
struct NoCopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator= (const NoCopy&) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
};
与default不同,=delete必须出现在函数第一次声明的时候;可以对任何函数指定=delete
。注意:析构函数不能是删除的成员
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。但是有一个规则:若一个类有const成员,则它不能使用合成的拷贝赋值
2 拷贝控制和资源管理
2.1 行为像值的类
类值拷贝赋值运算符
如下例子,通过先拷贝右侧运算符对象,在拷贝完成后,释放左侧运算对象的资源,并更新指针指向新分配的string,代码示例如下:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; //返回本对象
}
要点:
- 若将一个对象赋予其自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作
2.2 定义行为指针的类
令一个类展现类似指针的行为的最后办法是:使用shared_ptr来管理类中的资源。其特点:拷贝(或赋值)一个shared_ptr会拷贝(赋值)shared_ptr所指向的指针;shared_ptr类自己记录有多少用户共享其所指向的对象;当没有用户使用对象时,shared_ptr类负责释放资源。
引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来距离有多少对象与正在创建的对象共享状态。当创建对象时,计数器初始化为1.
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,若左侧运算对象的计数器变为0,则拷贝赋值运算符就必须销毁状态。
解决确定在哪存放引用计数的方法是:将计数器保存在动态内存中
定义一个使用引用计数的类,其示例如下:
class HasPtr{
public:
//构造函数分配新的string
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),