Skip to content

3. C++ 中的复合类型

By FunnyAWM

在 C++ 中,除了基本数据类型以外,还有一系列数据类型,它们本身是由基本数据类型组成,能够实现更复杂的功能。常用的复合类型有数组、结构体、共用体、枚举、指针等。

1. 数组

数组是 C++ 中最基础也是最重要的数据结构之一。数组利用一片连续的内存空间实现同种数据的规范化存储。我们用如下代码声明数组:

cpp
{数据类型} {数组名}[{长度}];

数组长度描述了一个数组能存储的最大数据量。例如,下面的代码声明了一个最多能存储 5 个整形的数组:

cpp
int values[5];

数组在声明时,长度必须是确定的值,且数组声明以后,长度便不能再改变。例如下面的代码:

cpp
int n = 5;
int values[n] = {0};
n = 10; // 这里不会再改变数组的大小

如果需要数组的大小可变,后续会说明如何创建这样的数组。

数组中存储的每一个单个数据被称为数组的元素。数组在访问时,下标应当从 0 开始。例如下面的代码访问了一个数组中的所有元素:

cpp
int values[2] = {0, 1};
cout << values[0] << " " << values[1] << endl;

代码的运行结果应该是:

text
0 1

如果数组在访问时不慎访问到了数组中不存在的区域,会发生什么呢?我们以下面的代码进行说明:

cpp
#include <iostream>

using std::cout;

int main() {
    int values[2] = {0, 1};
    for (int i = 0; i < 5;
         i++) { // 读取从values[0]到values[4]之间的值并输出
        cout << values[i]
             << "\n"; // 这个数组并没有values[2]到values[4]之间的值
    }
}

我们会发现,从 values [2] 开始,程序每次都会输出不可预测的值。这是因为虽然我们限定了 values 只有 2 的长度,但 C++ 语言本身不会限制数组访问的位置。在 Python 和 Java 等语言中,这样的访问都会抛出数组越界错误,但 C++ 比这些语言更加接近硬件,所以没有对这些行为进行自动检查或加以限制。这样的代码在一些系统下能够运行,在另一些下则不行,并且这些代码的行为也可能因为编译器的不同发生变动。这样的代码可能产生未定义行为。在洛谷有题上提交这样的代码的话,会得到一个红红的、大大的 RE

未定义行为:在 C++ 标准中没有说明,没有明确的执行结果的行为。这样的行为结果完全由编译器、操作系统、硬件平台决定,可能导致程序崩溃、安全漏洞或难以调试的错误。

上面我们仅仅演示了读取数组中不存在的区域的结果,如果对这些区域进行写入,又会发生什么呢?我们用下面的代码进行说明:

cpp
// 由于这里我们并没有获取输入与输出,所以不需要包含iostream头文件
int main() {
    int values[2] = {0, 1};
    values[10000] = 0;
}

程序运行结果如下:

text
Segmentation fault

我们使用 $?变量获取程序返回值:

bash
echo $?

程序的返回值是 139,说明程序触发了系统的 SIGSEGV 信号。这个信号说明程序不是正常退出的,操作系统内核出于防止数据损坏或系统不稳定的目的(或程序自身的栈内存被耗尽强制终止了这个程序。我们使用 valgrind 继续验证这个说法,输出如下:

text
orangepi@orangepi5pro:~/CLionProjects/expLinux/build$ valgrind ./my_program
==16318== Memcheck, a memory error detector
==16318== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==16318== Using Valgrind-3.19.0 and LibVEX; rerun with -h for copyright info
==16318== Command: ./my_program
==16318==
==16318== Invalid write of size 4
==16318==    at 0x108728: main (in /home/orangepi/CLionProjects/expLinux/build/my_program)
==16318==  Address 0x1fff0097c8 is not stack'd, malloc'd or (recently) free'd
==16318==
==16318==
==16318== Process terminating with default action of signal 11 (SIGSEGV)
==16318==  Access not within mapped region at address 0x1FFF0097C8
==16318==    at 0x108728: main (in /home/orangepi/CLionProjects/expLinux/build/my_program)
==16318==  If you believe this happened as a result of a stack
==16318==  overflow in your program's main thread (unlikely but
==16318==  possible), you can try to increase the size of the
==16318==  main thread stack using the --main-stacksize= flag.
==16318==  The main thread stack size used in this run was 8388608.
==16318==
==16318== HEAP SUMMARY:
==16318==     in use at exit: 0 bytes in 0 blocks
==16318==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==16318==
==16318== All heap blocks were freed -- no leaks are possible
==16318==
==16318== For lists of detected and suppressed errors, rerun with: -s
==16318== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Segmentation fault

我们注意到 valgrind 有如下输出:

text
==16318==  Access not within mapped region at address 0x1FFF0097C8
==16318==    at 0x108728: main (in /home/orangepi/CLionProjects/expLinux/build/my_program)

这里指出的问题正好是我们尝试访问那个不存在的元素的操作!为什么会这样?原因在于在程序运行时,操作系统内核会为程序划定一块内存块,程序所有对内存的操作都应在这个内存块中进行。当程序访问到不属于自己的内存块时,操作系统内核为了防止程序对其他内存块的写入导致数据损坏或系统不稳定,被迫强行终止了这个程序。这段代码在 Windows 下运行会返回 0xC0000005(违规访问意为程序访问了未被分配的内存。就算在读取的情况下,强行读取未被分配给程序的内存同样会触发访问越界导致程序异常终止。

访问越界:指程序访问(无论读或写)到了未被分配给自身的内存的行为,这样的行为会导致操作系统内核强行终止程序。

数组的元素也可以是数组,这样的数组被称为 N 维数组(N 代表数组嵌套的层数

嵌套:多次套入相同对象的行为。在这个例子中,嵌套表现为数组的元素本身也是数组。

我们可以用以下代码声明一个二维数组:

cpp
int values[{长度}][{长度}]

二维数组在记录一些非线性数据时十分有用。例如我们需要记录一张简单地图,用 0 代表可通过的空间,用 1 代表不能通过的墙,那么 x * y 的地图就可以声明为:

cpp
bool map[x][y];

2. 结构体

结构体允许我们将一些关联的数据放在一起管理。例如:

cpp
struct Point {
    float x;
    float y;
};

在这个例子中,我们创建了一个描述点的结构体,包含了这个点的 X 轴坐标与 Y 轴坐标。用下面的代码来声明一个点结构体变量:

cpp
Point p1;

这样我们便创建了一个点 p1。如果希望在结构体创建时指定初始值,只需要写:

cpp
Point p2{3, 4};

这样我们就初始化了一个点 p2,它的坐标是 (3, 4)。根据勾股定理,我们可以用下面的方式计算这个点与坐标原点之间的距离:

cpp
#include <cmath>
#include <iostream>

struct Point {
    float x;
    float y;
};

using std::cout;

int main() {
    Point p{3, 4};
    double distance = sqrt(pow(p.x, 2) + pow(p.y, 2));
    cout << distance << "\n";
}

这个程序最终会输出 5,也就是坐标为 (3, 4) 的点距离原点的距离。其中 cmath 头文件为我们提供了必要的函数,sqrt 函数用于计算传入参数的平方根,而 pow 函数用于计算指定数的幂。假设 pow 函数接收一个 x 与一个 y 参数,那么 pow 函数的计算结果就应为 xy。cmath 头文件中同时包含了 sin 函数、cos 函数等用于三角函数计算,以及更多用于数学计算的函数。

上面的代码同时说明了结构体中成员的访问方式:使用. 访问符进行访问。访问形式为:

text
[要访问的结构体变量].[要访问的成员变量]

与数组相同,结构体同样支持嵌套。我们可以在上面的 Point 结构体上进行扩展,例如:

cpp
struct Line {
    Point start;
    Point end;
}

上面的结构体描述了一条直线以及它的成员:用于确定这条直线的两个点。理论上,我们能够通过解方程的方法确定这条直线的表达式。

3. 共用体

共用体与结构体的声明方式类似:

cpp
union MyUnion {
    int i;
    char ch;
}

但共用体有一个限制:由于共用体变量的所有成员共享同一块内存空间,所以共用体在同一时间只能存储一个值。例如,我们创建一个 MyUnion 共用体,代码如下:

cpp
#include <iostream>

using std::cout;

union MyUnion {
    int x;
    char y;
};

int main() {
    MyUnion p{3};
    cout << p.x << "\n";
    p.y = 'a';
    cout << p.y << "\n";
    cout << p.x << "\n";
}

上面的程序输出如下:

text
3
a
97

其中 97 是字母 a(小写)的 ASCII 码,说明共用体的 x 被 y 覆盖。

共用体的好处在于:在一些内存紧张的场合(例如嵌入式开发场景下)能够更方便的编写代码,同时不用过于担心内存占用过多的情况。例如我们可以创建下面的一个共用体,这个共用体能够存储 UART 串口的数据:

cpp
union UART_Data {
    uint16_t tx;
    uint16_t rx;
};

上面的共用体中,tx 与 rx 分别表示串口中的 TX 线(发送)与 RX 线(接收)中的内容(这里假设 TX 线与 RX 线不会同时有数据待发送与接收这样我们只使用了 2 个字节的内存空间就能够复用同一内存区域来存储 TX 与 RX 线的内容。我们可以再声明一个结构体,包含一个额外的指示符标记共用体中存储的是 TX 线还是 RX 线的内容。不过在一般计算机程序中,共用体的使用场景有限,一般会选择使用结构体而不是共用体。

4. 枚举

枚举用于限定某些变量的取值范围。例如我们有一个字段需要用户填写性别,我们就可以用枚举来限定性别的范围:

cpp
enum Gender { MALE, FEMALE };

这样我们就可以使用枚举限定用户输入的性别范围。只需要创建这样的枚举变量:

cpp
Gender gender;

枚举有如下特点:

  • 除非进行强制转换,否则只能用枚举定义的值赋值给枚举;
  • 枚举只能使用赋值运算符;
  • 枚举能够转换为整形,转换结果是枚举值对应的序号,但整形无法转换为枚举。

5. 指针

接下来我们来到了全文最重磅的部分 —— 指针。如果只是解释指针本身,其实并不难理解。

1. 指针的声明、定义与使用

在现实生活中,我们要找一个人,通常需要知道以下信息:

  • 这个人叫什么;
  • 这个人在哪;
  • 这个人长什么样子。

对于计算机来说,查找变量需要的已知条件与找人类似。查找变量需要以下信息:

  • 变量的名称;
  • 变量在内存中的位置;
  • 变量的数据类型。

在通常情况下,我们会创建一个变量,然后为这个变量指定值,由程序自动分配和管理这个变量的地址。

相比之下,指针对于地址与值的查找恰好相反 —— 我们为指针指定地址,由程序自动根据地址访问这个指针指向的值。

我们用如下语句声明一个指针变量:

cpp
[数据类型] *[指针变量名]; // 写法1
[数据类型] *[指针变量名]; // 写法2

这两种写法其实在语法上完全等价,我们只需要选择一种自己习惯(或团队代码规范要求)的写法即可。之所以有这样两种不同的写法,是因为程序员们对 C++ 的指针有两种理解:对于习惯写法 1 的人来说,他们将指针看做基础类型的派生,所以 [数据类型]* 应该作为一个与原数据类型相关的类型存在;而对于习惯写法 2 的人来说,他们将指针看做一个变量,所以应该在变量名前加 * 来代表这是一个指针变量。

与其他任何变量的声明相同,指针在声明时,其中的内容由程序运行时内存中的内容决定。在使用指针前,应将指针初始化为一个确定的值。如果目前不打算将指针指向任意变量,应将指针设置为 nullptr,防止指针在未初始化时意外使用。可以用下面的方式来初始化尚不打算使用的指针:

cpp
int *ptr = nullptr;

这样的指针被称作空指针。

空指针:不指向任何有效对象的指针,这样的指针地址一般是 0 或 nullptr。

在实际使用中,我们通常将指针用于指向某个特定的变量。例如:

cpp
int i = 0;
int *ptr = &i;

其中 & 运算符称作取地址运算符。

取地址:由变量的名称得到这个变量在内存中的地址的过程。

我们可以通过下面的程序证明指针读取的是变量所在内存地址的值:

cpp
#include <iostream>

using std::cout;
using std::endl;

int main() {
    int i = 0;
    int *ptr = &i;
    cout << i << endl;
    cout << *ptr << endl;
    i = 5;
    cout << i << endl;
    cout << *ptr << endl;
}

程序的输出如下:

0
0
5
5

我们可以发现,在 ptr 指向 i 之后,*ptr 的值便与 i 完全相同,当 i 变动时,*ptr 的值也同步反映了 i 的修改。

如果我们直接输出 ptr 的值,会发生什么呢?我们将上面程序中输出 * ptr 部分中的 * ptr 改为 ptr,程序输出如下:

text
0
0x7fc4b312f4
5
0x7fc4b312f4

ptr 输出的这两个值代表什么呢?这是 ptr 中存储的内存地址的十六进制表示形式,它与 i 的内存地址相同。为什么在去掉 * 之后,指针的输出会变成这样呢?这是因为这个星号本身也是一个运算符,称作解引用运算符。

解引用:从变量的地址获取变量的值的过程。

我们能够通过下面的程序验证这个说法:

cpp
#include <iostream>

using std::cout;
using std::endl;

int main() {
    int i = 0;
    int *ptr = &i;
    cout << &i << endl;
    cout << ptr << endl;
}

程序输出如下:

text
0x7fd1884a74
0x7fd1884a74

我们发现,不管输出的值是什么,这两行输出的值始终相同,进一步验证了我们的说法。这个例子同时揭示了指针与常规变量的区别:常规变量中存储的是值,而指针本身存储的是内存地址的十六进制值;直接访问常规变量得到的是变量的值,而直接访问指针得到的是这个指针指向地址的十六进制值。因此我们可以认为,变量通过变量名来直接操作数据,而指针通过存储地址间接操作数据,而解引用是从地址访问值的关键步骤。

2. 自由内存空间

在上面对指针的讲解中,我们大量提到了内存与内存地址、内存中的值等。这些名词到底是什么意思?对我们设计 C++ 程序有什么意义?为什么对于指针来说内存地址如此重要?我们将在下面的内容中解答这些疑问。

类似汇编语言的抽象模型,内存可视为线性地址空间。我们可以在这里沿用这个概念,将内存空间想象为一个逻辑上连续的空间。在这个连续空间里,我们可以将内存按需划分成不同大小的区块,用来存储占用空间不同的各种变量。

理论上说,C++ 编译器允许划分的最小区块大小是 1 字节,也可以说 C++ 可寻址的最小内存单元大小是 1 字节。实际程序中,硬件会对内存做对齐限制,所以硬件上能分配的最小区块大小有可能会大于 1 字节。大部分情况下,现代操作系统(例如 Windows 与 Linux)通常使用 4KB 的页大小作为内存管理的基本单位,也就是说在操作系统层面,4KB 是系统为程序分配的最小内存大小,任何分配给程序的内存都必须是 4KB 的整数倍数。这样的限制主要是为了方便系统分配内存,减小内存碎片。

内存碎片:由于内存中的数据回收,内存变成空闲状态,但系统无法为这些内存分配任何适合空区大小的数据,内存碎片指的就是这些无法被利用的空闲内存。内存碎片分为外部碎片(页之间无法利用的空间)和内部碎片(程序在页内未用到的空间,这些空间也无法分配给其他程序

那么内存地址是什么呢?内存地址是获取这些内存块中内容的逻辑指示符。由于操作系统的 MMU(内存管理单元,Memory Management Unit)接管了从程序访问物理内存的所有请求,所以我们说内存地址是获取内存内容的逻辑指示符。

而为什么内存地址对指针来说如此重要呢?根本原因在于:指针中存储的内容,正是我们之前提过的由操作系统标记的内存逻辑地址。没有这个地址,程序就没有办法知道自己的数据到底存放在什么地方,指针也就失去了意义。同时,指针也依靠这样的连续逻辑空间来实现一些与数组相关的特性,将在后面详细讲解这些特性。

3. 指针与数组 —— 指向数组的指针与指针构成的数组

1. 指针构成的数组 —— 指针数组

与前面的任何基础数据类型和复合类型相同,我们同样可以为指针创建数组。例如:

cpp
int *ptr[5];

上面的代码定义了一个拥有 5 个元素的指针数组,其中每个元素都是独立的指针,可以各自指向独立的变量。这样的数组在我们后续要说明的动态内存分配中非常有用。

2. 指向数组的指针

如果我们希望一个指针指向数组,只需要这样写:

cpp
int values[5];
int *ptr_val = values;

我们注意到,在将指针指向数组时,我们并没有对数组取地址。为什么指针在指向常规变量时需要取地址,而数组不需要呢?原因在于 C++ 标准规定了数组名本身是指向数组第一个元素的指针。这种现象在 C++ 标准文档(C++98 标准的 §7.3.2 一节)中的描述如下:

An lvalue or rvalue of type “array of N T” or “array of unknown bound of T” can be converted to an rvalue of type “pointer to T.” The result is a pointer to the first element of the array.

上面的文档翻译如下:

类型为 “N 个 T 的数组” 或 “未知边界的 T 数组” 的左值或右值可转换为类型为 “指向 T 的指针” 的右值。结果是指向该数组首元素的指针。

这也就解释了为什么在将指针指向数组时不需要解引用 —— 数组在这里被转换成了指向首元素的指针。在 C++ 的新标准中,有一个名词描述这种现象 —— 数组退化。如果理解不了上面的内容,那么只需知道 —— 在 C++ 中,如果将数组赋值给了指针,那么指针就指向数组中第一个元素的地址。毕竟我自己刚开始看到这部分的时候也花了好长时间才理解

在指针指向数组时,指针本身也可以进行加减运算,结果为原地址加上或减去对应数据占用的内存后的地址。例如我们可以编写下面的代码:

cpp
#include <iostream>

using std::cout;
using std::endl;

int main() {
    int values[3] = {1, 2, 3};
    int *ptr = values;
    cout << "使用指针算术访问:\n";
    for (int i = 0; i < 3; i++) { // 意为输出数组中的3个元素
        cout << *(ptr + i)
             << endl; // 由于指针进行加减以后的结果是地址,所以需要解引用
    }
    cout << "使用下标访问:\n";
    for (int i = 0; i < 3; i++) { // 同上
        cout << ptr[i] << endl;
    }
}

程序输出如下:

text
使用指针算术访问:
1
2
3
使用下标访问:
1
2
3

为什么使用指针算术和使用下标的访问结果完全一样呢?使用下标访问的过程非常容易理解 —— 既然指针已经设置为指向了数组,那么它就应该能够像访问常规数组一样被访问,并且在 C++ 中,ptr [i] 也会自动被解析为 *(ptr + i);但是这里的 *(ptr + i) 又代表什么呢?这就是我们上面描述的指针算术运算结果。我们用下面的程序加以说明:

cpp
#include <iostream>

using std::cout;
using std::endl;

int main() {
    int values[3] = {1, 2, 3};
    int *ptr = values;
    for (int i = 0; i < 3; i++) { // 意为输出数组中的3个元素
        cout << ptr + i << endl;
    }
    cout << "该系统中int占用的大小(单位字节): " << sizeof(int) << endl;
}

程序输出如下:

text
0x7fc9b5f380
0x7fc9b5f384
0x7fc9b5f388
该系统中int占用的大小(单位字节): 4

我们注意到,这里面的 3 个值,每个值都等于前一个值 + 4 后的结果。那么一个 int 占用了多少内存呢?正好是 4 个字节!这样的程序结果进一步印证了我们的说法。

4. 指针与动态内存分配

在某些场景下,我们并不希望程序在编译时就提前定好所有的变量细节。例如我们现在有一个程序,其中有一个可选的配置文件。如果这个配置文件存在,我们就加载这个配置文件中的内容并完成配置;如果不存在,那我们就跳过这个步骤。

如果这个配置步骤中的变量占用的内存不大(比如 512K 或 1M我们当然可以为这个配置步骤预留内存。如果这个配置非常复杂,导致配置步骤中的变量内存占用很大(比如 32M 或 64M)或者这个配置文件中有非固定长度的变量(非固定长度的数组在配置文件中其实十分常见这个时候我们会更希望只在配置文件存在时分配必要的内存。

那么可能有人就要问了:主播主播,在 C++ 里有没有办法能实现这样让程序的内存占用 “随地大小变” 的方法呢?

有的兄弟,有的。

C++ 中定义了 new 与 delete 运算符来实现相关的特性,这样的特性被称作动态内存分配。动态内存分配允许程序在需要的内存大小不确定时根据不同运行环境的差距进行动态调整,以适配不同的场景。

动态内存又引出了一个问题:分配的内存应该交给谁管理呢?毕竟常规变量的内存是在声明时分配的,并且常规变量在分配内存以后,这个变量的内存地址就无法再改变,不能作为动态分配内存的管理者。

答案是指针。由于指针在声明时只为指针本身分配内存,并且指针本身在声明时不携带指向的数据。更棒的是,指针指向的内存地址是可以随时变动的,刚好可以作为动态分配内存的理想管理者。

我们用以下语句动态声明一个变量:

cpp
[数据类型] *[指针名] = new[数据类型];

例如下面的语句就可以在程序运行时声明一个新的 int 变量,并用指针管理:

cpp
int *valuePtr = new int;

new 同样可以用来在运行时声明数组。例如:

cpp
int *valuePtr2 = new int[3];

这样我们就可以通过使用前面介绍的方式使用这个动态分配的数组。

在使用完动态变量后,使用 delete 来释放使用后的内存。例如下面的代码释放了我们刚刚创建的 valuePtr 与 valuePtr2:

cpp
delete valuePtr;
delete[] valuePtr2; // 对于动态分配的数组,要使用delete[]

如果我们没有做这个释放的步骤,会发生什么呢?程序会帮我们自动清理这些被占用的内存吗?我们还能不能访问这些变量?答案是 —— 程序不会帮我们自动清理这些内存,并且这些变量将会随着指针被释放而无法访问,这些内存会直接泄露。

内存泄露:指在动态分配内存后没有释放,导致虽然无法显式访问这些变量,但这些变量仍然存在的现象。这些变量在程序运行期间无法被回收,直到程序退出。 内存:如果在这里漏出来的话,硬件生涯就要结束了罢…… 对了,那就用很大的声音掩盖过去罢(指风扇的声音)

为什么在指针释放以后我们再也没有办法访问这些变量了呢?原因在于在指针中,内存地址是唯一的访问动态分配内存的方式。当指针被释放时,访问这些变量的唯一方式也会消失,我们将彻底失去对这些内存的访问权限,但是这些内存也不会被操作系统自动回收,因为我们没有释放这些变量,程序也没有帮我们清理这些变量。因此操作系统认为这些内存块仍在使用,不会回收这些内存。

内存泄露的后果有时十分可怕。假设我们编写了下面的函数,这个函数读取用户自定义的密钥并返回:

cpp
string readKey(string filePath) {
    // 此处忽略文件读取逻辑
    string *key = new string;
    // 此处忽略将密钥存入变量的逻辑
    return *key;
}

由于存储密钥的数据对象仍然在内存中没有被回收,我们可以通过一些方式猜测这些数据在程序内存中的位置,进而使用冷启动攻击(例如运行时在程序中注入指定代码,这种攻击的成功率很高)或内存扫描(例如通过外接 DMA(直接内存访问,Direct Memory Access)硬件获取内存快照)的方式直接获取用户密钥是的,DMA 这东西不止能用来开挂)

5. 指针与函数

除了基础数据类型外,指针还可以用于指向函数。这样的写法通常用于指定回调函数。例如在嵌入式软件设计中,我们需要在硬件中断中引入一个 ISR(中断服务例程,Interrupt Service Routine)作为参数指定接收到该频道硬件中断时执行的操作,这时我们就可以用一个函数指针传入需要的中断例程函数,使这个函数在接收到指定频道的硬件中断时调用它。关于函数指针的内容将在讲解函数时展开讲解。

硬件中断:指一些事件(例如键盘输入)可以打断 CPU 执行原先正在运行的函数,处理事件后再返回继续执行原先的函数的机制。

DLC:智能指针

在传统的动态内存分配中,我们发现简单的动态内存分配存在许多问题。那么可能有人就要问了:主播主播,你的 new 和 delete 还是太吃操作了,有没有更无脑的写法?

有的兄弟,有的。

C++11 开始引入的智能指针为 C++ 的自动回收内存提供了可能。常用的智能指针包括 auto_ptr(在 C++17 中被弃用unique_ptr(在指针被回收时自动回收内存shared_ptr(在 unique_ptr 的基础上允许多个指针指向同一内存块)等,将在后续进行详细讲解。