先来说一下什么叫拷贝?
所谓拷贝,是把一个事物变成两个或多个的过程,我们了解到的最多的拷贝方式应该就是CTRL+C/V吧,但是在C/C++中的拷贝有它独特的地方,独特在哪呢?
独特在于C/C++中拷贝的事物不同:只拷贝指针而不管指针指向的内容,这种称为浅拷贝
;拷贝了指针并且拷贝指针指向的内容叫做深拷贝
。
先来看一下浅拷贝:
class Func {
public:
Func(const char* str)
:_str(new char[strlen(str) + 1])
{
strcpy(_str, str);
}
Func(const Func& fun)
:_str(fun._str)
{}
Func& operator=(const Func& fun)
{
if (this != &fun)
{
_str = fun._str;
}
return *this;
}
~Func()
{
if (_str)
{
delete _str;
_str = nullptr;
}
}
private:
char *_str;
};
int main()
{
Func s1("hello");
Func s2(s1);
return 0;
}
通过调试并查看监视窗口发现:
说明构造函数创建的对象s1和拷贝构造函数创建的对象s2指向的是同一块空间,如图示:
这个就是简单的拷贝了指针,而没有拷贝指针所指向的内容,浅拷贝会出现什么问题呢?
- 浅拷贝只是拷贝了指针,使得两个指针指向同一个地址,这样在对象块结束,调用析构函数时,会造成同一份资源析构2次,即delete同一块内存2次,造成程序崩溃;
- 浅拷贝使得s1和s2指向同一块内存,任何一方的变动都会影响到另一方;
- 释放内存时,会造成s1原有的内存没有被释放(如果没有走自定义的拷贝构造函数,申请内存空间,Func s2(s1);也不走默认构造函数,走的是默认的拷贝构造函数,何来分配空间之说,更不会造成s1原有的内存没有被释放),造成内存泄露。
事实是这样的,当delete s2, s2内存被释放后,由于之前s2和s1指向的是同一个内存空间,s2所指的空间不能在被利用了,delete s1时不会成功,无法操作该空间,所以会导致内存泄露。
为了解决这个问题,我们引入了深拷贝:
- 传统写法:
用s1对象拷贝构造或赋值给s2(s2(s1)或 s2 = s1),当涉及到深浅拷贝的问题时:
对于拷贝构造函数来说,s2先开一块和s1一样大的空间,再将s1里的数据拷贝给s2;
对于赋值运算符重载函数来说s2已经存在,则必须先释放s2的空间,再让s2开辟一块与s1一样大的空间(否则就会导致s2里面的指针没有释放),然后让s2指向这块新开的空间,最后将s1里面的数据拷贝至s2指向的空间(自己开空间自己拷数据)
//拷贝构造
String(const String& s)
{
_str = new char[strlen(s._str) + 1]; //开空间
strcpy(_str, s._str); //拷数据
}
//赋值运算符
String& operator=(const String& s)
{
if (this != &s)
{
delete[] _str;
_str = NULL;
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
- 现代写法(调用其他的函数实现功能)
-
本质:让别人去开空间,去拷数据,我只需要将你完成操作后的空间与我交换即可
-
实现:用s1拷贝构造一个s2对象(s2(s1)),可以通过构造函数用s1里的指针_str构造一个临时对象tmp(构造函数不仅会开空间还会将数据拷贝至tmp),此时tmp就是我们想要的对象,然后将新tmp的指针_ptr与自己的指针进行交换;
对于构造函数来说,因为String有一个带参数的构造函数,则用现代写法写拷贝构造时可以调用构造函数,而对于没有无参的构造函数的类只能采用传统写法(开空间然后拷数据)
//拷贝构造的现代写法
String(const String& s)
:_str(NULL)
{
String tmp(s._str); //调用构造函数,则tmp是需要的
swap(_str, tmp._str); //将_str与tmp的_str指向的空间进行交换,
//tmp._str就会指向_str的空间,出了这个作用域,tmp就会调用析构函数,
//但是tmp里面的_str值可能不确定,所以在初始化列表中将_str置空,这样tmp._str=NULL
}
//赋值的现代写法
String& operator=(const String& s)
{
if (this != &s)
{
String tmp(s._str); //调用构造函数
swap(_str, tmp._str); //tmp是局部对象,出了这个作用域就会调用析构函数,
//就会将tmp里面的指针指向的空间释放掉,
}
return *this;
}
其他:
- 有时候为了防止默认拷贝发生,可以声明一个私有的拷贝构造函数(不用写代码),这样的话,如果试图调用 A b(a); 就调用了私有的拷贝构造函数,编译器会报错,这也是一种偷懒的做法。
- 一个类中可以存在多个拷贝构造函数,例如:
Calss A
{
Public:
X(const X&); //const拷贝构造
X(X &); //非const拷贝构造
X(X& , int iData);
}
写时拷贝:
- 常用场景:
有时会多次调用拷贝构造函数,但是拷贝构造的对象并不会修改这块空间的值;如果采用深拷贝,每次都会重复的开空间,然后拷数据,最后再释放这块空间,这会花费很大的精力; 浅拷贝不用重复的开空间,但是会有问题。
为了解决释放多次的问题可以采用引用计数,当有新的指针指向这块空间的时候,我们可以增加引用计数,当这个指针需要销毁时,就将引用计数的值减1,当引用计数的值为1时才去释放这块空间。
当有一个指针指需要修改其指向空间的值时,才去开一块新的空间(也就是写时拷贝),这相当于一个延缓政策,如果不需要修改,则不用开新的空间,毕竟开空间需要很大的消耗。 引用计数解决了空间被释放多次的问题,写时拷贝解决了多个指针指向同一块空间会修改的问题。
- String写时拷贝的的三种方案的选择:
- 将引用计数定义为int类型
class Func {
private:
char* _str;
int _count;
};
缺陷:
每个对象的引用计数之间是独立的,如果增加指向这块空间的指针,也只会修改新增这个指针所在对象的引用计数,就会使得每块空间对应引用计数不相同
- 将引用计数定义为static int
class Func {
private:
char* _str;
static int _count;
};
缺陷 :
静态成员为该类的所有对象所共享,理论上就会使得利用String类创建的所有对象哪怕他们指针指向不同的空间,但是这些对象的引用计数都相等,实际上根本不能编译通过
- 将引用计数定义为int*的指针
该指针指向一块空间,这块空间里面存放的是引用计数,当用s1拷贝构造s2时,s1与s2里面的_str指向同一块空间,s1与s2的引用计数也存放在同一块空间里。创建3个Func对象,s1与s2指向同一块空间,则s1与s2的引用计数都为2,s3指向另一块空间,s3的引用计数为1。
class Func {
private:
char* _str;
int* _count;
};
3. 写时拷贝的改进
如果将引用计数单独的定义为一个int的指针,它占4个字节,每次创建一个String对象,都会为其向操作系统申请4个字节的内存,这样就会经常申请许多小块内存,会造成内存碎片,也会对效率造成影响。这时可以考虑将_str与引用计数放在一起,就在_str的头上4个字节存放引用计数,当我们取引用计数时,只用将((int*)(_str-4))。