字符串和字符串函数

掌握字符串表示与I/O、string.h函数、字符串指针排序、命令行参数和可诊断的字符串转数值

学习目标

  • 能解释 C 字符串为何必须以 \0 结尾,并说明 strlensizeof 对同一数组为何常差 1
  • 能正确使用 strlenstrcpystrcatstrcmp,并判断 fgets 是否读到完整记录
  • 能用字符串指针数组和 qsort 排序词表,并解释比较器为何接收二级指针
  • 能验证 argc/argv 命令行参数,并用 strtol 区分无数字、尾随垃圾和范围错误

机制总览

字符串和字符串函数:机制路径

  1. 1

    为什么需要专门的「字符串函数」?

    上一章你已经知道:C 里没有 string 类型,文本就是 字符数组(character array) 。但「知道地址从哪开始」还不够——你还得知道「到哪结束」。

  2. 2

    字符串表示:可见字符 + 看不见的 \0

    编译器放入 4 个字符: C 、 a 、 t 、 \0 。数组总长 10,剩余 6 个字节自动填 \0 (若未初始化则内容不确定,但字符串有效部分仍到第一个 \0 为止)。

  3. 3

    string.h 四大基础函数

    参数必须是 以 \0 结尾 的有效字符串。若数组中间没有 \0 , strlen 会一直往后读——危险。

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

章级决策实验

字符串和字符串函数:机制与证据

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

选择推理阶段

当前阶段 · 为什么需要专门的「字符串函数」?

上一章你已经知道:C 里没有 string 类型,文本就是 字符数组(character array) 。但「知道地址从哪开始」还不够——你还得知道「到哪结束」。

可核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要专门的「字符串函数」?」的实际行为。

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

失效—证据矩阵

字符串和字符串函数:失效与核验

为什么需要专门的「字符串函数」?

典型失效

若只记语法而忽略「为什么需要专门的「字符串函数」?」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要专门的「字符串函数」?」的实际行为。

字符串表示:可见字符 + 看不见的 \0

典型失效

若只记语法而忽略「字符串表示:可见字符 + 看不见的 \0」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「字符串表示:可见字符 + 看不见的 \0」的实际行为。

string.h 四大基础函数

典型失效

若只记语法而忽略「string.h 四大基础函数」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「string.h 四大基础函数」的实际行为。

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

为什么需要专门的「字符串函数」?

上一章你已经知道:C 里没有 string 类型,文本就是 。但「知道地址从哪开始」还不够——你还得知道「到哪结束」。

想象一排信箱:每个格子里一个字母,最后一格贴着「到此为止」的标签。如果没有这个标签,邮递员(strlenstrcpy)会一直往后走,直到误闯邻居家的信箱——那就是 未定义行为,轻则乱码,重则程序崩溃。

这一章把 \0 铁律、string.h 四大函数、安全读写的 fgets,以及自己写字符串处理函数的方法讲清楚。搞懂这些,后面结构体、文件 I/O、动态内存都会轻松很多。

字符串表示:可见字符 + 看不见的 \0

是 C 字符串的「终点站」。声明:

char animal[10] = "Cat";

编译器放入 4 个字符:Cat\0。数组总长 10,剩余 6 个字节自动填 \0(若未初始化则内容不确定,但字符串有效部分仍到第一个 \0 为止)。

char animal[10] = "Cat";字面量 "Cat" 带来 4 个字符:C、a、t、\0;数组总长 10,其余字节为 0C[0]a[1]t[2]\0[3]\0[4]\0[5]\0[6]\0[7]\0[8]\0[9]strlen → 3(数到 \0 之前)字符串结束标记铁律:所有 string.h 函数都靠 \0 判断「字符串到哪结束」没有 \0 → strlen/strcpy 会一直往后读,直到撞上别的内存(未定义行为)。char ar[n] 初始化不足时,剩余元素自动填 \0;手动逐字赋值时你必须自己写上结尾 \0。
C 字符串 = 连续 char + 结尾 \0。strlen 只数可见字符;sizeof 数整个数组占用的字节。

从首字符数到 \0 之前;sizeof animal 则是整个数组 10 字节。二者差的那 1(或更多)字节,往往就是结尾的 \0 和未用空间。

#include <stdio.h>
#include <string.h>
 
int main(void) {
    char animal[10] = "Cat";
    printf("strlen = %zu, sizeof = %zu\n",
           strlen(animal), sizeof(animal));
    /* 输出:strlen = 3, sizeof = 10 */
    return 0;
}

手动逐字符赋值时,你必须自己写上 \0,否则不是合法字符串:

char word[4];
word[0] = 'B';
word[1] = 'i';
word[2] = 'g';
word[3] = '\0';   /* 缺一不可 */

string.h 四大基础函数

#include <string.h> 后,最常用的四个函数如下。

strlen:测长度

size_t len = strlen("Hello");  /* 5 */

参数必须是\0 结尾的有效字符串。若数组中间没有 \0strlen 会一直往后读——危险。

strcpy 与 strcat:复制与拼接

src 逐字节拷到 dest,包括结尾的 \0

char dest[20];
strcpy(dest, "Hello");
/* dest 现在是 "Hello" */

src 接到 dest 已有内容的末尾:

char buf[20] = "Hello";
strcat(buf, ", world!");
/* buf 现在是 "Hello, world!" */

猜一猜:若 dest 只有 8 字节,却 strcpy(dest, "Hello, world!"),会发生什么?用下面 Stepper 看溢出过程。

分步1 / 3

第一步:目标缓冲区太小

char dest[8] 只能安全存 7 个可见字符 + \0。源串更长时,空间从设计上就不够。

strcmp:字典序比较

比较的是内容,不是指针地址:

if (strcmp(s1, s2) == 0)
    printf("相同\n");
else if (strcmp(s1, s2) < 0)
    printf("s1 在字典序中更靠前\n");

不要写 if (s1 == s2)——那比较的是两个指针是否指向同一地址,不是字符串内容是否相同。

char 数组 vs char 指针:能不能改?

这是 C 字符串里最容易混淆的一点:

char ar[] = "Hello";   /* 数组:栈上副本,可修改 */
const char *ptr = "Hello"; /* 指针:只读访问字符串字面量 */
char ar[] = "Hi";数组在栈上,内容可修改栈 memoryHi\0ar[0] = 'h'; ✓ 合法sizeof(ar) = 3(含 \\0)char *ptr = "Hi";指针变量在栈,指向只读字面量ptr只读数据段Hi\0ptr[0] = 'h'; ✗ 未定义行为sizeof(ptr) = 指针大小(8 或 4)记忆口诀• char ar[] = "..." → 在栈上分配字符数组,**可改**• char *p = "..." → p 存字面量地址,字面量**只读**;要可改请写 char ar[] 或 malloc• 函数参数 char s[] 与 char *s 等价——都接收指针,但调用方传入的若是字面量则仍不可改
数组版拥有可写副本;指针版指向编译器放在只读区的字面量——修改会崩溃或静默失败。

ar[0] = 'h' 合法;字符串字面量在 C 中具有 char 数组类型,但任何修改尝试都是未定义行为 不保证位于某个特定“只读数据段”,所以规则应写成语言契约而不是内存段猜测。用 const char * 接收能让编译器阻止误写;要可改则用 char ar[] 或拥有足够容量的动态对象。

sizeof 也不同:sizeof(ar) 是数组总字节(含 \0);sizeof(ptr)指针变量本身的大小(通常 8 或 4),与字符串长度无关。

函数形参位置的 char s[] 会调整为 char *s,两种声明表示相同参数类型。只读接口应声明 const char *s;否则类型会错误暗示函数可能修改内容。

字符串输入输出:按流读写完整记录

输出:puts 与 fputs

puts(str) 输出字符串并自动换行fputs(str, stdout) 不自动换行,适合拼到已有输出流:

puts("Hi");           /* Hi + 换行 */
fputs("Hi", stdout);  /* 仅 Hi,无换行 */

输入:用 fgets,别用 gets

接受缓冲区大小,但“调用成功”不等于“整行已读完”:

char line[80];
if (fgets(line, sizeof line, stdin) != NULL) {
    char *newline = strchr(line, '\n');
    if (newline != NULL) {
        *newline = '\0';
        printf("完整一行:%s\n", line);
    } else {
        int ch = getchar();
        if (ch == '\n') {
            printf("完整一行:%s\n", line);
        } else if (ch == EOF) {
            if (ferror(stdin))
                fputs("读取失败\n", stderr);
            else
                printf("文件末尾的最后一行:%s\n", line);
        } else {
            while ((ch = getchar()) != '\n' && ch != EOF)
                ;
            fputs("输入行过长\n", stderr);
        }
    }
}

fgets 保留读到的换行;缓冲区填满前没有换行时,要区分“最后一行没有换行”和“当前记录被截断”。gets() 已被 C11 移除,因为接口根本没有容量参数。

自定义字符串函数

理解库函数原理后,可以自己实现简化版——逻辑都是「找 \0」或「逐字节复制直到 \0」。

手写 my_strlen

size_t my_strlen(const char *s) {
    size_t count = 0;
    while (s[count] != '\0')
        count++;
    return count;
}

const char * 表示函数只读字符串,不修改内容——好习惯。

手写 my_strcpy

char *my_strcpy(char *dest, const char *src) {
    char *save = dest;
    while ((*dest++ = *src++) != '\0')
        ;   /* 空循环体:拷贝含 \0 */
    return save;
}

while ((*dest++ = *src++) != '\0') 是经典 C 惯用法:赋值、比较、指针后移一步完成。

它与标准 strcpy 一样要求 dest 容量足够,并要求源与目标对象不重叠;接口没有容量参数,函数自身无法验证这两个前置条件。

场景:读一行并统计单词数(简化)

#include <stdio.h>
#include <ctype.h>
 
int count_words(const char *s) {
    int in_word = 0, count = 0;
    while (*s) {
        if (isspace((unsigned char)*s))
            in_word = 0;
        else if (!in_word) {
            in_word = 1;
            count++;
        }
        s++;
    }
    return count;
}

isspace 判断空白,用状态机 in_word 检测「从非空白进入空白」的边界——这就是自定义字符串处理的典型模式。

排序字符串:排序指针而不是复制字符

字符串列表常表示为指针数组。 可以交给 qsort

#include <stddef.h>
#include <stdlib.h>
#include <string.h>
 
static int compare_words(const void *lhs, const void *rhs) {
    const char *left = *(const char *const *)lhs;
    const char *right = *(const char *const *)rhs;
    return strcmp(left, right);
}
 
int main(void) {
    const char *words[] = {"pear", "apple", "plum"};
    size_t count = sizeof words / sizeof words[0];
    qsort(words, count, sizeof words[0], compare_words);
    return strcmp(words[0], "apple") != 0;
}

qsort 把“数组元素的地址”传给比较器。这里元素本身是 const char *,所以参数要解释为指向该指针元素的指针,再解引用一次取得字符串。排序只改变 words 中各指针的顺序,不会尝试修改字符串字面量。strcmp 是逐字节字典序,不是面向 locale 的自然语言排序。

命令行参数:argc 与 argv 的边界

宿主环境可以用 main(int argc, char *argv[]) 提供 。在宿主提供这些信息时,argc 非负,argv[argc] 是空指针;argv[0] 通常表示程序调用名,但它也可能是空字符串,不能拿来做安全判断。

#include <stdio.h>
 
int main(int argc, char *argv[]) {
    int i;
    if (argc < 2) {
        fprintf(stderr, "usage: %s WORD...\n",
                argc > 0 ? argv[0] : "program");
        return 2;
    }
 
    for (i = 1; i < argc; ++i)
        printf("argv[%d] = %s\n", i, argv[i]);
    return 0;
}

argc 是元素计数,不含末尾空指针;业务参数从 argv[1] 开始。访问前先验证 argc,不能假定用户一定传了某个位置。虽然 C 允许修改 argv 指向的字符串内容,接口设计通常把它们当输入,并在需要长期保存或重排内容时复制到自有存储。

字符串转数字:用 strtol 保留失败原因

atoi 无法把合法的 0 与转换失败区分开,也没有可用的结束位置和可靠范围诊断。 允许调用方逐层验证:

#include <errno.h>
#include <limits.h>
#include <stdlib.h>
 
static int parse_int(const char *text, int *result) {
    char *end;
    long value;
 
    errno = 0;
    value = strtol(text, &end, 10);
    if (end == text)                  /* 一个数字也没读到 */
        return 0;
    if (*end != '\0')                /* 有未接受的尾随字符 */
        return 0;
    if (errno == ERANGE || value < INT_MIN || value > INT_MAX)
        return 0;
 
    *result = (int)value;
    return 1;
}

调用前把 errno 清零,只在返回后结合结果检查 ERANGE;不能仅看 errno 的旧值。end == text 表示没有转换,*end 检查是否完整消费字符串,再检查 long 到 int 的目标范围。若产品允许尾随空白,应显式用 isspace((unsigned char)*end) 跳过后再要求结束;进制参数用 10 表示十进制,用 0 才会按前缀识别八进制或十六进制。

容易踩的坑

小结

  • C 字符串 = 连续 char + 结尾 \0strlen 数到 \0 前,sizeof 数整个数组
  • strcpy / strcat 不检查目标大小;strcmp 返回 0 表示相等
  • char ar[] 创建可修改副本;字符串字面量应经 const char 指针只读访问
  • fgets 成功后仍要判断换行、文件末尾与截断;输出可用 puts / fputs
  • qsort 排字符串指针时交换指针元素,比较器需先解引用到字符串再调用 strcmp
  • 命令行参数先检查 argc;字符串转整数用 strtol 检查 endptr、errno 和目标范围
  • 自定义函数核心:找 \0、逐字节复制、用 const char * 保护只读参数

练习

问题 1(改代码型) 下面程序想比较用户输入与 "yes" 是否相同,但有 bug。改正后输入 yes 应打印 匹配

char answer[10];
fgets(answer, sizeof answer, stdin);
if (answer == "yes")
    printf("匹配\n");

问题 2(问答型) char a[] = "Hi";const char *b = "Hi"; 执行 sizeof(a)sizeof(b) 通常各得多少(64 位系统)?为什么?

问题 3(综合实现型) 不用 strlen,写函数 size_t str_len(const char *s),并在 main 里测试 "Cat"""

名词解释

名词解释

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

字符数组(character array)

元素类型为 char 的数组,用来存文本。C 字符串要求以 \0 结尾。声明如 char s[20] = "Hi";。详见 CStringNullTerminatorDiagram。

空字符(null character)

ASCII 0,写作 \0。标记 C 字符串结束。strlen 数到它之前停止;string.h 函数都依赖它判断边界。

strlen()

string.h 函数,返回字符串中可见字符个数(不含 \0)。参数必须指向以 \0 结尾的有效字符串。

strcpy()

把源串(含 \0)复制到 dest。不检查 dest 大小,目标必须足够大。详见 StrcpyBufferDiagram。

strcat()

把 src 接到 dest 已有字符串末尾。需保证 dest 剩余空间 ≥ strlen(src)+1。

strcmp()

字典序比较两串。相等返回 0;不要用 == 比较字符串内容。详见 StringFunctionTableDiagram。

fgets()

从流读取至多 size-1 个字符;成功时写入空字符并返回目标指针。调用方仍须判断是否读到换行、无换行的文件末尾或被缓冲区截断的记录。

字符串字面量

双引号中的文本,在程序中形成带空字符结尾的静态存储期字符数组。C 不给它 const 类型,但任何修改行为未定义;物理存放位置由实现决定。

字符串指针排序

对字符串指针数组重排指针元素,而不复制各字符串字节。qsort 比较器收到元素地址,需先解引用为字符串指针再调用 strcmp。

命令行参数

程序启动时由宿主环境提供的字符串数组。argc 给出参数数量,argv 保存指针,argv[argc] 是空指针哨兵;访问业务参数前应检查 argc。

strtol()

把字符串前缀转换为 long,并通过 endptr 返回首个未转换字符。可靠解析需检查是否读到数字、尾部策略、errno 的 ERANGE 和目标类型范围。

原版目录概念补充核对

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

字符串转数字:机制、边界与证据

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

讨论

评论区加载中…