7 三大函数 : 拷贝构造 拷贝复制 析构函数

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;
}

我们现在看一下这个检测自我赋值, 谁会去做自我赋值呢 ? 应该是没有, 有很多时候是看起来不是自我赋值, 但本质是自我赋值, 因为指针的名称变动, 或者将来的继承

如果你没有写这两行, 结果甚至会出错, 不只是效率的问题

一开始左边和右边就是指向同一个, 那赋值动作第一个就是杀掉, 这一下就把唯一的这个杀掉了, 那下一个动作就没法进行了

所以写这个自我赋值, 不只是为了效率, 还有正确性