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 数组绝大多数静默缺陷。

标签: none

添加新评论