第9章:数组与指针:地址计算与指针运算

这一章跟着柯南追踪"数据流":数组在内存里到底怎么铺?指针 +1 到底加了多少?为什么数组一传进函数就"缩水"?每一处结论都用本机实测取证

🔍

本章导师:柯南

核心方法论:追踪数据流

「案发现场是一段连续的内存,受害者是一个数组,唯一的线索是每个元素头顶上的地址。很多程序员写了几年代码,却说不清 a[1] 和 *(a+1) 为什么是同一件事,也说不清 p+1 到底往前走了几步。真相只有一个,但这次我们不猜——把地址一个一个打出来,跟着数据流走一遍,案就破了。」

9.1 数组的内存真相:连续的一段地址

第 7、8 章我们研究透了单个变量在内存里的"长相"(大小端、补码、IEEE 754),这一章开始追踪更复杂的猎物——数组。说人话:数组就是一段连续的内存格子,每个格子装一个同类型的元素,格子之间首尾相接、不留缝隙。当编译器看到 int a[3],它会在栈上一次性划出 3 × 4 = 12 字节的连续空间(4 是 int 的大小),并且向你保证:这 12 个字节是"一整块"的——绝不是东一块西一块拼出来的。这个"连续"不是细节,它决定了后面所有地址计算的规则。

既然是一整块,三兄弟 a[0]a[1]a[2] 的地址就应该像排队一样挨着。怎么证明?把地址打出来。在 printf 里用 %p(pointer 的缩写,以十六进制打印指针/地址的格式化占位符),就能看到每个元素住在哪:

// addr.cpp —— 打印数组每个元素的地址
#include <cstdio>
int main() {
    int a[3] = {10, 20, 30};
    printf("&a[0] = %p\n", (void*)&a[0]);
    printf("&a[1] = %p\n", (void*)&a[1]);
    printf("&a[2] = %p\n", (void*)&a[2]);
    printf("a     = %p\n", (void*)a);    // 数组名在表达式中退化为指针
    printf("&a    = %p\n", (void*)&a);   // 取整个数组的地址
    printf("sizeof(a) = %zu\n", sizeof(a));
    printf("a[1] = %d, *(a+1) = %d\n", a[1], *(a+1));  // 下标 ≡ 指针
    return 0;
}
# 运行输出(本机真实输出):
&a[0] = 0x16fcb60d8
&a[1] = 0x16fcb60dc
&a[2] = 0x16fcb60e0
a     = 0x16fcb60d8
&a    = 0x16fcb60d8
sizeof(a) = 12
a[1] = 20, *(a+1) = 20

把输出画成格子图,一切一目了然:三个元素从 0x…d8 开始一个接一个排到 0x…e0,每个格子正好 4 字节(int 的大小),相邻地址的差恒为 4。注意 a&a[0] 打印出来是同一个数值,而 sizeof(a) 是 12——"整个数组"的大小。最后一行更关键:a[1]*(a+1) 结果一样——编译器眼里根本没有"下标",下标只是"指针加偏移再解引用"的语法糖a[i] 等价于 *(a+i),这是标准写死的等价关系。

# 内存布局(每个格子 = 4 字节;地址取自上面这次真实运行)
0x16fcb60d8 ┌──────────┐
            │ a[0] = 10<- &a[0]
0x16fcb60dc ├──────────┤
            │ a[1] = 20<- &a[0] + 4 字节
0x16fcb60e0 ├──────────┤
            │ a[2] = 30<- &a[0] + 8 字节
0x16fcb60e4 └──────────┘ <- 数组结束(12 字节用尽)
@连续 三个格子首尾相接,地址差恒等于元素大小 4 字节
元素地址(一次实测)与 &a[0] 的差结论
a[0]0x16fcb60d80起点(基地址)
a[1]0x16fcb60dc4 字节相差一个 int
a[2]0x16fcb60e08 字节相差两个 int
柯南提示

细心的你会问:为什么我运行的结果和你贴的地址不一样?因为现代操作系统有 ASLR(地址空间布局随机化,每次启动程序都随机挑一块内存区域放栈和堆,用于防攻击)。所以柯南的取证原则是:看关系,不看绝对值——地址每次都在变,但"相邻差 4、总共 12"这些关系永远不变。规律才是线索,数字只是线索的载体。

注意:用 %p 打印地址,必须把指针强转成 (void*) 再传进去,否则是未定义行为(undefined behavior,标准不承诺任何结果的程序行为)——多数编译器会警告,但标准允许它打印出任何东西。写 printf("%p", (void*)&a[0]),别偷懒。

9.2 指针的运算规则:p+1 到底加了多少

柯南的放大镜对准下一个疑点:p + 1 到底在地址上加了多少?直觉会说是"1 个字节"——错了。指针的加法是"带单位的加法",单位就是它指向的类型的大小。这个"走一步跨多远"的距离叫步长(stride,指针算术中一次位移对应的字节数):int* 的 +1 是 +4 字节,char* 的 +1 是 +1 字节,double* 的 +1 是 +8 字节。同样是"加 1",三种指针在内存里走的距离完全不同。

光说不练假把式。用 sizeof 查类型大小,再用"指针减指针"实测三种指针的步长——注意先把指针转成 char* 再相减,因为指针相减的结果单位也是"元素",只有 char 的步长是 1 字节,才能直接读出字节数:

// stride.cpp —— p+1 到底加了多少?sizeof + 指针减法实测
#include <cstdio>
int main() {
    printf("sizeof(int)    = %zu 字节\n", sizeof(int));
    printf("sizeof(char)   = %zu 字节\n", sizeof(char));
    printf("sizeof(double) = %zu 字节\n", sizeof(double));
    printf("sizeof(int*)   = %zu 字节(指针本身大小,本机 64 位)\n\n", sizeof(int*));
    int    arr_i[3] = {1, 2, 3};
    char   arr_c[3] = {'a', 'b', 'c'};
    double arr_d[3] = {1.0, 2.0, 3.0};
    int*    p_i = arr_i;
    char*   p_c = arr_c;
    double* p_d = arr_d;
    printf("int 指针 p_i:    %p -> %p, 步长 %ld 字节\n",
           (void*)p_i, (void*)(p_i + 1), (long)((char*)(p_i+1) - (char*)p_i));
    printf("char 指针 p_c:   %p -> %p, 步长 %ld 字节\n",
           (void*)p_c, (void*)(p_c + 1), (long)((char*)(p_c+1) - (char*)p_c));
    printf("double 指针 p_d: %p -> %p, 步长 %ld 字节\n",
           (void*)p_d, (void*)(p_d + 1), (long)((char*)(p_d+1) - (char*)p_d));
    printf("\n&arr_i[0] = %p, arr_i = %p, 相等: %d\n",
           (void*)&arr_i[0], (void*)arr_i, &arr_i[0] == arr_i);
    return 0;
}
# 运行输出(本机真实输出):
sizeof(int)    = 4 字节;sizeof(char)   = 1 字节;sizeof(double) = 8 字节
sizeof(int*)   = 8 字节(指针本身大小,本机 64 位)
int 指针 p_i:    0x16b8aa0c8 -> 0x16b8aa0cc, 步长 4 字节
char 指针 p_c:   0x16b8aa0a8 -> 0x16b8aa0a9, 步长 1 字节
double 指针 p_d: 0x16b8aa0b0 -> 0x16b8aa0b8, 步长 8 字节
&arr_i[0] = 0x16b8aa0c8, arr_i = 0x16b8aa0c8, 相等: 1
指针类型指向类型大小 sizeofp+1 跳过的字节说人话
int*sizeof(int) = 44 字节每格 4 字节,一步跨一格
char*sizeof(char) = 11 字节每格 1 字节,逐字节勘察
double*sizeof(double) = 88 字节每格 8 字节,一步跨一大格
柯南提示

char* 是逐字节勘察内存的"一格格走"工具:网络协议解析、文件格式解析、内存 dump(把一段内存原样打印出来)全指着它——步长 1 字节,可以精确落到内存的每一个字节上。而 &a[0]a 数值相等,是因为数组名在表达式里自动退化为指向首元素的指针——这个"退化"(decay)机制是 9.4 节的主角,先记住结论:打印出来一样,类型不一样

注意:指针越界是静默杀手。p + 100 编译器不拦,因为标准认为"你比编译器更懂你的数据";真要访问那个地址(解引用)才是未定义行为——可能碰巧正常,也可能触发段错误(segmentation fault,程序访问了不属于自己的内存,操作系统直接开枪)当场崩溃。指针算术本身是安全的,危险的是拿着算出来的地址去解引用。

顺带破一个小案:&a[0]a 是同一个数,但类型完全不同——a 的类型是 int[3](数组类型),&a 的类型是 int(*)[3](指向整个数组的指针)。类型不同,步长就不同:&a[0] + 1 只跳 4 字节,而 &a + 1 直接跳过整个数组 12 字节。实测为证:

// probe.cpp —— &a+1 与 &a[0]+1 的步长对决
#include <cstdio>
int main() {
    int a[3] = {10, 20, 30};
    printf("&a      = %p\n", (void*)&a);
    printf("&a+1    = %p   (跳过整个数组)\n", (void*)(&a + 1));
    printf("跳过字节数 = %ld\n", (long)((char*)(&a+1) - (char*)&a));
    printf("&a[0]+1 = %p   (只跳一个元素)\n", (void)(&a[0] + 1));
    return 0;
}
# 运行输出(本机真实输出):
&a    = 0x16af6a0c8;&a+1  = 0x16af6a0d4;跳过字节数 = 12
&a[0]+1 = 0x16af6a0cc(只跳一个元素)

9.3 多维数组的存储:按行优先的地址计算

把数组叠成二维:int a[3][4]。先别被"二维"骗了——内存是线性的,根本没有"面"。编译器把 a[3][4] 理解为"3 个一维数组,每个一维数组里有 4 个 int",即数组的数组。整块内存依然连续,总共 3 × 4 × 4 = 48 字节。关键问题:第 0 行铺完之后,第 1 行从哪个地址开始?

答案是按行优先(row-major,先把一整行铺完,再铺下一行)。于是每个元素的地址都能用一条公式算出来:&a[i][j] = 基地址 + (i × 列数 + j) × sizeof(int)。对 a[3][4] 来说,a[1][2] 的偏移就是 1×4+2 = 6 个元素。公式记不住没关系,下面把 12 个格子的地址全部打出来,与公式逐一核对——柯南办案讲究证据链完整:

// md.cpp —— 二维数组按行优先平铺,地址公式逐一验证
#include <cstdio>
int main() {
    int a[3][4] = {
        { 0,  1,  2,  3},
        {10, 11, 12, 13},
        {20, 21, 22, 23}
    };
    printf("sizeof(a)      = %zu 字节(整个 3x4 数组)\n", sizeof(a));
    printf("sizeof(a[0])   = %zu 字节(一行,4 个 int)\n", sizeof(a[0]));
    printf("sizeof(a[0][0])= %zu 字节(一个元素)\n\n", sizeof(a[0][0]));
    printf("第 0 行首地址 &a[0][0] = %p\n", (void*)&a[0][0]);
    printf("第 1 行首地址 &a[1][0] = %p\n", (void*)&a[1][0]);
    printf("第 2 行首地址 &a[2][0] = %p\n\n", (void*)&a[2][0]);
    printf("行间差 = %ld 字节(= 4 个 int)\n\n",
           (long)&a[1][0] - (long)&a[0][0]);
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 4; j++)
            printf("a[%d][%d] 地址 %p  元素值 %2d  公式验证偏移 = %2ld\n",
                   i, j, (void*)&a[i][j], a[i][j],
                   (long)(&a[i][j] - &a[0][0]));
    printf("\na[1][2] vs *(*(a+1)+2) = %d / %d\n", a[1][2], *(*(a+1)+2));
    return 0;
}
# 运行输出(本机真实输出,节选):
sizeof(a)      = 48 字节;sizeof(a[0]) = 16 字节;sizeof(a[0][0]) = 4 字节
第 0 行首地址 &a[0][0] = 0x16f7a60a8;第 1 行 = 0x16f7a60b8;第 2 行 = 0x16f7a60c8
行间差 = 16 字节(= 4int);偏移 0..11 全部与公式吻合(见下表)
a[1][2] vs *(*(a+1)+2) = 12 / 12
# 地址公式:一维与二维的通式(T 是元素类型,如 int)
&a[i]    = 基地址 + i × sizeof(T)                    // 一维数组
&a[i][j] = 基地址 + (i × 列数 + j) × sizeof(T)      // 二维数组,行优先
@例 int a[3][4],a[1][2] 偏移 = 1×4+2 = 6int = 24 字节
元素 a[i][j]公式偏移 i×4+j实测偏移(元素差)验证
a[0][0]00吻合
a[0][1]11吻合
a[1][0]44吻合(第 1 行紧接第 0 行)
a[1][2]66吻合
a[2][0]88吻合
a[2][3]1111吻合(最后一块)
柯南提示

为什么"按行遍历"比"按列遍历"快?因为 Cache(高速缓存,第 1 章讲过:主存的"常用抽屉")是成块搬运数据的。按行访问时 a[0][0]a[0][1]a[0][2] 在内存里紧挨着,一次就被整块读进缓存,后面几个元素直接命中;按列访问则在 48 字节里跳来跳去,每次都得去慢吞吞的主存现取。内存布局决定性能直觉——这就是为什么要把地址算得明明白白。

注意:公式里的"列数"千万别写错。把 a[3][4] 的偏移写成 i×3+j(拿行数当了列数),程序不会报错,但你会读到"隔壁家"的数据——多维数组越界是静默的,编译器根本不检查。同理,访问不存在的 a[3][0](第 4 行)也是越界。写下标时心里默念:偏移 = 行 × 列数 + 列

9.4 数组与指针的经典陷阱:数组名不是指针

数组名用起来像指针:能赋值给 int*,能下标访问,打印出来还是个地址。但柯南要揭穿这个"伪装":数组名根本不是指针变量。真相是:数组名在绝大多数表达式中会退化(decay,自动转换成指向首元素的指针)——所以 int* p = a; 合法。但数组名本身没有自己的存储空间、不是左值,因此不能 a++、不能 a = p;。实测编译器对 a++ 的判决,铁证如山:

# clang++ aplus.cpp(真实编译错误):
error: cannot increment value of type 'int[3]'
    2 | int main(){ int a[3]={1,2,3}; a++; return 0; }
      |                               ~^
@解读 "cannot increment value of type 'int[3]'"——数组名不是可修改的左值,想挪它?没门

a++ 更阴险的是 sizeof 陷阱。在 main 里,sizeof(a) 是"整个数组"的大小 20 字节(5 个 int × 4);但一旦把数组传进函数,形参 int arr[]int* arr 完全等价——参数退化为指针sizeof(arr) 只剩 8 字节(指针大小)。同一个名字,在函数内外"体型"完全不同,编译器甚至会专门发一条警告提醒你:

// trap.cpp —— 数组名陷阱:sizeof 双面性与参数退化
#include <cstdio>
void f(int arr[]) {  // 形参 int arr[] 与 int* arr 完全等价
    printf("  函数内 sizeof(arr) = %zu —— 参数已退化为指针!\n", sizeof(arr));
}
void g(int* p) {
    printf("  函数内 sizeof(p)   = %zu —— 与 int* 参数完全一致\n", sizeof(p));
}
int main() {
    int a[5] = {1, 2, 3, 4, 5};
    printf("main 中 sizeof(a) = %zu(整个数组 5*4 字节)\n", sizeof(a));
    printf("main 中 sizeof(&a[0]) = %zu(一个指针)\n\n", sizeof(&a[0]));
    printf("调用 f(a):\n"); f(a);
    printf("调用 g(a):\n"); g(a);
    int* p = a;
    printf("\np = a 之后:p[2] = %d, *(p+2) = %d, a[2] = %d\n", p[2], *(p+2), a[2]);
    printf("p++ 之后 p 指向 a[1]:*p = %d\n", *(p++));
    return 0;
}
# 编译(真实警告,clang++):
warning: sizeof on array function parameter will return size of 'int *' instead of 'int[]' [-Wsizeof-array-argument]
# 运行输出(本机真实输出):
main 中 sizeof(a) = 20(整个数组);main 中 sizeof(&a[0]) = 8(一个指针)
调用 f(a):函数内 sizeof(arr) = 8;调用 g(a):函数内 sizeof(p) = 8
p = a 之后:p[2] = 3, *(p+2) = 3, a[2] = 3;p++ 之后 *p = 1
表达式 / 场景sizeof 结果原因
main 中 sizeof(a)(a 是 int a[5]20类型是 int[5],5×4 字节
main 中 sizeof(&a[0])8&a[0] 是指针,不是数组
函数内 sizeof(arr)(形参 int arr[]8形参已退化为 int*
函数内 sizeof(p)(形参 int* p8本来就是指针
柯南提示

想保住"数组类型"?把参数声明成引用:void f(int (&arr)[5]),这样 sizeof(arr) 还是 20,传错长度编译器直接报错。但更实用、更 C++ 的做法是:传数组时把长度一起传进去void f(int* arr, int n))——标准库的 std::vector 也是这个思路:数据和长度永不分离。从今往后,凡是"数组参数",第一反应就是问"长度传了没"。

注意:那条 -Wsizeof-array-argument 警告是友军,它明明白白告诉你"这里 sizeof 的是指针"。无视它的下场很经典:函数里写 for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) 想遍历"整个数组",结果 sizeof(arr)/sizeof(arr[0]) 是 8/4 = 2——只处理了前两个元素,后面三个悄悄失踪。这是初学者最容易踩的坑,没有之一。

9.5 实战:用指针遍历与修改数组

理论归理论,跟着柯南做一个小案子:一个班 6 个学生的成绩,老师心软统一加 5 分,然后算总分和平均分,最后把数组整个倒过来。全程指针操作——不对,下标也一起用,实战里两者本就混着来,谁方便用谁:

// practice.cpp —— 用指针遍历、修改、反转数组
#include <cstdio>
int main() {
    int scores[6] = {72, 85, 91, 66, 78, 88};
    int n = 6;
    printf("原始成绩:");
    for (int i = 0; i < n; i++) printf("%d ", scores[i]);
    printf("\n");
    // 1) 指针遍历:每个元素 +5(老师心软,全员加 5 分)
    for (int* p = scores; p < scores + n; p++) {
        *p += 5;   // 解引用后自增,等价 scores[i] += 5
    }
    printf("加 5 分后:");
    for (int* p = scores; p < scores + n; p++) printf("%d ", *p);
    printf("\n");
    // 2) 指针求和
    int sum = 0;
    for (int* p = scores; p < scores + n; p++) sum += *p;
    printf("总分 = %d,平均 = %.1f\n", sum, (double)sum / n);
    // 3) 双指针原地反转(头尾交换,相向而行)
    for (int* lo = scores, *hi = scores + n - 1; lo < hi; lo++, hi--) {
        int t = *lo; *lo = *hi; *hi = t;   // 经典三行交换
    }
    printf("反转后:");
    for (int i = 0; i < n; i++) printf("%d ", scores[i]);
    printf("\n");
    // 4) 指针 + 下标混用
    printf("scores[2] = %d,*(scores+2) = %d\n", scores[2], *(scores + 2));
    return 0;
}
# 运行输出(本机真实输出):
原始成绩:72 85 91 66 78 885 分后:77 90 96 71 83 93
总分 = 510,平均 = 85.0
反转后:93 83 71 96 90 77
scores[2] = 71,*(scores+2) = 71

三个要点。第一,遍历的循环边界是哨兵指针(sentinel pointer,指向"数组末尾之后一个位置"的指针)scores + n:条件写 p < scores + n,指针一路 +1 直到越过最后一个元素——比数下标更贴近"内存视角",也天然免疫"数组长度改变"的麻烦。第二,反转用双指针lo 从前往后、hi 从后往前,每次交换两端值,相向而行直到交叉(lo < hi 不成立)。第三,*p += 5 是"解引用 + 自增"的组合拳:先拿到格子里的值,加 5 再写回去——和 scores[i] += 5 是同一件事的两种写法。

// 反转的核心骨架(上面第 3 步的提炼)
for (int* lo = arr, *hi = arr + n - 1; lo < hi; lo++, hi--) {
    int t = *lo; *lo = *hi; *hi = t;
}
@边界 n == 0arr + n - 1 越界;条件必须写 lo < hi,不能写 lo != hi
操作指针写法下标写法等价性
第 k 个元素加 5*p += 5a[k] += 5完全相同
求和sum += *psum += a[i]完全相同
取第 k 个元素*(p + k)a[k]标准等价
反转双指针 lo/hi 交换临时数组拷贝指针版省内存
柯南提示

"begin/end 哨兵"正是 C++ 标准库迭代器的雏形:std::vectorbegin()end() 就是"指向第一个元素"和"指向末尾之后一个位置"这两个指针的类型安全封装,std::sortstd::for_each 等算法全部建立在这个约定上。学透指针遍历,迭代器对你来说就只是一层语法糖——这也是为什么前辈们敢说"学会指针,你就学会了一半 C++"。

注意:双指针反转有两个边界要命。一是空数组:n == 0scores + n - 1 直接指向数组前面的未知内存,是野指针;二是循环条件:写成 lo != hi 而不是 lo < hi,当数组长度为偶数时两个指针会"擦肩而过"(lo 越过 hi 却不相等),循环停不下来直接越界。反转前先问自己:数组可能为空吗?长度是奇数还是偶数?

9.6 章节小结:跟着数据流回家

把这一章的"数据流"完整串一遍:声明数组 → 编译器划出一整块连续内存 → 数组名在表达式中退化为指针 → 指针算术按"类型单位"走步长 → 多维数组按行优先平铺成一维 → 传参时数组退化成指针、sizeof 随之缩水。整章其实只回答了三类问题:这块内存多大(sizeof)、从哪开始(地址)、走一步多远(步长)。这三问答全了,任何数组/指针代码在你眼里都是透明的。

柯南的结案陈词:以后遇到数组或指针,条件反射地问自己三个问题——内存多大?首地址在哪?指针 +1 跳多远?程序出了问题,取证顺序也一样:先打印 &x[0],再打印 &x[1],看差值对不对——两步就能锁定是"步长错"还是"越界"。

# 柯南的取证口诀:四问四答
内存多大?   sizeof 一测便知         -> int a[3] 就是 12 字节
首址在哪?   &a[0] / a 打印即见      -> 两者数值相同、类型不同
一步多远?   看指向类型               -> int 4 / char 1 / double 8
多维怎么铺? 按行优先                 -> 偏移 = i × 列数 + j
传参之后?   退化成指针               -> sizeof(arr) 只剩 8
// selfcheck.cpp —— 三行代码,把本章结论一次性验完
#include <cstdio>
int main() {
    int a[3][4] = {0};
    printf("sizeof(a)      = %zu 字节\n", sizeof(a));
    printf("a[1] 相对 &a[0][0] 偏移 %ld 字节\n",
           (long)((char*)a[1] - (char*)&a[0][0]));
    printf("a[1][2] = %d, *(*(a+1)+2) = %d\n", a[1][2], *(*(a+1)+2));
    return 0;
}
# 本机真实输出:sizeof(a) = 48;偏移 16 字节;a[1][2] = 0,*(*(a+1)+2) = 0 —— 全部吻合
三问答案出处
这块内存多大?sizeof(a) = 12 / 489.1、9.3
首地址在哪?&a[0]a 数值相同9.2
指针 +1 跳多远?int 4 / char 1 / double 8 字节9.2
柯南提示

养成一个习惯:怀疑一切结论,验证一切规律。本章的每条结论(步长、sizeof、行优先)都只用两行代码就能自己验出来,而且换机器、换编译器结果一致(只有绝对地址会变)。下次被问"指针和数组什么关系",别背定义——直接说:数组名退化成指针,但 sizeof 和 & 面前它还是数组,我用两行代码就能证明。

注意:别死记口诀。口诀是为了调试时快速回忆,不是背完就完——把 selfcheck.cpp 自己编译运行一遍,亲眼看到 48、16、0 这三个数,比读十遍文章都管用。柯南的办案原则永远是:证据优先于记忆,实测优先于直觉。下一章开始,我们会遇到比数组更"有结构"的对象——struct 和 class,那时候"内存多大、首址在哪、步长多少"这三问,会以更精彩的方式继续发挥作用。

章末练习

练习 1:地址推算 入门

一次实测中,int a[3]&a[0] = 0x16fcb60d8。请推算:① &a[1]&a[2];② a&a 打印出来是多少;③ sizeof(a)

提示

回顾 9.1 节:int 是 4 字节,三个格子首尾相接;数组名在表达式中退化为指针,&a 取整个数组的地址。

参考答案

&a[1] = 0x16fcb60dc&a[2] = 0x16fcb60e0(每次 +4);② a&a 都打印为 0x16fcb60d8(数值相等,类型不同:前者退化为 int*,后者是 int(*)[3]);③ sizeof(a) = 12(3 × 4 字节)。

练习 2:步长计算 进阶

double b[5];double* p = b;。① p + 1p 相差多少字节?② &b&b[0] 数值上相等吗?③ &b + 1 跳过多少字节?④ b[2] 用指针怎么写?

提示

double 是 8 字节;&b 的类型是 double(*)[5],指向整个数组;下标 b[i] 等价 *(b+i)。

参考答案

① 8 字节(步长 = sizeof(double));② 相等(都是数组首地址,类型不同);③ 40 字节(整个数组 5 × 8,一步跨完整条街);④ *(p + 2)*(b + 2)

练习 3:sizeof 陷阱 进阶

有函数 void f(int arr[10]) { printf("%zu", sizeof(arr)); },main 里声明 int a[10];。① main 中 sizeof(a)?② main 中 sizeof(&a[0])?③ f 内 sizeof(arr)?④ 为什么③和①不一样?

提示

回顾 9.4 节:形参 int arr[10] 与 int* arr 完全等价,参数退化为指针。

参考答案

① 40(整个数组 10 × 4);② 8(&a[0] 是指针);③ 8(形参已退化为 int*);④ 因为标准规定数组作为函数参数时退化为指向首元素的指针,函数只拿到"首地址"、拿不到"长度"——这正是编译器发 -Wsizeof-array-argument 警告的原因。所以传数组必须同时传长度。

练习 4:指针反转实战 挑战

① 写一个 void reverse(int* arr, int n) 函数,用双指针原地反转数组(不申请新数组),并解释为什么函数内不能用 sizeof(arr) 求元素个数。② 加分项:把 9.5 的反转改成"一边反转一边打印每次交换后的数组",观察双指针从两端向中间收拢的过程。

提示

双指针模板:lo 指向 arr[0],hi 指向 arr[n-1],循环条件 lo < hi,交换后 lo++、hi--;n 的奇偶和空数组都要考虑。

参考答案

for (int* lo = arr, *hi = arr + n - 1; lo < hi; lo++, hi--) { int t = *lo; *lo = *hi; *hi = t; }。不能用 sizeof(arr) 求长度:arr 是形参,已退化为 int*sizeof(arr) 恒为 8(指针大小),所以必须靠参数 n 传递长度。② 在交换后加一行 printf 打印数组即可——你会看到头尾逐渐"翻转",这正是双指针追踪数据流的过程。