前言
上一篇攻略中,我们已经充分理解了不带指针的类的设计原则,并且还从标准库设计大师的作品里收获了不少功力。而这一篇攻略,将继续完成基于对象的类的关卡,解决这一关的最后一个问题,那就是带指针的类。在这途中,我们将会遇到知名的 BIG THREE ,剖析 BIG THREE 的设计原则!
带指针的类
一个带指针的类,我们可以想到很多,但是 String 类一定是一个最经典的例子。在这里,我们自己设计一个 String 类,看看带指针的类在设计过程中到底需要注意什么情况。source code
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;
};
一些人可能会问,这个类的实现也可以不使用指针,直接声明一个数组来存储字符不就行了么?使用数组当然可以,不过这样的设计很low,比较明显的一个理由就是我们在一开始并不知道字符串的长度多长,太少则浪费空间,太长则导致溢出。在这种情况下,使用指针是非常好而且非常普遍的,在32位的操作系统中,一个指针的大小是4个字节,因此一个 String 对象的大小十分小,对于效率的提升非常大。
BIG THREE
我们常说的 BIG THREE 就是如下三个:
String(const String &str);
String& operator = (const String &str);
~String();
分别是拷贝构造函数、拷贝赋值函数和析构函数。那么问题来了,在上一篇攻略中,我们的 complex 类为什么没有 BIG THREE 呢?其实也有的,只是我们没有显式地写出来,而是使用的编译器提供的 BIG THREE。以下我们逐一分析为什么要使用它们,为什么不能使用默认的 BIG THREE。
接下来我们考虑使用者会怎么使用我们的 String 类。首先能够想到的就是构造函数,这部分同上一篇攻略类似,考虑下参数、默认参数以及初始化列表的问题:
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';
}
}
对于一个带指针的类,最为麻烦,而且也是最需要关注的点就是如何进行赋值,赋值的方法有如下几种:
String s1("hello");
String s2(s1);
String s3 = s1;
s3 = s1;
对于
String s2(s1);
s2是第一次出现,因此会调用构造函数,而参数是 String 对象,因此会调用 BIG THREE 的第一个函数,拷贝构造函数。
String::String(const String &str)
{
m_data = new char[strlen(str) + 1];
strcpy(m_data, str.m_data);
}
拷贝构造函数也是构造函数,它的特征非常明显,就是参数const String &str
为同类对象,这个函数比较简单,我们就不多嘴了。一些朋友可能会对下面这个等式产生疑问,
String s3 = s1;
其实这个用法和String s3(s1);
完全相同,同样也是调用拷贝构造函数。那么我们为什么需要手写,而不能使用默认的拷贝构造函数呢?这是由指针导致的。一个默认的拷贝构造函数大概长这样:
String::String(const String &str)
{
m_data = str.m_data;
}
这样的构造函数会造成大麻烦,这涉及到深浅拷贝的问题。默认的拷贝函数是浅拷贝:
s1和s2都指向同一份字符串,当这份字符串被释放了,就会导致野指针问题,但是这并不是我们想要的,我们需要的是深拷贝:
接下来是
s3 = s1;
这种情况的赋值是操作在两个已存在的对象上,这就不关构造函数任何事了。我们同样需要s3和s1指向两份不同的字符串,而默认的拷贝赋值函数会是这样的:
String& String::operator=(const String& str)
{
this.m_data = str.m_data;
return *this;
}
这样造成的后果就是,两个对象指向的还是同一份字符串,这是很不好的,因此我们必须手写拷贝赋值函数,也就是操作符重载函数:
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;
}
我们的函数首先判断是不是自我赋值s1 = s1;
,如果是自我赋值,那么直接返回自己。一般情况下,我们必须首先杀死自己,然后再重新分配一片内存,再将字符填进去。这样就完成了我们的赋值函数。
最后是我们的析构函数函数,同样地,我们不使用默认的析构函数,而是自己写一个析构函数,那是因为默认的析构函数只会回首指针占用的4个字节的空间,而指针指向的空间却没有回收,导致内存泄漏:
因此我们必须要手动回收字符串占有的空间,写出我们的析构函数:
String::~String()
{
delete[] m_data;
}
总结
这一篇攻略我们讨论了拷贝构造函数、拷贝赋值函数和析构函数,以及默认的 BIG THREE 。在带指针的类中,我们必须要手写 BIG THREE ,这样做的原因在于编译器默认给我们的拷贝赋值的方法是浅拷贝的方法,而我们需要深拷贝。在对象结束生命时,我们需要手动回收分配的空间,以免造成内存泄漏。
这一篇攻略就到此为止,接下来我们将深度分析分配空间的种种问题,包括 new delete 操作以及内存空间管理的浅层问题。
Reference
C++面向对象高级编程, 侯捷.