在 C++ 中,由于存在指针与引用这类概念,便会产生这种类的实例之间复制时底层原理不同的问题。本文将通过编写一个 string 类来讲讲,到底什么是深复制、浅复制。

1. 引入

尝试阅读下面一段代码:

#include <iostream>
#include <cstring>
using namespace std;
class String { //模仿C++的string类
    char * str;
public:
    String(const char * s); //构造函数1:通过字符串常量来初始化
    String(); //构造函数2:默认构造函数
    ~String(); //析构函数
    void OutPut();
};
String::String(const char * s) {
    int len = strlen(s); //检查字符串的长度
    str = new char[len + 1]; //给字符串分配存储空间,并用指针指向
    strcpy(str, s); //把字符串常量复制到存储空间中
}
String::String() {
    int len = 4; //默认字符串长度为4
    str = new char[4]; //给字符串分配存储空间,并用指针指向
    strcpy(str, ""); //把字符串常量复制到存储空间中,默认为空
}
String::~String() {
    delete [] str; //删除曾给字符串分配的空间
}
void String::OutPut() {
    cout << str << endl; //输出字符串
}
int main() {
    String name("Huizhou University");
    name.OutPut();

    return 0;
}
运行结果:
Huizhou University

--------------------------------
Process exited after 0.6706 seconds with return value 0

这段代码演示了一个类似C++的string类的功能,当然,这只是一个简化版。接下来,我们通过这个程序来讲解今天的主题——深复制与浅复制

2. 发现问题

相信,学习C语言已久的你受够了必须要使用库函数 cstring 的 strcpy() 来复制字符数组的事实。前文的程序既然作为一个类似string的类,我们要求他能做许多事情。就如便捷地将一个字符串赋值给另一个字符串

就前文这个程序而言,能否实现这点呢?

我们来大胆地尝试一下,在 return 0; 之前添加了两行代码

#include <iostream>
#include <cstring>
using namespace std;
class String { //模仿C++的string类
    char * str;
public:
    String(const char * s); //构造函数1:通过字符串常量来初始化
    String(); //构造函数2:默认构造函数
    ~String(); //析构函数
    void OutPut();
};
String::String(const char * s) {
    int len = strlen(s); //检查字符串的长度
    str = new char[len + 1]; //给字符串分配存储空间,并用指针指向
    strcpy(str, s); //把字符串常量复制到存储空间中
}
String::String() {
    int len = 4; //默认字符串长度为4
    str = new char[4]; //给字符串分配存储空间,并用指针指向
    strcpy(str, ""); //把字符串常量复制到存储空间中,默认为空
}
String::~String() {
    delete [] str; //删除曾给字符串分配的空间
}
void String::OutPut() {
    cout << str << endl; //输出字符串
}
int main() {
    String name("Huizhou University");
    name.OutPut();
    //新添加的代码
    String name2(name);
    name2.OutPut();

    return 0;
}

若你执行编译,你可能会惊讶地发现它竟然能通过编译,但是问题却出现在了运行结束时:

运行结果:
Huizhou University
Huizhou University

--------------------------------
Process exited after 3.015 seconds with return value 3221226356

的确能够输出两行相同的字符串,但返回值告诉我们,程序并未正常地结束(因为其返回值并不是 0)。

为什么呢?

3. 分析问题

在解释为什么之前,我们先要了解一个概念:复制构造函数

既然名中有“构造函数”四字,说明它的确是一种构造函数,只不过它有点特殊而已。它的原型通常是:

ClassName(const ClassName & AnotherClass)

它与普通的构造函数一样,在每一个类中,也有默认的复制构造函数(只要你还未显式地定义它)。

默认复制构造函数的默认实现是这样的:

ClassName(const ClassName & AnotherClass) {
    member1 = AnotherClass.member1;
    member2 = AnotherClass.member2;
    member3 = AnotherClass.member3;
    //...
}

没错,它会默认地把 AnotherClass 中的所有成员变量 赋值给 ClassName 的所有成员变量。

回到前文的程序中,我们也用到了系统自动给我们实现的默认复制构造函数。它应是这样的:

String::String (const String & s) {
    str = s.str;
}

没错,你只是把一个 str 指针(所指向的地址)赋值过去了(这也是本文的主角之一:浅复制)。name2 中的 str 与 name 中的 str 指针都是指向同一个动态存储空间

通过前文知道,str 是一个指向通过运算符 new 创建的动态空间指针。在程序结束前,类会调用析构函数。而类析构函数中有运用运算符 delete 归还动态空间的行为。

而在函数 main 中,我们声明了两个类:name, name2,在这两个类调用析构函数时,它尝试把同一个空间归还了两次!这样,程序在第二次归还空间时,发现找不到这个空间。因为前一个空间已经被归还,从而导致运行时错误

这就是问题的根源所在。

4. 解决问题

我们如何避免这个错误呢?

很简单,只要在我们复制的时候,另外地创建一个新的空间来存储(这也是本文的另一个主角:深复制),那么当析构函数被调用时,程序便能分别将两个空间归还。如:

String(const String & s); //函数原型
String::String(const String & s) { //函数实现
    int len = strlen(s.str); //检查字符串的长度
    str = new char[len + 1]; //给字符串分配存储空间,并用指针指向
    strcpy(str, s.str); //把字符串常量复制到存储空间中
}

完整程序便是:

#include <iostream>
#include <cstring>
using namespace std;
class String { //模仿C++的string类
    char * str;
public:
    String(const char * s); //构造函数1:通过字符串常量来初始化
    String(); //构造函数2:默认构造函数
    ~String(); //析构函数
    void OutPut();
    String(const String & s);
};
String::String(const char * s) {
    int len = strlen(s); //检查字符串的长度
    str = new char[len + 1]; //给字符串分配存储空间,并用指针指向
    strcpy(str, s); //把字符串常量复制到存储空间中
}
String::String() {
    int len = 4; //默认字符串长度为4
    str = new char[4]; //给字符串分配存储空间,并用指针指向
    strcpy(str, ""); //把字符串常量复制到存储空间中,默认为空
}
String::~String() {
    delete [] str; //删除曾给字符串分配的空间
}
void String::OutPut() {
    cout << str << endl; //输出字符串
}
String::String(const String & s) { //函数实现
    int len = strlen(s.str); //检查字符串的长度
    str = new char[len + 1]; //给字符串分配存储空间,并用指针指向
    strcpy(str, s.str); //把字符串常量复制到存储空间中
}
int main() {
    String name("Huizhou University");
    name.OutPut();
    String name2(name);
    name2.OutPut();

    return 0;
}
运行结果:
Huizhou University
Huizhou University

--------------------------------
Process exited after 1.17 seconds with return value 0

至此,程序的确正常运行了。

5. 总结

本文通过编写一个 string 类来讲解了深复制与浅复制

  • 浅复制:对进行逐个成员的复制。若成员是一个值,则复制值。若成员是一个指针,则复制指针。
  • 深复制:对进行逐个成员的复制。若成员是一个值,则复制值。若成员是一个指针,则另外对该指针所指向的内容分配空间,并复制相应值。
  • 了解复制构造函数

  • 注意,当我们的类成员变量包含指针类型时,应利用运算符 new 及 delete 人工编写构造函数析构函数以及复制构造函数(其实还应编写重载赋值运算符,但其不在本文讨论范围)以避免发生内存错误。