C++Primer 5th:第十三章 拷贝控制

本文最后更新于:2021年4月27日 上午

第十三章 拷贝控制(copy control)

  • 类如何控制在对象拷贝、赋值、移动和销毁时做什么
    • 拷贝构造函数(copy constructor)
    • 拷贝赋值运算符(copy-assignment operator)
    • 移动构造函数(move constructor)
    • 移动赋值运算符(move-assignment operator)
    • 析构函数(destructor)
  • 新标准:右值引用和移动操作

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

  • 第一个参数是自身类类型的引用,且任何额外参数都有默认值
  • 几乎总是一个const的引用
  • 拷贝构造函数不应该是explicit的(拷贝构造函数在几种情况下会被隐式地使用)
1
2
3
4
5
class Foo{
public:
Foo(); //默认构造
Foo(const Foo&); //拷贝构造
};

1. 合成拷贝构造函数

  • 阻止我们拷贝该类类型的对象
  • 编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Sales_data {
public:
//与合成的拷贝构造函数等价的拷贝构造函数的声明
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
//与Sales_data 的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data& orig) :
bookNo(orig.bookNo),
units_sold(orig.units_sold),
revenue(orig.revenue)
{}

2. 拷贝初始化

1
2
3
string dots(10, '.');		//直接初始化,要求编译器使用普通的函数匹配
string s(dots);
string s2 = dots; //拷贝初始化,要求编译器将右侧运算对象拷贝到正在创建的对象中

拷贝初始化何时发生:

  • =定义变量时

  • 将一个对象作为实参传递给一个非引用类型的形参

  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组的元素或一个聚合类中的成员

  • 当初始化标准库容器或调用其insertpush成员时,容器会对其元素进行拷贝初始化

  • emplace成员创建的元素都进行直接初始化

3. 参数和返回值

  • 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化
  • 当函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果
  • !拷贝构造函数被用来初始化非引用类类型参数,如果其参数不是引用类型,则调用永远不会成功:为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,又需要调用拷贝构造函数。

4. 拷贝初始化的限制

1
2
3
4
5
vector<int> v1(10);		//正确:直接初始化
//vector<int> v2 = 10; //错误:接受大小参数的构造函数是explicit的
void f(vector<int>); //f的参数进行拷贝初始化
//f(10); //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector

5. 编译器可以绕过拷贝构造函数

  • 拷贝构造函数必须是存在且可访问的
1
2
3
4
//编译器允许将下面的代码
string null_book = "9-999-99999-9"; //拷贝初始化
//改写为
string null_book("9-999-99999-9"); //编译器略过了拷贝构造函数

13.1.2 拷贝赋值运算符

1. 重载赋值运算符

  • 赋值运算符是一个名为operator=的函数

  • 赋值运算符通常应该返回一个指向其左侧运算对象的引用

1
2
3
4
class Foo{
public:
Foo& operator=(const Foo&); //赋值运算符
};

2. 合成拷贝赋值运算符

  • 对于某些类,用来禁止该类型对象的赋值
1
2
3
4
5
6
7
8
//等价于合成拷贝赋值运算符
Sales_data& Sales_data::operator=(const Sales_date &rhs)
{
bookNo = rhs.bookNo; //调用string::operator=
units_sold = rhs.units_sold; //使用内置的int赋值
revenue = rhs.revenue; //使用内置的double赋值
return *this; //返回一个此对象的引用
}

13.1.3 析构函数

  • 没有返回值,也不接受参数
  • 不能被重载
1
2
3
4
class Foo{
public:
~Foo(); //析构函数
};

1. 析构函数完成什么工作

  • 构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化
  • 析构函数中,首先执行函数体,然后按初始化顺序的逆序销毁成员
  • 隐式销毁一个内置指针类型的成员不会delete它所指向的对象

2. 什么时候调用析构函数

  • 变量在离开其作用域时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁
1
2
3
4
5
6
7
8
9
10
11
{//新作用域
//p 和 p2指向动态分配的对象
Sales_data *p = new Sales_data; //p是一个内置指针
auto p2 = make_shared<Sales_data>(); //p2是一个shared_ptr
Sales_data item(*p); //拷贝构造函数将 *p 拷贝到item中
vector<Sales_data> vec; //局部对象
vec.push_back(*p2); //拷贝p2指向的对象
delete p; //对p指向的对象执行析构函数
}//退出局部作用域:对item、 p2 和 vec 调用析构函数
//销毁p2会递减其引用计数;如果引用计数变为0,对象被释放
//销毁vec会销毁它的元素
  • 当指向一个对象的引用或指针离开作用域时,析构函数不会执行

3. 合成析构函数

  • 对于某些类,合成析构函数被用来阻止该类型的对象被销毁

  • 析构函数体自身并不直接销毁成员

  • 成员是在析构函数体之后隐含的析构阶段中被销毁的
1
2
3
4
5
class Sale_data{
public:
//成员会被自动销毁,除此之外不需要做其他事情
~Sales_data(){}
};

13.1.4 三/五法则

1. 需要析构函数的类也需要拷贝和复制操作

HasPtr定义一个析构函数,但使用合成版本的拷贝构造函数和拷贝赋值运算符

1
2
3
4
5
6
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0){}
~HasPtr() {delete ps;}
}

这些函数简单拷贝指针成员,多个HasPtr对象可能指向相同的内存

1
2
3
4
5
6
HasPtr f(HasPtr hp)
{
HasPtr ret = hp; //拷贝给定的HasPtr
//处理 ret
return ret; //ret 和 hp 被销毁
}

析构函数 delete ret 和 hp中的指针成员,但这两个对象包含相同的指针值,代码会导致此指针被 delete 两次。

2. 需要拷贝操作的类也需要赋值操作,反之亦然

13.1.5 使用=default

  • 将拷贝控制成员定义为=default 来显式的要求编译器生成合成的版本
  • 只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default
1
2
3
4
5
6
7
8
class Sales_data{
public:
Sales_data() = default; //隐式地声明为内联的
Sales_data(const Sales_data&) = default;
Sales_data& operate=(const Sales_data &); //不希望合成的成员是内联函数
~Sales_data() = default;
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;

13.1.6 阻止拷贝

iostream类组值了拷贝,以避免多个对象写入或读取相同的 IO 缓冲

1. 定义删除的函数(deleted function)

  • 虽然声明了它们,但不能以任何方式使用它
  • 在函数的参数列表后面加上=delete
  • =delete 必须出现在函数第一次声明的时候
  • 可以对任何函数指定=delete
1
2
3
4
5
6
struct NoCopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
};

2. 析构函数不能是删除的成员

对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针,但可以动态分配这种类型的对象

1
2
3
4
5
6
7
struct NoDtor{
NoDtor() = default; //合成默认构造函数
~NoDtor() = delete; //不能销毁NoDtor类型的对象
};
//NoDtor nd; //错误:NoDtor的析构函数是删除的
NoDtor *p = new NoDtor(); //正确:但不能 delete p
//delete p; //错误:NoDtor的析构函数是删除的

3. 合成的拷贝控制成员可能是删除的

  • 如果一个类有数据成员不能默认构造、拷贝、赋值和销毁,则对应的成员函数将被定义为删除的

4. private拷贝控制

  • 通过声明(但不定义)private 的拷贝构造函数,可以预先阻止任何拷贝该类型对象的企图:试图拷贝对象的用户代码将在编译阶段标记为错误;成员函数或友元函数中的拷贝操作将会导致链接时错误
  • 希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将他们声明为private

13.2 拷贝控制和资源管理

如何拷贝指针成员决定了像HasPtr这样的类是具有类值行为还是类指针行为。

13.2.1 行为像的类

对于类管理的资源,每个对象都应该拥有一份自己的拷贝。

为了实现类值行为,HasPtr需要

  • 定义一个拷贝构造函数,完成 string 的拷贝,而不是拷贝指针
  • 析构函数释放 string
  • 拷贝赋值运算符释放当前对象的 string,并从右侧对象拷贝 string
1
2
3
4
5
6
7
8
9
10
11
class HasPtr {
public:
HasPtr(const std::string& s = std::string()) :ps(new std::string(s), i(0)) { }
//对 ps 指向的 string,每个 HasPtr对象都有自己的拷贝
HasPtr(const HasPtr& p) : ps(new std::string(*p.ps), i(p.i)) { }
HasPtr& operator=(const HasPtr&);
~HasPtr() { delete ps; }
private:
std::string* ps;
int i;
};

1. 类值拷贝赋值运算符

先拷贝右侧运算对象,处理自赋值情况,并保证在异常发生时代码也是安全的

1
2
3
4
5
6
7
8
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层 string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this;
}

编写赋值运算符时,记住两点

  1. 如果将一个对象赋予它自身,赋值运算符必须能正确工作
  2. 大多数赋值运算符组合了析构函数和拷贝构造函数的工作

13.2.2 定义行为像指针的类

  • 令一个类展现类似指针的行为的最好方法:使用shared_ptr来管理类中的资源
  • 直接管理资源时,使用引用计数

1. 引用计数

计数器不能直接作为HasPtr对象的成员

1
2
3
HasPtr p1("Hiya!");
HasPtr p2(p1); //p1和p2指向相同的 strig
HasPtr p3(p1); //p1,p2和p3指向相同的 string

解决方法:将计数器保存在动态内存中

2. 定义一个使用引用计数的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(new std::string(*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 的成员
};

3. 类指针的拷贝成员篡改引用计数

析构函数

  • 递减引用计数,但计数器变为0,释放 ps 和 use 指向的内存
1
2
3
4
5
6
7
8
HasPtr::~HasPtr()
{
if (-- * use == 0)
{
delete ps;
delete use;
}
}

拷贝赋值运算符

  • 递增右侧运算对象的引用计数,递减左侧运算对象的引用计数,在必要时释放使用的内存
  • 处理自赋值
1
2
3
4
5
6
7
8
9
10
11
12
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++* rhs.use;
if (-- * use == 0){
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

13.3 交换操作

交换两个类值HasPtr对象的代码可能像这样:

1
2
3
HasPtr temp = v1;
v1 = v2;
v2 = temp;

理论上这些内存分配是不必要的,我们更希望 swap 交换指针

1
2
3
stirng *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;

1. 编写自己的 swap 函数

对于分配了资源的类,定义swap可能是一种很重要的优化手段

1
2
3
4
5
6
7
8
9
class HasPtr{
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);
}

2. swap 函数应该调用 swap,而不是 std::swap

标准库 swap 对 HasPtr 管理的 string 进行了不必要的拷贝

3. 在赋值运算符中使用 swap

拷贝并交换(copy and swap)

  • 自动处理了自赋值情况,且天然就是异常安全的
1
2
3
4
5
6
//注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数
HasPtr& HasPtr::operator=(HasPtr rhs)
{
swap(*this, rhs);
return *this;
}

13.4 拷贝控制示例(邮箱处理应用)

(重要实践 - 待补充)

13.5 动态内存管理类(StrVec类)

(重要实践 - 待补充)

13.6 对象移动

  1. 新标准的一个最重要的特性:可以移动而非拷贝对象的能力
  2. IO 类或 unque_ptr 这样类都包含不能被共享的资源(如指针或IO 缓冲),因此这些类型的对象不能拷贝但可以移动

13.6.1 右值引用

  • 必须绑定到右值的引用
  • 通过&&来获得
  • 只能绑定到一个将要销毁的对象
  • 一个左值表达式表示的是一个对象的身份,一个右值表达式表示的是对象的值
1
2
3
4
5
6
int i = 42;	
int &r = i; //正确:r 引用 i
//int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
//int &r2 = i * 42; //错误:i*42 是一个右值
const int &r3 = i * 42; //正确:可以将一个 const 的引用绑定到一个右值上
itn &&rr2 = i * 42; //正确:将 rr2 绑定到乘法结果上

1. 左值持久:右值短暂

右值引用只能绑定到临时对象

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

使用右值引用的代码可以自由的接管所引用的对象的资源

2. 变量是左值

不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行

1
2
int &&rr1 = 42;		//正确:字面常量是右值
//int &&rr2 = rr1; //错误:表达式是左值!

3. 标准库 move 函数

显式的将一个左值转换为对应的右值引用类型

头文件:utility

1
int &&rr3 = std::move(rr1);
  1. 调用 move 意味着承诺:除了对 rr1 赋值或销毁它外,将不再使用它
  2. 使用 move 的代码应该使用 std::move 而不是 move,这样可以避免潜在的名字冲突

13.6.2 移动构造函数和移动赋值运算符

(待补充)

13.6.3 右值引用和成员函数

(待补充)