xzh 发布的文章

C 语言内存安全问题的排查与思考

摘要:内存安全问题是 C/C++ 工程实践中最难定位的一类缺陷。与 Java、Go 这类带运行时检查的语言不同,C 语言在编译期不做数组边界检查,对未定义行为也不提供任何保证。一段存在越界写入、悬空指针或资源泄漏的代码,往往可以编译通过、通过功能测试,甚至在长期运行中表现稳定,然后在某个不确定的时刻以难以复现的方式失效。本文从一段命令行通讯录程序出发,梳理了它在文件读写、序列化存取、资源管理三个方面暴露出的 11 类缺陷,也一并讨论了链表宏实现本身带来的使用约束,逐一给出标准依据与实测证据,并进一步讨论了 AddressSanitizer 这类动态检测工具的能力边界与使用注意事项。

本文分为四个部分:第一部分介绍背景与基本概念;第二部分逐个剖析四类问题;第三部分讨论消毒剂工具的检测边界与一套可复用的排查方法;第四部分进行总结并列出实践中的常见误区。

说明:本文案例取自一个完整的命令行通讯录程序,出于篇幅考虑,文中只保留与主题相关的代码片段。文中所有结论均在 Linux 6.8 / gcc 11.4.0 / x86_64 环境下实测验证,输出原样引用。

1 背景

1.1 问题的来源

这份通讯录程序的功能很朴素:用双向链表保存联系人,支持增删查,并能把链表写进文本文件、再从文件读回。它通过了所有的功能性自测——每条记录都能正确插入、查找、删除,保存文件里确实有内容,读取之后链表也确实不为空。

但它同时存在 11 类缺陷,其中若干条会让功能直接失效。这些缺陷有一个共同点:编译器不报错(只有一处例外),sanitizer 也不一定报,而常规的功能测试完全覆盖不到。

促使我系统梳理这份代码的,是一次存取往返测试中发现的现象:把一条记录保存到文件、再读回来,电话号码的前面会多出一个 phone: 前缀;反复存取,前缀会不断累积。这个过程中没有任何越界、没有任何崩溃、没有触发任何 sanitizer——但数据已经错了。

由此引出一个值得展开的问题:在 C 语言里,"能跑"和"正确"之间的距离究竟在哪里?

1.2 为什么要专门讨论内存安全

C 语言把内存管理的责任完全交给了程序员。这既是它能在操作系统内核、嵌入式固件、高性能中间件等领域长期占据主导的原因,也是这些领域缺陷密度偏高的原因。具体来说,C 语言在以下三个方面不设保护:

  • 不做边界检查。 数组下标、指针运算、字符串写入,编译器都不验证是否落在对象范围内。
  • 不追踪对象生命周期。 对象被释放之后,指向它的指针不会失效,继续使用是未定义行为。
  • 不定义越界与未定义行为的后果。 C11 只声明这类行为"未定义",具体表现取决于编译器、优化级别、运行时内存布局,可能是静默的数据损坏,可能是崩溃,也可能看起来一切正常。

这三个特征叠加起来,使得内存安全缺陷的现场往往远离成因。一个被字符串覆写坏掉的链表指针,可能在几十次操作之后才被解引用,此时栈上早已没有留下任何有用的线索。

1.3 本文的组织

本文按缺陷的分布层次组织,从 I/O 边界,到持久化约定,再到资源管理,最后讨论检测工具的能力边界:

章节讨论范围缺陷数
第 3 章双向链表的宏实现(机制与使用约束)——
第 4 章文件读写循环与缓冲区边界5
第 5 章序列化存取不对称2
第 6 章返回值与内存所有权4

第 3 章讨论的是链表实现方式本身带来的使用约束(属于设计层面的注意事项,不计入缺陷清单),第 7 章讨论验证工具的能力边界与排查方法论,不单独对应缺陷。

2 基本概念

在展开具体问题之前,先统一定义本文反复使用的几个概念。

2.1 内存安全(Memory Safety)

内存安全指程序在运行期间只访问属于它的、并且仍然有效的内存区域。与之相对的是内存安全缺陷,常见形态包括:

  • 缓冲区溢出(buffer overflow):写入超出目标对象容量。
  • 释放后使用(use-after-free):对象释放之后继续通过指针访问。
  • 重复释放(double free):同一块内存被释放两次。
  • 内存泄漏(memory leak):对象失去所有引用但未被释放。

C11 未对上述行为定义语义,它们统一落入"未定义行为(undefined behavior)"的范畴。C11 3.4.3 对未定义行为的定义是:使用了一个不可移植或错误的程序构造,或错误的数据时,标准不施加任何要求。Annex J.2 中列举了大量属于未定义行为的情形,越界访问与解引用无效指针均在列。

2.2 数组容量与字符串长度

这是后面多个缺陷的根源,需要严格区分:

  • 数组容量是编译期确定的字节数量。char name[16] 的容量恒为 16 字节,不随内容变化。
  • 字符串长度是运行期由第一个空字符('\0')的位置决定的量。

C 语言的字符串处理函数(printf("%s")、strlen、strcpy)只信任终止空字符,不信任数组长度。这意味着写入方必须自行保证:在容量范围内为终止空字符留出一个字节。也就是——

数组的最大有效字符串长度 = 容量 − 1

NAME_LENGTH 定义为 16,可实际能容纳的姓名上限是 15 个字符。这个"减一"如果被遗忘,后果不是一个字符的截断,而是字符串失去终点:读取方会越过数组边界一直读下去。

2.3 对象边界与字段边界

这个区分在讨论检测工具时至关重要。

  • 对象指一次分配产生的内存块。栈上的一个局部变量、堆上的一次 malloc、一个结构体实例,都是独立的对象。
  • 字段指结构体内部的成员。

对下面这个结构体:

struct person {
    char name[16];      /* 偏移 0  */
    char phone[32];     /* 偏移 16 */
    struct person *next; /* 偏移 48 */
    struct person *prev; /* 偏移 56 */
};

sizeof(struct person) 为 64 字节(实测确认)。从 name 的第 17 个字节开始写入,在字段层面是越界——它踩坏了 phone;但在对象层面它仍在 64 字节范围之内。第 7 章会看到,这个差别直接决定了 AddressSanitizer 能否发现该缺陷。

2.4 所有权(Ownership)

所有权指"谁负责释放一块内存"这一约定。本文案例中涉及的所有权关系是:

对象分配点释放点(应有)
struct contactsmainmain 退出前
struct personinsert_entry / load_file链表所有者(struct contacts)统一释放
FILE *fopen同一函数内的 fclose

所有权一旦不明确,就会同时导致泄漏和重复释放。第 6.2 节会给出这两种情况的实测结果。

3 链表实现:宏带来的收益与代价

3.1 双向链表与头插法

链表的插入与删除被实现为两个宏:

#define LIST_INSERT(item, list) do {                        \
    (item)->prev = NULL;                                    \
    (item)->next = (list);                                  \
    if ((list) != NULL) (list)->prev = (item);              \
    (list) = (item);                                        \
} while(0)

#define LIST_REMOVE(item, list) do {                        \
    if (item->prev != NULL) item->prev->next = item->next;  \
    if (item->next != NULL) item->next->prev = item->prev;  \
    if (list == item) list = item->next;                    \
    item->prev = item->next = NULL;                         \
} while(0)

选择双向链表而非单链表,动机在删除操作上。删除一个已知节点时,单链表需要从头遍历找到它的前驱才能修改前驱的 next,时间复杂度 O(n);双向链表通过 item->prev 直接拿到前驱,可以做到 O(1)。通讯录这类以删除为高频操作的数据结构,这个差异是实质性的。

LIST_INSERT 采用的是头插法:(list) = (item); 让新节点成为链表头。头插法不需要维护尾指针,插入代价恒为 O(1)。但它的可见后果是——遍历输出顺序与插入顺序相反。实测印证:

head->id = 2, head->next->id = 1(头插法,顺序反过来)

LIST_REMOVE 完整覆盖了四种情形:被删节点位于表头(prev == NULL,跳过第一句,链表头改为 item->next)、位于表中(前后指针都需修改)、位于表尾(next == NULL,跳过第二句)、以及链表仅剩一个节点(两个条件均不成立,链表头置为 NULL)。

值得单独指出的是最后一句 item->prev = item->next = NULL;。它保证一旦发生"删除后再次访问"的错误,访问到的是空指针,而不是一个已经脱离链表、却仍指向原邻居的节点。后者会导致链表结构被意外重新连上,问题更难定位。不过需要明确:该宏只做指针摘除,不释放内存,free 由上层负责,这样职责划分才清晰。

3.2 为什么用宏,以及 do { } while(0) 的用途

用宏而不是函数的理由:函数参数是强类型的。要用函数实现通用的链表插入,只有两条路——为每种节点类型各写一个专用函数,或把参数退化为 void * 并额外传递成员偏移量。宏在预处理阶段做纯文本替换,不参与类型检查,因此同一套逻辑可以同时服务于 struct person、struct task、struct worker 等任意节点类型。这是用宏换取泛型能力。

do { } while(0) 的用途:这两个宏都是多语句宏。如果直接写成 { ... },下面的代码会出错:

if (cond)
    LIST_INSERT(p, head);   /* 展开为大括号块 */
else
    do_something();         /* 编译错误 */

展开后是 if (cond) { ... }; else ...,多出的分号提前结束了 if 语句,else 失去配对的 if。用 do { ... } while(0) 包裹,整体加上末尾分号后恰好构成一条完整语句,在任何上下文都不会被拆散。编译器在优化阶段会消除 while(0) 判断,不产生运行时开销。这是 C 语言中多语句宏的标准写法。

3.3 宏的固有代价:参数重复求值

宏参数在展开时是纯文本替换,参数名在宏体中出现几次,传入的表达式就会被求值几次。统计 LIST_INSERT 中 (list) 的出现位置:

  1. (item)->next = (list);
  2. if ((list) != NULL)
  3. (list)->prev = (item);
  4. (list) = (item);

共四处。若实参是普通变量名,读取四次不产生副作用;若实参是带副作用的表达式,问题立即显现。构造一个返回链表头地址并同时计数的函数,可以量化这一现象:

static int eval_count = 0;

static struct node **head_slot(void) {
    eval_count++;
    return &head;
}

LIST_INSERT(&a, *head_slot());

实测输出:

第 1 次插入:实参被求值 3 次
第 2 次插入:实参被求值 4 次

同一行调用,求值次数随运行状态变化。 原因在于第 2 和第 3 处位于同一个 if 内:链表为空时 (list) != NULL 为假,短路求值使第 3 处被跳过,因此是 3 次;链表非空时两处都要求值,变为 4 次。

这一实验的结论不是"宏不可用",而是一条使用约束:

宏的实参不应包含带副作用的表达式——自增、自减、函数调用、赋值都属于此类。

若必须在宏中保证单次求值,常见做法有二:改用 static inline 函数;或在 GNU C 中使用语句表达式 ({ ... }) 先将实参存入临时变量。

3.4 选型建议

方案泛型能力参数求值次数类型检查调试友好度
宏强(不区分类型)多次(取决于宏体)无差(调试器看不到宏内变量)
static inline 函数弱(需固定类型)一次有好
void * + 偏移量中一次弱一般
C11 _Generic中一次有一般

实践中的取舍是:若节点类型固定、且宏体较复杂,优先用 static inline 函数;只有在需要跨多种节点类型复用、且实参确无副作用时,才使用宏。

4 文件读写:循环条件与缓冲区边界

4.1 feof 的语义

读文件时最经典的一种写法:

while (!feof(fp)) {
    char buffer[BUFFER_LENGTH] = {0};
    fgets(buffer, BUFFER_LENGTH, fp);
    /* 处理 buffer */
}

C11 7.21.10.2 对 feof 的规定是:当且仅当流的文件结束指示器(end-of-file indicator)被设置时,返回非零值。而该指示器由一次失败的读取操作来设置。

因此 feof 是一个事后标志:它回答的是"上一次读取是否撞到了文件末尾",而不是"下一次读取是否会读到内容"。把它放在循环条件里,等价于用后视镜判断前方路况。

4.2 实测:2 行的文件跑了 3 轮

构造一个只有 2 行的数据文件,观察循环行为:

数据文件共 2 行:
    name: aa, phone: 111
    name: bb, phone: 222

第 1 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: aa, phone: 111
"
第 2 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: bb, phone: 222
"
第 3 轮: fgets 返回 NULL   strlen(buffer) = 0  buffer = ""

实际执行 3 轮,文件只有 2 行 —— 最后一轮读的是空内容。

时序是:进入第 3 轮 → fgets 尝试读取、失败、返回 NULL、同时设置文件结束指示器 → buffer 保持初始化时的全零状态 → 处理逻辑拿到一个空缓冲区。

4.3 为什么这个缺陷在测试中隐身

如果"多跑一轮"只是多处理一次空数据,它算不上严重问题。它真正值得警惕的地方在于会与另一个缺陷互相掩盖。

本例的解析函数开头有这样一句:

if (length < MIN_TOKEN_LENGTH) return -2;   /* MIN_TOKEN_LENGTH = 5 */

第 3 轮的 buffer 全零,strlen 得到 0,小于 5,函数返回 -2,调用方 continue 跳过。空轮被这道长度检查挡住了。数据文件中的空行(长度为 1)同样是被这一句跳过的。

于是两个缺陷叠加的结果是:程序看起来完全正常。但只要调整或删除这句长度检查,隐藏的空轮问题立刻现形。

这给出一条工程经验:不能仅凭"当前测试通过"判断某段代码可以删除。 一个防御性检查可能正在掩盖另一个缺陷,删除它之前需要先确认它拦截的到底是哪些输入。

4.4 正确写法

把读取本身作为循环条件,让循环次数由成功读取的次数决定:

while (fgets(buffer, BUFFER_LENGTH, fp) != NULL) {
    /* 处理 buffer */
}

fgets 的返回值既是控制条件,也是有效性证明,循环体只在真正读到内容时执行。这是一个通用模式,读取二进制数据时同理:

size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
    /* 处理 n 字节 */
}

4.5 无界读入:scanf("%s")

struct person *p = (struct person*)malloc(sizeof(struct person));
memset(p, 0, sizeof(struct person));

scanf("%s", p->name);       /* name 只有 16 字节 */

C11 7.21.6.2 对 %s 转换说明符的定义是:匹配一段非空白字符的最长序列,将其加上终止空字符写入对应参数指向的数组;标准同时要求"该数组必须足以容纳这段序列和终止空字符"。

也就是说,容量是否足够由调用方负责,scanf 不做任何检查。不写宽度限制,%s 会一直读到空白字符为止。

输入 100 个字符时的实测结果:

==ERROR: AddressSanitizer: heap-buffer-overflow
0x506000000060 is located 0 bytes to the right of 64-byte region [0x506000000020,0x506000000060)
    #1 ... in insert_entry contacts.c:186
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in scanf_common

被破坏的是 insert_entry 中 malloc 得到的 64 字节对象,写入位置正好在该对象右侧边界外 0 字节处。

修法是限定宽度,让 scanf 最多读取 容量 − 1 个字符:

scanf("%15s", p->name);     /* 16 字节数组,最多写 15 个字符 */

需要注意 %15s 中的 15 是字符数而非字节数,处理多字节编码时不能直接套用数组长度。更稳妥的做法是先读入一个足够大的缓冲区再截断:

char buf[256];
if (fgets(buf, sizeof(buf), stdin) != NULL) {
    buf[strcspn(buf, "\n")] = '\0';          /* 去掉换行符 */
    strncpy(p->name, buf, sizeof(p->name) - 1);
    p->name[sizeof(p->name) - 1] = '\0';     /* 兜底:确保终止 */
}

至少 fgets 的容量参数是显式写出的,不会遗漏。

4.6 手写解析器的两处边界错误

解析一行 name: liaoqf,telephone: 23456543789 的函数如下:

int parser_token(char *buffer, int length, char *name, char *phone) {
    if (buffer == NULL) return -1;
    if (length < MIN_TOKEN_LENGTH) return -2;

    int i = 0, j = 0, status = 0;
    for (i = 0; buffer[i] != ','; i++) {          /* 缺陷 1 */
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            name[j++] = buffer[i];                /* 缺陷 2 */
        }
    }

    status = 0;
    j = 0;
    for (; i < length; i++) {
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            phone[j++] = buffer[i];
        }
    }
    return 0;
}

缺陷 1:循环终止条件只认逗号。 for (i = 0; buffer[i] != ','; i++) 只检查字符是否为逗号。如果该行不含逗号,'\0' 不等于 ',',循环不会在字符串末尾停止,而是继续向后读取。

单独复现这一条需要一点技巧——构造一行既无逗号也无空格的数据,使写入路径保持安静,从而单独暴露读取问题:

==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1
    #0 ... in parser_token contacts.c:119
    #1 ... in load_file contacts.c:160
    [128, 256) 'buffer' (line 151) <== Memory access at offset 256 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:119 in parser_token

buffer 是 load_file 中的 char buffer[128]。报告显示访问发生在偏移 256 处,即越过整个 128 字节数组后又前进了 128 字节。

修法是把长度纳入条件:

for (i = 0; i < length && buffer[i] != ','; i++)

length 由调用方通过 strlen(buffer) 计算并传入,本身是正确的,问题只在于循环没有使用它。

缺陷 2:写入时不比较目标容量。 name[j++] = buffer[i]; 中 j 只增不减,也从未与 NAME_LENGTH 比较。调用方传入的缓冲区:

char name[NAME_LENGTH] = {0};    /* 16 字节 */
char phone[PHONE_LENGTH] = {0};  /* 32 字节 */

只要文件中某一行的姓名字段超过 15 字节,就会写穿这 16 字节。实测:

==ERROR: AddressSanitizer: stack-buffer-overflow
    #0 ... in parser_token contacts.c:123
    #1 ... in load_file contacts.c:160
    [32, 48) 'name' (line 157) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:123 in parser_token

这里有两点值得注意。

其一,两处缺陷存在先后关系。 写越界(第 123 行)在多数输入下先触发,把读越界(第 119 行)挡住。这解释了为什么 ASan 报告的往往不是第一个缺陷,而是执行顺序上最先到达的那一个——修完一处必须重跑,才能看到后面藏着的另一处。

其二,length 参数在这里帮不上忙。 它约束的是源缓冲区的长度,而缺陷在于目标缓冲区的容量,两者是独立的量,必须分别检查:

if (status == 1) {
    if (j >= NAME_LENGTH - 1) return -3;   /* 留一个字节给终止空字符 */
    name[j++] = buffer[i];
}

4.7 终止符缺失引发的连锁后果

越界写入最直观的后果是"覆盖了相邻变量",但实际影响往往更隐蔽。将 40 个 A 从标准输入喂入 insert_entry(p->name 仅 16 字节),ASan 未报告任何错误,而程序打印出:

name: AAAAAAAAAAAAAAAA13900001111,phone: 13900001111

对照正常输入(alice):

name: alice,phone: 13900001111

分析其成因。在 64 字节的结构体中,偏移 0–15 属于 name,16–47 属于 phone。scanf 从 name 起始处写入 40 个 A,随后写入终止空字符,于是:

  1. name 的 16 字节被完全填满,终止空字符落在 name 之外,进入 phone 所在区域;
  2. 接下来 scanf("%s", p->phone) 又把电话号码写入 phone,覆盖了那个终止符;
  3. 结果 name 数组中不含任何 0 字节。

由于 C 语言的字符串以终止空字符界定,printf("%s", item->name) 会从 name[0] 逐字节输出,直到遇见 0 字节为止——而 0 字节此时已经在 phone 内。因此它先输出 16 个 A,随后越过数组边界继续输出,把紧邻的 phone 内容一并印出。

这个案例把 2.2 节的结论具体化了:数组容量与字符串长度是两个不同的量,写入方必须保证容量范围内留有一个字节给终止空字符。 忘记"减一"的后果不是截断一个字符,而是让字符串失去终点。

4.8 缓冲区操作的检查清单

综合以上各点,凡涉及缓冲区写入的地方,建议逐项确认:

  • [ ] 目标缓冲区容量是否已知(sizeof(array) 或显式传入)?
  • [ ] 写入长度是否在写入前与容量比较,而不是写入之后?
  • [ ] 是否为终止空字符预留了位置(最大长度 = 容量 − 1)?
  • [ ] 源数据的长度与目标缓冲区的容量是否分别检查?
  • [ ] scanf("%s")、strcpy、sprintf、gets 这类无界接口是否已替换?
  • [ ] 循环中是否存在依赖输入内容的终止条件(如"直到遇见某字符")?是否已加上长度上界?

5 持久化:存取不对称

5.1 格式约定的隐性依赖

保存函数写出的是这样的格式:

fprintf(fp, "name: %s, phone: %s\n", item->name, item->phone);

注意逗号后面带有一个空格。

而解析函数的规则是:以逗号切分两段,然后在每一段中取第一个空格之后的全部内容作为值。

这条规则本身不难理解,问题在于它对格式极为敏感——"第一个空格"出现的位置,取决于逗号后面有没有空格。对仓库中三份数据文件的实测结果:

文件行格式实测解析结果
a.txtname: liaoqf,telephone: 23456543789liaoqf --> 23456543789(正确)
b.txtname: qiuxiang,telephone: 98765678123qiuxiang --> 98765678123(正确)
c.txtname: king, phone: 344567754king --> phone:344567754(错误)

a.txt / b.txt 中逗号紧跟字段名,第一个空格出现在字段名冒号之后,取到的值是纯数字;c.txt 中逗号后面先有一个空格,这个空格被当作字段名与值的分隔符,于是字段名 phone: 连同冒号一起被保留在值里。

差别只在一个空格,解析结果却完全不同——这说明格式约定存在隐性依赖,而这类依赖通常不会被写进文档。

5.2 实测:一次往返就损坏数据

把上面的格式问题与保存格式对应起来,结果就不只是"某份测试数据格式不对"了。保存时写出的是逗号加空格——恰恰是解析会出错的那一种。实测一次完整的存取往返:

保存进 rt.txt 的内容:
name: alice, phone: 13900001111

读回时解析结果:
file token : alice --> phone:13900001111

遍历输出:
name: alice,phone: phone:13900001111       ← 从文件读回的记录
name: alice,phone: 13900001111             ← 内存中原有的记录

每执行一次"保存再读取",电话号码前面就会多一个 phone: 前缀。 反复存取会不断累积。这个缺陷不会触发任何 sanitizer,因为从内存安全的角度看,代码一行越界都没有。

同时还可以看到两个相关问题:

  • 加载是追加语义。 load_file 将读到的节点逐个头插进链表,调用方已有的节点一个都不动。因此输出中 alice 出现两次——一次是内存中原有的,一次是从文件读入的。cts->count 也随之累加,对同一文件加载两次,记录数会翻倍。
  • 顺序被反转两次。 save_file 按链表顺序逐行写出,load_file 用头插法读回,两个方向相反,因此"保存 → 读取"之后链表顺序与原状态相反。

5.3 往返测试:拦截这类缺陷的唯一手段

这类缺陷不越界、不崩溃、不产生编译警告,动态检测工具完全无力。能守住它的只有"往返测试"——把序列化与反序列化作为一对操作来验证:

构造数据 → 序列化 → 清空内存 → 反序列化 → 逐字段比较

实现成本极低,却能覆盖一整类缺陷。需要注意的是,仅验证"保存后文件非空"和"读取后链表非空"是远远不够的:

/* 不足够的断言 */
assert(file_size > 0);
assert(list_is_empty() == false);

/* 有效的断言 */
save(list, path);
clear(list);
load(&list, path);
assert(list_count() == original_count);
for (i = 0; i < original_count; i++)
    assert(strcmp(list[i].name, original[i].name) == 0 &&
           strcmp(list[i].phone, original[i].phone) == 0);

只有比较内容本身,才能发现"值里多了个字段名前缀"这类错误。

6 资源管理:返回值与所有权

6.1 隐式返回值

int save_file(struct person *people, const char *filename) {
    FILE *fp = fopen(filename, "w");
    if (fp == NULL) return -1;
    /* ... */
    fclose(fp);
}                                    /* 函数结束,但没有 return 语句 */

本例中 save_file、print_entry、save_entry、load_entry 四个函数都是这种形态:声明返回 int,正常路径上却没有任何 return。

C11 6.9.1p12 规定:若执行到终结函数的 },且调用方使用了该次调用的返回值,则行为未定义。注意构成未定义行为需要同时满足两个条件——"到达函数末尾"与"调用方使用返回值"。当前 main 中:

case OPER_SAVE:
    save_entry(cts);        /* 返回值被丢弃 */
    break;

返回值确实未被使用,因此按标准字面,当前代码不构成未定义行为。但这是一个脆弱的巧合:只要有人将调用改为 if (save_entry(cts) != 0) { ... },未定义行为立即成立,而届时编译器未必还会警告——尤其是当该函数被编译在另一个翻译单元、-Wreturn-type 看不到函数体时。

好消息是,GCC 在 -Wall 下会直接指出(-Wreturn-type 属于 -Wall):

contacts.c: In function 'save_file':
contacts.c:111:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'print_entry':
contacts.c:218:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'save_entry':
contacts.c:274:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'load_entry':
contacts.c:284:1: warning: control reaches end of non-void function [-Wreturn-type]

四个函数的位置被逐行列出。也就是说这类问题在零成本的条件下就能被发现。之所以长期存在,只是因为警告没有被当作需要处理的问题。

由此得到一条实践建议:编译带上 -Wall -Wextra,并要求零警告。 警告数量一旦被允许存在,人就会习惯性地忽略它们,而忽略的代价是放弃了一层免费的静态检查。若希望更严格,可叠加 -Werror 将警告升级为错误,或使用 -fanalyzer(GCC 10 起提供)做过程间分析。

6.2 内存泄漏实测

main 退出前的清理只有一句:

exit:
    free(cts);
    return 0;

cts 是 malloc 得到的 struct contacts,释放它是正确的。但 cts->people 指向的整条链表——每个节点都由 insert_entry 单独 malloc——一个都没有被释放。用 LeakSanitizer 运行完整流程(插入一条记录后退出):

==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 64 byte(s) in 1 object(s) allocated from:
    #0 ... in __interceptor_malloc
    #1 ... in insert_entry contacts.c:186
    #2 ... in main contacts.c:317

SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).

泄漏 64 字节,正是 sizeof(struct person);分配点在 insert_entry 第 186 行。插入 N 条记录即泄漏 64 × N 字节。

6.3 释放顺序

正确的清理函数如下:

void contacts_destroy(struct contacts *cts) {
    struct person *item = cts->people;
    while (item != NULL) {
        struct person *next = item->next;   /* 先保存后继,再释放当前节点 */
        free(item);
        item = next;
    }
    cts->people = NULL;
    cts->count = 0;
}

next 必须在 free(item) 之前取出。 若写成:

free(item);
item = item->next;      /* 错误:释放后使用 */

就是典型的释放后使用(use-after-free)。free 之后那块内存的内容可能已被分配器改写,再读 item->next 得到的可能是任意地址。使用 -fsanitize=address 时,这类访问会以 heap-use-after-free 的形式报出,报告中会注明该区域"已被释放"。

此外,load_file 中还有一处错误路径泄漏:

struct person *p = (struct person*)malloc(sizeof(struct person));
if (p == NULL) return -2;      /* 直接返回,fp 没有 fclose */

分配失败时函数提前返回,fopen 得到的文件句柄永不关闭。修法是让失败路径统一在 fclose(fp) 之后返回,即每个函数只保留一个出口;或者用 goto cleanup 集中处理。

这一点容易被忽略,因为"分配失败"在测试中几乎不会出现——64 字节的分配失败通常意味着进程早已无法继续。但在内存受限的嵌入式环境中,这恰恰是最需要正确处理的分支。

7 消毒剂的能力边界

7.1 影子内存与红区机制

前文多处越界之所以能被发现,依靠的是 AddressSanitizer。其原理是:为每个内存对象两侧添加红区(redzone),并用一段影子内存(shadow memory)记录每个字节是否可访问;编译阶段在每次内存访问前插入检查,命中不可访问区域即报告并打印调用栈。

影子内存的粒度可以从 ASan 自身的输出中读出:

Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack right redzone:     f3
  Intra object redzone:    bb

一个影子字节对应 8 个应用字节。红区的分类中,f1 / f3 是理解检测边界的关键:红区加在栈变量之间,而不是加在结构体字段之间。

7.2 实测:同一个越界,两种结果

下面这段对比程序,在 struct person 的 name[16] 上做越界写入,分别放在结构体内部和独立局部数组中:

__attribute__((noinline))
static void write_into_struct(const char *src, int n) {
    struct person p;
    memset(&p, 0, sizeof(p));
    for (int i = 0; i < n; i++) p.name[i] = src[i];   /* A:溢出落在结构体内部 */
    printf("  [A] phone 字段读到: %.32s\n", p.phone);
}

__attribute__((noinline))
static void write_into_local(const char *src, int n) {
    char name[16];
    memset(name, 0, sizeof(name));
    for (int i = 0; i < n; i++) name[i] = src[i];     /* B:溢出踩到相邻栈对象 */
    printf("  [B] name 前 16 字节: %.16s\n", name);
}

以 -fsanitize=address 编译,改变写入长度,实测结果如下:

写入长度情形 A(写在 struct person 内部,对象共 64 字节)情形 B(写在独立的 char name[16] 之外)
17未报告,phone 被污染 1 字节报告 stack-buffer-overflow
32未报告报告
64未报告(刚好写满整个结构体)报告
80报告 stack-buffer-overflow报告

情形 A 在写入 32 字节时的完整输出:

情形 A:越界写在 struct person 内部
  [A] 写入 32 字节后,phone 字段读到: XXXXXXXXXXXXXXXX

16 个 X 恰好对应从 name 溢出的 16 个字节——数据已经写坏,ASan 却未报告。原因在于,从 ASan 的视角看,p 是一个 64 字节的对象,偏移 0 至 63 全部可访问;至于程序员认为 name 只应占前 16 字节,影子内存无从得知。只有当写入越过整个 64 字节对象时(情形 A 的 80 字节),红区才会被踩到。

7.3 这一边界对实际代码的影响

回到 4.5 节的代码:

struct person *p = (struct person*)malloc(sizeof(struct person));  /* 64 字节 */
scanf("%s", p->name);       /* 写入 17~64 字节之间:静默损坏 phone / next / prev */

输入长度在 17 到 64 之间时,越界发生在对象内部,ASan 不报告;只有超过 64 字节才会报 heap-buffer-overflow。而 next / prev 两个指针一旦被字符串覆写,链表结构会被破坏为任意值,之后任何一次遍历都是解引用野指针。这类缺陷的现场往往远离成因,定位难度极高。

这正是 ASan 最需要覆盖、却恰恰覆盖不到的区域。

能否让 ASan 也检测对象内部越界?可以,Clang 提供 -fsanitize-address-field-padding=<n>,在字段之间插入填充与红区。但这是 Clang 独有的选项:本文验证环境仅有 gcc 11.4.0,加上该参数直接报错——

gcc: error: unrecognized command-line option '-fsanitize-address-field-padding=2'

因此这一条未能在 GCC 上实测,只能从 ASan 输出中 Intra object redzone: bb 这一项确认该机制存在。若工程中使用 GCC 并希望覆盖对象内部越界,需依赖代码审查与显式的边界检查。

7.4 一个实践细节:泄漏检测会吞掉标准输出

用同一程序演示内存泄漏时,容易遇到一个会造成误判的现象。将程序的标准输出接入管道,输出会是完全空的:

$ printf "1\nalice\n13900001111\n2\n0\n" | ./contacts_asan 2>/dev/null
(无输出)

原因在 LeakSanitizer 的退出方式:检测到泄漏后,它走的是 _exit 一类不执行清理的终止路径,不会刷新 stdio 缓冲区。标准输出接终端时是行缓冲,每次换行即落盘;接入管道时变为全缓冲,内容仍留在缓冲区中,进程直接终止便一并丢失。

要看正常输出,显式关闭泄漏检测即可:

ASAN_OPTIONS=detect_leaks=0 ./contacts_asan

这不是程序的缺陷,但排查时若不了解,很容易把"输出为空"误判为"程序未运行"。

8 排查方法论

8.1 缺陷全景

将本文涉及的全部缺陷汇总如下,标注各自的可见性:

#问题编译警告ASan 捕获修法要点
1保存格式与读取规则不匹配否否统一格式,写往返测试
2加载采用追加而非替换否否明确语义,同步 count
3scanf("%s") 无宽度限制否仅越过整个对象时加宽度限制或改用 fgets
4解析循环终止条件只认逗号否是(越界读)加入长度上界
5解析写入不检查目标容量否对象内部不报源长度与目标容量分别检查
6while (!feof(fp)) 多跑一轮否否改用 while (fgets(...) != NULL)
7错误路径文件句柄泄漏否部分(LSan 可报)统一出口
8四个函数缺少 return是否编译零警告
9退出时未释放链表否是(LeakSanitizer)实现 destroy,注意释放顺序
10调用方不检查返回值否否检查并反馈
11循环内 fflush 增加 I/O 次数否否交给 fclose 统一刷新

可以看到,11 类缺陷中只有 1 类会产生编译警告,3 类能被 ASan 捕获。其余 7 类需要依靠代码审查、往返测试与边界检查清单来覆盖。

8.2 建议的排查流程

结合以上经验,对涉及内存操作与 I/O 的模块,可以采用如下流程:

  1. 零警告编译。 gcc -Wall -Wextra -O0 -g,要求零警告。这一步成本最低,收益最直接。
  2. 静态审查缓冲区。 按 4.8 节的清单逐项核对,重点是所有无界接口与依赖输入内容的循环条件。
  3. 接入 ASan 运行。 -fsanitize=address,跑通全部功能路径。注意它的检测边界(第 7 章):对象内部的越界不会报告,需要人工补位。
  4. 补写往返测试。 凡涉及序列化与反序列化的模块,必须验证"写出去再读回来,内容相等"。
  5. 检查所有权。 每个 malloc 都要能对应到一处 free,每个 fopen 都要能对应到一处 fclose(包括所有错误路径)。
  6. 逐个修复,逐个重跑。 ASan 通常只报告执行顺序上最先到达的缺陷——修完一处必须重跑,才能暴露后面隐藏的问题。

8.3 关于"缺陷互相掩盖"

第 4.3 节提到的现象值得单独强调:load_file 的长度检查(length < MIN_TOKEN_LENGTH)恰好掩盖了 feof 多跑一轮的后果,使缺陷在测试中隐身。

缺陷之间存在互相伪装的可能。 一份通过了全部测试的代码,未必是"没有缺陷的代码",也可能是"缺陷恰好被彼此抵消或掩盖的代码"。这也是为什么修复应当逐个进行、每改一处就重跑一次验证——批量修改会让新的问题难以归因。

9 总结

本文从一段功能上"完全正常"的通讯录程序出发,梳理了 11 类内存安全与资源管理缺陷,覆盖双向链表的宏实现、文件读写循环、缓冲区边界、序列化存取与内存所有权五个方面。主要结论如下:

第一,feof 是事后标志,不是预测。 C11 7.21.10.2 规定它查询的是文件结束指示器,而该指示器仅在一次失败的读取之后才被设置。任何形如 while (!feof(fp)) 的循环都会多执行一轮,正确做法是让读取操作的返回值充当循环条件。

第二,数组容量与字符串长度是两个独立的量。 容量决定可写入的字节数,字符串长度由终止空字符界定。写入方必须预留终止空字符的位置(最大长度 = 容量 − 1);读取方只信任那个 0 字节。忘记这一条,后果不是截断,而是字符串失去终点、越界读取。

第三,宏的参数会被求值多次。 实测同一个宏在链表为空时求值 3 次、非空时 4 次,差异来自宏体内 if 的短路求值。宏的实参不应包含带副作用的表达式。

第四,ASan 的检测边界是"对象"而非"字段"。 红区加在变量之间而不加在结构体字段之间,因此结构体内部的越界写入不会报告,只有越过整个对象才会。而越是容易破坏链表结构的越界(覆写 next / prev),越落在这个盲区内。GCC 不支持 Clang 的字段填充选项,无法用它在 GCC 上补齐。

第五,序列化必须成对设计,并配往返测试。 本例中 save 写出 ", ",load 却把逗号后的空格当成分隔符,每存取一次就给电话号码加上一个字段名前缀。这类缺陷不触发任何 sanitizer,只能靠"写出去再读回来、比较内容"的测试发现。

第六,编译警告应当清零。 四个缺少 return 的函数没有被人工发现,而 GCC 早已逐行指出。允许警告存在,等于放弃了一层免费的静态检查。

9.1 常见误区

实践中,有几个容易出现的认识偏差:

误区一:程序能跑通就说明没有内存问题。 未定义行为的特征恰恰是"可能看起来正常"。越界写入未必落在关键字段上,释放后使用未必立刻崩溃,泄漏在内存充足时完全无症状。

误区二:接入了 sanitizer 就万事大吉。 ASan 有明确的检测边界(第 7 章)。它覆盖不到对象内部的越界,也覆盖不到语义错误(如第 5 章的存取不对称)。工具是必要而非充分条件。

误区三:用增加检查的方式保证安全,不考虑检查本身的正确性。 本例中 if (length < MIN_TOKEN_LENGTH) return -2; 就属于这类"看似周全"的检查——它在挡住空行的同时,也掩盖了 feof 的缺陷。检查的语义必须与它要拦截的输入精确对应。

误区四:把编译警告当作噪声。 -Wall 报出的是标准明确要求提示的内容。长期忽略警告,等于让编译器这层免费的检查失效。

误区五:批量修复所有缺陷。 ASan 一次通常只报告最先到达的问题。批量修改会让验证结果无法归因,反而延长定位时间。逐个修复、逐个验证才是更快的路径。

参考文献

  1. ISO/IEC 9899:2011(C11)6.9.1p12 —— 函数返回值与到达函数末尾的未定义行为
  2. ISO/IEC 9899:2011(C11)7.21.6.2 —— fscanf 系列对 %s 转换说明符的容量要求
  3. ISO/IEC 9899:2011(C11)7.21.10.2 —— feof 函数语义
  4. ISO/IEC 9899:2011(C11)3.4.3、Annex J.2 —— 未定义行为的定义与情形列举
  5. AddressSanitizer 官方文档 —— 影子内存布局与红区类型
  6. GCC 手册 —— -Wreturn-type、-fsanitize=address、-fanalyzer

从竞态条件到双重释放:C 并发编程的几个关键问题

多线程编程里,真正难处理的往往不是"写不出来",而是写出来了、跑通了、然后在某个不确定的时刻崩溃。

本文讨论四个在实际代码中反复出现的核心问题:

  1. 竞态条件——count++ 为什么不是原子操作,以及四种同步方案的取舍
  2. 条件变量的正确用法——为什么等待必须包在 while 里,为什么执行阶段要放锁
  3. 内存所有权的归属——双重释放是怎么产生的,如何从设计上规避
  4. 运行期验证——如何用消毒剂在测试阶段捕捉上述问题

一、竞态条件:count++ 为什么不是原子操作

多线程对同一个变量做自增时,count++ 在高级语言里看是一条语句,编译成机器指令却是三条:

  1. 从内存读取 count 到寄存器
  2. 寄存器加一
  3. 把寄存器写回内存

这三步之间存在可被抢占的窗口。假设 count 当前为 5,线程 A 执行完第 1 步读到 5,此时发生线程切换;线程 B 完整执行三步,把 count 变成 6;切回 A,它寄存器里还是 5,加一后写回,count 依然是 6。

两次自增,结果只增加了 1。 这就是竞态条件(race condition)。

注意问题的根源是复合操作的不可分割性被破坏,而不是"读到了脏数据"。即使内存访问本身是原子的(现代 CPU 对对齐的机器字访问确实是原子的),自增这个复合操作仍然不是。

二、四种同步方案的取舍

方案实现未获取到时的行为适用场景
无同步(*pcount)++——仅用于验证竞态确实存在
互斥锁pthread_mutex_lock线程休眠,让出 CPU临界区长、竞争激烈
自旋锁pthread_spin_lock线程忙等,持续占用 CPU临界区极短,省下切换开销
CPU 原子指令lock; xaddl无锁,单条指令完成读改写单个变量的简单操作

互斥锁与自旋锁的分界线是"临界区长度"。互斥锁在拿不到锁时会把线程挂起,这涉及两次上下文切换(挂起、唤醒),每次切换的开销在微秒量级。如果临界区本身就只需要几十纳秒,那么切换的开销远大于等待本身——这种情况下自旋锁更划算,它让线程原地空转等待,避免了切换代价,代价是空转期间持续占用 CPU 周期。

原子指令方案则是另一个层次:它不依赖锁,而是让 CPU 直接提供一条不可分割的读-改-写指令。

int inc(int *value, int add) {
    int old;
    __asm__ volatile(
        "lock; xaddl %2, %1;"      /* lock 前缀保证多核下原子执行 */
        : "=a" (old)                /* 输出:旧值放入 eax */
        : "m" (*value), "a"(add)    /* 输入:内存操作数、加数放入 eax */
        : "cc", "memory"            /* 会修改标志寄存器与内存,阻止编译器重排 */
    );
    return old;
}

几个要点:

  • xaddl 的语义是交换后相加——先交换寄存器值和内存值,再把两者之和写回内存。返回值(old)是交换出来的旧值,这正是自增操作需要的语义。
  • lock 前缀不是"加锁"的意思,而是一个指令前缀,作用是让该指令在执行期间锁定对应的缓存行,其他核心无法同时访问同一地址。这是硬件层面的互斥,粒度远小于互斥锁。
  • 约束列表里的 "cc" 表示该指令会修改条件码寄存器(EFLAGS),"memory" 表示会读写内存。如果漏掉 "memory",编译器可能把其他内存访问重排到这条指令之前或之后,破坏原子性——这是内联汇编里最容易出错的地方。

三、线程池:条件变量的正确用法

线程池的本质是复用。每来一个任务就 pthread_create 有两个代价:创建本身需要分配内核对象和栈空间;任务量突增时线程数失控,CPU 大量时间耗在上下文切换而不是实际计算上。

固定大小的线程池用三个结构体撑起:

结构体作用挂在哪条链表
nTask待执行任务:函数指针 + 用户数据manager->tasks
nWorker工作线程:线程 ID + 终止标志manager->workers
nManager管理器:两条链表头 + 互斥锁 + 条件变量本身

任务队列和线程列表都用双向链表实现,LIST_INSERT 采用头插法,LIST_REMOVE 分别处理被删元素位于表头、表中、表尾以及链表只剩一个元素的情形。用宏而不是函数实现,是因为宏不区分类型,一套逻辑可以同时服务于两种节点。

条件变量的语义

条件变量(condition variable,pthread_cond_t)必须与互斥锁配合使用。它的核心 API 是:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

这个调用做三件事,且整体是原子的:

  1. 释放传入的互斥锁
  2. 让当前线程进入睡眠
  3. 被唤醒时重新获取该互斥锁,然后才返回

"释放锁"和"进入睡眠"必须是原子的,否则会有一个致命窗口:如果先释放锁、还没睡着,此时另一个线程插入任务并发出信号,这个信号就会丢失,而当前线程随后睡下去,永远等不到唤醒。

消费循环的三个细节

pthread_mutex_lock(&manager->mutex);
while (manager->tasks == NULL) {           /* 细节 1 */
    if (worker->terminate) break;
    pthread_cond_wait(&manager->cond, &manager->mutex);
}
if (worker->terminate) {                   /* 细节 2 */
    pthread_mutex_unlock(&manager->mutex);
    break;
}
struct nTask *task = manager->tasks;
LIST_REMOVE(task, manager->tasks);
pthread_mutex_unlock(&manager->mutex);     /* 细节 3 */
task->task_func(task);

细节 1:用 while 而不是 if。 pthread_cond_wait 返回不代表条件一定成立。原因有两类:一是 pthread_cond_broadcast 会唤醒所有等待者,但其中只有一个能抢到任务;二是 POSIX 标准明确允许虚假唤醒(spurious wakeup)——即使没有任何线程发出信号,等待也可能返回。因此被唤醒后必须重新检查条件,if 只检查一次,会漏掉这两种情况。

细节 2:终止检查要在两处都有。 内层循环里的检查处理"线程在等待中被唤醒"的路径;循环外的检查处理"队列非空、直接跳过内层循环"的路径。少任何一处都有线程退不出去或提前退出的问题。

细节 3:执行任务时不持锁。 这是整个设计中最重要的性能决定。task_func 是用户代码,执行时间不可预知。如果在锁内调用,所有工作线程都会被串行化,线程池退化成"单线程加一个队列",完全失去并发意义。

正确的做法是:持锁期间只做"取出任务并摘链"这个 O(1) 的链表操作,拿到任务指针后立即放锁,再执行。

四、内存所有权:双重释放的成因与规避

这个问题在一次线程池销毁逻辑的修正中暴露出来。

原始代码在回调函数结尾有一句 free(worker);——线程退出时释放自己的结构体。而销毁流程缺了 pthread_join 和资源释放,属于资源泄漏。看起来"补上 join 和 free"就能修好,但这样做会踩进一个更严重的坑:

同一个指针被释放两次。

线程退出时 free 一次,销毁流程再 free 一次,构成双重释放(double free)。这是 C 语言中的未定义行为,实际表现通常是堆管理器的内部链表被破坏,然后在完全无关的代码位置崩溃——排查成本极高,因为崩溃点不是案发现场。

问题的根源不是"忘记加判断",而是所有权(ownership)没有定义清楚:这块内存该由谁负责回收?写代码时如果没有明确回答这个问题,释放逻辑迟早会出现重复或遗漏。

可行的所有权模型主要有两种:

模型 A:生产者/管理者持有。 由创建方统一负责释放,使用者只借用。

模型 B:自持有。 谁创建谁释放,或者对象自行负责生命周期。

线程池这里选择模型 A——worker 结构体的所有权归线程池管理器:

  • 回调函数不再自释放,改为 return NULL;
  • 销毁流程统一负责回收

确定所有权之后,销毁顺序也被约束了,每一步的前后关系都不能调换:

置终止标志 → 广播唤醒 → 逐个 join → free(worker) → 释放残留任务 → 销毁同步原语
  • join 必须在 free 之前:线程还在运行时,它的结构体不能被回收,否则线程会访问已释放的内存。
  • terminate 的写入必须在持锁状态下:否则与工作线程读取该标志构成数据竞争。虽然 int 的读写在多数平台上是原子的,但 C 语言的内存模型并不保证这一点,编译器的重排和 CPU 的乱序执行都可能让另一个线程读到意外值。

五、运行验证:用消毒剂捕捉并发缺陷

代码"看起来对"和"确实对"之间差距很大,并发场景下尤其明显——很多缺陷只在特定时序下出现,低负载测试根本触发不到。

GCC 和 Clang 内置的消毒剂(sanitizer)可以在运行时捕捉这类问题,代价只是重新编译一次。

AddressSanitizer:内存错误

gcc -fsanitize=address -g -pthread program.c -o program_asan

ASan 的原理是在每次内存分配的周围插入不可访问的"红区"(redzone),并记录每个内存块的分配与释放状态。一旦发生越界访问、访问已释放内存、重复释放,它会立刻中止程序并打印完整的调用栈。程序退出时若存在内存泄漏,也会给出泄漏点。

用 ASan 验证线程池的销毁逻辑,可以直接确认双重释放是否真的被消除。

ThreadSanitizer:数据竞争

gcc -fsanitize=thread -g -pthread program.c -o program_tsan

TSan 通过记录每个内存访问的线程标识和同步顺序,检测多个线程未经同步地访问同一内存、且至少有一个是写操作的情况。它会输出冲突双方的调用栈以及涉及的内存地址。

一个环境兼容问题

TSan 在部分系统上会直接启动失败:

FATAL: ThreadSanitizer: unexpected memory mapping 0x58de4eb36000-0x58de4eb37000

这不是代码缺陷,而是 TSan 对进程的内存布局有特定要求,与系统的 ASLR(Address Space Layout Randomization,地址空间布局随机化)冲突。解决办法是关闭 ASLR 后运行:

setarch $(uname -m) -R ./program_tsan

-R 表示禁用地址随机化,只对当前进程生效,不影响系统全局设置。

两项检查全部无报告,才能说明这段并发代码通过了内存安全和数据竞争的基本检验。

六、两个容易被忽略的工程细节

换行符的跨平台差异

三种系统对"换行"的定义不同:

名称字节序列使用系统
LF\n(1 字节)Unix / Linux / macOS
CRLF\r\n(2 字节)Windows
CR\r早期 Mac,已淘汰

混用的后果是:Git 会认为整个文件的每一行都被修改过,因为每个换行符的字节序列都不同,diff 显示为全文重写。

处理办法是配置 core.autocrlf:

git config --global core.autocrlf true

它在两个方向上自动转换:提交时把工作区的 CRLF 转成 LF 存入仓库,检出时把仓库的 LF 转成 CRLF 写回工作区。结果是仓库里永远只存 LF,换行符差异被完全隔离在本地。

提交时偶尔会看到警告 LF will be replaced by CRLF the next time Git touches it,这是提示而非错误,说明下次检出时会执行转换。

二进制文件与版本控制

编译产物不应纳入版本管理——它们体积大、每次重编译都产生巨大差异、且可以从源码重新生成。

.gitignore 的常规写法能覆盖大部分情况:

build/
bin/
*.o
*.obj
*.exe
*.so

但有一个特例:Linux 下的可执行文件通常不带扩展名。gcc lock.c -o lock 生成的文件就叫 lock,无法用 *.xxx 匹配,必须按路径显式列出:

# Linux 可执行文件没有扩展名,无法用通配符匹配
/01-concurrent-lock/lock

开头的斜杠表示路径从仓库根目录算起,避免误伤其他同名文件。这也意味着每新增一个可执行文件都要检查是否需要补一行——相比之下,把编译输出统一约定到被忽略的 bin/ 目录下更省心。

七、小结

几个可以直接带走的结论:

  1. 竞态条件的根源是复合操作不可分割性被破坏,而不只是"内存访问不同步"。解决思路要么是让操作变得原子(lock 前缀),要么是让临界区互斥(锁)。
  2. pthread_cond_wait 必须包在 while 里,因为存在广播唤醒和虚假唤醒两种情况。
  3. 持锁期间只做必要的临界操作——线程池在锁内只摘链,执行任务时放锁。
  4. 写释放逻辑之前先明确所有权归属,否则双重释放和内存泄漏迟早出现。
  5. join 永远在 free 之前,销毁顺序由所有权模型决定,不能随意调整。
  6. 并发代码要用 ASan 和 TSan 验证,而不是"跑一遍没崩"就认为没问题。

C/C++ 数组:内存布局、指针退化与二维数组的本质

一份从 Java 数组讲义迁移过来时最容易踩空的清单。文中所有示例均在
Linux 6.8 / gcc 11.4.0(x86-64)上实际编译运行,输出为真实结果。
编译命令统一为 gcc -std=c11 -Wall -Wextra。

引子

Java 里 int[] arr 是一个对象:它有类型、有 length 字段、越界会抛异常、不初始化会自动填零。

C 里 int a[5] 只是一块连续内存。它没有长度字段、越界不会报错、局部变量不初始化就是一堆垃圾数据。C 把"边界与初始化的责任"全部交给了程序员。

下面这句话是理解全部差异的起点:凡是 Java 用语言机制替你保证的,C 都换成了两种东西——未定义行为,或者要求你自己记账。


一、数组的本质:C 里没有"数组对象"

1.1 数组类型

C 标准对数组类型(array type)的定义是(C11 §6.2.5p20):

元素类型确定、元素个数确定,且连续分配的非空对象集合。

拆开看三个要点:

  • 元素类型确定:所有元素类型完全一致,这一点与 Java 相同。
  • 元素个数确定:长度在编译期或运行期确定后不可更改,没有 push、没有扩容。
  • 连续分配:元素在内存里紧挨着排布,中间没有任何空隙或元数据。这是 C 数组全部性能优势的来源,也是它全部安全隐患的来源。

1.2 数组名退化

数组名退化(array-to-pointer conversion,数组到指针的转换)是 C 里最重要的隐式转换规则(C11 §6.3.2.1p3):

除作为 sizeof 运算符的操作数、_Alignof 运算符的操作数、一元 & 运算符的操作数,或用于初始化数组的字符串字面量外,类型为"数组类型"的表达式会被转换为"指向该数组首元素的指针"。

这条规则的含义是:在绝大多数表达式里,a 这个名字根本不是数组,而是一个 int *。所以 C 里没有 a.length —— 名字已经退化成一个纯粹的首地址,长度信息在这个转换中丢失了。

1.3 对照表

维度JavaC
数组是什么堆上的对象,含长度字段一块连续内存
获取长度arr.length(字段)sizeof(a)/sizeof(a[0]),且只在未退化时有效
越界访问抛 ArrayIndexOutOfBoundsException未定义行为,不报错
默认值一定被填成 0 / 0.0 / false / null只有静态存储期才保证为零
能否变长不能,但可重新赋新数组不能,只能重新申请
传参传引用,长度随之传递只传首地址,长度丢失
元素交换直接交换需要临时变量,跨函数需传指针

二、初始化

2.1 静态初始化

C 的静态初始化指元素值在写代码时就已确定,长度可由初始化列表自动推导:

#include <stdio.h>

int main(void)
{
    int a[3] = {12, 24, 36};       /* 显式长度 */
    int b[]  = {12, 24, 36};       /* 长度由元素个数推出,等于 3 */
    int c[3] = {12, 24, 36};

    for (int i = 0; i < 3; ++i)
        printf("a[%d]=%d  b[%d]=%d  c[%d]=%d\n", i, a[i], i, b[i], i, c[i]);

    return 0;
}

有两点和 Java 不同:

第一,C 没有 [] 与 {} 混用的写法。 Java 里 new int[]{1,2,3} 是合法的,C 里 int a[3] = {1,2,3} 的等号右侧只能是初始化列表,不能带 new 也不能带长度。写 int a[3] {1,2,3} 或 int a[3] = new int[3]{1,2,3} 一律编译错误。

第二,C 允许"部分初始化",未给出的元素被补零(C11 §6.7.9p21)。实测:

#include <stdio.h>

int global_a[5];          /* 静态存储期:未显式初始化即为零 */

int main(void)
{
    int part[5]  = {1, 2};                 /* 只给前两个 */
    int desig[5] = {[2] = 30, [4] = 50};   /* C99 指定初始化器 */

    printf("global_a :");
    for (int i = 0; i < 5; ++i) printf(" %d", global_a[i]);
    putchar('\n');

    printf("part     :");
    for (int i = 0; i < 5; ++i) printf(" %d", part[i]);
    putchar('\n');

    printf("desig    :");
    for (int i = 0; i < 5; ++i) printf(" %d", desig[i]);
    putchar('\n');

    return 0;
}

实际输出:

global_a : 0 0 0 0 0
part     : 1 2 0 0 0
desig    : 0 0 30 0 50

第三行用到的指定初始化器(designated initializer)是 C99 引入的语法,[2] = 30 表示"下标 2 的元素是 30"。这在初始化稀疏数组时比逐项写零清晰得多。注意:C++ 直到 C++20 才支持这个语法,所以写 .cpp 文件时用不了(GCC 会把它当作扩展放行,但那不可移植)。

2.2 动态大小:Java 的 new int[8] 在 C 里对应什么

Java 的动态初始化 new int[8] 语义是"在堆上分配 8 个 int,全部填零"。C 里没有 new,按场景有三条路:

路径 A:栈上固定大小(最简单)

double scores[8];   /* 8 个 double,在栈上,内容是垃圾数据 */

编译期长度就确定了。缺点是长度写死在源码里,且栈空间有限(Linux 默认 8 MB),大数组会栈溢出。

路径 B:变长数组 VLA(C99,有风险)

int n = 8;
int a[n];           /* 长度在运行期确定,仍然分配在栈上 */

变长数组(Variable Length Array,VLA)是 C99 引入、C11 改为"可选特性"、C++ 从未采纳的语法。三个必须知道的风险:

  1. 不能处理分配失败。栈溢出的表现是段错误,你没有任何机会做错误处理。
  2. 不能是 0 或负数,否则是未定义行为。
  3. C++ 不支持,写 .cpp 编译不过;C11 下也取决于编译器是否实现该可选特性。

路径 C:堆分配(工程上推荐)

#include <stdlib.h>

double *scores = malloc(sizeof(double) * n);
if (scores == NULL) {           /* malloc 可能失败,必须检查 */
    perror("malloc");
    return 1;
}
/* ... 使用 ... */
free(scores);                   /* 必须配对释放 */

malloc 从堆申请内存,成功返回首地址、失败返回 NULL。需要注意:

  • malloc 不初始化内存,内容是不确定值;
  • calloc(n, sizeof(double)) 会把内存清零,语义上更接近 Java 的 new;
  • 用完后必须 free,否则内存泄漏;
  • sizeof(double) * n 里要把 n 转成 size_t 避免有符号/无符号混算,写成 sizeof(double) * (size_t)n 更稳妥。

2.3 最容易致命的一条:不初始化 ≠ 零

Java 保证数组元素一定有默认值。C 完全不保证,这取决于存储期(storage duration)——一个对象从分配到释放的时间区间,以及这段时间内它的存储是否被隐式初始化。

存储期例子是否隐式初始化
静态存储期全局数组、static 数组是,全部为零
自动存储期函数内 int a[5];否,内容不确定
动态存储期malloc 得到的空间否;calloc 才清零

"未初始化的自动存储期数组,其内容为不确定值(indeterminate value),读取它是未定义行为(undefined behavior)"——这是 C11 §6.2.4p5 与 §6.7.9p10 的直接结论。

未定义行为的定义是(C11 §3.4.3):本国际标准对程序的行为不作任何要求。编译器可以假定它永远不会发生,因此可以据此做优化;一旦真的发生,程序的运行结果不可预测——可能输出垃圾、可能崩溃、也可能碰巧"看起来正常"。

实测对照(这段代码刻意不读 local_a,只取地址):

#include <stdio.h>

int global_a[5];            /* 静态存储期:自动零初始化 */
static int static_a[8];     /* 同上 */

int main(void)
{
    int part[5]  = {1, 2};                 /* 其余补零 */
    int desig[5] = {[2] = 30, [4] = 50};
    int local_a[5];                        /* 自动存储期且未初始化 */

    printf("global_a (全局,未显式初始化):");
    for (int i = 0; i < 5; ++i) printf(" %d", global_a[i]);
    putchar('\n');

    printf("static_a (静态,未显式初始化):");
    for (int i = 0; i < 8; ++i) printf(" %d", static_a[i]);
    putchar('\n');

    printf("local_a  (局部,未初始化)地址 = %p,内容不确定,此处不读取\n",
           (void *)local_a);

    return 0;
}

输出:

global_a (全局,未显式初始化): 0 0 0 0 0
static_a (静态,未显式初始化): 0 0 0 0 0 0 0 0
local_a  (局部,未初始化)地址 = 0x7ffe73b85530,内容不确定,此处不读取

工程建议:局部数组一律显式初始化。最省事的两种写法:

int a[5] = {0};                        /* 整个数组全零,常用惯例 */
double buf[64];
memset(buf, 0, sizeof(buf));           /* 需要 <string.h>,适合大数组 */

三、元素访问、越界与长度

3.1 a[i] 的真实含义是 *(a + i)

C 标准对下标运算符的定义(C11 §6.5.2.1p2):

表达式 E1[E2] 等价于 (*((E1) + (E2)))。

也就是说,a[2] 在形式上是"先做指针加法 a + 2 得到地址,再解引用"。指针加法不是地址数值加 2,而是加 2 个元素——加法的结果地址 = 首地址 + 2 * sizeof(int)。

这个定义有个著名推论:由于加法满足交换律,a[i] 和 i[a] 完全等价。下面两行都合法且含义相同:

int a[3] = {10, 20, 30};
printf("%d\n", a[2]);    /* 30 */
printf("%d\n", 2[a]);    /* 30,同样合法 */

写 2[a] 没有任何实用价值,但理解它证明了"下标只是加法的语法糖",能帮你把数组和指针彻底统一起来。

另外,a[i] 还能出现在赋值号左边(a[0] = 1;)。能出现在赋值号左边的表达式叫左值(lvalue),a[i] 作为解引用表达式正是左值。

3.2 越界:从异常到未定义行为

这是从 Java 迁移到 C 时最危险的一处差异。

Java 的越界检查由语言保证:任何 arr[i] 在访问前都要验证 0 <= i < arr.length,不满足就抛 ArrayIndexOutOfBoundsException。

C 不做任何检查。数组类型里根本不存长度,编译器没见过 a 的边界信息,所以无从检查。越界访问是未定义行为。

下面这段代码,越界写了一格:

#include <stdio.h>

int main(void)
{
    int a[5] = {1, 2, 3, 4, 5};
    int victim = 0x61616161;          /* 'aaaa',一旦被改写一眼可见 */

    a[5] = 0x62626262;                /* 合法下标只有 0..4 */

    printf("程序照常跑完了,没有任何报错\n");
    printf("victim = 0x%x\n", (unsigned)victim);

    return 0;
}

第一步,编译。 gcc -Wall -Wextra -O0 与 -O2 都没有给出任何越界警告:

t6_oob.c: In function ‘main’:
t6_oob.c:5:9: warning: variable ‘a’ set but not used [-Wunused-but-set-variable]

唯一的警告是"变量 a 赋值后没有被使用"——与越界毫无关系。注意:开启优化确实能让编译器发现一部分越界(-Warray-bounds),但那只在它能静态推出下标范围时生效,不能指望它兜底。

第二步,运行。 输出是:

程序照常跑完了,没有任何报错
victim = 0x61616161

程序正常退出,victim 甚至没有被改写——越界写落在栈上的填充区里,没有任何可见症状。这就是 C 数组越界最典型的形态:它经常不出声。今天没症状,不代表明天换一个编译器版本、换一个优化等级、多一个局部变量之后,它不会去改写别的关键数据。

第三步,用 AddressSanitizer 抓它。 加一个编译选项重新编译:

gcc -std=c11 -Wall -Wextra -g -fsanitize=address t6_oob.c -o t6_asan
./t6_asan

输出(节选):

==997100==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc717b2c54 at pc 0x5bc6c722745f
WRITE of size 4 at 0x7ffc717b2c54 thread T0
    #0 0x5bc6c722745e in main /tmp/aryverify/t6_oob.c:8

Address 0x7ffc717b2c54 is located in stack of thread T0 at offset 52 in frame
    #0 0x5bc6c7227258 in main /tmp/aryverify/t6_oob.c:4

  This frame has 1 object(s):
    [32, 52) 'a' (line 5) <== Memory access at offset 52 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/aryverify/t6_oob.c:8 in main

AddressSanitizer(ASan)是 GCC/Clang 内置的运行期检查器,原理是在每次内存分配的边界插入不可访问的"红区(redzone)",并记录每个内存块的分配与释放状态。越界访问一旦命中红区,立刻中止程序并打印完整调用栈。

注意报告里的 [32, 52) 和 offset 52:a 占据栈帧里第 32 到 52 字节(5 个 int = 20 字节,恰好对上),而访问落在 offset 52——正好是第一个越界的位置。ASan 把"越界了多少字节、越的是哪个变量"都指出来了。

结论:C 数组越界不会给你异常,也不会保证崩溃,它只会让程序进入未定义行为。唯一的可靠防线是在测试阶段开启 ASan,而不是"跑一遍没崩就当它对"。

3.3 长度:sizeof 技巧与它的失效边界

C 没有 length 字段,所以取长度的标准做法是 sizeof:

int a[5];
size_t n = sizeof(a) / sizeof(a[0]);   /* 20 / 4 = 5 */

sizeof 是运算符而不是函数,它在编译期求值,结果是整个数组占用的字节数。除以单个元素的字节数就得到元素个数。

但这个技巧有一个致命的失效边界:它只在 a 依然是数组类型时成立。一旦把 a 作为参数传给函数,退化就发生了,此时 sizeof(a) 得到的是指针的大小(x86-64 上是 8 字节),不再与数组长度相关。

实测(t1_sizeof.c):

#include <stdio.h>

/* 形参写成 int a[],与 int *a 完全等价 —— 退化已经发生 */
static void show_decay(int a[])
{
    printf("  函数内部 sizeof(a)     = %2zu   <-- 指针的大小,不是数组\n", sizeof(a));
    printf("  函数内部 sizeof(a[0])  = %2zu\n", sizeof(a[0]));
    printf("  算出的\"长度\"           = %2zu   <-- 错的\n", sizeof(a) / sizeof(a[0]));
}

int main(void)
{
    int a[5] = {1, 2, 3, 4, 5};

    printf("main 内部 sizeof(a)    = %2zu   <-- 整个数组的字节数\n", sizeof(a));
    printf("main 内部 sizeof(a[0]) = %2zu\n", sizeof(a[0]));
    printf("算出的长度             = %2zu   <-- 对的\n", sizeof(a) / sizeof(a[0]));

    puts("把同一个 a 传给函数之后:");
    show_decay(a);

    return 0;
}

输出:

main 内部 sizeof(a)    = 20   <-- 整个数组的字节数
main 内部 sizeof(a[0]) =  4
算出的长度             =  5   <-- 对的
把同一个 a 传给函数之后:
  函数内部 sizeof(a)     =  8   <-- 指针的大小,不是数组
  函数内部 sizeof(a[0])  =  4
  算出的"长度"           =  2   <-- 错的

同一个表达式 sizeof(a) / sizeof(a[0]),在 main 里得 5,在函数里得 2。 正确值 5 被无声地变成 2,之后的循环只会处理前两个元素——没有任何报错,只是少算了三个。

更值得注意的是:GCC 对这个错误有专门的警告。用 -Wall -Wextra 编译上面这段代码,会得到:

t1_sizeof.c:6:82: warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int *’ [-Wsizeof-array-argument]
    6 |     printf("  函数内部 sizeof(a)     = %2zu   <-- 指针的大小,不是数组\n", sizeof(a));
      |                                                                                  ^
t1_sizeof.c:4:28: note: declared here
    4 | static void show_decay(int a[])
      |                        ~~~~^~~

这条警告的名字叫 -Wsizeof-array-argument,它说明这个坑普遍到编译器都专门为它设了一条规则。所以务必开启 -Wall -Wextra——它能把这类静默错误变成显式警告。

正确的做法:长度必须显式传给函数。

static void show(const int *a, size_t n)   /* 长度作为独立参数 */
{
    for (size_t i = 0; i < n; ++i)
        printf("%d\n", a[i]);
}

形参写成 const int *a 比 int a[] 更好,因为它诚实地告诉你"这里收到的其实是指针",不会让人误以为还有数组信息可用。


四、遍历

4.1 索引遍历

最基础的形式,与 Java 的 for 循环结构相同,只是 arr.length 换成了显式传入的 n:

int a[4] = {20, 30, 40, 50};

for (size_t i = 0; i < n; ++i)          /* n 由调用方传入 */
    printf("%d\n", a[i]);

类型选择:下标变量用 size_t(无符号)还是 int(有符号)?两种都常见。用 int 的风险是,当它与 sizeof 的结果(size_t)比较时,会触发 -Wsign-compare 类警告;用 size_t 的风险是,递减循环 for (size_t i = n - 1; i >= 0; --i) 会因无符号下溢而变成死循环。经验规则:正向遍历用 size_t,需要倒序时用 int(或写成 for (size_t i = n; i-- > 0; ))。

4.2 指针遍历与尾后指针

既然 a[i] 就是 *(a + i),遍历当然可以直接用指针:

int a[4] = {20, 30, 40, 50};

for (int *p = a; p != a + 4; ++p)      /* p 从首元素走到尾后 */
    printf("%d\n", *p);

这里有三个必须讲清的点:

第一,p != a + 4 里的 a + 4 叫尾后指针(one-past-the-end pointer),它指向最后一个元素之后的那一格。C 标准明确允许形成这个指针并参与比较(C11 §6.5.6p8):

若指针指向数组的最后一个元素,则 P + 1 指向该数组最后一个元素之后的位置;若该位置存在,它不指向任何元素。

第二,尾后指针只能比较,不能解引用。 *(a + 4) 是越界访问,未定义行为。它存在的唯一意义是作为"终点标记",让循环有一个干净的终止条件。

第三,指针版与索引版编译出的机器码通常完全一致。 选哪个是表达意图的问题,不是性能问题——现代编译器的优化器会把两者归约到同一形式。

4.3 C++ 的范围 for 与它的退化

C++11 的范围 for(range-based for)语法上最简洁:

int a[5] = {12, 24, 36, 48, 60};
for (int x : a) std::cout << x << ' ';

但有一个关键限制:它同样依赖 a 是数组类型。一旦 a 退化成指针,范围 for 就无法推导出长度,编译直接失败:

void f(int a[]) {
    for (int x : a) { }   // 编译错误:范围 for 不能作用于指针
}

范围 for 能"知道"长度,靠的是 std::begin / std::end 这个辅助函数族,而它们对数组类型的重载同样要用 sizeof 推导。所以它继承了一切退化问题。

改进手段:std::array 或 std::span。 std::array<T, N> 把长度做成了类型的一部分,拷贝和传参都不丢;std::span<T>(C++20)作为一个"指针 + 长度"的轻量视图。详见第八节。

4.4 三种遍历的适用性

方式语法是否受退化影响适用场景
索引遍历for (i = 0; i < n; ++i)不受(长度自己传)需要下标做额外计算时
指针遍历for (p = a; p != a + n; ++p)不受与指针语义打交道的底层代码
范围 for(C++)for (T x : a)受,退化后编译失败只读遍历且 a 是数组/容器

五、四个经典场景

5.1 求最值

思路:先用数组的第一个元素初始化最值变量,再从头遍历比较。

为什么初值取 a[0] 而不是 0? 如果初值取 0,那么当数组元素全部是负数时,最大值会错误地保持为 0——这个 0 根本不是数组里的元素。取 a[0] 则保证初值一定属于该数组。

#include <stdio.h>

int main(void)
{
    int scores[] = {15, 9000, 10000, 20000, 9500, -5};
    size_t n = sizeof(scores) / sizeof(scores[0]);

    int max = scores[0];
    int min = scores[0];

    for (size_t i = 1; i < n; ++i) {        /* 从 1 开始,第 0 个已经当过初值 */
        if (scores[i] > max) max = scores[i];
        if (scores[i] < min) min = scores[i];
    }

    printf("最大值 = %d\n", max);
    printf("最小值 = %d\n", min);

    return 0;
}

另一种写法是用 <limits.h> 里的 INT_MAX / INT_MIN 作为初值,好处是不依赖数组非空:

#include <limits.h>

int max = INT_MIN;      /* 比任何 int 都小 */
int min = INT_MAX;

但要注意 INT_MIN 是个常量,如果数组确实为空,结果就是 INT_MIN 这个无意义的值。工程上更稳妥的做法是先判断 n == 0 并单独处理空数组。 C 语言不允许零长度数组(int a[0] 在标准 C 里非法),所以"空数组"在 C 里通常表示为"指针非空但长度为 0",需要调用方保证。

5.2 成绩统计:输入 + 统计

Java 用 Scanner,C 用 scanf。这里有两个必须注意的细节:

#include <stdio.h>

#define N 8

int main(void)
{
    double scores[N];
    double sum = 0.0;

    for (int i = 0; i < N; ++i) {
        /* scanf 必须拿到地址才能写回;返回值是"成功赋值的个数" */
        if (scanf("%lf", &scores[i]) != 1) {
            fprintf(stderr, "第 %d 个成绩读入失败\n", i + 1);
            return 1;
        }
    }

    double max = scores[0];
    double min = scores[0];

    for (int i = 0; i < N; ++i) {
        sum += scores[i];
        if (scores[i] > max) max = scores[i];
        if (scores[i] < min) min = scores[i];
    }

    printf("最高分 = %.2f\n", max);
    printf("最低分 = %.2f\n", min);
    printf("总分   = %.2f\n", sum);
    printf("平均分 = %.2f\n", sum / N);

    return 0;
}

输入 88 76.5 92 65 100 58 81 79.5,实际输出:

最高分 = 100.00
最低分 = 58.00
总分   = 640.00
平均分 = 80.00

三个要点:

第一,scanf 的参数必须是地址。 C 是值传递,只有把地址交给 scanf,它才能把读到的数据写回你的变量。scanf("%lf", scores[i]) 少了 & 会导致 scanf 把变量值当作地址去写,这是未定义行为,通常直接段错误。

第二,格式说明符必须与类型严格匹配。 double 用 %lf,float 用 %f,int 用 %d。用错说明符不会报错,但会写出错误的数据。

第三,必须检查返回值。 scanf 返回成功赋值的项数,输入不合法(比如用户敲了字母)时会返回 0 或 EOF。不检查就会拿着未初始化的元素继续算。

C++ 的等价写法(cin >> 天然按类型解析,不需要写说明符和取地址符):

#include <iostream>

double x;
while (std::cin >> x) { /* ... */ }

5.3 元素交换:值传递与指针

交换两个变量用临时变量,这部分和 Java 一样:

int t = a[0];
a[0] = a[4];
a[4] = t;

但一旦把交换封装成函数,C 和 Java 就完全不同了。 关键在于 C 的参数传递方式是值传递(pass by value):函数收到的永远是实参的副本,形参的修改不影响调用方。

#include <stdio.h>

/* 错误版本:交换的是形参这两个副本 */
static void swap_wrong(int x, int y)
{
    int t = x;
    x = y;
    y = t;
}

/* 正确版本:传指针,交换的是调用方那块内存里的值 */
static void swap_right(int *x, int *y)
{
    int t = *x;
    *x = *y;
    *y = t;
}

int main(void)
{
    int a = 10, b = 20;

    swap_wrong(a, b);
    printf("调用 swap_wrong(a, b) 之后: a = %d, b = %d\n", a, b);

    swap_right(&a, &b);
    printf("调用 swap_right(&a, &b) 之后:a = %d, b = %d\n", a, b);

    return 0;
}

实际输出:

调用 swap_wrong(a, b) 之后: a = 10, b = 20
调用 swap_right(&a, &b) 之后:a = 20, b = 10

swap_wrong 里确实交换了,但交换的是函数内那两份副本,函数一返回就随栈帧消失,调用方的 a、b 原封不动。

swap_right 接收的是指针——指针本身也是按值传递的,但传递的这个"值"是 a 的地址。函数内部通过 *x 解引用,操作的就是调用方那块内存,所以修改能保留下来。

Java 里的说法在 C 里要对齐一下:Java 说"基本类型传值、对象传引用"。C 里只有一条规则——一切都是值传递。当你想让函数修改调用方的数据时,传的是"指针这个值",即地址的副本。理解成"引用传递"会掩盖掉一个事实:你完全可以修改指针本身而不影响调用方(比如让 x 指向别处),因为改动只作用于副本。

顺带说明 C++ 的两种改良写法:

void swap_ref(int &x, int &y) { int t = x; x = y; y = t; }   /* 引用,调用处无需 & */
void swap_ptr(int *x, int *y) { int t = *x; *x = *y; *y = t; }

引用(reference)是 C++ 引入的独立类型,它是某个已存在对象的别名,不是新对象、不需要取地址。swap_ref(a, b) 的调用形式更干净,但语义上和传指针完全一致。C 里没有引用,只能传指针。

5.4 随机点名

Java 用 Random.nextInt(n)。C 用 rand() 配合 srand():

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    const char *names[] = {
        "张誉", "刘疏桐", "田启峰", "伊成元", "赵志刚",
        "解天野", "高续升", "王世举", "周字伦", "刘帅",
        "陈小伟", "陈伟北", "高明", "毕振宇", "王乾麟",
    };
    size_t n = sizeof(names) / sizeof(names[0]);

    srand((unsigned)time(NULL));           /* 只播种一次,放在循环外面 */
    size_t idx = (size_t)rand() % n;       /* 0 .. n-1 */

    printf("%s同学,出来回答问题!\n", names[idx]);

    return 0;
}

这里有四个知识点:

第一,const char *names[] 是"字符串数组"的正确写法。 C 里没有 String 类型,字符串是以 '\0' 结尾的 char 数组。"张誉" 这样的字符串字面量(string literal),类型是 const char[7]——两个汉字按 UTF-8 各占 3 字节共 6 字节,加上结尾的 '\0' 正好 7。names 是一个元素类型为 const char *(指向字符的指针)的数组,共 15 个元素,每个元素指向一个字符串字面量。

注意 sizeof 的区别:

  • sizeof(names) = 15 × 8 = 120(指针数组的总字节数)
  • sizeof(names[0]) = 8(单个指针的大小)
  • 而 sizeof("张誉") 会是字符串本身的字节数
    所以 sizeof(names)/sizeof(names[0]) 得到的是元素个数 15,这正是我们要的。但如果写成 sizeof(names[0])/sizeof(names[0][0]) 就完全错了——sizeof(names[0][0]) 是单个 char 的大小 1,得到 8 这个无意义的值。

第二,srand 只能调用一次。 time(NULL) 的精度是秒。如果把 srand 放进循环,同一秒内每次调用都播种成同一个种子,rand() 就返回同一个数。实测:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    puts("在循环里反复 srand(time(NULL)):");
    for (int i = 0; i < 3; ++i) {
        srand((unsigned)time(NULL));
        printf("  %d\n", rand());
    }

    puts("只播种一次,然后连续取值:");
    srand((unsigned)time(NULL));
    for (int i = 0; i < 3; ++i)
        printf("  %d\n", rand());

    return 0;
}

输出:

在循环里反复 srand(time(NULL)):
  1547618903
  1547618903
  1547618903
只播种一次,然后连续取值:
  1547618903
  297209375
  677920273

前三个数完全相同。这是"随机数看着不随机"最常见的成因。

第三,rand() 不是密码学安全的。 rand() 是伪随机数生成器(pseudorandom number generator,PRNG):它由种子完全确定,同一份种子必然产生同一串序列。这在需要可复现的场合(测试、实验)是优点,但在涉及安全(生成令牌、密钥、防猜测)的场合是致命缺陷——序列可以被反推。

第四,Linux 上应当用 getrandom() 获取真随机。 需要不可预测的随机数时,现代 Linux 的正确做法是

#include <sys/random.h>

unsigned char buf[16];
ssize_t got = getrandom(buf, sizeof(buf), 0);   /* 内核 CSPRNG,阻塞直到有足够熵 */

getrandom(2) 从内核的密码学安全伪随机数生成器(CSPRNG)取值,是 rand() 在安全性上的替代方案。老一些的写法是读 /dev/urandom:

#include <fcntl.h>
#include <unistd.h>

int fd = open("/dev/urandom", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);

六、洗牌:一个被流传很广的错误实现

6.1 讲义里的写法错在哪

先看常见写法(下标每次都从整个数组里随机取):

/* 每一轮都从整个数组里随机取一个下标来交换 */
static void shuffle_naive(int *a, int n)
{
    for (int i = 0; i < n; ++i)
        swap(&a[i], &a[rand() % n]);
}

它把"随机"理解成了"做 n 次随机交换"。但n 次随机交换的结果分布不是均匀分布——这一点在 n=2 时看不出来(两种结果的概率恰好都是 1/2),n≥3 才开始明显偏离。

6.2 实测:跑 100 万次看频率

固定种子、跑 100 万轮,统计"每个元素落在每个位置"的频率。理论上每种情况都应该是 1/4 = 0.2500:

#include <stdio.h>
#include <stdlib.h>

#define N 4
#define ROUNDS 1000000L

static void swap(int *x, int *y) { int t = *x; *x = *y; *y = t; }

/* 讲义写法:每一轮都从整个数组里随机取一个下标来交换 */
static void shuffle_naive(int *a, int n)
{
    for (int i = 0; i < n; ++i)
        swap(&a[i], &a[rand() % n]);
}

/* Fisher-Yates:每一轮只在"尚未确定"的区间 [0, i] 里取下标 */
static void shuffle_fy(int *a, int n)
{
    for (int i = n - 1; i > 0; --i)
        swap(&a[i], &a[rand() % (i + 1)]);
}

int main(void)
{
    long cnt_naive[N][N] = {{0}};   /* cnt[位置][元素] */
    long cnt_fy[N][N] = {{0}};
    int a[N];

    srand(20261011u);              /* 固定种子,让结果可复现 */

    for (long r = 0; r < ROUNDS; ++r) {
        for (int i = 0; i < N; ++i) a[i] = i;
        shuffle_naive(a, N);
        for (int i = 0; i < N; ++i) cnt_naive[i][a[i]]++;

        for (int i = 0; i < N; ++i) a[i] = i;
        shuffle_fy(a, N);
        for (int i = 0; i < N; ++i) cnt_fy[i][a[i]]++;
    }
    /* ... 打印频率表 ... */
    return 0;
}

实际输出:

=== 讲义写法:每轮都从整个数组取下标 ===
元素\位置    位0      位1      位2      位3
元素 0     0.2496   0.2499   0.2498   0.2508
元素 1     0.2932   0.2227   0.2342   0.2500
元素 2     0.2462   0.2820   0.2224   0.2494
元素 3     0.2111   0.2454   0.2937   0.2499

=== Fisher-Yates:可取区间逐轮缩小 ===
元素\位置    位0      位1      位2      位3
元素 0     0.2502   0.2499   0.2502   0.2497
元素 1     0.2504   0.2500   0.2502   0.2494
元素 2     0.2495   0.2509   0.2498   0.2499
元素 3     0.2499   0.2492   0.2498   0.2510

第一张表里,元素 1 落在位置 0 的概率是 0.2932,元素 3 落在位置 0 的只有 0.2111——相对 0.25 的理论值,偏差达到 ±17% 和 ±16%。元素 0 看起来"正常"只是巧合。

有意思的是位置 3 那一列,四种元素的频率全是 0.2500,完全均匀。原因是循环最后一次执行的是 swap(a[3], a[rand()%4]),这一步把一个均匀随机挑选的元素放到了位置 3,所以最后一位反而是无偏的。偏差全都集中在它前面的若干位上。

第二张表——Fisher-Yates——全部落在 0.2492 ~ 0.2510 之间,与理论值 0.25 的差距在 100 万次采样的统计涨落范围内。

6.3 正确实现:Fisher–Yates 洗牌

/* 每一轮只在"尚未确定"的区间 [0, i] 里取下标 */
static void shuffle_fy(int *a, int n)
{
    for (int i = n - 1; i > 0; --i)
        swap(&a[i], &a[rand() % (i + 1)]);
}

关键差异只有一个:随机下标的取值范围逐轮缩小。 第 i 轮取的是 [0, i],而不是 [0, n-1]。

不变量(invariant):每一轮结束时,位置 i+1 .. n-1 上的元素已经定稿,且区间 [0, i] 内每个元素落入已定稿位置的概率均等。这个不变量逐步归纳即得整体均匀性——每一步都是"从剩余候选中均匀挑一个放到当前位置",等价于对排列空间做一次均匀采样。

6.4 顺带排除一个干扰因素:rand() % n 的取模偏差

看到 rand() % n 时还有一个常见怀疑:取模偏差(modulo bias)。rand() 返回值在 [0, RAND_MAX] 上均匀,但如果 RAND_MAX + 1 不能被 n 整除,某些余数就会比其他的多出现一次。

在 glibc 上实测:

RAND_MAX = 2147483647
RAND_MAX 是否为 2^31-1:是
取值个数 RAND_MAX+1 = 2^31 = 2147483648
2147483648 % 4 = 0(为 0 说明 n=4 时没有取模偏差)

因为取值空间大小 2^31 能被 4 整除,上表里 n=4 的偏差完全来自算法本身,与取模无关。这一点很重要:如果偏差来自取模,那么换一个随机源就能解决;而这里偏差来自算法,必须改算法。

反过来说,如果 n 是不能整除 2^31 的值(比如 n = 3),取模偏差就会叠加进来,必须用拒绝采样消除:

/* 拒绝采样:把落在尾部"不完整区间"的取值丢掉重取 */
static unsigned rand_below(unsigned n)
{
    unsigned limit = UINT_MAX - (UINT_MAX % n);   /* n 的最大整数倍范围内 */
    unsigned r;
    do { r = (unsigned)rand(); } while (r >= limit);
    return r % n;
}

更省事的做法是用标准库的成熟实现:C 用 arc4random_uniform(n)(BSD/Linux 部分实现),C++ 用 <random> 里的 std::uniform_int_distribution(见第八节),它会自动处理取模偏差。


七、二维数组

7.1 本质差异:整块内存 vs 数组的数组

维度Java new int[3][5]C int a[3][5]
结构数组的数组,3 个独立的一维数组对象一块连续内存,15 个 int 紧挨排布
各行长度可以不等(锯齿数组)全部相同
内存是否连续行与行之间不连续完全连续
行对象的开销每行有一个对象头无
元素定位两次指针跳转一次乘法:base + (i*5 + j) * sizeof(int)
类型int[][]int[3][5],退化成 int (*)[5]

C 的二维数组是行主序(row-major order)存储:先放第 0 行的全部元素,再放第 1 行,依此类推。a[i][j] 等价于 *(*(a + i) + j),展开成地址计算就是"首地址 + (i * 列数 + j) 个元素"。

连续存储带来两个实际收益:一是 元素定位只需一次乘法,没有指针跳转;二是 遍历时访问相邻元素落在同一条或相邻的缓存行里,缓存局部性(cache locality)更好。

7.2 真二维数组的 sizeof

实测(t5_2d.c):

#include <stdio.h>

#define ROWS 3
#define COLS 5

int main(void)
{
    int a[ROWS][COLS];

    printf("sizeof(a)        = %2zu  ( %d * %d * %zu )\n",
           sizeof(a), ROWS, COLS, sizeof(int));
    printf("sizeof(a[0])     = %2zu  ( %d * %zu )\n",
           sizeof(a[0]), COLS, sizeof(int));
    printf("sizeof(a[0][0])  = %2zu\n", sizeof(a[0][0]));
    printf("行数 = sizeof(a)/sizeof(a[0])            = %zu\n",
           sizeof(a) / sizeof(a[0]));
    printf("列数 = sizeof(a[0])/sizeof(a[0][0])      = %zu\n",
           sizeof(a[0]) / sizeof(a[0][0]));
    printf("总元素个数 = sizeof(a)/sizeof(a[0][0])   = %zu\n",
           sizeof(a) / sizeof(a[0][0]));

    return 0;
}

输出:

sizeof(a)        = 60  ( 3 * 5 * 4 )
sizeof(a[0])     = 20  ( 5 * 4 )
sizeof(a[0][0])  =  4
行数 = sizeof(a)/sizeof(a[0])            = 3
列数 = sizeof(a[0])/sizeof(a[0][0])      = 5
总元素个数 = sizeof(a)/sizeof(a[0][0])   = 15

关键是理解 a[0] 是什么:a[0] 是"第 0 行那个一维数组",类型是 int[5],不是 int。 所以 sizeof(a[0]) = 20 而不是 4。三层 sizeof 分别对应整个数组、一行、一个元素,行数/列数正是由前两层之比得到。

7.3 声明读法:数组指针 vs 指针数组

这两个声明只差一对括号,含义完全不同,是 C 里最经典的读法练习:

int  *p[3];    /* 指针数组:p 是"含 3 个 int* 的数组" */
int (*p)[5];   /* 数组指针:p 是"指向含 5 个 int 的数组 的指针" */

读声明的方法:从变量名出发,先看右边,再看左边,括号优先。

  • int *p[3]:p 右边是 [3],说明 p 是含 3 个元素的数组;向左看 int *,说明元素类型是 int *。整体:含 3 个 int * 的数组。
  • int (*p)[5]:p 被括号包住,先看括号内 *p,说明 p 是指针;再看括号右边的 [5],说明它指向的是含 5 个元素的数组;再看 int。整体:指向 int[5] 的指针。

第一行占 3 × 8 = 24 字节(3 个指针),第二行只占 8 字节(1 个指针)——尽管两个声明的文字几乎一样。

7.4 传参:为什么必须写列数

把二维数组传给函数时,列数必须写在形参里:

static void inspect(int a[][COLS], int rows)   /* 等价于 int (*a)[COLS] */
{
    /* ... */
}

原因还是退化。int a[3][5] 在表达式中退化成指向首行的指针,而"一行"的类型是 int[5],所以退化结果是 int (*)[5]——不是 int **。

用 -Wall -Wextra 编译时,GCC 会把这个退化类型明确打印出来:

t5_2d.c:9:71: warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int (*)[5]’ [-Wsizeof-array-argument]
    9 |     printf("  函数内部 sizeof(a)  = %2zu   <-- 一行的字节数\n", sizeof(a));
      |                                                                       ^
t5_2d.c:7:25: note: declared here
    7 | static void inspect(int a[][COLS], int rows)
      |                     ~~~~^~~~~~~~~

注意警告原文里的 int (*)[5]——编译器的措辞直接确认了退化后的类型。

传参之后的行为实测:

传参之后:
  函数内部 sizeof(a)  =  8   <-- 一行的字节数
  函数内部 sizeof(*a) = 20   <-- 整个一行

sizeof(a) = 8 是指针大小,sizeof(*a) = 20 才是"一行"的字节数(*a 是 int[5],没有退化,因为它是 sizeof 的操作数)。

为什么不能写成 int **? 因为 int ** 表示"指向 int * 的指针",而真二维数组里根本没有 int * 这一层——它是 3 × 5 个连续的 int。用 int ** 接收 int a[3][5],程序会把数组的前 8 字节当作一个指针去解引用,然后跳转到一个由元素值拼成的地址上。这通常立刻段错误。只有"指针数组"(每个元素是独立指针)才对应 int **。

7.5 不规则二维:C 里怎么做锯齿数组

Java 里 int[][] a = new int[4][]; a[0] = new int[3]; 是合法的。C 里没有"数组的数组"这种原生结构,需要手动构造。三条路:

路径 A:指针数组 + 逐行 malloc(最接近 Java 的写法)

#include <stdlib.h>

int rows = 4;
int *grid[rows];                        /* 4 个指针;VLA 数组,C99 */

for (int i = 0; i < rows; ++i) {
    grid[i] = malloc(sizeof(int) * (size_t)col_len[i]);   /* 每行单独申请 */
    if (!grid[i]) { /* 处理失败,注意释放前面已成功的行 */ }
}

/* 使用:grid[i][j] */
grid[0][0] = 1;

for (int i = 0; i < rows; ++i)
    free(grid[i]);                      /* 逐行释放 */

缺点有两个:行与行之间不连续(每行独立分配,中间可能夹着别的数据),缓存局部性差;释放时必须逐行 free,容易漏。

路径 B:一维扁平数组 + 手工索引换算(工程上推荐)

#include <stdlib.h>

int rows = 4, cols = 5;
int *flat = malloc(sizeof(int) * (size_t)rows * (size_t)cols);
if (!flat) { /* 处理失败 */ }

/* 访问 (i, j) */
#define AT(i, j) flat[(size_t)(i) * (size_t)cols + (size_t)(j)]

AT(2, 3) = 7;
printf("%d\n", AT(2, 3));

free(flat);                             /* 只需一次释放 */

整块内存连续,一次 malloc 一次 free,缓存友好。代价是索引要手工换算(或者像上面这样封装成宏)。注意宏里的括号不能省——(size_t)(i) * (size_t)cols + (size_t)(j) 如果漏掉括号,AT(a+1, b) 这类实参会因为运算符优先级被错误展开。

路径 C:真二维数组,但用最大列数(浪费一点空间)

int grid[4][5];    /* 所有行长度相同 */

如果各行长度差异不大,直接用最大列数最省心。这也是性能最好的一种。

路径内存连续性释放方式适用场景
A 指针数组逐行 malloc不连续逐行 free行长差异大,且必须保留 [i][j] 语法
B 一维扁平连续一次 free性能敏感、可接受索引换算
C 真二维数组连续无需释放行长统一

7.6 案例:座位表与游戏地图

座位表:4 排、每排人数不同,用"名字指针数组 + 行长数组"表示。

#include <stdio.h>

#define ROWS 4

int main(void)
{
    const int row_len[ROWS] = {3, 3, 4, 2};
    const char *seats[] = {           /* 长度由初始化列表推出:3+3+4+2 = 12 */
        "张无忌", "赵敏", "周芷若",
        "张三丰", "宋远桥", "殷梨亭",
        "灭绝", "陈昆", "玄冥二老", "金毛狮王",
        "杨逍", "纪晓芙",
    };

    int k = 0;
    for (int i = 0; i < ROWS; ++i) {
        printf("第%d排:", i + 1);
        for (int j = 0; j < row_len[i]; ++j)
            printf("%s  ", seats[k + j]);
        k += row_len[i];
        putchar('\n');
    }

    return 0;
}

实际输出:

第1排:张无忌  赵敏  周芷若
第2排:张三丰  宋远桥  殷梨亭
第3排:灭绝  陈昆  玄冥二老  金毛狮王
第4排:杨逍  纪晓芙

这里有一个值得注意的编译细节。如果把 seats 写成 const char *seats[ROWS](即固定 4 个元素),而初始化列表里有 12 个字符串,GCC 会报:

t9_2d_cases.c:14:19: warning: excess elements in array initializer

"声明长度 4、初始化 12 个元素"会被编译器拦住,这一点 C 比 Java 严格(Java 的 new int[4] 后面跟着 12 个元素同样报错,但 C 这里还额外提醒你类型上是"指针数组"还是"二维数组"完全是两回事)。写成 seats[] 让长度自动推导,才是这里正确的写法。

游戏地图:4×4 石头迷阵,同时验证行主序。

#include <stdio.h>

#define ROWS 4
#define COLS 4

int main(void)
{
    int maze[ROWS][COLS];
    int num = 1;

    for (int i = 0; i < ROWS; ++i)
        for (int j = 0; j < COLS; ++j)
            maze[i][j] = (num <= 15) ? num++ : 0;

    puts("===== 石头迷阵 =====");
    for (int i = 0; i < ROWS; ++i) {
        for (int j = 0; j < COLS; ++j)
            printf("%2d\t", maze[i][j]);
        putchar('\n');
    }

    /* 用一维视角访问同一块内存,验证行主序 */
    int *flat = &maze[0][0];
    printf("maze[2][1] = %d, flat[2*4+1] = %d  -> %s\n",
           maze[2][1], flat[2 * COLS + 1],
           maze[2][1] == flat[2 * COLS + 1] ? "一致,确认行主序" : "不一致");

    return 0;
}

实际输出:

===== 石头迷阵 =====
 1     2     3     4
 5     6     7     8
 9    10    11    12
13    14    15     0
maze[2][1] = 10, flat[2*4+1] = 10  -> 一致,确认行主序

&maze[0][0] 取的是第一个元素的地址,把它当一维数组用(int *flat),flat[2*COLS + 1] 与 maze[2][1] 访问到的是同一个 int——这一行实测结果直接证实了二维数组就是按行主序排布的一整块内存。

注意 int *flat = &maze[0][0]; 这里必须取 &maze[0][0](一个 int 的地址),而不是 maze(类型 int (*)[4],与 int * 不兼容)。

堆上的真二维数组:需要运行期决定大小时,用"指向数组的指针"接收 malloc 的返回值。

#include <stdio.h>
#include <stdlib.h>

static void heap_2d(int rows, int cols)
{
    /* 注意类型:int (*)[cols] 是指向"含 cols 个 int 的数组"的指针 */
    int (*grid)[cols] = malloc(sizeof(int) * (size_t)rows * (size_t)cols);
    if (!grid) { perror("malloc"); return; }

    for (int i = 0; i < rows; ++i)
        for (int j = 0; j < cols; ++j)
            grid[i][j] = i * cols + j;

    printf("grid[%d][%d] = %d\n", rows - 1, cols - 1, grid[rows - 1][cols - 1]);
    printf("整块大小 = %zu 字节,行与行之间连续\n",
           sizeof(int) * (size_t)rows * (size_t)cols);

    free(grid);     /* 一次释放即可 */
}

实测输出:

grid[2][4] = 14
整块大小 = 60 字节,行与行之间连续

int (*grid)[cols] 里的 cols 是运行期值,所以这是一个 VLA 类型,属于 C99 特性。它让 grid[i][j] 这种二维写法能直接作用在 malloc 得到的一整块内存上,同时只做一次 free。


八、C++ 里应该用什么

前面所有内容都适用于 C++,但 C++ 提供了三件工具,可以系统性地消除"退化"和"手工释放"这两个问题源头。

8.1 std::array:长度成为类型的一部分

#include <array>
#include <iostream>

std::array<int, 5> b = {1, 2, 3, 4, 5};

std::cout << b.size() << '\n';      // 5
std::cout << b[2]     << '\n';      // 3
std::cout << sizeof(b) << '\n';     // 20

关键性质:std::array<int, 5> 和 std::array<int, 6> 是两个不同的类型。长度进入了类型,因此:

  • 拷贝、赋值传参都不会丢长度(不像原生数组一传就退化);
  • b.size() 永远正确,不需要 sizeof 技巧;
  • 没有隐式转换到指针,std::array 不会静默退化成 int *;
  • 内存布局与 int b[5] 完全相同,零额外开销。

代价是长度必须是编译期常量。需要运行期长度就用 std::vector。

8.2 std::vector:运行期可增长

#include <iostream>
#include <vector>

std::vector<double> scores;
scores.push_back(88.5);
scores.push_back(76.0);
scores.push_back(92.5);

double sum = 0.0;
for (double s : scores) sum += s;

std::cout << scores.size() << '\n';                             // 3
std::cout << sum / static_cast<double>(scores.size()) << '\n';  // 85.6667

(实体输出:vector 元素数 = 3, 平均分 = 85.6667)

std::vector 是动态数组:内部维护一块连续内存,容量不足时重新分配一块更大的并把元素搬过去。它是 C 里 malloc + 手工扩容的完整替代品,且内存由析构函数自动释放——不再需要 free,不再有泄漏。

它同时保留了 C 数组的连续性优势(&v[0] 或 v.data() 可以拿到首地址喂给任何 C 接口),这是它相对 std::list 的关键区别。

8.3 std::span:作为函数参数的"指针 + 长度"(C++20)

#include <span>

void show(std::span<const int> a)      // 长度随 span 一起传,无需第二个参数
{
    for (int x : a) std::cout << x << ' ';
}

std::span 是一个轻量视图,内部就是"指针 + 长度"两个字段。它直接解决了第 3.3 节那个"长度退化了"的根本问题:调用 show(a) 时,如果 a 是原生数组,span 会通过 std::begin/std::end 推导出长度;如果 a 是 vector,则用容器自身的 size()。两种来源都能接。

8.4 std::shuffle:标准库的无偏洗牌

#include <algorithm>
#include <random>

std::mt19937 gen(20261011u);            /* 梅森旋转,比 rand() 质量高得多 */
std::shuffle(std::begin(a), std::end(a), gen);

实测输出:

std::shuffle 之后: 60 48 24 36 12

std::mt19937(Mersenne Twister,梅森旋转)是一个周期为 2^19937-1 的 PRNG,统计质量远优于 rand()。std::shuffle 内部实现的就是 Fisher–Yates,且用 std::uniform_int_distribution 消除了取模偏差——所以第六节里那两个坑它一次都不踩。

8.5 选型表

需求C 做法C++ 做法
编译期固定长度int a[5]std::array<int, 5>
运行期长度malloc + freestd::vector<int>
作为函数参数接收任意长度(const int *a, size_t n)std::span<const int>
二维矩阵(规则)int a[3][5] + int (*)[5] 形参std::vector<std::array<int,5>>
不规则二维指针数组 + 逐行 mallocstd::vector<std::vector<int>>
洗牌手写 Fisher–Yatesstd::shuffle + std::mt19937
获取长度sizeof(a)/sizeof(a[0]).size()

九、编译与验证

C 数组的绝大多数缺陷(越界、未初始化读取、类型不匹配)都属于未定义行为,编译器默认不会告警,运行时也不会报错。所以必须主动开启检测手段。

第一层:打开警告。 至少 -Wall -Wextra,它们能把退化陷阱(-Wsizeof-array-argument)、符号不匹配等变成显式警告。

gcc -std=c11 -Wall -Wextra -O2 program.c -o program

第二层:开启运行期检查器。 -fsanitize=address 抓越界与释放后使用,-fsanitize=undefined 抓有符号溢出、除零、非法移位等。

gcc -std=c11 -Wall -Wextra -g -fsanitize=address    program.c -o program_asan
gcc -std=c11 -Wall -Wextra -g -fsanitize=undefined  program.c -o program_ubsan

有一个环境兼容问题值得先知道:ThreadSanitizer(-fsanitize=thread)在部分系统上会因与 ASLR 冲突而启动失败,报 FATAL: ThreadSanitizer: unexpected memory mapping。解决办法是关闭地址随机化后运行:

setarch $(uname -m) -R ./program_tsan

第三层:养成"边界自己算"的习惯。 凡是把数组传给函数,同时把长度传过去;凡是索引来自外部输入,先校验范围。


十、小结

可以直接带走的结论:

  1. C 的数组没有长度信息,数组名在表达式中会退化成首元素指针。 Java 的 arr.length 在 C 里没有任何对应物,sizeof(a)/sizeof(a[0]) 只在 a 未退化时有效。
  2. 越界不是异常,是未定义行为。 实测中 a[5] = ... 既没编译警告也没运行报错,-Wall -Wextra 和 -O2 都放行,程序正常退出——但 ASan 精确指出了"偏移 52 越过了 [32, 52) 的 a"。没有症状不等于正确。
  3. 只有静态存储期的数组才保证零初始化。 局部数组的内容是不确定值,malloc 也不清零(calloc 才清)。
  4. 长度必须显式传递。 传参后 sizeof 得到的是指针大小,实测中长度 5 会静默变成 2。GCC 为此专设了 -Wsizeof-array-argument 警告,说明这是高频错误。
  5. C 只有值传递。 想修改调用方的数据就必须传指针,swap(int, int) 什么也改不了;而指针本身也是按值传递的。
  6. 随机数只用 srand 播种一次。 time(NULL) 精度是秒,放进循环会让 rand() 反复返回同一个值。安全性要求高时用 getrandom(2)。
  7. n 次随机交换不是洗牌。 实测 100 万次:rand()%n 全范围取下标时,元素落在位置 0 的概率是 0.2932 而非 0.25;Fisher–Yates 把取值区间逐轮缩小,实测全部落在 0.2492~0.2510。用 std::shuffle 则两个坑都不会踩。
  8. C 的二维数组是行主序的一整块内存,不是数组的数组。 int a[3][5] 退化成 int (*)[5],不是 int **;传给函数时列数必须写在形参里。
  9. 不规则二维有三条路:指针数组逐行 malloc(不连续、逐行释放)、一维扁平数组加索引换算(连续、一次释放)、真二维数组取最大列数(最简单)。性能敏感选中间那条。
  10. 用工具而不是用"跑一遍没崩"来验证。 -Wall -Wextra + ASan + UBSan,这三样能覆盖 C 数组绝大多数静默缺陷。


一、类与对象的核心定义

1.1 对象的本质

在Java中,对象是一种结构化的数据载体,本质是用于描述一个具体事物的专属数据结构,它可以完整存储一个事物的相关属性数据,并承载该事物对应的行为能力。

在实际开发中,无论是现实世界中的人物、商品,还是业务系统中的用户、订单、电影,都可以被抽象为对象,通过对象来统一管理其相关数据与行为。

1.2 类与对象的关系

类是对象的模板(也称为设计图),它定义了一类事物所具备的通用属性与行为;而对象是类的实例化产物,通过类可以创建出多个具备相同特征的独立对象。

简单来说,类是对一类事物的抽象描述,对象是该类事物的具体实例。例如:“明星”是一个类,而赵丽颖、杨幂就是该类的具体对象;“学生”是一个类,而播妞、播仔就是该类的具体对象。

1.3 类的基础语法

一个标准的类主要由两部分组成:

  • 成员变量(属性):用于描述事物的特征数据;
  • 成员方法(行为):用于描述事物的行为能力,即对数据的处理逻辑。

类的基础定义语法如下:

/**
 * 明星类(模板)
 * 定义了明星这类事物的通用属性
 */
public class Star {
    // 成员变量(属性)
    String name;    // 姓名
    int age;        // 年龄
    double height;  // 身高
    double weight;  // 体重
}

1.4 对象的创建与使用

在Java中,通过new关键字可以实例化类,得到一个具体的对象。每执行一次new操作,都会在内存中创建一个全新的独立对象。

对象创建完成后,可以通过对象名.属性名的方式为属性赋值,也可以通过对象名.方法名()的方式调用对象的行为方法。

public class StarTest {
    public static void main(String[] args) {
        // 实例化Star类,创建第一个明星对象
        Star s1 = new Star();
        // 为对象属性赋值
        s1.name = "赵丽颖";
        s1.age = 36;
        s1.height = 165.0;
        s1.weight = 44.6;

        // 实例化Star类,创建第二个明星对象
        Star s2 = new Star();
        s2.name = "杨幂";
        s2.age = 37;
        s2.height = 166.5;
        s2.weight = 45.0;
    }
}

1.5 带行为方法的类定义

类中不仅可以定义属性,还可以定义处理属性数据的行为方法。以学生类为例,我们可以在类中定义计算总成绩、平均成绩的方法,实现“谁的数据谁处理”的面向对象核心思想。

/**
 * 学生类
 * 包含学生属性与成绩处理的行为方法
 */
public class Student {
    // 成员变量(属性)
    String name;        // 学生姓名
    double chinese;     // 语文成绩
    double math;        // 数学成绩

    // 成员方法(行为):打印总成绩
    public void printTotalScore() {
        System.out.println(name + "同学的各科总分是:" + (chinese + math));
    }

    // 成员方法(行为):打印平均成绩
    public void printAverageScore() {
        System.out.println(name + "同学的各科平均分是:" + (chinese + math) / 2.0);
    }
}

方法的调用示例:

public class StudentTest {
    public static void main(String[] args) {
        // 创建学生对象并赋值
        Student s1 = new Student();
        s1.name = "播妞";
        s1.chinese = 100;
        s1.math = 100;
        // 调用对象的行为方法
        s1.printTotalScore();
        s1.printAverageScore();

        Student s2 = new Student();
        s2.name = "播仔";
        s2.chinese = 59;
        s2.math = 100;
        s2.printTotalScore();
        s2.printAverageScore();
    }
}

执行结果:

播妞同学的各科总分是:200.0
播妞同学的各科平均分是:100.0
播仔同学的各科总分是:159.0
播仔同学的各科平均分是:79.5

1.6 对象的内存模型

Java程序运行时,对象的内存分配主要涉及三块内存区域:

  1. 栈内存:存储对象的引用变量(即对象的内存地址),方法执行时的局部变量也存放在栈中;
  2. 堆内存:存储new关键字创建的对象本身,包括对象的所有成员变量;
  3. 方法区:存储类的字节码文件(.class),包括类的成员变量定义、方法定义等信息。

当执行Student s1 = new Student();时,会在堆内存中创建Student对象的实例,在栈内存中创建引用变量s1,s1中存储的是堆内存中对象的内存地址,通过该地址实现对对象属性和方法的访问。


二、构造器

2.1 构造器的定义与作用

构造器(也称为构造方法)是类中一种特殊的方法,它在使用new关键字创建对象时会被自动调用,核心作用是完成对象的初始化操作,最常见的场景是在创建对象的同时为成员变量赋值。

2.2 构造器的语法规范

构造器的定义需要遵循以下语法规则:

  • 构造器的名称必须与类名完全一致;
  • 构造器没有返回值类型,连void都不能写;
  • 构造器可以定义参数,也可以无参数。

基础语法如下:

public class Student {
    String name;
    int age;

    // 无参构造器
    public Student() {
        // 初始化逻辑
    }

    // 有参构造器
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

2.3 构造器的使用注意事项

  1. 默认无参构造器:任何一个类在定义时,即使不手动编写构造器,Java编译器都会自动为该类生成一个默认的无参构造器;
  2. 构造器的覆盖规则:如果手动为类定义了有参构造器,编译器将不再生成默认的无参构造器。此时如果需要使用无参构造器,必须手动显式定义;
  3. 构造器的重载:一个类中可以定义多个参数列表不同的构造器,即构造器的重载,以满足不同的初始化需求。

构造器的使用示例:

public class StudentTest {
    public static void main(String[] args) {
        // 调用无参构造器创建对象
        Student s1 = new Student();
        s1.name = "播妞";
        s1.age = 18;

        // 调用有参构造器创建对象,创建时直接完成赋值
        Student s2 = new Student("播仔", 19);
    }
}

三、this关键字

3.1 this的本质

this是Java中的一个关键字,本质是当前对象的引用。在成员方法中,this指向调用该方法的当前对象;在构造器中,this指向正在创建的对象。简单来说,哪个对象调用方法,this就指向哪个对象。

3.2 this的核心应用场景

this最主要的作用是解决成员变量与方法内局部变量的命名冲突问题。当方法内的局部变量与类的成员变量名称相同时,Java会默认采用就近原则访问局部变量,此时需要通过this.变量名的方式明确指定访问类的成员变量。

示例代码:

public class Student {
    String name;
    double score;

    // 带参数的方法,参数名与成员变量名冲突
    public void checkPass(double score) {
        // this.score 访问成员变量,score 访问方法入参的局部变量
        if (this.score >= score) {
            System.out.println("成绩达标");
        } else {
            System.out.println("成绩未达标");
        }
    }

    // 有参构造器中使用this
    public Student(String name, double score) {
        this.name = name;
        this.score = score;
    }
}

四、封装特性

4.1 封装的核心定义

封装是面向对象编程的三大核心特性之一(封装、继承、多态)。其核心思想是:在设计类时,将事物的属性数据与处理数据的行为方法封装到同一个类中,对外部隐藏内部的实现细节,仅暴露必要的访问与操作入口。

4.2 封装的设计原则

封装的核心设计原则是合理隐藏、合理暴露:

  • 合理隐藏:对类内部的属性数据进行隐藏,避免外部直接随意修改,保证数据的安全性;
  • 合理暴露:对外提供统一的、受控的访问与操作入口,通过方法实现对数据的校验与处理。

在Java代码层面,通过访问修饰符实现隐藏与暴露的控制:

  • private(私有):被修饰的成员只能在当前类内部访问,实现成员的隐藏;
  • public(公开):被修饰的成员可以在任意类中访问,实现成员的暴露。

4.3 封装的标准实现

规范的封装实现,通常会将类的成员变量用private修饰进行隐藏,然后为每个成员变量提供public修饰的getter(获取值)和setter(设置值)方法,实现对属性的受控访问。

示例代码:

public class Student {
    // 私有成员变量,外部无法直接访问
    private String name;
    private double chinese;
    private double math;

    // 公开的getter/setter方法
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public double getChinese() {
        return chinese;
    }

    public void setChinese(double chinese) {
        // 可以在setter方法中添加数据校验逻辑
        if (chinese >= 0 && chinese <= 100) {
            this.chinese = chinese;
        } else {
            System.out.println("语文成绩输入不合法,必须在0-100之间");
        }
    }

    public double getMath() {
        return math;
    }

    public void setMath(double math) {
        if (math >= 0 && math <= 100) {
            this.math = math;
        } else {
            System.out.println("数学成绩输入不合法,必须在0-100之间");
        }
    }

    // 成绩处理方法
    public void printTotalScore() {
        System.out.println(name + "同学的各科总分是:" + (chinese + math));
    }
}

通过封装,我们可以在setter方法中添加数据校验逻辑,避免非法数据的写入,保证了对象数据的安全性与完整性,这也是封装的核心价值之一。


五、JavaBean(实体类)规范

5.1 实体类的定义

JavaBean也称为实体类,是Java开发中一种符合特定规范的特殊类,其核心作用是对业务数据进行封装,仅负责数据的存取,是实现数据与业务逻辑分离的基础。

5.2 实体类的规范要求

一个标准的JavaBean实体类,必须满足以下两个核心要求:

  1. 类中的所有成员变量必须用private修饰私有化,并为每个成员变量提供对应的public修饰的getter和setter方法;
  2. 类中必须提供一个无参构造器,有参构造器为可选。

5.3 实体类的设计思想

实体类的设计遵循数据与业务处理相分离的原则:实体类的对象仅负责数据的存储与读取,而对数据的业务处理逻辑,交由专门的业务处理类来实现。这种设计方式可以让代码职责更加清晰,提升代码的可维护性与可扩展性。

标准实体类示例:

/**
 * 用户实体类,仅负责用户数据的存取
 */
public class User {
    // 私有成员变量
    private Long id;
    private String username;
    private Integer age;

    // 必须的无参构造器
    public User() {
    }

    // 可选的有参构造器
    public User(Long id, String username, Integer age) {
        this.id = id;
        this.username = username;
        this.age = age;
    }

    // 所有成员变量的getter/setter方法
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

业务处理类示例:

/**
 * 用户业务处理类,负责用户相关的业务逻辑
 */
public class UserOperator {
    private User user;

    public UserOperator(User user) {
        this.user = user;
    }

    // 业务方法:判断用户是否成年
    public boolean isAdult() {
        return user.getAge() >= 18;
    }
}

六、static关键字

static关键字意为静态,可以用于修饰类的成员变量、成员方法,被修饰的成员称为静态成员,属于类本身,而非类的实例对象。

6.1 静态变量与实例变量

成员变量根据是否被static修饰,分为静态变量(类变量)和实例变量两类,二者的核心区别如下表:

特性静态变量(类变量)实例变量(对象变量)
修饰符被static修饰无static修饰
所属主体属于类本身属于每个实例对象
内存存储内存中仅存在一份,与类一起加载每个对象都有独立的一份,随对象创建
访问方式推荐:类名.变量名;也可通过对象访问仅能通过对象名.变量名访问
共享特性被该类的所有实例对象共享每个对象独有,互不影响

6.1.1 静态变量的使用示例

public class User {
    // 静态变量:统计创建的用户对象数量,属于类,所有对象共享
    public static int number;
    // 实例变量:每个用户的用户名,属于每个对象
    private String username;

    public User() {
        // 每创建一个对象,静态变量number自增1
        User.number++;
    }

    // getter/setter省略
}
public class UserTest {
    public static void main(String[] args) {
        // 类名直接访问静态变量,初始值为0
        System.out.println(User.number);

        // 创建3个用户对象
        new User();
        new User();
        new User();

        // 静态变量值变为3,被所有对象共享
        System.out.println(User.number);
    }
}

执行结果:

0
3

6.1.2 静态变量的应用场景

当某个数据只需要一份,且需要被该类的所有实例对象共享访问、修改时,该数据就适合定义为静态变量。例如:系统配置项、计数器、全局常量等。

6.2 静态方法与实例方法

成员方法根据是否被static修饰,分为静态方法(类方法)和实例方法两类,二者的核心区别如下:

特性静态方法(类方法)实例方法
修饰符被static修饰无static修饰
所属主体属于类本身属于每个实例对象
访问方式推荐:类名.方法名();也可通过对象调用仅能通过对象名.方法名()调用
访问限制只能直接访问静态成员,不能直接访问实例成员和this关键字可以直接访问静态成员和实例成员,可使用this

6.2.1 静态方法的典型应用:工具类设计

静态方法最核心的应用场景是设计工具类。工具类是一种仅提供通用功能方法的类,其中的方法都是静态方法,每个方法完成一个通用的功能,供开发者直接调用,无需创建对象。

使用静态方法设计工具类的优势:

  1. 调用方便,直接通过类名即可调用方法,无需实例化对象;
  2. 节省内存,避免了创建大量仅用于调用方法的对象,减少了堆内存的开销。

工具类的设计规范:

  1. 工具类中的所有方法都定义为静态方法;
  2. 将工具类的构造器私有化,避免外部创建该类的实例对象。

标准工具类示例:

/**
 * 字符串处理工具类
 */
public class StringUtils {
    // 私有化构造器,禁止外部实例化
    private StringUtils() {
    }

    /**
     * 判断字符串是否为空
     * @param str 待判断的字符串
     * @return 为空返回true,否则返回false
     */
    public static boolean isEmpty(String str) {
        return str == null || str.trim().length() == 0;
    }

    /**
     * 判断字符串是否为邮箱格式
     * @param email 待判断的邮箱字符串
     * @return 符合邮箱格式返回true,否则返回false
     */
    public static boolean isEmail(String email) {
        if (isEmpty(email)) {
            return false;
        }
        String emailRegex = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
        return email.matches(emailRegex);
    }
}

工具类的使用示例:

public class UtilsTest {
    public static void main(String[] args) {
        // 直接通过类名调用静态方法
        System.out.println(StringUtils.isEmpty(""));
        System.out.println(StringUtils.isEmail("test@163.com"));
    }
}

6.3 static关键字的使用注意事项

  1. 静态方法中可以直接访问类中的静态成员,不可以直接访问实例成员;
  2. 实例方法中既可以直接访问静态成员,也可以直接访问实例成员;
  3. 实例方法中可以使用this关键字,静态方法中不可以使用this关键字;
  4. 在本类中访问静态成员时,可以省略类名不写;访问其他类的静态成员时,必须携带类名访问。

七、综合实践:电影信息展示系统

7.1 需求说明

  1. 展示系统中的全部电影基础信息(电影名称、票价);
  2. 支持用户根据电影编号(ID)查询电影的详细信息。

7.2 实现思路

  1. 设计电影实体类Movie,封装电影的相关属性,符合JavaBean规范;
  2. 设计电影业务处理类MovieSystem,实现电影列表展示、根据ID查询电影详情的业务逻辑;
  3. 编写测试主类,完成功能的测试验证。

7.3 代码实现

7.3.1 电影实体类

/**
 * 电影实体类,封装电影数据
 */
public class Movie {
    // 电影属性
    private int id;             // 电影编号
    private String name;        // 电影名称
    private double price;       // 电影票价
    private double score;       // 电影评分
    private String director;    // 导演
    private String actor;       // 主演
    private String description; // 电影简介

    // 无参构造器
    public Movie() {
    }

    // 有参构造器
    public Movie(int id, String name, double price, double score, String director, String actor, String description) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.score = score;
        this.director = director;
        this.actor = actor;
        this.description = description;
    }

    // getter/setter方法
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    public double getScore() {
        return score;
    }

    public void setScore(double score) {
        this.score = score;
    }

    public String getDirector() {
        return director;
    }

    public void setDirector(String director) {
        this.director = director;
    }

    public String getActor() {
        return actor;
    }

    public void setActor(String actor) {
        this.actor = actor;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }
}

7.3.2 电影系统业务处理类

/**
 * 电影信息系统业务处理类
 */
public class MovieSystem {
    // 存储电影数据的数组
    private Movie[] movies;

    // 初始化电影数据
    public MovieSystem() {
        movies = new Movie[4];
        movies[0] = new Movie(1, "长津湖之水门桥", 45.0, 9.6, "徐克", "吴京、易烊千玺", "抗美援朝战争题材电影");
        movies[1] = new Movie(2, "一点就到家", 38.0, 8.7, "许宏宇", "刘昊然、彭昱畅", "农村电商创业题材电影");
        movies[2] = new Movie(3, "月球陨落", 52.0, 7.9, "罗兰·艾默里奇", "哈莉·贝瑞", "科幻灾难题材电影");
        movies[3] = new Movie(4, "出拳吧,妈妈", 35.0, 7.9, "唐晓白", "谭卓、田雨", "女性励志题材电影");
    }

    /**
     * 展示全部电影的基础信息
     */
    public void showAllMovies() {
        System.out.println("===== 系统全部电影信息 =====");
        for (Movie movie : movies) {
            System.out.println("电影编号:" + movie.getId() +
                    " | 电影名称:" + movie.getName() +
                    " | 电影票价:" + movie.getPrice() + "元");
        }
        System.out.println("============================");
    }

    /**
     * 根据电影编号查询电影详情
     * @param id 电影编号
     * @return 查到的电影对象,未查到返回null
     */
    public Movie queryMovieById(int id) {
        for (Movie movie : movies) {
            if (movie.getId() == id) {
                return movie;
            }
        }
        return null;
    }
}

7.3.3 测试主类

public class MovieTest {
    public static void main(String[] args) {
        // 初始化电影系统
        MovieSystem movieSystem = new MovieSystem();

        // 1. 展示全部电影基础信息
        movieSystem.showAllMovies();

        // 2. 根据ID查询电影详情
        int queryId = 2;
        Movie movie = movieSystem.queryMovieById(queryId);
        if (movie != null) {
            System.out.println("\n===== 电影详情信息 =====");
            System.out.println("电影编号:" + movie.getId());
            System.out.println("电影名称:" + movie.getName());
            System.out.println("电影评分:" + movie.getScore() + "分");
            System.out.println("电影票价:" + movie.getPrice() + "元");
            System.out.println("导演:" + movie.getDirector());
            System.out.println("主演:" + movie.getActor());
            System.out.println("电影简介:" + movie.getDescription());
            System.out.println("=========================");
        } else {
            System.out.println("未查询到编号为" + queryId + "的电影");
        }
    }
}

7.4 执行结果

===== 系统全部电影信息 =====
电影编号:1 | 电影名称:长津湖之水门桥 | 电影票价:45.0元
电影编号:2 | 电影名称:一点就到家 | 电影票价:38.0元
电影编号:3 | 电影名称:月球陨落 | 电影票价:52.0元
电影编号:4 | 电影名称:出拳吧,妈妈 | 电影票价:35.0元
============================

===== 电影详情信息 =====
电影编号:2
电影名称:一点就到家
电影评分:8.7分
电影票价:38.0元
导演:许宏宇
主演:刘昊然、彭昱畅
电影简介:农村电商创业题材电影
=========================

本文包含异常概念、体系分类、处理方式、自定义异常四大部分。

一、什么是异常

异常就是Java程序在编译或运行过程中出现的错误,会导致程序非正常终止。

开发中最常见的异常场景:

  1. 数组索引越界:访问了数组不存在的下标
  2. 算术异常:除数为0
  3. 空指针异常:调用null对象的方法
  4. 文件不存在:读取本地不存在的文件
// 常见运行时异常示例
public class ExceptionTest {
    public static void main(String[] args) {
        int[] arr = {10, 20, 30};
        System.out.println(arr[3]); // 数组索引越界异常
        System.out.println(10 / 0); // 算术异常
        String str = null;
        System.out.println(str.length()); // 空指针异常
    }
}

异常的核心作用

  1. 定位BUG:异常信息会精准打印错误位置和原因,是调试程序的关键
  2. 流程通知:作为方法的特殊返回值,告诉调用者方法执行失败的原因

二、Java异常完整体系

Java中所有异常和错误的根类是 java.lang.Throwable,整体分为两大分支:

1. Error(系统级错误)

属于JVM底层的严重问题(如内存溢出、系统崩溃),也无法通过代码处理。

2. Exception(程序异常,开发核心处理对象)

程序运行/编译中可预见的错误,我们所有异常处理都围绕这个类展开,又细分为运行时异常和编译时异常。

三、异常的两大分类

1. 运行时异常(RuntimeException)

  • 定义:RuntimeException 及其所有子类
  • 特点:编译阶段不报错,运行时才触发
  • 常见示例:数组越界、空指针、算术异常、类型转换异常

2. 编译时异常(受检异常)

  • 定义:非 RuntimeException 的Exception子类
  • 特点:编译阶段强制报错,必须处理才能运行
  • 常见示例:日期解析异常、文件读取异常、IO流异常
// 编译时异常示例(必须处理,否则编译失败)
import java.text.SimpleDateFormat;
import java.util.Date;

public class CompileException {
    public static void main(String[] args) throws Exception {
        String time = "2024-07-09";
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
        Date date = sdf.parse(time); // 日期解析异常(编译时异常)
    }
}

四、异常的两种核心处理方式

Java提供了抛出异常和捕获异常两种处理方案,开发中可根据场景灵活使用。

1. 抛出异常(throws)

  • 作用:方法内部不处理异常,抛给调用者处理
  • 语法:在方法声明后添加 throws 异常类名
  • 简化写法:直接抛出 Exception 接收所有异常
// 抛出异常示例
public class ThrowException {
    public static void main(String[] args) throws Exception {
        // 调用者接收异常,也可继续抛出
        testException();
    }

    // 方法抛出异常,交给上层处理
    public static void testException() throws Exception {
        int num = 10 / 0;
    }
}

2. 捕获异常(try...catch)

  • 作用:直接拦截并处理异常,保证程序不终止
  • 语法:

    try {
        // 可能出现异常的代码
    } catch (异常类型 变量名) {
        // 异常处理逻辑
    }
  • 简化写法:catch (Exception e) 捕获所有异常
// 捕获异常示例
public class TryCatchException {
    public static void main(String[] args) {
        try {
            int num = 10 / 0;
        } catch (Exception e) {
            e.printStackTrace(); // 打印异常堆栈信息
            System.out.println("程序出现异常,已处理!");
        }
        // 异常处理后,程序继续执行
        System.out.println("程序正常结束");
    }
}

五、自定义异常

Java内置的异常类无法覆盖所有业务场景(如年龄非法、手机号格式错误),此时需要自定义异常来管理业务错误。

自定义异常的两种类型

1. 自定义编译时异常

  • 继承:Exception
  • 特点:编译强制处理,提醒严格
// 自定义编译时异常
public class AgeIllegalException extends Exception {
    // 无参构造
    public AgeIllegalException() {}
    // 带异常信息的构造
    public AgeIllegalException(String message) {
        super(message);
    }
}

2. 自定义运行时异常

  • 继承:RuntimeException
  • 特点:编译不报错,运行触发,开发更常用
// 自定义运行时异常
public class AgeIllegalRuntimeException extends RuntimeException {
    public AgeIllegalRuntimeException() {}
    public AgeIllegalRuntimeException(String message) {
        super(message);
    }
}

自定义异常使用示例

public class CustomExceptionTest {
    public static void main(String[] args) {
        try {
            saveAge(300); // 传入非法年龄
        } catch (AgeIllegalException e) {
            e.printStackTrace();
            System.out.println("年龄校验失败!");
        }
    }

    // 业务方法:校验年龄并抛出自定义异常
    public static void saveAge(int age) throws AgeIllegalException {
        if(age < 1 || age > 200){
            throw new AgeIllegalException("年龄非法,范围必须是1-200");
        }
        System.out.println("年龄保存成功:" + age);
    }
}

六、企业级异常处理实践

  1. 分层抛出,统一捕获
    底层方法不处理异常,全部向上抛出,最外层统一捕获,打印日志并返回友好提示
  2. 异常重试修复
    针对用户输入错误等场景,捕获异常后引导用户重新输入,保证程序不中断
// 异常重试修复示例
import java.util.Scanner;

public class ExceptionRetry {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        while (true) {
            try {
                System.out.print("请输入商品价格:");
                double price = sc.nextDouble();
                System.out.println("价格设置成功:" + price);
                break;
            } catch (Exception e) {
                System.out.println("输入格式错误,请重新输入数字!");
                sc.next(); // 清空错误输入
            }
        }
    }
}

七、总结

  1. 异常根类 Throwable,分 Error(无需处理)和 Exception(开发处理)
  2. 异常分类:运行时异常(编译不报错)、编译时异常(编译强制处理)
  3. 处理方式:throws 抛出(上层处理)、try...catch 捕获(自行处理)
  4. 自定义异常:继承 Exception(编译时)/RuntimeException(运行时)
  5. 开发规范:底层抛异常,上层统一捕获,避免程序崩溃