主页
文章
分类
系列
标签
简历
【C++ Primer(edition 5) 12】拷贝控制
发布于: 2021-10-11   更新于: 2021-10-11   收录于: Cpp
文章字数: 729   阅读时间: 4 分钟   阅读量:

拷贝控制操作(copy control): 拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。 拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。

  • 拷贝构造函数(copy constructor)
  • 拷贝赋值运算符(copy-assignment operator)
  • 移动构造函数(move constructor)
  • 移动赋值函数(move-assignement operator)
  • 析构函数(destructor)

一、拷贝、赋值和销毁

1、拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

1
2
3
4
5
class Foo
{ 
public: 
	Foo(const Foo&); 
}

1.1 合成的拷贝构造函数(synthesized copy constructor)

没有为一个类定义拷贝构造函数,编译器会为我们定义一个,即使我们定义了其他构造函数,编译器也会合成一个拷贝构造函数

每个成员的类型决定了它如何拷贝:

  • 对类类型的成员,会使用其拷贝构造函数来拷贝;
  • 内置类型的成员则直接拷贝。
  • 虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

1.2 拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。 当我们使用拷贝初 始 化 (copy initialization) 时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

拷贝初始化通常使用拷贝构造函数来完成。 但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成。

拷贝初始化发生的时机

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

为什么拷贝构造函数的参数必须是引用形参 因为,将一个对象作为实参传递给一个非引用类型的形参时,会发生拷贝构造,这样就会发生套娃现象

1.3 拷贝构造的限制

[[类#2.4 转换构造函数|拷贝构造遇到explicit]]

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

2、拷贝赋值运算符

与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。 重写一个名为operator=的函数,通常返回一个指向其左侧运算对象的引用。

1
Foo& operator=(const Foo&);

合成拷贝赋值运算符 拷贝赋值运算符接受一个与其所在类相同类型的参数

  • 将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员。

3、析构函数

释放对象所使用的资源,并销毁对象的非static数据成员。

  • 名字由波浪号接类名构成。没有返回值,也不接受参数。
1
~Foo();

调用时机

  • 变量在离开其作用域时。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器被销毁时,其元素被销毁。
  • 动态分配的对象,当对指向它的指针应用delete运算符时。
  • 对于临时对象,当创建它的完整表达式结束时。
  • 当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成析构函数

  • 空函数体执行完后,成员会被自动销毁。
  • 注意:析构函数体本身并不直接销毁成员。

析构函数完成的工作 在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

4、三/五法则

  • 需要析构函数的类也需要拷贝和赋值操作。
  • 需要拷贝操作的类也需要赋值操作,反之亦然。

5、使用=default

可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。合成的函数将隐式地声明为内联的。

6、阻止拷贝

大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。

  • 定义删除的函数:=delete
  • 虽然声明了它们,但是不能以任何方式使用它们。
  • 析构函数不能是删除的成员。
  • 如果一个类有数据成员不能默认构造、拷贝、复制或者销毁,则对应的成员函数将被定义为删除的。
  • 老版本使用private声明来阻止拷贝。

二、拷贝控制和资源管理

  • 类的行为可以像一个值,也可以像一个指针。
    • 行为像值:对象有自己的状态,副本和原对象是完全独立的。
    • 行为像指针:共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;

};

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	auto newp = new string(*rhs.ps);//拷贝底层string
	delete ps; //释放就内存
	ps = newp; //从右侧运算对象拷贝数据到本对象
	i = rhs.i;
	return *this; //返回本对象
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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;
	i = rhs.i;
	use = rhs.use;
	return * this;

}

三、交换操作

  • 管理资源的类通常还定义一个名为swap的函数。
  • 经常用于重排元素顺序的算法。
  • swap而不是std::swap
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
};

class HasPtr{
	friend void swap(HasPtr&,HasPtr&);
	//其他成员定义...
};

inline void swap(HasPtr &lhs,HasPtr &rhs)
{
	using std::swap;
	swap(lhs.ps,rhs.ps);   //交换指针,而不是string数据
	swap(lhs.i,rihs.i);   //交换int成员
}

四、对象移动

很多拷贝操作后,原对象会被销毁,因此引入移动操作可以大幅度提升性能。 在新标准中,我们可以用容器保存不可拷贝的类型,只要它们可以被移动即可。 标准库容器、stringshared_ptr类既可以支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

1、右值引用

新标准引入右值引用以支持移动操作,所谓右值引用就是必须绑定到右值的引用。通过&&获得右值引用。 重要性质:只能绑定到一个将要销毁的对象。 右值引用特性:我们可以将一个右值引用绑定到要求转换的表达式、字面常量或是返回右值的表达式上,但不能将一个右值引用直接绑定到一个左值上。左边值引用则完全相反。

1.1 左值引用VS右值引用

  1. 可绑定类型完全相反
引用 可绑定表达式
左值引用 返回左值引用的函数,赋值、下标、解引用和前置/递减运算符
右值引用 返回非引用类型的函数,算术、关系、位和后置递增/递减运算符
2. 左值持久、右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知
  • 所引用的对象将要被销毁
  • 该对象没有其他用户

1.2 move函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility

1
int &&rr2 = std::move(rr1);

move告诉编译器,我们有一个左值,但我希望像右值一样处理它。调用move意味着:除了对rr1赋值或者销毁它外,我们将不再使用它。

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

2.1 移动构造函数

第一个参数是该类类型的一个引用,关键是这个引用参数是一个右值引用

1
2
3
4
5
StrVec::StrVec(StrVec &&s) noexcept:
elements(s.elements),first_free(s.first_free),cap(s.cap)
{
	s.elements = s.first_free = s.cap = nullptr;
}
  • 不分配任何新内存,只是接管给定的内存。接管内存后,源对象置空,防止源对象控制内存。
  • 移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的,一旦资源完成移动,源对象必须不再指向被移动的资源—— 这些资源的所有权已经归属新创建的对象
  • 任何额外的参数都必须有默认实参

2.2 移动操作、标准库容器和异常

使用移动操作必须通知标准库移动构造函数不会抛出异常,否则标准库总认为会有异常存在。可在声明中标明noexcept,承诺一个函数不抛出异常的一种方法。

2.3 移动赋值运算符

1
StrVec& StrVec::operator=(StrVec && rhs) noexcept{}

2.4 移后源对象必须可析构

  1. 当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。可以通过将移后源对象的指针成员置为nullptr来实现
  2. 移动操作还必须保证对象仍然是有效的——安全使用、不依赖当前值
  3. 移动操作对移后源对象中留下的值没有任何要求

2.5 合成的移动操作

编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同

2.5.1 何时会合成移动操作?

与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。 只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//编译器会为X 和 hasX合成移动操作
struct X{
	int i; // 内置类型可以移动
	std::string s; // string定义了自己的移动操作
};
struct hasX {
	X mem; // X 有合成的移动操作
};
X x, x2 = std::move(x); // 使用合成的移动构造函数 
hasX hx, hx2 = std::move(hx) ; // 使用合成的移动构造函数

2.5.2 删除的移动操作

如何删除移动操作? 移动操作永远不会隐式定义为删除的函数

  • 显示地要求编译器生成=default,编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数
  • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。
  • 果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
  • 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算 符被定义为删除的

2.5.3 调用时究竟使用哪个

如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。

2.5.4 没有移动构造函数,右值被拷贝

如果一个美有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。

2.5.5 同时实现拷贝和移动赋值运算符

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 hp , hp2;
hp = hp2;
hp = std::move(hp2);

更新三/五法则:如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。

3、右值引用和成员函数

如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const的左值引用,第二个版本接受一个指向非 const的右值引用

为啥这样设置参数呢? 一般来说,我们不需要为函数操作定义接受一个const X&&或是一个(普通的)X& 参数的版本。当我们希望从实参’‘窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const的。类似的,从一个对象进行拷贝的操作不应该改变该对象。 因此,通常不需要定义一个接受一个(普通的)X&参数的版本。

3.1 引用限定符阻止灵活的右值操作

一个对象上调用成员函数,而不管该对象是一个左值还是一个右值

1
2
3
string s1 = "a value", s2 = "another";   
auto n = (si + s2).find('a');   //直接在右值对象使用成员函数
sl + s2 = "wow!";     //直接给右值对象赋值!!!

在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象(即,this指向的对象)是一个左值。

指出this的左值/右值属性的方式,在参数列表后放置一个引用限定符(reference qualifier) ,引用限定符可以是&&&,分别指出this可以指向一个左值或右值。类似const限定符, 引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中,如果有const限定则引用限定位于其后

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Foo{
public:
	Foo &operator=(const Foo&) &;    // 只能向可修改的左值赋值
};

Foo &Foo::operator=(const Foo &rhs) &
{
	return *this;
}


Foo &retFoo(); // 返回一个引用;retFoo调用是一个左值 1
Foo retVal();  // 返回一个值;retVal调用是一个右值
Foo i, j;      // i 和 j 是左值
i = j;         // 正确:i是左值
retFoo() = j;  // 正确:retFoo ()返回一个左值
retVal() = j;  // 错误:retVal () 回一个右值
i = retVal();  // 正确:我们可以将一个右值作为赋值操作的右侧运算对象


class Foo { 
public: 
	Foo anotherMem() const &;
};