指针与数组

对照 K&R 第五章区分数组对象与指针对象,掌握数组到首元素指针的转换、半开指针范围、函数数组形参、对象生存期和多维数组的行类型与行主序。

学习目标

  • 能区分数组对象、首元素指针和指向整个数组的指针,并逐项说明数组转换发生与不发生的语境
  • 能实现带长度的半开指针范围算法,证明每次加减、比较、相减和解引用都在同一存活数组内
  • 能回答:int matrix[3][4] 为什么转换为 int (*)[4] 而不是 int **matrix + 1 的步长怎样由类型推出?

机制总览

指针与数组:机制路径

  1. 1

    从“数组不是指针”开始

    int a[5] 定义一个包含五个 int 子对象的数组对象, int p 定义一个保存地址值的指针对象。二者大小、可赋值性与类型结构都不同。常见说法“数组名就是指针”只描述了多数表达式中的一次隐式转换,不能拿来替代完整规则。

  2. 2

    数组到指针转换

    在 K&R 对应的 ANSI C 中, sizeof a 和 &a 不触发该转换。前者得到整个数组大小;后者类型是 int ( )[5] ,即指向含五个 int 的数组。

  3. 3

    半开范围约束指针算术

    C 的指针加减不是对任意整数地址做数学。若指针指向数组元素,可形成同一数组内的其他元素地址或 尾后一位指针 ;越过这两端,即使暂不解引用,也不能作为普通数组指针算法依赖。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

指针与数组:机制与证据

切换《指针与数组》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 从“数组不是指针”开始

int a[5] 定义一个包含五个 int 子对象的数组对象, int p 定义一个保存地址值的指针对象。二者大小、可赋值性与类型结构都不同。常见说法“数组名就是指针”只描述了多数表达式中的一次隐式转换,不能拿来替代完整规则。

可核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“数组不是指针”开始」的实际契约。

学完《指针与数组》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

指针与数组:失效与核验

从“数组不是指针”开始

典型失效

若把「从“数组不是指针”开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“数组不是指针”开始」的实际契约。

数组到指针转换

典型失效

若把「数组到指针转换」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「数组到指针转换」的实际契约。

半开范围约束指针算术

典型失效

若把「半开范围约束指针算术」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「半开范围约束指针算术」的实际契约。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

从“数组不是指针”开始

int a[5] 定义一个包含五个 int 子对象的数组对象,int *p 定义一个保存地址值的指针对象。二者大小、可赋值性与类型结构都不同。常见说法“数组名就是指针”只描述了多数表达式中的一次隐式转换,不能拿来替代完整规则。

数组对象不能整体赋值,指针变量可以重新指向别处;sizeof a 计算整个数组对象,sizeof p 计算指针对象。两者具体字节数都由实现和声明决定,不能硬编码 20 或 8。

数组到指针转换

int *p = a; 等价于用 &a[0] 初始化 p。它发生在表达式求值中,不会把变量 a 的声明类型永久改成 int *

在 K&R 对应的 ANSI C 中,sizeof a&a 不触发该转换。前者得到整个数组大小;后者类型是 int (*)[5],即指向含五个 int 的数组。a&a 在常见实现中打印出的起始地址数值相同,但类型不同,a + 1 跨一个元素,&a + 1 跨整个数组。

下标运算定义为 a[i] == *(a + i),甚至 i[a] 语法上也成立;这不表示“所有数组访问编译后必定是某条固定指令”,而是抽象语义等价。只有当结果指向数组内合法元素时才能解引用。

#include <stddef.h>
 
long sum_ints(const int values[], size_t count)
{
    size_t i;
    long total = 0;
    for (i = 0; i < count; ++i)
        total += values[i];
    return total;
}

形参 const int values[] 在函数类型中调整为 const int *valuescount 才给出范围。契约应说明:count == 0 时是否允许空指针;count > 0 时必须存在至少 count 个可读 int 对象;求和还要评估 long 是否可能溢出。

半开范围约束指针算术

C 的指针加减不是对任意整数地址做数学。若指针指向数组元素,可形成同一数组内的其他元素地址或 ;越过这两端,即使暂不解引用,也不能作为普通数组指针算法依赖。

使用 [first, last) 可让空范围自然表示为 first == last,长度为 last - first。指针相减结果类型是 ptrdiff_t,只对同一数组对象(含尾后一位)的两个指针有定义;不相关对象指针做关系比较也没有可移植的数组顺序语义。

void reverse_ints(int *first, int *last)
{
    while (first != last) {
        int temp;
        --last;
        if (first == last)
            break;
        temp = *first;
        *first++ = *last;
        *last = temp;
    }
}

last 初始指向尾后一位,先递减才解引用。奇数长度时两端在中心相遇后退出;空范围不会递减。调用者仍需保证两个指针来自同一可写数组,且 first 不在 last 之后。

对象生存期决定指针能否使用

指针保存地址不会延长对象的 。返回局部自动数组地址的问题不在于标准保证“栈帧被覆盖”,而在于函数返回后数组对象生存期结束;即使字节暂时没变,解引用旧指针也没有合法对象语义。

正确接口可让调用者提供缓冲区和容量,返回写入长度/状态;或动态分配并明确释放责任;使用 static 局部缓冲虽延长生存期,却会共享单份状态、不可重入、后续调用覆盖旧结果。选项之间是所有权和并发协议差异,不只是“哪块内存还在”。

空指针不指向任何对象,不能做解引用或普通数组算术。void * 可以承载对象指针的通用表示并在 C 中与其他对象指针互转,但解引用前必须转换到正确完整对象类型,并满足对齐、有效类型和生存期要求。

多维数组是数组的数组

int matrix[3][4] 的外层数组含三个元素,每个元素类型为 int[4]。因此 matrix 在表达式中转换为 int (*)[4],指向第一行;它不是 int **,因为内存中没有三枚指向各行的指针对象。

C 多维数组按 存放。matrix + 1 跨过一个 int[4],步长是 sizeof matrix[0],不是固定“16 字节”;matrix[i][j] 等价于 *(*(matrix + i) + j),两次转换与加法分别按行类型和元素类型推进。

ANSI C 函数形参要知道除第一维外的后续维度:

#include <stddef.h>
 
long sum_rows(const int matrix[][4], size_t rows)
{
    size_t row, column;
    long total = 0;
    for (row = 0; row < rows; ++row)
        for (column = 0; column < 4; ++column)
            total += matrix[row][column];
    return total;
}

形参也可写成 const int (*matrix)[4],含义相同。第一维由 rows 提供;第二维 4 属于行指针类型,编译器用它计算跨行步长。C99 的变长数组形参可把列数运行时传入,但那不是 K&R 第二版的 ANSI C90 特性。

数组长度宏只适用于数组对象

在调用点可写 sizeof array / sizeof array[0],因为此处 array 仍是数组对象。若封装成宏,参数传入指针时会静默得到错误结果,因此宏必须有清楚命名、局限说明,并尽可能由现代编译器静态检查。

#define ARRAY_COUNT(array) (sizeof(array) / sizeof((array)[0]))
 
int values[] = { 3, 5, 8 };
/* sum_ints(values, ARRAY_COUNT(values));  在声明所在作用域使用 */

先预测:把 values 传给另一个函数后,再对形参使用 ARRAY_COUNT 会得到什么?它会比较指针大小与元素大小,不再表示原数组长度。长度和容量属于接口数据,应显式传递。

指针值不是可移植的裸整数地址

整数常量 0 是空指针常量,转换到指针类型后得到不指向任何对象的空指针值;(void *)0 也构造空 void *。标准不要求空指针的对象表示是“所有位都为零”。静态存储期指针的零初始化会得到空指针;对指针对象直接 memset 全零却不具有同一语言保证。判断空值用 pointer == NULL,不要检查内部字节。

对象指针可转换为 void * 再无损转回原对象指针类型,这是通用容器和分配接口的基础;函数指针不自动享有完全相同的对象指针往返规则。把指针转成整数的结果由实现定义,只有实现提供的足够整数类型和文档契约才能用于诊断或底层接口,不能通过截断成 unsigned long 建立通用序列化格式。

两个指针用 ==/!= 比较的是标准定义的指向关系,不应通过转整数再比较。关系比较 </> 主要用于同一数组内的位置;若要给任意对象指针建立容器排序,必须使用平台协议或库提供的专门比较规则,不能假设虚拟地址数值就是 ISO C 的全序。

任何对象的存储表示都可通过 unsigned char * 逐字节查看,这给二进制 I/O 和哈希实现提供底层入口;但填充字节可能取未指定值,整数有字节序,浮点表示也由实现选择。把结构体原始字节直接作为跨进程格式会泄漏填充和平台布局,应该逐字段编码。

指针打印使用 %p 并传 void *,输出格式由实现定义,只适合诊断。日志里看到两个十六进制数相差某个固定值,只能描述这次运行,不足以证明数组边界、生存期或类型契约;这些仍要从声明和标准规则推导。

边界、别名与可修改性审查

两个指针可以指向同一对象或重叠范围,函数若一边读源区一边写目标区,就必须规定是否允许别名。标准库 memcpy 要求源目标不重叠,memmove 支持重叠;自写算法也要给出同样明确的前置条件。

const int * 表示不能通过该指针修改所指 int,不是保证底层对象永远不变;另一个非 const 指针仍可能修改它。int *const 则限制指针对象本身不能改指向。阅读声明时从名字向外推导,并把“指针可改”“所指对象可改”分成两项。

对每个指针参数至少审查六项:可否为空、对象类型与对齐、最少元素数、生存期、读写权限、与其他参数是否允许重叠。缺任何一项,调用者就无法证明一次解引用安全。

小结

  • 数组对象与指针对象不同;数组只在规定表达式语境中转换为首元素指针,且不携带长度
  • sizeof array 测整个数组,&array 得到指向整个数组的指针;二者都不发生普通转换
  • 指针算术、相减和关系比较围绕同一数组及尾后一位推理,尾后指针不可解引用
  • 指针不延长对象生存期;返回局部数组地址错误来自对象已经不存在,不依赖具体栈实现
  • 二维数组是数组的数组,外层转换保留行类型,所以 matrix + 1sizeof matrix[0]

练习

问题 1int a[5]; int *p = a;,分别写出 ap&a 的声明类型,以及 sizeof asizeof pa + 1&a + 1 表达的大小或步长。

问题 2 为什么 [first, last)last 可以参与相减和比较却不能解引用?逆序遍历空范围时应如何避免先做 --last

问题 3 比较 int matrix[3][4]int *rows[3] 的对象布局和表达式转换,说明为什么前者不能传给接收 int ** 的函数。

名词解释

本章出现的专业名词,用大白话再讲一遍。

数组到指针转换

数组表达式在多数语境中转换为首元素指针值。转换不复制数组,也不携带长度;sizeof 和单目 & 等规定语境不做该转换。

尾后一位指针

同一数组首元素指针加元素个数形成的合法边界值。它可作结束标记、比较和距离运算,但没有对应元素,不能解引用。

半开指针范围

[first, last) 包含起点、不包含终点。空范围为两者相等,长度为同一数组中的 last - first,遍历时永不解引用 last

生存期

对象在 C 抽象机中存在并可被访问的时间区间。指针值不延长生存期,对象结束后旧地址不能继续用于访问。

行主序

多维数组按最右维连续存放,一整行完成后才进入下一行。行指针步长由行数组类型决定。

原版目录概念补充核对

以下条目补齐官方目录中容易被示例主线掩盖的概念。它们不重复罗列目录,而是明确每项概念的机制、适用边界和验收证据。

字符指针:机制、边界与证据

在《指针与数组》的官方单元 kr2-05 中,字符指针连接本章第 3 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。

命令行参数:机制、边界与证据

在《指针与数组》的官方单元 kr2-05 中,命令行参数连接本章第 5 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。

资料与写作方式声明

本章以The C Programming Language, Second Edition, Chapter 5: Pointers and Arrays合法公开试读核定可见范围,并以目录限定未公开部分,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…