目录
包含指针的类中必须要有copy ctor(拷贝构造函数)和copy op=(拷贝赋值函数)
拷贝赋值函数(copy assignment operator)
一定要在拷贝赋值函数(operator=)中检查是否自我赋值(self assignment)
假设我现在定义了一个对象,其里面有一个对应的指针,然后,我现在再拷贝产生一个新的对象,就将是只把指针拷贝了过来,造成了两个指针其实都是指向的同一个地方,本质上只是把指针给拷贝了一份,并不是真正的拷贝,而且,风险性很大。
所以,只要类中带指针,就不能用编译器给的默认拷贝方式,一定要自己写。
下面来定义一个带指针的类,字符串的类(string.h):
想将设计的字符串类可以完成如下的调用的功能:
#include "string.h"
#include <iostream>
using namespace std;
int main()
{
String s1("hello");
String s2("world");
String s3(s2);
cout << s3 << endl;
s3 = s1;
cout << s3 << endl;
cout << s2 << endl;
cout << s1 << endl;
}
还是分成类的声明和类的实现两个部分来进行逐行的分析:
类声明部分
一般情况下对字符串类的设计都是这样的:让字符串类里面有一根指针,在需要内存的时候才创建另外一块空间来放字符,这是因为字符本身的大小是不确定的,所以,这种设计最好是动态的,根据字符的大小来调整开辟的内存空间。所以,在这个字符串中的数据定义成一个指针"char* m_data;",放在private区域内,同时,因为字符串类中的数据是通过指针进行操作的,所以,需要自己去定义一套拷贝函数放在public区域当中,具体如下:
class String
{
public:
String(const char* cstr=0); //构造函数
String(const String& str); //拷贝构造
String& operator=(const String& str);
~String();
char* get_c_str() const { return m_data; }
private:
char* m_data;
};
但是仔细发现,上面的public区域的函数,除了第一个是构造函数外,剩下的都是比较特殊的!下面逐个进行分析:
只要类带着指针,就一定要写出下面这三个函数!!!
Big Three:三个特殊函数
拷贝构造函数
这个函数和类的名称一样,应该也是一种构造函数,同时,它传递的就是一个函数本身定义的传入参数,就是一个对象"String&",这就显的很特殊,自己处理自己?,这是一个构造函数,但接收的是自己这种东西"String&",所以这种函数就称为拷贝构造函数。
String(const String& str); //拷贝构造
拷贝赋值函数
可以发现有一个"operator=",这个是给等号操作符进行复制操作,将括号里的String& str,复制给函数名前的"String&"。
String& operator=(const String& str);
析构函数
以这个类创建的对象,当其离开它的作用域或者其他的时候,对象死亡,析构函数会将对象所占用的内存给释放掉。
~String();
析构函数是相对于构造函数而言的,
类定义部分
ctor和dtor(构造函数和析构函数)
在各种程序语言中,字符串如何处理就是比较经典的问题,大部分都把一个字符串定义为前面有一个指针指着,后面以“\0”做结尾,字符串的求长度,主流有两种方式来计算,
一种是不管字符串有多长,只要检测到最后的"\0",那么就可以算出来,比如C/C++。
还有一种是,字符串后面没有"\0",这样的结束符号,但字符串的前面多了一个字符串长度信息的数值,直接提取这个,也可以获得字符串的长度(len),比如Python。
那么下面是通过第一种的方式来操作字符串:
先判断字符串是否指定初始值,为空的话(cstr=0),对应的外界调用情况为:"String s1()",就是创建的对象就没有设置初始值,就返回一个空字符串;如果非空,意味着外界的调用情况为:"String s2(”hello“)",就要分配一块足够的空间(意味着要计算字符串长度),用来存放字符串。
并且,在构造函数的时候用"new"动态的调用空间来存储字符串,最后,运行完了,也是需要用析构函数去动态的delete将这块内存空间释放的。
经验总结
-
类里面包含指针,多半是要进行动态分配的,即使用new和delete.
包含指针的类中必须要有copy ctor(拷贝构造函数)和copy op=(拷贝赋值函数)
如果不这么做会怎样?比如,想完成下面的操作,将a中的数据拷贝一份到b中,让两个对象独立的拥有同一份内容的数据("Hello\0"):
浅拷贝
如果按照编译器提供的默认的拷贝方式会是怎样的呢?如下:
可以发现,其实只是调用了指针而已,只是改变了一下指针的指向,b中的,内容"World\0"还是没改,而且,原来指向这块儿的指针指向了新的"Hello\0",造成了对象b的内存泄露。再看a中的数据"Hello\0",对象a和对象b的指针都指向它,通过任意一个指针对其作修改,都会影响另一个的调用。这种状况就叫作浅拷贝。
深拷贝(拷贝构造函数 copy ctor)
拷贝构造函数可以完成深拷贝(拷贝内容,而不是拷贝指针)的任务,这样当进行拷贝的时候,会调用拷贝构造函数,将”自己拷贝给自己“,
拷贝构造会创建出足够的空间,来放蓝本,比如:”String s2(s1)“意思是说,以s1为蓝本创建出来一个s2;"String s2 = s1;",创建一个新对象s2,将s1赋值给它,既然s2是新创建出来的,就需要去调用构造函数。
拷贝赋值函数(copy assignment operator)
接着按上面的例子来举例:
如果想把a中的值拷贝到b中去,应该分三步:
- 第一步:把b中的数据清空;
- 第二步:在b中开一块儿可以容纳a中要拷贝过去的数据的空间;
- 第三步:将放在a中的数据拷贝到b中去。那么拷贝赋值函数的设计思路也是如此的:
如图上标蓝的部分:"s2 = s1;",就是在调用拷贝赋值函数,其中的1,2,3序号,对应的上面的第一、二、三步。这也是经典的写法。
一定要在拷贝赋值函数(operator=)中检查是否自我赋值(self assignment)
接着上面的代码,在三步开始前,有一个对自我赋值的检测,这是要防止因为指针名称变动或者继承等原因,先进行检测(这是高手的经验的写法)。如果不作这个判断,不止是影响效率,甚至会影响结果。
比如下面这种情况,两个对象的指针指向同一块地方:
两个变量的指针都指向同一块内存空间,如果不作是否存在自我赋值的检查的话,直接执行拷贝构造步骤,先清除掉内存存放的数据,再留对应的字节,最后,放新数据,会发现,中间清除完要拷贝到的数据也被清除了!
这就不是效率低的事儿了,是直接就报错了!
output函数
由于我们再使用字符串的时候,会需要对字符串作输出,需要将其打印到外界,就需要这么一个函数:
所以,这里也需要一个操作符"<<"的重载的函数,需要注意的话,这个函数不能变成一个成员函数,必须写成全局函数,否则cout的输出顺序会与习惯性相反。在进行设计的时候,我们只要把字符串对应的指针抛出来,那么可以顺着指针把指向的内容都给打印出来了!如何获取这个指针呢?在类的定义部分其实就已经定义出来了!
char* get_c_str() const { return m_data; }
那么,在output函数中,直接把get_c_str()函数的结果就可以将其给输出了!
类定义部分代码
#include <cstring>
inline
String::String(const char* cstr)
{
if (cstr) {
m_data = new char[strlen(cstr)+1];
strcpy(m_data, cstr);
}
else {
m_data = new char[1];
*m_data = '\0';
}
}
inline
String::~String()
{
delete[] m_data;
}
inline
String& String::operator=(const String& str)
{
if (this == &str)
return *this;
delete[] m_data;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
return *this;
}
inline
String::String(const String& str)
{
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
}
#include <iostream>
using namespace std;
ostream& operator<<(ostream& os, const String& str)
{
os << str.get_c_str();
return os;
}