复杂数据类型:结构与类

结构体

假如我们要存储 3 名同学的语文、数学、外语成绩,该怎么做呢?

要表明三个数据是相关的,我们可以将数据存放在一个数组里。然后,我们可以定义 3 个数组,每个数组有 3 个元素,分别是语文、数学、外语成绩。

int susan[3] = {89, 90, 91};
int jason[3] = {95, 89, 90};
int kevin[3] = {92, 93, 94};

但是,假如我们要存储 20 个学生的成绩,很显然不能这样定义了。这时,我们有两种思路:

一是定义 3 个大小为 20 的数组,分别存储 20 个学生的每门成绩:

int chinese[20];
int math[20];
int english[20];

或者,我们可以定义一个数组的数组,也称“二维数组”:

int student_scores[3][20];

假如我们要再加入一个项目,比如以 char 类型存储的学生的成绩等级,由于类型不同,我们不能将同一个学生数据放在一个数组里了,因为 C/C++ 要求数组中必须是相同的数据。因此,“定义数组的数组”的方法不再可行。不过我们仍然可以通过定义多个数组的方式来满足使用需求:

int chinese[20];
int math[20];
int english[20];
char grade[20];

不过我们也可以看到,定义多个数组的方式,在语义上是割裂的——同一个学生的数据实际上存储在不同数组中,只能通过下标联系起来——比如,Lucy 是 3 号学生,则她的成绩分别存储在 chineses[3]math[3]english[3] 中。

在 C/C++ 中,用户可以将若干数据组合在一起,定义自己的数据类型,称为“结构体(struct)”。下面,是在 C++ 中定义一个结构体的语句:

struct StudentScore {
    int chn;
    int math;
    int eng;
    char grade;
};

如此,我们便定义了 StudentScore 这种自定义数据类型,即“结构体”。结构体中的各个变量,称为成员(member)变量。

对于自定义数据类型的使用和普通变量一样,可以单独声明一个结构体变量,也可以声明结构体变量的数组。

StudentScore score;
StudentScore scores[20];

一个 int 占用 32 位空间,char 占用 8 位,因此我们的 StudentScore 会占用至少 3 × 32 + 8 = 104 bit 的空间。之所以说“至少”,是因为编译器可能将各个成员物理内存上的逻辑布局对齐(align)在整字节处,这样虽然会多占用一些空间,但是能够提高读写的效率。

我们可以通过 . 运算符(成员关系运算符),来访问结构中的成员(member):

scores[0].chn   = 89;  // Susan's Chinese score
scores[0].math  = 90;  // Susan's Math score
scores[0].eng   = 91;  // Susan's English score
scores[0].grade = 'A'; // Susan's grade

对于计算机来说,一个结构体,不过只是一个长度比较长的数据段(data segment),而我们人为规定了其中各部分将以何种方式来使用。这也正是我们之前说的,“变量的类型,决定了声明出的空间种具体存储什么类型的信息,以及以何种格式来读取信息”。

当我们想要在数据之上规定一些操作方法时,我们可以定义一个“类(class)”,当然,C++ 也允许我们在结构体中定义方法。两者从表面上看只有些微的不同(如成员默认对外的的可见性),但是类和结构体还是有概念上的区别。一般来说,单纯的“数据”或者“数据 + 方法”,我们就用“结构体”来实现;而当一个结构比较复杂,其中的数据之间有较为复杂的作用关系,相对于单纯的数据条目来说更像是一个小系统或者黑箱时,我们则会采用“类”来实现。

类(class)是一种对实体的描述与定义。一个类的实例(instance),也就是 class 类型的变量,叫做对象(object)。

语言比较难以解释清楚两者的不同,我们来看具体例子。比如我们刚刚定义的 StudentScore 类型,可以看出,它只是单纯的把数据组合在一起

现在假如我们需要加入计算总分的功能,可以声明并定义一个函数,访问并操作结构中的数据:

struct StudentScore {
    int chn;
    int math;
    int eng;
};

int calc_tot(StudentScore s) {
    return s.chn + s.math + s.eng;
}

使用时,调用 calc_tot 函数并向其中传入一个 StudentScore 类型的变量,可以完成对结构中三个数据的求和。

使用这种思路定义方法没有问题,但与 StudentScore 这个类型之间显得较为割裂。事实上,我们可以为结构声明成员函数。这样,这个方法就仅归该类所有,并且可以直接通过对象来调用。

struct StudentScore {
    int chn;
    int math;
    int eng;
    int calc_tot() { return chn + math + eng; }
};

直接通过关系运算符访问成员函数即可:

// 调用成员函数 `calc_tot` 计算结构体中三个成员的和
StudentScore s{90, 90, 90};
int tot = s.calc_tot(); // tot: 270

以上便是类的雏形。实际使用中的类一般更加封闭与精巧,其内部具有私有成员变量和方法,对外不可访问,通常只能通过一些接口来与外界进行交互。比如 std::string 类就是一个例子,用户完全不用关心其内部的数据构成与实现细节,只需要知道其对外接口的含义和用法即可使用。

#include <string>

int main() {
    std::string str = "Hello!";
    int size = str.size();  // size: 6
    str = str + str;        // str: "Hello!Hello!"
}

构造函数与析构函数

在创建一个类的对象时,除了分配空间外,还需要进行初始化,包括成员的赋值以及一些其他操作,比如动态获取内存。这个过程也称为对象的构造(construction)。

根据一些参数进行构造的函数就是构造函数(constructor)。一般来说,构造函数为在类中定义的成员函数,与类同名,不指定返回值。

有一种特殊的构造函数叫做拷贝构造函数,它以一个同类型的对象为参数进行构造。

同样,在类的生命期结束时,也需要进行一些收尾工作,即析构(deconstruction)操作,负责这些工作的就是析构函数(destructor)。

常见的析构操作如释放动态获取到的内存——类内部普通类型(trivial)成员占用的空间,如 intdouble 等,会在生命期结束时被回收,但额外申请的内存,也需要编程者额外进行释放操作。

浅拷贝与深拷贝

在 C/C++ 代码中对某一类型的变量声明,即意味着程序运行时所具有的一块相应的、固定大小的数据空间。在 C/C++ 中直接使用 = 进行赋值,即为将等号右边所对应的值(或变量所对应的这块空间中的数据),原样复制进等号左边变量所对应的存储空间中。

std::string 这样的类型,其中存储的字符串长度不等,理论上,可以为每一个字符串预先定义足够大的空间,但这样既不经济也不现实。事实上,字符串所需空间的大小应随着使用而改变,则就需要使用动态内存分配。

即,需要时,std::string 可以获取一块内存空间,并将这块外部的空间与这个类型关联起来;不需要时,则应将申请的空间归还。用户在类型中只需设置一个用来存储外部空间地址(以及属性)的相关成员变量,便能动态调整类型所能存储的数据容量了。

可见,对于这些使用了动态内存的对象,其复制则会变得复杂一些。默认情况下,直接使用 = 进行赋值,则两对象的数据完全一致,也就意味着二者内部记录的外部空间的地址值完全一致,修改其中一个对象的内容,另一个对象也会随之发生变化。如此,虽然通过这两个对象访问到的数据是相同的,但这个过程中并没有对所有的数据进行复制,仅复制了外部空间的地址等信息,故称为“浅拷贝(shallow copy)”。

如果希望两个对象具有独立的两份拷贝,即对其中一个的修改不会影响另一个,则应为另外一个对象重新分配一块空间,并将外部空间中的内容复制进新的空间。这样,两个对象中的内容虽然相同,但分别存储于不同的外部空间。这种拷贝称为“深拷贝(deep copy)”。

三法则

默认情况下,编译器会生成默认的拷贝构造函数与拷贝赋值运算符——其行为等于直接拷贝类内部的所有普通类型成员——以及一个空的析构函数。

但,编写 C/C++ 代码需要注意“三原则(The Rule of Three)”,即,如果用户自定义了如下函数中的任意一种,则也应该定义其他的两个函数:

  • 拷贝构造函数(以一个同类型的对象为参数的构造函数)
  • 拷贝赋值运算符(即右操作数为同类型对象的 = 赋值运算符)
  • 析构函数

如果类型使用了动态内存分配存储数据,一般需要定义析构函数完成资源的释放。同时,编程者也不得不考虑对象发生复制时的行为(浅拷贝或深拷贝),因此需要重新定义 = 运算符以及拷贝构造运算符。从另一个角度来说,如果要自定义拷贝行为,但为了保证资源能够被合理释放,编程者也需要考虑析构函数的设计。