C++Primer笔记-拷贝控制

前言

该系列是《C++Primer第五版》的笔记,包含本人认为值得记录和整理的主要的知识点,并不是全部内容,也不是具体的内容。
该系列文章的作用应该是作为复习或预习的参考,有哪些知识点忘记或想学,可以大致浏览下该文章,然后再去书中寻找详细解答。(本系列文章基本是按书本顺序罗列的知识点,便于大家去书中寻找)
所以看该文章前,需要有一定的C++基础,否则阅读起来可能有困难。

本文大致整理了第十三章的知识点,涉及到C++关于类的拷贝控制内容(拷贝构造、拷贝赋值、析构、移动构造、移动赋值)、动态内存分配中的拷贝控制、右值引用的知识,虽然内容非常多,但比较重要!

链接目录

拷贝构造函数

拷贝构造函数:第一个参数是自身类类型的引用,其他额外参数都有默认值。通常第一个参数是const引用,而且不应该是explicit的(拷贝函数经常被隐式使用)。

合成拷贝构造函数:将每个非static成员拷贝到正在创建的对象中。内置类型直接拷贝,类类型会调用拷贝构造函数。

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(const Sales_data &orig):
	bookNo(orig.bookNo),
	units_sold(orig,units_sold),
	revenue(orig.revenue){}

拷贝初始化:

string dots(10, '.');//直接初始化
string s(dots);//直接初始化
string s2 = dots;//拷贝初始化
string null_book = "9-999-99999";//拷贝初始化
string nines = string(100, '9');//拷贝初始化

拷贝构造函数调用的情况:

  • =定义变量。
  • 将一个对象作为实参传递给一个非引用类型的形参。
  • 从一个返回类型为非引用类型的函数返回一个对象。
  • 用花括号列表初始化一个数组中的元素或一个就聚合类中的成员。
string null_book = "a";//拷贝初始化
string null_book("a");//略过了拷贝构造函数

编译器可以绕过拷贝构造函数,但拷贝构造函数仍然是需要存在且可访问的,并不能是private

拷贝赋值运算符

=重载为拷贝赋值运算符:如果类未定义,编译器会自动合成一个。

析构函数

析构函数释放对象使用的资源,并销毁对象非static数据成员。通常编译器的合成析构函数就能完成任务,此时只需要声明一个析构函数,其函数体为空。但动态分配的对象、指针或引用需要注意,并不会自动释放。

class Foo{
public:
	~Foo();//析构函数,没有返回值和参数,也不能被重载
};

隐式销毁一个内置指针类型的成员不会delete它所指向的对象,销毁的仅仅是指针。但智能指针是类类型,具有析构函数,会被自动销毁。
内置类型没有析构函数,所以销毁内置类型什么都不需要做。

析构函数调用:

  • 变量离开其作用域。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器被销毁时,其元素被销毁。
  • 动态分配的对象,当其指针使用delete时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

三/五法则

一个类需要析构函数->几乎肯定需要拷贝构造函数和拷贝赋值运算符。
当一个类内有动态内存分配时,需要析构函数手动对其指针进行释放操作。此时如果使用默认的拷贝构造函数,仅进行指针的复制,当对象进行析构时,会多次delete该指针所指向的内存。

一个类需要拷贝构造函数->几乎肯定需要拷贝赋值运算符。反之亦然。

使用=default

显式要求编译器生成合成的版本。合成的函数将隐式地声明为内联的(任何声明在类内部的函数都是内联的),如果不希望是内联的,应该只对类外定义使用default

class Sales_data{
public:
	Sales_data() = default;
	Sales_data(const Sales_data&) = default;
	~Sales_data() = default;
};

阻止拷贝

定义删除的函数:不能以任何方式使用它们,在参数列表后加=delete(必须出现在函数第一次声明的时候)。另外,析构函数不能是删除的函数,否则会导致对象无法销毁。

struct NoCopy{
	NoCopy(const NoCopy&) = delete;//阻止拷贝
	NoCopy &operator=(const NoCopy&) = delete;//阻止赋值
};

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

图片.png

  • 如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果析构函数是删除的或不可访问的,合成拷贝构造函数也是删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或类有一个引用成员,没有类内初始化器,或类有一个const成员,没有类内初始化器且其类型未显式定义默认构造函数,则类的默认构造函数被定义为删除的。

private拷贝控制:新标准之前,将拷贝控制成员定义成private阻止拷贝进行,但友元和成员函数仍能访问,此时只声明拷贝控制成员但不定义(试图访问一个未定义的成员会导致链接错误)。

拷贝控制和资源管理

类的行为像值:拷贝值对象,副本和原对象独立互不影响。
类的行为像指针:共享状态,拷贝指针对象,副本和原对象使用相同的底层数据。

行为像值的类

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

  • 定义一个拷贝构造函数,进行资源的拷贝。
  • 定义一个析构函数,来释放对象的资源。
  • 定义一个拷贝赋值运算符,释放当前资源,并从右侧运算对象拷贝资源。

类值拷贝赋值运算符:赋值运算符通常组合了析构函数和构造函数,一方面需要释放当前资源,另一方面又需要拷贝右侧对象数据。但是这些操作的顺序必须正确,即使是将一个对象赋予本身。

HasPtr& HasPtr::operator=(const HasPtr &rhs){
	auto newp = new string(*rhs.ps);//拷贝底层string
	delete ps;//释放旧内存
	ps = newp;//拷贝数据
	i = rhs.i;
	return *this;
}

定义行为像指针的类

与值行为类不同,拷贝只拷贝指针本身,但析构时需要注意此时资源有没有被其他对象使用。
于是引入引用计数:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。
  • 拷贝构造函数不分配新的计数器。
  • 析构函数递减计数器,如果计数器为0,释放资源。
  • 拷贝赋值运算符递增右侧对象的计数器,递减左侧对象的计数器。

定义一个使用引用计数的类:

class HasPtr{
public:
	//构造函数分配新的string和新的计数器
	HasPtr(const string &s = string())://带有默认参数的构造函数
		ps(new 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:
	string *ps;
	int i;
	std::size_t *use;//引用计数
};

HasPtr::~HasPtr(){
	if(--*use == 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交换两个对象的成员。

class HasPtr{
	friend void swap(HasPtr&, HasPtr&);
	...
};
inline void swap(HasPtr &lhs, HasPtr &rhs){//重载标准库的swap
	using std::swap;
	swap(lhs.ps, rhs.ps);//交换指针
	swap(lhs.i, rhs.i);//交换int成员
};
//如果一个类包含一个HasPtr成员
void swap(Foo &lhs, Foo &rhs){
	using std::swap;
	swap(lhs.h, rhs.h);//使用HasPtr版本的swap,匹配优先级比标准库更高
}

在赋值运算符中使用swap:通过swap来定义赋值运算符(拷贝并交换技术)。

HasPtr& HasPtr::operator=(HasPtr rhs){
	swap(*this, rhs);//交换rhs和本对象的成员,从而实现赋值
	return *this;//rhs在函数结束后被销毁,实际上销毁的是赋值前的本对象
}

拷贝控制示例

以下是代码示例,帮助理解,非常建议阅读一遍。

Message:存储一条信息,可与多个Folder关联

  • save:储存至某Folder
  • remove:从某Folder中删除本Message

Folder:存储多条Message

  • addMsg:添加Message至本Folder
  • remMsg:从本Folder删除某Message

Message类:

class Message{
	friend class Folder;
public:
	explicit Message(const string &str = ""):contents(str){}
	//拷贝控制成员
	Message(const Message&);
	Message& operator=(const Message&);
	~Message();
	//添加/删除本Message
	void save(Folder&);
	void remove(Folder&);
private:
	string contents;
	set<Folder*> folders;//包含本Message的Folder
	void add_to_Folders(const Message&);
	void remove_from_Folders();
};

//向某Folder添加/删除本Message
void Message::save(Folder &f){
	folders.insert(&f);
	f.addMsg(this);
}
void Message::remove(Folder &f){
	folders.erase(&f);
	f.remMsg(this);
}

//拷贝控制
void Message::add_to_Folders(const Message &m){
	for(auto f : m.folders)
		f->addMsg(this);
}
void Message::(const Message &m):contents(m.folders){
	add_to_Folders(m);
}

//析构函数
void Message::remove_from_Folders(){
	for(auto f : folders)
		f->remMsg(this);
}
Message::~Message(){
	remove_from_Folders();
}

//赋值运算符
Message& Message::operator=(const Message &rhs){
	remove_from_Folders();
	contents = rhs.contents;
	folders = rhs.folders;
	add_to_Folders(rhs);
	return *this;
}

void swap(Message &lhs, Message &rhs){
	using std::swap;
	for(auto f : lhs.folders)
		f->remMsg(&lhs);
	for(auto f : rhs.folders)
		f->remMsg(&rhs);
	//标准库swap
	swap(lhs.folders, rhs.folders);
	swap(lhs.contents, rhs.contents);
	for(auto f : lhs.folders)
		f-addMsg(&lhs);
	for(auto f : rhs.folders)
		f->addMsg(&rhs);
};

Folder类:

class Folder{
public:
	Folder();
	~Folder();
	Folder& operator=(const Folder&);
	Folder(const Folder&);
 
	void addMsg(Message *m3){
		messages.insert(m3);
	}
	void remMsg(Message *m4){
		messages.erase(m4);
	}
private:
	set<Message*> messages;//保存Message的指针
};

动态内存管理类

以下是一个动态内存管理类的示例,建议阅读一遍加深理解。

StrVecVector的简化版本,只适用于string类型
使用allocator获得原始内存,用construct成员创建对象,用destroy销毁元素。
StrVec有三个指针成员:

  • elements,指向分配的内存中的首元素
  • first_free,指向最后一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置

四个工具函数:

  • alloc_n_copy,分配内存,并拷贝给定范围中的元素
  • free,销毁构造的元素并释放内存
  • chk_n_alloc,保证StrVec有足够的空间,如果空间不足,调用reallocate分配新内存
  • reallocate,在内存用完时分配新内存

StrVec类定义:

  • 默认构造函数默认初始化alloc并将指针初始化为nullptr
  • size成员返回当前真正在使用的元素的数目,等于first_free-elements
  • capacity成员返回StrVec可以保存的元素的数目,等于cap-elements
  • 当没有空间容纳新元素,cap==first_freecnk_n_alloc会重新分配内存
  • beginend成员分别返回首指针和尾后指针
class StrVec{
public:
	StrVec():elements(nullptr), first_free(nullptr), cap(nullptr){}
	StrVec(const StvVec&);
	StrVec &operator=(const StrVec&);
	~StrVec();
	void push_back(const string&);
	size_t size()const{return first_free - elements;}
	size_t capacity()const{return cap - elements;}
	string *begin()const{return elements;}
	string *end()const{return first_free;}
private:
	Static std::allocator<string> alloc;//分配元素
	void chk_n_alloc(){//确保空间内存
		if(size() == capacity())
			reallocate();
	}
	//工具函数
	std::pair<string*, string*> alloc_n_copy
		(const string*, const string*);
	void free();//释放内存
	void reallocate();//重分配内存并拷贝
	string* elements;//首元素指针
	string* first_free;//内存第一个空闲位置
	string* cap;//内存尾后位置
};

void StrVec::push_back(const string& s){
	cnk_n_alloc();
	alloc.construct(first_free++, s);//构造未初始化的内存
}

pair<string*, string*> Str::alloc_n_copy(const string* b, const string* e){
	auto data = alloc.allocate(e - b);
	return {data, uninitialized_copy(b, e, data)};
}

void StrVec::free(){
	if(elements){
		for(auto p = first_free; p != elements;)
			alloc.destroy(--p);
		alloc.deallocate(elements, cap - elements);
	}
}

//分配的空间和元素所需一样多
StrVec::StrVec(const StrVec &s){
	auto newdata = alloc_n_copy(s.begin(), s.end());
	elements = newdata.first;
	first_free = cap = newdata.second;
}

StrVec::~StrVec(){
	free();
}

StrVec &StrVec::operator=(const StrVec &rhs){
	auto data = alloc_n_copy(rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = cap = data.second;
	return *this;
}

移动构造函数和std::move:避免string内容的拷贝,移动资源而不是拷贝资源,因此不会调用拷贝构造函数。

void StrVec::reallocate(){
	auto newcapacity = size() ? 2*size() : 1;
	auto newdata = alloc.allocate(newcapacity);
	auto dest = newdata;
	auto elem = elements;
	for(size_t i = 0; i != size(); ++i)
		alloc.construct(dest++, std::move(*elem++));
	free();
	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;
}

对象移动

对于那些通过拷贝完成移动的操作,对象拷贝后就立即被销毁,此时通过移动而非拷贝可以大幅度提升性能。
某些类也不支持拷贝操作,此时只能移动(IO类、unique_ptr类)。

右值引用

通过&&获得右值引用,只能绑定到一个将要销毁的对象,将一个右值引用的资源移动到另一个对象中。
右值引用本质上也只是一个对象的另一个名字。

int i = 1;
int &r = i;
int &&rr = i;//错误,i是一个左值
int &r2 = i*5;//错误,i是一个右值
const int &r3 = i*5;//const引用可以绑定右值
int &&rr2 = i*5;//正确,右值引用绑定右值

左值持久,右值短暂,由于右值只能绑定到临时对象,所以:

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

变量是左值:

int &&rr1 = 1;//正确,字面常量是右值
int &&rr2 = rr1;//错误,rr1是左值

标准库move函数(定义在utility中):虽然不能将右值引用直接绑定到左值上,但是可以显式转换,也可以通过move获得绑定到左值上的右值引用。

int &&rr3 = std::move(rr1);

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

移动构造函数的第一个参数是右值引用,和拷贝构造函数一样,任何额外的参数都必须有默认实参。
资源完成移动,源对象必须不再指向被移动的资源,释放所有权,而移动完成后,源对象会被销毁,调用析构函数。

StrVec::StrVec(StrVec &&s)noexcept//不抛出异常
	:elements(s.elements), first_free(s.first_free), cap(s.cap){
	s.elements = s.first_free = s.cap = nullptr;		
}

移动操作、标准库容器和异常:
noexcept告知标准库该函数不抛出异常,省去一些额外的工作,必须在头文件的声明和定义都指定noexcept
在实际中,为了恢复异常导致的任务中断,如vector在重新分配内存的过程中都必须使用拷贝构造函数,以便在异常中断后仍能恢复至原状态。如果希望进行移动而不是拷贝,则需要显式告诉标准库,我们的移动构造函数可以安全的完成任务。

移动赋值运算符:

StrVec &StrVec::operator=(StrVec &&rhs)noexcept{
	//检测自赋值,不能在释放之后调用资源
	if(this != &rhs){
		free();
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}

移后源对象必须可析构:
完成移动操作后,对象的销毁不是保证的,所以需要确保源对象是可析构的,还需保证对象仍是有效的(可以赋予新值),另外,我们的程序不应该依赖源对象中的数据。

在移动操作后,源对象必须保持有效、可析构的状态,但是用户不能对其值进行任何假设(使用)。

合成的移动操作:当一个类没有任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,才会合成移动构造函数或移动赋值运算符。

struct X{
	int i;;
	string s;
};
struct hasX{
	X mem;//X有合成的移动操作
};
X x, x2 = std::move(x);//使用合成的移动构造函数
hasX hx, hx = std::move(hx);//使用合成的移动构造函数

合成移动构造函数/移动赋值运算符也可能是删除的:

  • 类成员定义了拷贝构造函数但未定义移动构造函数、未定义拷贝构造函数且不能合成移动构造函数
  • 类成员的移动构造函数/移动赋值运算符被定义为删除/不可访问
  • 析构函数被定义为删除/不可访问
  • 类成员有const或引用

另外,如果类定义了拷贝构造函数/移动赋值运算符,则类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

移动右值,拷贝左值,如果没有移动构造函数,右值也被拷贝

StrVec v1, v2;;
v1 = v2;//v2是左值,使用拷贝赋值
StrVec getVec(...);
v2 = getVec(...);//getVec返回右值,使用移动赋值

Foo x;//x未定义移动构造函数
Foo y(x);//拷贝构造函数,x是左值
Foo z(std:move(x));//拷贝构造函数,因为为定义移动构造函数

拷贝并交换赋值运算符和移动操作:因为赋值运算符中的参数是非引用的,所以此参数要进行拷贝初始化,因为定义了移动构造函数,左值被拷贝,右值被移动。

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;//hp2是左值,拷贝构造函数
hp = std::move(hp2);//移动构造函数移动hp2

三/五法则:
一个类定义了拷贝操作,它就应该定义所有五个操作(拷贝构造、拷贝赋值、析构、移动构造、移动赋值)。拷贝资源会导致额外开销,在拷贝是非必要的情况下,使用移动可以避免额外开销。

Message类的移动操作:

void Message::move_Folders(Message *m){
	folders = std::move(m->folders);
	for(auto f : folders){
		f->remMsg(m);
		f->addMsg(this);
	}
	m->folders.clear();//确保销毁m是无害的
}

Message::Message(Message &&m):contents(std::move(m.contents)){
	move_Folders(&m);
}

Message& Message::operator=(Message &&rhs){
	if(this != &rhs){
		remove_from_Folders();;
		contents = std::move(rhs,contents);
		move_Folders(&rhs);
	}
	return *this;
}

移动迭代器:一般迭代器的解引用返回指向元素的左值,移动迭代器解引用生成一个右值引用。
调用标准库的make_move_iterator将普通迭代器转换为一个移动迭代器。

void StrVec::reallocate(){
	auto newcapacity = size() ? 2*size() : 1;
	auto first = alloc.allocate(newcapacity);
	//移动迭代器返回的右值,uninitialized_copy的construct会调用移动构造函数来构造元素。
	auto last = uninitialized_copy(make_move_iterator(begin())),
									make_move_iterator(end()),
									first);
	free();
	elements = first;
	first_free = last;
	cap = elements + newcapacity;
}

不要随意使用移动操作:
移动之后,源对象的状态是不确定的,必须保证源对象没有其他用户。

右值引用和成员函数

void push_back(const string& s){//左值引用,拷贝
	cnk_n_alloc();
	alloc.construct(first_free++, s);
}
void push_back(string&& s){//非const右值引用,移动
	cnk_n_alloc();
	alloc.construct(first_free++, std::move(s));
}

string s = "some";
vec.push_back(s);//调用const string&
vec.push_back("ss");//调用string&&

右值和左值引用成员函数:有时向右值赋值是合法的,为了阻止此操作,可以使用引用限定符

//向右值赋值
s1 + s2 = "wow";

//引用限定符&和&&分别指出this可以指向一个左值或右值
class Foo{
public:
	Foo &operator=(const Foo&) &;//只能向可修改的左值赋值
};

Foo& retFoo();//返回引用,左值
Foo retVal();//返回值,右值
Foo i, j;
retFoo() = j;//正确,返回左值
retVal() = j;//错误,返回右值

重载和引用函数:

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();//右值,在原址排序
retFoo().sorted();//左值,在拷贝上排序
上一篇 下一篇

评论 | 0条评论