C++ 拷贝控制

拷贝,赋值与销毁

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

class Foo{
public:
	Foo();
	Foo(Foo&); //拷贝构造函数
};

拷贝构造函数在几种情况下都会被隐式使用,因此通常不会是explicit(限制隐式转换)的。
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个合成的拷贝构造函数

2.拷贝构造函数完成拷贝初始化(拷贝初始化有时会借助移动构造函数完成,后面会有介绍)。这里要区分直接初始化

string dots(10,'.');   //直接初始化
string s(dots);		//直接初始化
string s2 = dots;	//拷贝初始化
string book = '123-2233-12-6';	//拷贝初始化
string nines = string(100,'9');		//拷贝初始化

同时,拷贝初始化也会在下列情况下发生:

  • 将对象作为实参传递给一个非引用类型的形参
  • 从返回类型为非引用类型的函数返回一个对象
  • 使用花括号列表初始化一个数组中的元素或集合成员

拷贝构造函数的参数是引用类型,如果不是,则在调用拷贝构造函数时,参数的拷贝行为需要调用拷贝构造函数,如此无限循环。

  1. C++中,有参数的构造函数不仅仅是一个构造器,同时还是一个默认且隐含的类型转换操作符。在下面的例子中,Example类创建对象并且赋值10时,Example的参数为(int len)的构造函数参数类型与等号右侧类型相同,右侧类型被隐式转换为temp类型。我们可以使用explicit来禁止该类用法。

class Example {
public:
	Example(int len) {
		this->length = len;
	}
	Example(Example&) {}

	void Out() {
	
		cout <<"length : val == " <<this->length<<" " << this->val << endl;
	}
private:
	int length = 0;
	int val = 0;
};
 
//用户代码

	Example temp = 10;
	temp.Out();
	//length : val == 10 0


4.拷贝赋值运算符

Sales_data trans,accum;
trans = accum; //使用Sales_data的拷贝赋值运算符

如果我们没有定义,则编译器会自动生成一个合成的拷贝赋值运算符

5.析构函数中首先执行函数体,然后按照初始化的逆序销毁成员。同样的,当我们没有定义析构函数时,编译器也会为我们定义一个合成的析构函数

三五法则

如上所述,有三个基本操作可以控制类的拷贝操作:拷贝构造函数,拷贝赋值运算符,析构函数。在新标准下一个类还可以定义移动构造函数,移动赋值运算符
我们没有必要在所有的类中定义以上所有操作。但是,这些操作应该被视为一个整体

  1. 需要析构函数的类通常也需要拷贝和赋值操作
class HashPtr{
public:
	HashPtr(const string &s = string()):ps(new string(s)),i(0){}
	~HashPtr(delete ps;)
	
private:
	string *ps;
	int i;
}

上述代码有一个严重的错误:该类使用了合成的拷贝构造函数和拷贝复制运算符。这些函数简单的拷贝指针成员,意味着多个HashPtr对象可能指向相同的内存。

//用户代码
HashPtr f(HashPtr hp){//拷贝操作
	HashPtr ret = hp;
	//函数过程
	return hp;	//拷贝操作
}

函数f返回时,hp和ret都被销毁,两个对象都会调用析构函数,指针ps指向的内存被delete两次!

  1. 需要拷贝操作的类也需要赋值操作

  2. 使用default显示要求编译器生成合成的版本。

class Sales_data{
public:
	Sales_data() = default;
};
  1. 阻止拷贝
    某些情况下,我们需要阻止拷贝操作。如iostream类阻止了拷贝,防止多个对象读取或写入相同IO。新标准下,我们可以将拷贝赋值运算符和拷贝构造函数定义为删除的函数
class Nocopy{
public:
	NoCopy() = default;
	NoCopy(const NoCopy&) = delete; //删除的函数,组织拷贝
	NoCopy &operator=(const NoCopy&) = delete; //阻止赋值;
	~NoCopy() = default;	
};

与=default不同,=delete必须出现在函数第一次声明的时候。
析构函数不能是删除的成员。对于一个删除了虚构函数的类,编译器不允许创建该类型的对象(包括临时对象)。但是我们可以动态分配该类型对象,只是无法释放相应内存。

class NoDtor{
public:
	~NoDtor() = delete; 
};

//用户代码
NoDtor d1; //错误
NoDtor *p = new NoDtor(); //正确
delete p; //错误,析构函数是删除的

  1. 合成的拷贝控制成员可能是删除的。书上给出了很多具体情况,本质上,当不可能拷贝,赋值,或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。其中需要注意,如果类的某个成员的赋值运算符是删除或不可访问的,或是类有一个const或引用对象,则合成的拷贝复制运算符被定义为删除的。

6.在删除的函数出现之前,类通过将拷贝构造函数和拷贝赋值运算符放入private中以阻止拷贝。

拷贝控制和资源管理

  1. 行为像值的类
    对于类管理的资源,每个对象都应该拥有一份自己的拷贝。为了实现值的行为。HashPtr需要:
  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针。
  • 定义一个析构函数释放string
  • 定义拷贝复制运算符释放对象当前的string,并从右侧对象拷贝string
//类值版本的HashPtr
class HashPtr{
public:
	HashPtr(const string &s = string()):
		ps(new string(s)),i(0){}
	//对于ps指向的string,每个对象都有自己的拷贝
	HashPtr(const HashPtr &p):
		ps(new string(*p.ps)),i(p.i){}
	HashPtr& operator=(const HashPtr&);
	HashPtr() {delete ps;}
private:
	string *ps;
	int i;
};

HashPtr& HashPtr::operator=(const HashPtr&rhs){
/*
注意拷贝赋值过程中的先后顺序
*/
	auto newp = new string(*rhs.ps); //先拷贝右侧对象
	delete ps;//释放左侧对象的资源
	ps = newp;
	i = rhs.i;
	return *this;
}
  • 行为类似指针的类
    对于行为类似指针的类,我们需要拷贝指针本身而不是它指向的string。我们的类仍需要析构函数释放接受string参数的构造函数分配的内存。但是,在本例中,只有最后一个指向string的HashPtr销毁时,才可以释放string。在这种情况下,我们不适应shared_prt(),而定义自己的引用计数
    引用计数的工作方式如下:
  • 除初始化对象外,每个构造函数还要创建一个引用计数,用来记录一共有多少个对象在共享状态。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增计数器,指出给定对象状态又被新的用户共享。
  • 析构函数递减计数器,如果计数器为0,则释放状态。??
  • 拷贝赋值运算符递减左侧对象计数器,递增右侧对象计数器。

我们现在的问题就是在哪里存放引用计数器。计数器不能直接作为HashPtr的成员,原因且看下例。

HashPtr p1("ahula!");
HashPtr p2(p1);
HashPtr p3(p1);

两次拷贝赋值操作都更新了p1的计数器,但是在p1赋值给p3时,p2的计数器该如何更新呢?
一种方法是将计数器保存在动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。

class HashPtr{
public:
	HashPtr(const string &s = string()):
		ps(new string(s)),i(0),use(new size_t(1));
	HashPtr(const Hashptr &p):
		ps(p.ps),i(p.i),use(p.use){++*use;}
	HashPtr& operator=(const HashPtr&);
	~HashPtr();
private:
	string *ps;
	int i;
	size_t *use;
};

Hash::~HashPtr(){
	if(*use == 0){
		delete ps;	//释放string内存
		delete use; //释放计数器内存
	}
}
HashPtr& HashPtr::operator=(HashPtr&rhs){
	++*rhs.use;//递增左侧对象引用计数
	if(--*use == 0){
		delete ps;
		delete use;
	}
	ues=rhs.use;
	ps=rhs.ps;
	i.rhs.i;
	return *this;
}

交换操作

编写自己的swap函数
除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。

class HashPtr{
	friend void swap(HashPtr&,HashPtr&);
	
	inline 
	void swap(HashPtr& lhs,HashPtr& rhs){
		using std::swap;
		swap(lhs.ps,rhs.ps);
		swap(lhs.i,rhs.i);
	}
};

对象移动

某些情况下,对象被拷贝后立即被销毁,此时,使用移动可以大幅提升性能。例如IO或unique_ptr类,包含不能被共享的资源,这些类型的对象不能被移动但可以拷贝。

  1. 右值引用,即必须绑定到右值的引用。右值引用只能绑定到即将要销毁的对象。
    recall:左值与右值。一个左值表达式的求值结果是一个对象或一个函数。当一个对象被用作右值时,用的是对象的内容,用作左值时,用的是对象的身份。
    常规引用(为区别右值引用,我们将其称为左值引用),不能绑定到要求转换的表达式,字面常量,或返回右值的表达式,而我们可以将右值引用绑定到上述对象上,但是不能将其绑定到左值上。
	int i = 42;
	int &r = i;
	int &&rr = i;//错误,不能将右值引用绑定到左值
	int &r2 = i * 42;//错误,i*42为右值
	const int &r3 = i * 42;//const引用可绑定到右值
	int &&rr2 = i * 42;	//正确

左值具有持久的状态,而右值要么是字面常量,要么是表达式创建的临时对象。
由于右值引用只能绑定到临时对象,我们可以知道右值所引用的对象将被销毁,且对象没有其他用户

  1. 变量是左值。变量可以看作只有运算对象而没有运算符的表达式,且变量表达式都是左值。所以
int &&rr1=42;	//正确
int &&rr2=rr1;  //错误,表达式rr1是左值!
  1. 标准库move函数
    我们可以显示的将左值转换为右值引用类型。通过move函数(定义在utility库中)获得绑定到左值的右值引用。
int &&rr3 = move(rr1); //正确

注意,调用move就意味着承诺:除了对rr1进行赋值或销毁之外,我们不对再使用它。

  1. 移动构造函数与移动赋值运算符
    类似拷贝构造函数,移动构造函数的第一个参数是该类类型的引用,且是右值引用。与拷贝构造函数一样,任何额外参数都必须有默认实参。

strVec::strVec(strVec &&s)noexcept
	:elements(s.elements),first_free(s.first_free),cap(s.cap){		
		//令s进入这样的状态:对其运行析构函数是安全的
		s.elements=s.first_free=s.cap=NULL;
	}
strVec& strVec::operator=(strVec &&rhs) noexcept{
	//排除自赋值情况
	if(this !=&rhs){
		free();//释放右侧对象已有元素
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;
		//将rhs置于可析构状态
		rhs.elements=rhs.first_free=cap=NULL;
	}
	return *this;
}

noexcept通知标准库我们的构造函数不抛出任何异常。
移动操作之后,移后源对象必须保持有效的,可析构状态,但用户不能对其值做任何假设。

如果我们没有定义移动操作,类可以通过拷贝构造实现移动操作。当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员均可移动时编译器会为该类合成移动构造函数或移动赋值运算符。

struct X{
	int x;	//内置类型可移动
	string s;	//string定义了自己的移动操作
};

struct hasX{
	X mem;
};

X x,x2 = move(x);		//使用合成的移动构造函数
hasX hx,hx2 = move(hx); //使用合成的移动构造函数

与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显示的要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义删除的函数。

最后,一个类如果定义了移动操作,则该类的合成的拷贝构造函数和拷贝赋值运算符会被定义为删除的。

  1. 右值与左值引用成员函数
string s1="abc";
string s2="def";
auto n = (s1 + s2).find('c'); //我们在一个右值上调用成员函数

s1 + s2 = "wow!"; //我们对右值进行了赋值

我们没有办法阻止对右值的赋值操作,为了向后兼容,新标准同样允许该操作。但是,我们可以阻止这种用法,通过强制左侧运算对象为左值。

class Foo{
public:
	Foo& operator=(const Foo&) &; //只有可修改的左值可调用该函数。
	Foo sorted() &&;
	Foo sorted()const &;
};

//上述代码并没有这处这样的实例:引用限定符同样可以区分重载版本。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值