笔记会持续更新,有错误的地方欢迎指正,谢谢!
拷贝控制和资源管理
通过定义不同的拷贝操作,使自定义的类的行为看起来像一个值或指针。
- 类的行为像一个值,意味着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响,反之亦然。
- 类的行为像指针(共享状态),当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象,反之亦然。
标准库容器和string类的行为像一个值;shared_ptr类的行为像指针;IO和unique_ptr
不允许拷贝或赋值,值和指针都不像。
举例:
实现一个类HasPtr,让它的行为像一个值;然后重新实现它,再使其像一个指针:
我们的HasPtr有两个成员,一个int和一个string指针,对于内置类型int,直接拷贝值,改变它也不符合常理;我们把重心放到string指针,它的行为决定了该类是像值还是像指针。
行为像值的类
为了像值,每个string指针指向的那个string对象,都得有自己的一份拷贝,为了实现这个目的,我们需要做以下三个工作:
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算对象拷贝string
我们来抛代码:
class HasPtr
{
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0){} //默认实参,列表初始化
/*补充:const修饰s为字符串常量(只读),此处s恒为空字符串;&的作用是引用,避免再复制一个string,若是const string s的话还要再复制一次,岂不是很浪费。所以,既然已是只读,为啥不直接用引用。*/
HasPtr(const HasPtr &p) : ps(new string(*p.ps)), i(p.i){} //拷贝构造函数
HasPtr& operator=(const HasPtr &); //赋值运算符声明
~HasPtr(){delete ps;} //析构函数
private:
string *ps;
int i;
};
主要在于拷贝构造函数,它是有副本的,会拷贝string对象,所以析构函数要delete来释放内存。这个类写得很优雅~
类值拷贝赋值运算符
举例:a = b;
类值拷贝赋值:自赋值也是安全的,发生异常后左侧对象状态仍有意义;先拷贝构造右侧对象到临时变量,再销毁左侧成员并赋予新值。
所以,赋值运算符定义如下:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放对象指向的string,指针无析构函数,所以指针还在
ps = newp; //从右侧对象拷贝数据到本对象
i = rhs.i;
return *this; //返回本对象
}
定义行为像指针的类
拷贝指针就行了?没那么简单,还是要释放内存。而且这个释放内存的时机很重要:只有当最后一个指向string的HasPtr销毁时,才能释放内存,所以,我们可以用shared_ptr,但这里,不用智能指针,弄麻烦些,让大家看看底层如何实现引用计数:
class HasPtr
{
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0),
use(size_t(1)){} //默认实参,列表初始化
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use){++(*use)} //拷贝构造函数,要递增计数器
HasPtr& operator=(const HasPtr &); //赋值运算符声明
~HasPtr() //析构函数
{
if(--(*use) == 0) //没人引用了才释放
{
delete ps;
delete use;
}
}
private:
string *ps;
int i;
size_t *use; //引用计数
//补充:size_t是目标平台能够使用的最大的类型,考虑到了跨平台的效率问题。
};
类指针拷贝赋值运算符:
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
拷贝并交换:在赋值运算符中,若参数不是引用,此时结合拷贝和交换的赋值运算符是安全的。
比如我们来交换一下前面写的HasPtr(值拷贝版本):
HasPtr temp = v1;
v1 = v2;
v2 = temp;
这是很自然的一种写法,我们借助中间量temp来交换v1和v2,但是,有时候对象很大,你跑去拷贝交换对象很浪费,不如去交换指针更合算:
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
我们定义交换指针的swap函数后,就可以利用它写出更简洁的赋值运算符啦~
这样做的好处在哪?
在于你不用管内存拷贝释放等事,把类写好后,保证你调用的方式是最经济有效的。
拷贝控制示例
我们将建立两个类用于邮件处理,两个类命名为Message和Folder,分别表示邮件消息和消息目录。每个Message对象可以出现在多个Folder中,但是任意给定的Message的内容只有一个副本,这样的话,一条Message的内容改变,则我们从任意Folder来浏览它时看到的都是更新的内容。
书上写得挺优雅地,分析和代码请见P460
动态内存管理类
某些类需要在运行时分配可变大小的内存空间,这种类最好在底层使用标准容器库,例如vector。但是,有些类需要自己进行内存分配,它基本就要定义自己的拷贝控制成员来管理所分配的内存。
这一节,要实现标准库vector类的一个简化版,它只能用于string,命名为StrVec。
StrVec类的设计
主要参照vector<string>
源码来实现,vector源码请见我的另一篇博客:
http://blog.youkuaiyun.com/billcyj/article/details/78801834
我们将使用allocator来获得原始内存,由于它分配的内存是未构造的,我们将需要在添加新元素时,用allocator的construct成员在原始内存中创建对象;同样的,我们在删除元素时就使用destroy成员来销毁函数。
每个StrVec有三个指针成员指向其元素所使用的内存:
- elements 指向首元素
- first_free 尾后元素
- cap 指向分配的内存末尾的下一个位置
StrVec还有一个名为alloc的静态成员,其类型为allocator<string>
。alloc成员会分配StrVec使用的内存。我们的类还有4个工具函数:
- alloc_n_copy会分配内存,并拷贝一个给定范围内的元素
- free会销毁构造的元素并释放内存
- chk_n_alloc保证StrVec至少有容纳一个新元素的空间,如果空间不够的话,它会调用reallocate来分配更多内存
- reallocate在内存用完时为StrVec分配新内存
分析设计好了之后,我们就可以动手写类了:
class StrVec
{
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr){} //默认构造函数
StrVec(const StrVec&); //拷贝构造函数声明
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 allocator<string> alloc; //分配元素
void chk_n_alloc()
{
if(size() == capacity())
{
reallocate();
}
}
pair<string*, string*> alloc_n_copy(const string*, const string*);
void free();
void reallocate();
//数据成员
string *elements;
string *first_free;
string *cap;
};
该说明的都已经在注释中说明了。
接下来分别实现已经声明的函数定义:
push_back
void StrVec::push_back(const string& s)
{
chk_n_alloc(); //确保有空间
alloc.construct(first_free++, s); //调用allocator成员construct来插入
//至于construct怎么搞得咱们就不了解了,有兴趣自己去查吧
}
alloc_n_copy
分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中:
pair<string*, string*> StrVec::alloc_n_copy(const string *b, const string *e)
{
auto data = alloc.allocate(e-b); //分配正好的空间
return {data, uninitialized_copy(b, e, data)};
}
在返回语句中完成拷贝工作:
返回语句中对返回值进行了列表初始化,返回的pair中,first指向分配内存的开始位置(因为data作为名字来用就是首元素地址),second是uninitialized_copy的返回值,这个值是一个指向尾后元素的指针。
free
free要干两件事:
- destroy元素,是指确实有内容的元素
- 释放分配的内存空间,包括没有元素的内存
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 = 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;
}
reallocate
我们会用到一些之后要学的函数:
void StrVec::reallocate()
{
//空就分配一个,不空就变为2倍,好好看看,这个写法很装逼
auto newcapacity = size() ? 2*size() : 1;
auto newdata = alloc.allocate(newcapacity); //分配新内存
auto dest = newdata; //指向新数组的下一个空闲位置
auto elem = elemments; //指向旧数组的下一个元素
for(size_t i=0; i != size(); ++i)
{
alloc.construct(dest++, move(*elem++));
//这里的move函数你就理解为把旧数组元素移动到新数组中,不需要拷贝了
}
free(); //移动好元素就释放旧内存空间
//更新数据成员
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}