目录 Table of Contents
7 三大函数 : 拷贝构造 拷贝复制 析构函数
Classes 的两个经典分类
-
Class without pointer member(s)
- complex
-
Class with pointer member(s)
- string
讲完了复数之后, 我们现在看另一个例子
String class
string.h
#ifndef __MYSTRING__
#define __MYSTRING__
class String
{
//...
};
String::function(/*...*/) //...
//Global-function(...)...
#endif
一样的, 我们在设计这个 class, 设计这个头文件的时候, 要把它的防御式声明写出来
标准库也有这种 string, 但是不是我们这里讲的, 因为那个写的太复杂了, 标准库加上了很多的功能, 我们现在无法讲解
我们现在的测试程序如下, 做的是一些简单的事情
string-test.cpp
#include <ostream.h>
int main()
{
// 两个字符串对象, 一个有初值, 一个没有初值, 这时候我们需要想一想它的构造函数该如何设计
String s1(),
String s2("hello");
// 创建一个 s3, 以 s1 为初值, 所以这是一个拷贝的动作, 以 s1 为蓝本, 拷贝到 s3 身上
// 拷贝构造
String s3(s1);
// 把 s3 丢到 cout 去, 所以我们也要写一个操作符重载来应付这个事情
cout << s3 << endl;
// s2 赋值到 s3, 这也是一个拷贝的动作
// 拷贝赋值(复制)
s3 = s2;
cout << s3 << endl;
}
我们前面学的复数也有一个拷贝构造函数和一个拷贝复制函数, 因为你没有写的话, 编译器会帮你弄一份, 作用就是编译器帮你一个比特一个比特地复制过去
我们将会去思考到底要不要自己写这么一份函数, 也就是说编译器给的默认的那一套够不够用
你想一下, 复数有实部和虚部, 实部和虚部被编译器忠实地拷贝过去, 没问题啊, 我再怎么写也就这样了, 还是用编译器给的那一套吧
可是在这一种带着指针的 class 上面, 如果还是使用编译器给的拷贝构造和拷贝赋值的话, 会有非常不好的影响
我现在有一个对象, 它里面有一个指针, 指向另外一个地方; 我现在要弄一个新的对象, 如果是用默认的拷贝构造函数的话, 那就是指针拷贝过来, 它们俩就指到了同一个地方去了
我们后面有更详细的图来介绍这个
Big Three, 三个特殊函数
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; } // 一般的成员函数 inline function
private:
char* m_data;
};
一般而言大家都会这样设计字符串, 让字符串里面拥有一个指针, 在需要内存的时候才创建另外一个空间来放字符本身
这是因为字符串里面的东西有大有小, "hello" 有 5 个字符, 有大有小, 有时候是空字符串
这样的动态的设计是最好的, 而不要是在字符串类里面放一个数组, 这个数组你要设定为多大呢 ? 🙂
所以我们确定下来了, 这个 class 的 data, 首先应该放在 private 里面, 这个 data 应该是一个指针, 指向字符
然后我们去想, 显然不能用编译器给我们的那一套特殊函数, 所以我们要自己写一套
新增加的这一套函数见代码注释
有些书籍把这三个函数叫作 big three, 三个特殊函数
剩下的一个就是一个一般的成员函数了, 它返回一个指针, 它不改变数据所以加上一个 const
ctor 和 dtor (构造函数与析构函数)
inline
String::String(const char* cstr = 0) // 这里有一个默认值
{
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;
}
这是一个由 C 语言一来就有的概念, 字符串是什么. 就是有一个指针指着头, 然后后面就一串, 最后面会有一个 '\0' 这样的结束符号
一个字符串有多长, 有两种设计
一种是我不知道多长, 但是最后面有一个结束符号, 这样我就可以算出来它的长度
另外一个就是, 后面没有结束符号, 但是前面多了一个长度这样的整数
pascal 就是用的后面这种, 前面有长度的设计; C/C++ 用的是前面的这种, 最后面有结束符的设计
{
String s1();
String s2("hello");
String* p = new String("hello");
delete p;
}
所以这个构造函数, 使用者可能是像 s1 这样没有参数, 也可能是像 s2 这样传了一个指针进来
因为我们要创建一个字符串, 所以我们一进来就要检查, 这个指针是 0 吗 ?
如果是 0, 就跑到 else
这里来, 虽然是 0, 空指针, 但是我们还是要准备一个字符, 也就是这个结束符号 '\0'. 为了做这个事情, 所以 else
这里用 new
好好分配一块内存, 内存大小为一个字符, 不用数组也可以, 但是这么写的话就可以和上面的写法搭配, 上面也是一个数组.
如果传进来的不是空不是 0, 那我们就在这里面 new
, 动态分配一块足够的空间. 这个足够空间多大呢, 我得看看传进来的东西是多长, 然后 +1, +1 就相当于这个结束符号. 分配完了之后, 再把传进来的初值拷贝到我新分配的地方.
m_data = new char[strlen(cstr) + 1];
这边 new
了一个足够空间之后, 图上这个蓝色指针所指的就是分配出来的, 所以如果存进来是 hello 的话, 长度是 5 + 1, 这里就是 6
怎么没有看到 6, 它还是 5 呢 ? 因为没有把最后面那个 '\0' 画出来
分配之后, copy, 于是现在新创建的这个对象就拥有内容了, 这是它的构造函数
对应于构造函数, 有所谓的析构函数, 因为这个函数已经在 class 之外了, 所以要把全名写出来, 这个函数做清理工作
之前学的复数, 并不需要做清理工作, 因为设置的实部和虚部会随着对象而被释放掉, 所以无所谓
但是我们这里有动态内存分配, 如果没有释放掉的话就会造成内存泄露, 所以我们看到析构函数这边要 delete
这一块内存
强调一点, 你的类里面有指针, 你多半是要做动态分配, 就像这样; 你既然做了动态分配, 这个对象在死亡之前调用析构函数, 把这块内存清理掉
{
String s1();
String s2("hello");
String* p = new String("hello");
delete p;
}
s1 s2 就不说了, 我们看一下 *p, 这是一种新的创建对象的方法, 就是用的动态分配的方式
析构函数会把对象里面动态分配的内存释放掉, 这个是在外面动态分配的, 所以要我们在外面释放掉
class with pointer members 必须有 copy ctor(拷贝构造) 和 copy op=(拷贝赋值)
为什么如果你的 class 带有 pointer member 就一定要这么做呢 ?
想一想看如果不这么做会怎么样
我们现在看这个例子
现在我们有左右两个字符串 a b, 现在使用者做了一个 b = a;
a 赋值到 b 身上这个动作
如果你没有自己写这么些函数, 而是用编译器给你的那些个函数的话, 它就会做一个比特一个比特地拷贝
在这两个对象里面, 它们的数据是什么, 只有一个指针, 至于指针所指的那一块内存是不属于对象本身的
所以如果你使用编译器默认的拷贝函数的话, a 复制到 b 里面去, 也就是 b 的内容要和 a 里面相同, a 不变, a b 里面只有指针, 所以就变成这两个指针指向同一块地方了
我们希望的是赋值之后两个都有相同的内容, 但是不在同一块内存里面, 现在却变成它们看起来是有相同的内容, 但是 world\0
这一块不见了, 就是内存泄露
也不是不见了, 它还在, 就是没有指针指向它了, 它变成孤儿了
而 Hello\0
这一块两个指针指向它也非常危险, 因为将来你把 a 改了, 同时也会把 b 改了
这一种叫作浅拷贝
而什么叫作深拷贝呢, 就是我们要去写的这个函数里面要做的事情
copy ctor 拷贝构造函数
inline
String::String(const String& str)
{
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
}
这一个函数叫拷贝构造, 为什么呢, 因为这是一个构造函数, 然后因为它收到的参数就是它自己这种类型, 所以它是拷贝
这个可以传引用, 然后因为传进来的这个被复制的蓝本不会被改动, 所以加 const
拷贝构造应该做什么事情, 它应该创建出一个足够的空间用来存放蓝本, 然后空间创造出来之后把内容拷贝过去
{
String s1("hello ");
String s2(s1); // 以 s1 为蓝本创建出 s2
//String s2 = s1; // 把 s1 赋值 到 s2 身上, 不过 s2 是新创建出来的对象
}
这个才叫深拷贝
copy assignment operator 拷贝赋值函数
inline
String& String::operator=(const String& str)
{
if(this == &str)
return *this; //检测自我赋值 (self assignment)
delete[] m_data;
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
return *this;
}
我要把右边的东西赋值到左边, 左右本来都已经有了东西了, 应该怎么办呢 ?
我应该先把左边清空, 然后创建出跟右边一样大的空间, 再把右边赋值到左边身上
{
String s1("hello");
String s2;
s2 = s1;
}
我们现在看一下这个检测自我赋值, 谁会去做自我赋值呢 ? 应该是没有, 有很多时候是看起来不是自我赋值, 但本质是自我赋值, 因为指针的名称变动, 或者将来的继承
如果你没有写这两行, 结果甚至会出错, 不只是效率的问题
一开始左边和右边就是指向同一个, 那赋值动作第一个就是杀掉, 这一下就把唯一的这个杀掉了, 那下一个动作就没法进行了
所以写这个自我赋值, 不只是为了效率, 还有正确性