指针运算与字符串

延续 K&R 第五章,以空字符终止与容量契约区分字符串字面量、可修改字符数组和只读指针,并掌握指针数组、char **、二维字符矩阵、argc/argv、函数指针和复杂声明。

学习目标

  • 能比较字符串字面量、初始化字符数组与 const char * 的类型、存储期、可修改性、长度和容量契约
  • 能区分指针数组、指向指针的指针和二维字符数组,并安全遍历 argc/argv
  • 能读写函数指针与回调数组声明,实现不溢出的 qsort 比较器并解释复杂声明的结合顺序

机制总览

指针运算与字符串:机制路径

  1. 1

    从“字符数组不一定是字符串”开始

    C 没有独立运行时字符串对象;所谓字符串,是一段字符序列后紧跟空字符 \0 的协议。

  2. 2

    字符串字面量与可修改数组

    char text[] = "hello"; 用字面量初始化一个独立数组,元素可修改;数组是自动、静态还是线程存储期取决于声明位置与说明符,不等于“一定在栈上”。

  3. 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;这些都是函数前置条件。

字符串字面量与可修改数组

"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 * 子对象。每个元素可指向不同长度、不同存储期的字符序列;交换两个元素只重排视图,不搬移字符串字节。

char **cursor 只是一个指针对象。lines 在表达式中转换后可初始化它,但二者声明布局仍不同,sizeof linessizeof 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 是指针值副本,移动它不会改写宿主保存的参数表,但会丢失当前函数中的原始起点。保持原指针并移动索引,能让诊断和所有权推理更清楚。

函数指针把算法与策略分开

让排序、遍历和事件分派接收调用策略。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 * 可移植往返。

复杂声明

指针、数组与函数声明符的嵌套组合。从标识符开始,先处理括号,再按后缀和前缀向外解释。

资料与写作方式声明

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

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

讨论

评论区加载中…