第十三章 拷贝控制

13.1 拷贝、赋值与销毁

  1. 一个类通过定义五种特殊的成员函数来控制对象的拷贝、移动、赋值和销毁操作。这些操作称为拷贝控制操作。如果一个类没有定义这些操作,编译器会自动合成缺失的操作。
    拷贝和移动构造函数:定义了当用同类型的另一个对象初始化本对象时做什么。
    拷贝和移动赋值运算符:定义了将一个对象赋予同类型的另一个对象时做什么。
    析构函数:定义了当此类型对象销毁时做什么。
  2. 通常,实现拷贝控制操作最困难的地方是首先认识到什么时候需要定义这些操作。

13.1.1 拷贝构造函数

  1. 拷贝构造函数:第一个参数是自身类类型的引用,且任何额外参数都有默认值。
  2. 拷贝构造函数在几种情况下都会被隐式地使用。因此拷贝构造函数通常不应该是explicit的。
class Foo {
pblic:
	Foo() { cout << "默认构造!" << endl; };
	Foo(const Foo&) { cout << "拷贝构造!" << endl; }; //拷贝构造函数
	//...
};
Foo f(Foo f){  return f; };
  1. 对某些类来说,合成拷贝构造函数用来阻止拷贝该类类型的对象。
  2. 拷贝构造函数用来初始化非引用类类型参数,所以自己的参数必须是引用类型。
  3. 直接初始化和拷贝初始化
    直接初始化:要求编译器使用普通的函数匹配来选择与提供的参数最匹配的构造函数。
    拷贝初始化:要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
string dots(10,'.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string null_book = "9-999-99999-9"; //拷贝初始化
string nines = string(100,'9'); //拷贝初始化
  1. 拷贝初始化依靠拷贝构造函数或移动构造函数来完成。
  2. 拷贝初始化何时发生:
    用=定义变量。
    将一个对象作为实参传递给一个非引用类型的形参。
    从一个返回类型为非引用类型的函数返回一个对象。
    用花括号列表初始化一个数组中的元素或一个聚合类中的成员。
  3. 拷贝初始化的限制:
explicit vector (size_type n); 
vector (const vector& x); 

vector<int> v1(10); //正确:直接初始化
vector<int> v2 = 10; //错误:接受大小的构造函数是explicit的
void f(vector<int>); //f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数来拷贝一个实参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector
  1. 在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝、移动构造函数,直接创建对象。
string null_book = "9-999-99999-9";//拷贝初始化
//改写为
string null_book("9-999-99999-9"); //编译器略过了拷贝构造函数

13.1.2 拷贝赋值运算符

  1. 如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数。
  2. 赋值运算符通常应该返回一个指向其左侧运算对象的引用。
Sales_data trans, accum;
trans = accum; //使用Sales_data的拷贝赋值运算符

//拷贝赋值运算符接受一个与其所在类相同类型的参数
class Foo{
public:
	Foo& operator=(const Foo&); //赋值运算符
}
//等价于合成拷贝赋值运算符
Sales_data& Sales_data::operator=(const Sales_data &rhs)
{
	bookNo = rhs.bookNo; //调用string::operator=
	units_sold = rhs.units_sold; //使用内置的int赋值
	revenue = rhs.revenue; //使用内置的double赋值
	return *this; //返回一个此对象的引用
}

13.1.3 析构函数

  1. 析构函数释放对象使用的资源,并销毁对象的非static数据成员。
  2. 析构函数没有返回值,不接受参数,不能被重载。
  3. 析构函数先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
  4. 隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
  5. 智能指针是类类型,所以具有析构函数。
  6. 无论何时一个对象被销毁,就会自动调用其析构函数:
    变量在离开其作用域时被销毁。
    当一个对象被销毁时,其成员被销毁。
    容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
    对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
    对于临时对象,当创建它的完整表达式结束时被销毁。
{//新作用域
	//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会销毁它的元素
  1. 当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
  2. 析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

13.1.4 三/五法则

  1. 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
  ps(new std::string(s)),i(0){ }
  
  ~HasPtr(){ delete ps; }
  //错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
  //其他成员的定义省略…
};

HasPtr f(HasPtr hp) //HasPtr是传值参数,所以将被拷贝
{
	HasPtr ret = hp; //拷贝给定的HasPtr
	//处理ret
	return ret; //ret和hp被销毁
}

HasPtr p("some values");
f(p);//f结束时,p.ps指向的内存被释放
HasPtr q(p); //现在p和q都指向无效内存
  1. 需要拷贝操作的类也需要赋值操作,反之亦然。

13.1.5 使用=default

  1. 使用=default可以显式地要求编译器生成合成的版本。
class Sales_data{
public:
	//拷贝控制成员;使用default
	Sales_data() = default;
	Sales_data(const Sales_data&) = default;
	Sales_data& operator = (const Sales_data &);
	~Sales_data() = default;
	//其他成员的定义,如前
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;//非内联
//我们只能对具有合成版本的成员函数使用=default

13.1.6 阻止拷贝

  1. 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
  2. iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
  3. 可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。
  4. 删除的函数:虽然声明了,但不能以任何方式使用,通过在函数的参数列表后面加上=delete来定义为删除的。
  5. =delete与=default的不同:
    =delete必须出现在函数第一次声明的时候。
    可以对任何函数指定=delete。
//=delete通知编译器,我们不希望定义这些成员
//删除的函数(deleted function)
struct NoCopy {
	NoCopy() = default; //使用合成的默认构造函数
	NoCopy(const NoCopy&) = delete; //阻止拷贝
	NoCopy &operator = (const NoCopy&) = delete; //阻止拷贝
	~NoCopy() = default; //使用合成的析构函数
	//其他成员
};
  1. 当希望引导函数匹配过程时,删除函数有时也是有用的。
  2. 析构函数不能是删除的成员。如果析构函数被删除,就无法销毁此类型的对象了。
  3. 对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
struct NoDtor {
	NoDtor() = default; //使用合成默认构造函数
	~NoDtor() = delete; //我们不能销毁NoDTor类型的对象
};

NoDtor nd; //错误
NoDtor *p = new NoDtor(); //正确
delete p; //错误
  1. 合成的拷贝控制成员可能是删除的。
  2. 本质上,如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
  3. 希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。

13.2 拷贝控制和资源管理

  1. 通常,管理类外资源的类必须定义拷贝控制成员,这种类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它几乎肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
  2. 两种选择:定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

13.2.1 行为像值的类

  1. 赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
  2. 当编写赋值运算符时,需要记住:
    如果将一个对象赋予它自身,赋值运算符必须能正确工作。
    大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
    一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中,当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。
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; //返回本对象
}
//下面的赋值运算符是错误的!
HasPtr& HasPtr::operator=(const HasPtr &rhs){
	delete ps; //释放对象指向的string
	//如果rhs和*this是同一个对象,我们就将从已释放的内存中拷贝数据!
	ps = new string(*rhs.ps);
	i = rhs.i;
	return *this;
}

13.2.2 定义行为像指针的类

  1. 令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源。

13.3 交换操作

  1. 与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
  2. 使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

13.4 拷贝控制示例

  1. 拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。

13.5 动态内存管理类

13.6 对象移动

  1. 新标准的一个最主要的特性是可以移动而非拷贝对象的能力。
  2. 标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

13.6.1 右值引用

  1. 为了支持移动操作,新标准引入了右值引用,通过&&而不是&来获得右值引用。
  2. 右值引用重要性质:只能绑定到一个将要销毁的对象。
  3. 一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
  4. 常规引用(左值引用):不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。
  5. 左值持久,右值短暂。
  6. 使用右值引用的代码可以自由地接管所引用的对象的资源。
  7. 右值引用指向将要被销毁的对象,因此,可以从绑定到右值引用的对象“窃取”状态。
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 绑定到乘法结果上
  1. 变量是左值,因此不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
int &&rr1 = 42; //正确:字面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值
  1. 可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
  2. 使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。
//move告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它
int &&rr3 = std::move(rr1); //ok

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

  1. 类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。
  2. 与拷贝构造函数不同,移动构造函数不分配任何新内存。因此,移动操作通常不抛出任何异常,需要通知标准库。其中一个方法是在构造函数中指明noexcept,它出现在参数列表和初始化列表开始的冒号之间。
  3. 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
//移动构造函数
StrVec::StrVec(strVec &&s) noexcept //移动操作不应抛出任何异常
//成员初始化器接管s中的资源
:elements(s.elements),first_free(s.first_free),cap(s.cap)
{
	//令s进入这样的状态--对其运行析构函数是安全的
	s.elements = s.first_free = s.cap = nullptr;
}
// 最终,移后源对象会被销毁,如果我们忘记了改变s.first_free,则销毁移后源对象就会释放掉我们刚刚移动的内存
  1. 在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
StrVec &StrVec::operator = (StrVec &&rhs) noexcept{
	//直接检测自赋值
	if(this != &rhs){
		free(); //释放已有元素
		elements = rhs.elements; //从rhs接管资源
		first_free = rhs.first_free;
		cap = rhs.cap;
		//将rhs置于可析构状态
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}
  1. 只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
//编译器会为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); 	//使用合成的移动构造函数
struct hasY{
	hasY() = default;
	hasY(hasY&&) = default;
	//假定Y是一个类,定义了拷贝构造函数但未定义移动构造函数
	Y mem; //hasY将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); //错误:移动构造函数是删除的
  1. 与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。
  2. 定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
  3. 如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。
StrVec v1, v2;
v1 = v2; 			//v2是左值;使用拷贝赋值
StrVec getVec(istream &); 	//getVec返回一个右值
v2 = getVec(cin); 		//getVec(cin)是一个右值;使用移动赋值
  1. 如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
class Foo{
public:
	Foo() = default;
	Foo(const Foo&); //拷贝构造函数
	//其他成员定义,但Foo未定义移动构造函数
};
Foo x;
Foo y(x); 		//拷贝构造函数;x是一个左值
Foo z(std::move(x)); 	//拷贝构造函数,因为未定义移动构造函数
  1. 拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数—左值被拷贝,右值被移动。
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;}

	//其他成员函数的定义...
}

//假定hp和hp2都是HasPtr对象
hp = hp2; 		//hp2是一个左值;hp2通过拷贝构造函数来拷贝
hp = std::move(hp2); 	//移动构造函数移动hp2
  1. 所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
  2. 一个移动迭代器(C++11)通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
  3. 通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
void StrVec::reallocate()
{
	//分配大小两倍于当前规模的内存空间
	auto newcapacity = size() ? 2*size() : 1;
	auto first = alloc.allocate(newcapacity);
	//移动元素
	auto last = uninitialized_copy(make_move_iterator(begin()),
		make_move_iterator(end()),first);
	free(); //释放旧空间
	elements = first; 
	first_free = last;
	cap = elements + newcapacity;
}
  1. 建议:不要随意使用移动操作。由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当调用move时,必须绝对确认移后源对象没有其他用户。
  2. 在移动构造函数和移动赋值运算符这些类实现的代码之外的地方,只有当确信需要进行移动操作且移动操作是安全的,才可以使用std::move。

13.6.3 右值引用和成员函数

  1. 区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
class StrVec{
public:
	void push_back(const std::string&); 	//拷贝
	void push_back(std::string&&); 	//移动
	//其他成员定义,如前
};

void StrVec::push_back(const string&s)
{
	chk_n_alloc(); //确保有空间容纳新元素
	//在first_free指向的元素中构造s的一个副本
	alloc.construct(first_free++,s);
}

void StrVec::push_back(string &&s)
{
	chk_n_alloc();//如果需要的话为StrVec重新分配内存
	alloc.construct(first_free++,std::move(s));
}

StrVec vec; //空Strvec
string s = "some string or another";
vec.push_back(s); //拷贝
vec.push_back("done"); //移动
  1. 右值和左值引用成员函数,引用限定符可以是&或&&。
//阻止对一个右值进行赋值
//引用限定符
class Foo {
public:
	Foo &operator=(const Foo&) &; //只能向可修改的左值赋值
	//...
};
Foo &Foo::operator=(const Foo &rhs) &{
	//...
	return *this;
}

Foo &retFoo(); 	//返回一个引用;retFoo调用是一个左值
Foo retVal(); 	//返回一个值;retVal调用是一个右值
Foo i,j; 		//i和j是左值
i=j; 		//正确
retFoo() = j; 	//正确
retVal() = j; 	//错误
i = retVal(); 	//正确
class Foo{
public:
	Foo sorted() &&; 
	Foo sorted() const &; 
	//Foo的其他成员的定义
private:
	vector<int> data;
};

//本对象为右值,因此可以原址排序
Foo Foo::sorted() &&
{
	sort(data.begin(),data.end());
	return *this;
}
//本对象是const或是一个左值,不能对其进行原址排序
Foo Foo::sorted() const &{
	Foo ret(*this); //拷贝一个副本
	sort(ret.data.begin(),ret.data.end()); //排序副本
	return ret;
}

retVal().sorted(); //&&
retFool().sorted(); //&
  1. 指出this的左值/右值属性的方式与定义const成员函数相同,即在参数列表后放置一个引用限定符。(C++11)引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
  2. 一个函数可以同时用const和引用限定,在此情况下,引用限定符必须跟随在const限定符之后。
//与const一起使用必须在const限定符之后
class Foo{
public:
	Foo someMem() & const;	//错误:const限定符必须在前
	Foo anotherMem() const &; 	//正确
};
  1. 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
class Foo{
public:
	Foo sorted() &&;
	Foo sorted() const; //错误:必须加上引用限定符

	using Comp = bool(const int&,const int&);
	Foo sorted(Comp*); //正确:不同的参数列表
	Foo sorted(Comp*) const; //正确:两个版本都没有引用限定符
};
  • 30
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值