指针运算与字符串
延续 K&R 第五章,以空字符终止与容量契约区分字符串字面量、可修改字符数组和只读指针,并掌握指针数组、char **、二维字符矩阵、argc/argv、函数指针和复杂声明。
学习目标
- 能比较字符串字面量、初始化字符数组与
const char *的类型、存储期、可修改性、长度和容量契约 - 能区分指针数组、指向指针的指针和二维字符数组,并安全遍历
argc/argv - 能读写函数指针与回调数组声明,实现不溢出的
qsort比较器并解释复杂声明的结合顺序
机制总览
指针运算与字符串:机制路径
- 1
从“字符数组不一定是字符串”开始
C 没有独立运行时字符串对象;所谓字符串,是一段字符序列后紧跟空字符 \0 的协议。
- 2
字符串字面量与可修改数组
char text[] = "hello"; 用字面量初始化一个独立数组,元素可修改;数组是自动、静态还是线程存储期取决于声明位置与说明符,不等于“一定在栈上”。
- 3
字符指针、字符串函数与长度
K&R 用字符指针实现一组字符串函数: strlen 的指针写法从起点移动到首个空字符,再用同一数组中的指针差得到长度。返回类型在现代标准库中是 size t ,而指针差类型是 ptrdiff t ;从差值转成 size t 前,算法已经证明终点不早于起点。
章级决策实验
指针运算与字符串:机制与证据
切换《指针运算与字符串》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 从“字符数组不一定是字符串”开始
C 没有独立运行时字符串对象;所谓字符串,是一段字符序列后紧跟空字符 \0 的协议。
可核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“字符数组不一定是字符串”开始」的实际契约。
学完《指针运算与字符串》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
指针运算与字符串:失效与核验
从“字符数组不一定是字符串”开始
典型失效
若把「从“字符数组不一定是字符串”开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“字符数组不一定是字符串”开始」的实际契约。
字符串字面量与可修改数组
典型失效
若把「字符串字面量与可修改数组」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「字符串字面量与可修改数组」的实际契约。
字符指针、字符串函数与长度
典型失效
若把「字符指针、字符串函数与长度」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「字符指针、字符串函数与长度」的实际契约。
从“字符数组不一定是字符串”开始
C 没有独立运行时字符串对象;所谓字符串,是一段字符序列后紧跟空字符 \0 的协议。char bytes[4] = {'t', 'e', 'x', 't'}; 是合法字符数组,却不是 C 字符串,传给 strlen 或 %s 会越过边界寻找不存在的终止符。
接口必须同时知道可读范围与终止协议。只写 const char *text 表达“通过该指针不修改字符”,不证明指针非空、对象仍存活或一定能在边界内找到 \0;这些都是函数前置条件。
字符串字面量与可修改数组
↡源代码中由双引号包围的字符序列,翻译后形成带结尾空字符的静态存储期字符数组。C 中其元素类型不是 const char,但尝试修改的行为未定义。
"hello"
含六个字符对象,包括终止符。它具有静态存储期,但实现可以合并相同字面量,修改任何字符串字面量的行为都未定义。不要依赖它位于名为
.rodata 的段,也不要把“某次段错误”当成规则本身。
char text[] = "hello"; 用字面量初始化一个独立数组,元素可修改;数组是自动、静态还是线程存储期取决于声明位置与说明符,不等于“一定在栈上”。const char *view = "hello"; 则让接口在类型层阻止经 view 写字符,是读取字面量的推荐声明。
#include <stddef.h>
#include <string.h>
int copy_text(char *target, size_t capacity, const char *source)
{
size_t length;
if (target == NULL || source == NULL)
return 0;
length = strlen(source);
if (length >= capacity)
return 0;
memcpy(target, source, length + 1);
return 1;
}length + 1 包含终止符;先检查 length >= capacity 避免加一溢出的写法。函数仍要求 source 已在可达范围内终止,且源目标不重叠;若允许重叠应使用 memmove。失败时目标保持不变,因为容量验证发生在写入前。
字符指针、字符串函数与长度
K&R 用字符指针实现一组字符串函数:strlen 的指针写法从起点移动到首个空字符,再用同一数组中的指针差得到长度。返回类型在现代标准库中是 size_t,而指针差类型是 ptrdiff_t;从差值转成 size_t 前,算法已经证明终点不早于起点。
#include <stddef.h>
size_t text_length(const char *text)
{
const char *cursor = text;
while (*cursor != '\0')
++cursor;
return (size_t)(cursor - text);
}这段代码的前置条件与 strlen 相同:text 非空且存在可达终止符。*cursor++ 按优先级解析为 *(cursor++),先产生当前字符值,再递增指针;若可读性比紧凑更重要,拆成两条语句更容易检查边界。
指针加一的抽象步长是一个所指元素,不应把 int * 固定解释为四字节或 double * 固定八字节。对 char * 而言一个元素恰好是一个 C 字节,因为 sizeof(char) == 1;这个字节有多少位仍由 CHAR_BIT 决定。
指针数组不是 char ** 对象
↡元素本身是指针的数组,例如 char *lines[100]。数组对象存放固定数量的指针值;在多数表达式中才转换为指向首个指针元素的二级指针。
char *lines[100] 含一百个 char *
子对象。每个元素可指向不同长度、不同存储期的字符序列;交换两个元素只重排视图,不搬移字符串字节。
↡保存另一个指针对象地址的指针类型,例如 char **。它本身不声明数组长度,也不保证后面存在多个指针元素。
char **cursor 只是一个指针对象。lines
在表达式中转换后可初始化它,但二者声明布局仍不同,sizeof lines 与 sizeof cursor 也不同。
二维字符数组 char rows[100][80] 则连续存放一百个固定宽度行,转换为 char (*)[80]。它不是 char **:内存中没有一百个行指针。强制转换不会创建这些指针,只会让后续解引用错误解释字符数据。
argc 与 argv 的启动契约
main(int argc, char *argv[]) 的数组形参调整使 argv 在函数类型中成为 char **。argc 非负,argv[argc] 是空指针;当 argc > 0 时,argv[0] 由宿主环境提供用于表示程序调用名,但不能可移植地假设它是绝对路径。
#include <stdio.h>
int main(int argc, char *argv[])
{
int index;
for (index = 0; index < argc; ++index)
printf("argv[%d] = %s\n", index, argv[index]);
return 0;
}按 argc 遍历最清楚,也可用 argv[argc] 哨兵。命令行字符串由宿主环境构造,其编码、引号解析和可用长度是平台约定;不要把 shell 的展开规则误写成 ISO C 语言保证。
原书常通过移动 argv 指针解析选项,这会丢失原始基址。现代代码可保留 argv,移动单独索引或游标,便于错误信息引用原始参数位置。任何读取 argv[index + 1] 的选项都必须先验证剩余参数数量。
main 函数与宿主环境
在 hosted C 实现中,标准入口可写 int main(void) 或与 int main(int argc, char *argv[]) 等价的形式;实现还可以提供其他文档化形式。main 返回的零表示成功,非零状态交给宿主环境解释;不要把 shell 显示方式或某个操作系统的状态位宽当成 ISO C 保证。
argv[argc] 的空指针使指针式遍历可停止,但 argc 仍是主要边界,因为它能在访问前验证后续参数数量。argv[0] 不是可移植的安装路径 API,参数编码也取决于宿主。freestanding 实现(内核、引导程序、部分嵌入式环境)甚至不要求以这两种 main 形式启动,应遵循目标平台入口契约。
入口函数仍受普通数组形参调整和值传递规则约束:argv 是指针值副本,移动它不会改写宿主保存的参数表,但会丢失当前函数中的原始起点。保持原指针并移动索引,能让诊断和所有权推理更清楚。
函数指针把算法与策略分开
↡保存函数标识的指针,可按兼容函数类型调用。它不等同于对象指针,不能可移植地经 void * 存储。
让排序、遍历和事件分派接收调用策略。qsort
比较器接收两个元素地址,返回负、零、正值表示顺序;它必须在所有调用中保持一致,不能修改元素或依赖调用次数。
#include <stdlib.h>
int compare_ints(const void *left, const void *right)
{
int a = *(const int *)left;
int b = *(const int *)right;
return (a > b) - (a < b);
}
void sort_ints(int values[], size_t count)
{
qsort(values, count, sizeof values[0], compare_ints);
}不要写 return a - b;,差值可能有符号溢出。qsort 给出的地址按元素类型正确对齐,所以转换回 const int * 后读取合法;比较器函数类型若不兼容,强转函数指针也不能修复调用约定。
从名字向外读复杂声明
↡由指针、数组和函数声明符嵌套形成的声明。阅读时从标识符出发,先处理括号,再按后缀 []/() 与前缀 * 的结合关系向外解释。
的关键是括号:int *find(void) 中 find 先与 () 结合,所以它是返回 int *
的函数;int (*find)(void) 中括号让 *find 先结合,所以它是指向返回 int
函数的指针。
int (*handlers[4])(int) 从 handlers 开始:先看 [4],它是四元素数组;元素外有 *,是指针;再看 (int),指向接收 int 的函数;最外层 int 是函数返回类型。对公开 API,可用 typedef int (*handler_fn)(int); 分层命名,但仍要让所有权、可为空和调用线程规则可见。
函数名在多数表达式中也会转换为函数指针,调用可写 callback(value) 或 (*callback)(value)。调用前确认非空;函数指针比较只应用于相等/不等,不做对象数组式算术。
字符串集合的所有权
指针数组排序看似只交换地址,却带来所有权问题:每个字符串由谁分配、谁释放、是否共享字面量、数组销毁时是否逐项释放。把字面量、调用者缓冲和 malloc 结果混进同一 char * 数组后统一 free 会出错。
可用 const char * 表示只读借用集合;若模块拥有字符串,则复制每个字符串并记录成功数量,失败时只释放已经取得的对象。排序函数只重排借用指针,不转移字符串所有权。接口文档应把“元素可改”“指针可重排”“字符串由谁释放”分成三条。
字符串比较必须用 strcmp 等按内容比较,left == right 只比较两个指针是否指向同一位置。相同文本的字面量可能被合并也可能不合并,因此地址相等既不是内容 API,也不能作为跨构建稳定标识。
先预测:两个内容都为
"same"的字符串指针用==比较是否一定为真?不一定;内容相等应由逐字符协议判断,字面量合并是实现选择。
解析参数时保持错误可定位
命令行解析要把“当前参数”“该选项还需要几个值”“未知选项怎样处理”写成状态机。遇到 -o 后先检查 index + 1 < argc,再消费文件名;错误信息同时打印原始索引与选项文本。不要在检查前递增 argv,否则缺参时很难指出哪个参数触发错误。
对可重复选项决定覆盖、累加还是拒绝;对 -- 之后的文本决定是否停止选项解析;对空字符串和以连字符开头的文件名提供明确规则。这些属于程序协议,不由 C 标准替你定义。
环境可能允许修改 argv 字符串,但把参数原地切分会破坏后续日志与诊断。更稳妥的做法是保留原输入,使用指向子串的只读视图或复制需要持久化的值,并把所有权写进配置对象。
小结
- 字符数组只有在可达范围内含空字符时才是字符串;长度、容量和终止符是独立契约
- 字符串字面量具有静态存储期,C 中类型不是 const 数组,但任何修改行为都未定义
- 指针数组对象、
char **和二维字符数组布局不同;转换只在规定表达式语境发生 argc给命令行元素数,argv[argc]是空指针;程序名与参数编码受宿主环境约束- 函数指针要求兼容声明和稳定回调语义;复杂声明从名字出发按括号与后缀向外读
练习
问题 1 比较 char a[] = "hello";、const char *p = "hello"; 与 char bytes[5] = {'h','e','l','l','o'};:哪些是字符串,哪些元素可经当前名字修改,sizeof 分别表达什么?
问题 2 为什么 char *lines[10] 可在表达式中传给 char ** 形参,而 char matrix[10][32] 不可以?
问题 3 解释 int *f(void)、int (*f)(void) 与 int (*f[3])(void),并说明为何 qsort 比较器不应返回两个 int 的差。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 字符串字面量
双引号字符序列翻译成带结尾空字符的静态存储期字符数组。C 中其元素类型不是
const char,但修改行为未定义。- 指针数组
元素类型为指针的数组。对象实际存放固定数量指针;多数表达式中才转换为指向首个指针元素的二级指针。
- 指向指针的指针
保存另一个指针对象地址的指针,例如
char **。它本身不声明数组长度,也不等于二维字符数组。- 函数指针
指向兼容函数类型的指针,可用于回调和策略注入。它不属于对象指针,不能假设可经
void *可移植往返。- 复杂声明
指针、数组与函数声明符的嵌套组合。从标识符开始,先处理括号,再按后缀和前缀向外解释。