第6章 字符串

6.1 问题从哪来#

上一章学了数组,能用 int scores[5] 存一组分数。

但学生的数据不只是分数。每个学生还有名字:

Alice   85
Bob     92
Charlie 78

名字不是单个字符,而是一串字符连在一起。char c = 'A' 只能存一个字母,存不下 Alice

用数组的思路想:Alice 是 5 个字符,排成一排。char name[5] 似乎刚好够用。但事情没那么简单——程序需要知道这串字符在哪里结束。如果数组里有多余的空间,程序怎么区分"有效字符"和"空白垃圾"?

这就是字符串要解决的核心问题。

怎么在 C 语言里表示"一串字符",并且让程序知道它在哪里结束?


6.2 先看一个例子#

假设要做这件事:

  1. 输入一个名字。
  2. 和预设的名字比较。
  3. 如果一样,打印"Found"。

程序的输入输出大概长这样:

Enter name:
$ Alice
Found: Alice
Enter name:
$ Mallory
Not found

这就是本章要写的程序。


6.3 最小实验#

#include <stdio.h>
#include <string.h>     // strcmp 在这个头文件里

int main(void)
{
    char name[32];                          // 用 char 数组存名字,最多 31 个字符

    printf("Enter name: ");                 // 提示用户输入
    scanf("%31s", name);                    // 读一个单词,限制最多 31 个字符

    if (strcmp(name, "Alice") == 0) {       // strcmp 返回 0 表示两个字符串相同
        printf("Found: %s\n", name);        // 打印找到的名字
    } else {
        printf("Not found\n");              // 名字不匹配
    }

    return 0;
}

这段代码做了三件事:

步骤代码作用
声明空间char name[32]用 32 个 char 的数组来放名字
读入scanf("%31s", name)从键盘读一个单词
比较strcmp(name, "Alice") == 0判断输入的名字是不是 Alice

6.4 编译运行#

保存成 hello.c,编译:

$ gcc hello.c -o hello

运行,输入 Alice

Enter name:
$ Alice
Found: Alice

再运行,输入 Bob

Enter name:
$ Bob
Not found

程序做了什么:

  1. scanf("%31s", name) 等待键盘输入,输入的字符依次放进 name 数组。
  2. strcmp(name, "Alice") 逐字符比较 name"Alice"
  3. 比较结果是 0,说明相同,进入 if 分支,打印"Found"。
  4. 如果比较结果不是 0,进入 else 分支,打印"Not found"。

6.5 字符数组:名字在内存里怎么放#

char name[32] 声明了 32 个连续的 char 位置。每个位置占 1 个字节。

输入 Alice 后,内存里是这样的:

下标0123456731
内容Alice\0???

前 5 个位置放了 Alice 的 5 个字母。

第 6 个位置(下标 5)放了一个 \0

scanf 自动在读到的字符串末尾加上 \0。这个字符的 ASCII 值是 0,它不显示任何东西,它只有一个作用:

标记字符串到这里结束。

Alice\0 在字符数组里的布局

下标 6 到 31 的位置没有被初始化,里面可能是任意值。程序不会读那些位置,因为遇到 \0 就知道字符串已经结束了。


6.6 \0 为什么是结束标记#

C 语言没有专门的"字符串类型"。它用 char 数组来表示字符串,用 \0 来标记结束。

为什么需要这个标记?

因为数组只是一段连续的内存,程序拿到 name 之后,不知道里面放了几个有效字符。\0 就是一个哨兵:看到它,就知道字符串到此为止。

对比两种方案:

方案做法问题
方案 A数组长度就是字符串长度每个名字长度不同,要开很多不同大小的数组
方案 B固定大小数组,用一个特殊标记表示结束就是 C 语言的做法,这个标记就是 \0

方案 B 的好处是:数组大小可以比实际字符串长,程序靠 \0 知道该读到哪里。char name[32]"Alice" 只用了 6 个字节(5 个字母加 \0),剩下 26 个字节是空闲的,不影响读取。程序从 A 开始逐个读,读到 \0 就停——后面的 ? 不会被读走。

\0 为什么表示字符串结束

\0 的 ASCII 值是 0,也就是 8 个二进制位全是 0:00000000。它和字符 '0' 不一样——'0' 的 ASCII 值是 48。\0 不会显示任何东西,它只用来标记结束。

没有 \0 会怎样?程序会一直往后读,直到在内存里偶然碰到一个 0 为止。读到的内容完全不可预测——可能是别的变量的值,可能是垃圾数据,也可能程序直接崩溃。

警告:\0 决定了字符串在哪里结束。丢失 \0,程序会继续往后读,读到不可预测的数据,可能直接崩溃。


6.7 scanf 读字符串#

scanf("%31s", name);

几个细节:

写法说明
%s读一个单词(遇到空格、Tab、换行就停)
%31s最多读 31 个字符,留 1 个位置给 \0
name 不用加 &数组名会自动转换成地址

%31s 中的 31 是因为 name 的大小是 32。scanf 会在末尾自动加 \0,所以最多只能填 31 个字符。如果用户输入超过 31 个字符,scanf 只读前 31 个。

char 数组为什么最多只能放有限长度

注意:scanf 使用 %s 读取字符串时,遇到空格就停。输入 Hello World 只会读到 Hello。读一整行(包括空格)需要用 fgets

还有一个容易忽略的点:C 字符串按字节存放。Alice 这种 ASCII 名字里,一个可见字符通常就是一个字节;中文名字在常见 UTF-8 环境里,一个汉字会占多个字节。char name[32] 的 32 是 32 个字节,不是 32 个汉字。

所以限制输入长度时,程序真正限制的是字节数。这一章里的例子先用英文名字,是为了让数组下标、\0 和宽度限制看得更清楚。

还有一个现象可以留意:%31s 只保证这次最多写入 31 个字符。如果用户输入了更长的一串,后面的字符还留在输入流里,下一次 scanf 可能会继续读到它们。这个问题和数组越界不是一回事;宽度限制保护了数组,但不会自动清空剩下的输入。


6.8 字符串比较:为什么不能用 ==#

如果写:

if (name == "Alice") {    // 错!这样比的不是字符串内容
    printf("found\n");    // 这行通常不会执行(比的是地址,不是内容)
}

这行代码通常能通过编译,但结果不对。在表达式里,name 会转换成指向第一个元素的地址,"Alice" 也会转换成一个地址。== 比的是两个地址是否相同,不是两个字符串的内容是否一样。

C 语言用 strcmp 来比较字符串内容:

#include <string.h>                        // 引入字符串处理函数

int result = strcmp("Alice", "Bob");       // 比较两个字符串,返回比较结果
返回值含义
0s1s2 内容相同
负数s1 在字典序上排在 s2 前面
正数s1 在字典序上排在 s2 后面

strcmp 是逐字符比较的。它从两个字符串的第一个字符开始,一个一个往后比,直到遇到不同的字符或者 \0

两个字符串逐字符比较

比较 "Alice""Alice"

位置比较结果下一步
0A == A继续
1l == l继续
2i == i继续
3c == c继续
4e == e继续
5\0 == \0两个都结束了,完全相同,返回 0

比较 "Alice""Bob"

位置比较结果下一步
0A(65) < B(66)返回负数

第一个字符就不一样了,strcmp 直接返回结果,不再往后比。

警告:比较字符串永远用 strcmp,不要用 ==。这是初学者最常犯的错误之一。


6.9 printf 打印字符串#

printf("Found: %s\n", name);  // %s 从 name 读到 \0 为止,打印所有字符

%s 告诉 printf:从对应的参数那里读一个字符串,一直读到 \0 为止,把读到的字符全部打印出来。

char name[32] = "Alice";
printf("%s\n", name);          // 打印 Alice
printf("%.3s\n", name);        // 打印 Ali(只打印前 3 个字符)
printf("%10s\n", name);        // 左边补 5 个空格,再打印 Alice
printf("%-10s!\n", name);      // 打印 Alice,右边补 5 个空格,再打印 !

%10s 会在左边补空格,让输出总宽度达到 10 个字符。%-10s 则是左对齐,右边补空格。打印表格时这些格式控制很有用。


6.10 几个常用的字符串函数#

C 语言标准库提供了一些处理字符串的函数,都在 <string.h> 里。

strlen:字符串长度#

char name[] = "Alice";
int len = strlen(name);     // len 是 5,不包含 \0

strlen 从第一个字符开始数,一直数到 \0 为止。返回的长度不包含 \0

char name[32] = "Alice";
printf("Length: %zu\n", strlen(name));    // 打印 5
printf("Array size: %zu\n", sizeof(name)); // 打印 32

strlensizeof 不一样。strlen 数有效字符的个数,sizeof 看数组占多少字节。

表达式结果含义
strlen("Alice")55 个有效字符
sizeof("Alice")65 个字符 + 1 个 \0

snprintf:复制字符串#

char src[] = "Hello";
char dst[32];
snprintf(dst, sizeof(dst), "%s", src);   // 把 src 的内容复制到 dst
printf("%s\n", dst);        // 打印 Hello

不能用赋值运算符直接复制字符串:

char a[32] = "Hello";
char b[32];
b = a;                      // 错!数组不能整体赋值

要把内容复制过去,可以这样写:

char a[32] = "Hello";
char b[32];
snprintf(b, sizeof(b), "%s", a);         // 对:复制时带上目标大小
printf("%s\n", b);                       // 打印 Hello

注意:复制字符串时要知道目标数组的大小。snprintf 的第二个参数写目标数组大小,可以避免写出数组边界。

补充:拼接字符串#

字符串除了读入、比较、复制,还经常需要把一段内容接到另一段后面。下面这段代码把 "World!" 接到 "Hello, " 后面:

char greeting[64] = "Hello, ";
int used = strlen(greeting);
snprintf(greeting + used, sizeof(greeting) - used, "%s", "World!");
printf("%s\n", greeting);    // 打印 Hello, World!

greeting + used 指向原字符串结尾的 \0 位置,新的内容从这里接上去。sizeof(greeting) - used 是剩余空间大小,snprintf 会按这个大小写入,避免越界。

函数作用注意事项
strlen(s)返回字符串长度(不含 \0返回值类型是 size_t,用 %zu 打印
snprintf(dst, sizeof(dst), "%s", src)src 复制到 dst第二个参数写目标数组大小
snprintf(dst + used, size - used, "%s", src)src 接到 dst 已有内容后面先算出已用长度和剩余空间
strcmp(s1, s2)比较两个字符串返回 0 表示相同

6.11 字符串初始化的几种写法#

// 写法 1:声明之后逐字符赋值
char name1[32];
name1[0] = 'A';
name1[1] = 'l';
name1[2] = 'i';
name1[3] = 'c';
name1[4] = 'e';
name1[5] = '\0';       // 别忘了 \0

// 写法 2:用字符串字面量初始化
char name2[32] = "Alice";  // 编译器自动加 \0

// 写法 3:让编译器自动算大小
char name3[] = "Alice";    // 编译器算出需要 6 个字节(5 个字符 + \0)

写法 2 和写法 3 最常用。写法 3 让编译器自己决定数组大小,省去了手动数字符的麻烦。

注意:char name[] = "Alice" 的大小是 6,不是 5。\0 占一个字节。


6.12 小练习:输入多个名字,查找某个名字#

#include <stdio.h>
#include <string.h>

int main(void)
{
    char names[5][32];     // 5 个名字,每个最多 31 个字符
    int count = 0;         // 已输入的名字数量

    printf("Enter 5 names:\n");
    for (int i = 0; i < 5; i++) {
        printf("%d: ", i + 1);
        scanf("%31s", names[i]);       // names[i] 是第 i 个名字
        count++;
    }

    char target[32];                   // 要查找的名字
    printf("\nFind who? ");
    scanf("%31s", target);

    int found = 0;
    for (int i = 0; i < count; i++) {
        if (strcmp(names[i], target) == 0) {   // 逐个比较
            printf("Found at position %d\n", i + 1);
            found = 1;
            break;                              // 找到就不用继续了
        }
    }

    if (!found) {
        printf("%s not found\n", target);
    }

    return 0;
}

运行示例:

Enter 5 names:
1:
$ Alice
2:
$ Bob
3:
$ Charlie
4:
$ Dave
5:
$ Eve

Find who?
$ Charlie
Found at position 3

这段代码用了二维数组 char names[5][32]。可以把它想成 5 行、每行 32 个格子的表格,每一行存一个名字:

内容
names[0]A l i c e \0 ? ? ...
names[1]B o b \0 ? ? ? ? ...
names[2]C h a r l i e \0 ? ...
names[3]D a v e \0 ? ? ? ...
names[4]E v e \0 ? ? ? ? ...

每一行都是一个独立的字符串,各自有自己的 \0

如果再给每个名字配一个学号和分数,写法会变成几组数组一起使用:names[i] 保存第 i 个学生的名字,ids[i] 保存同一个学生的学号,scores[i] 保存同一个学生的分数。只要下标同步,数据就能对上;一旦下标不同步,名字和分数就会错位。


6.13 常见坑#

坑 1:忘记 \0

char name[5];
name[0] = 'A';
name[1] = 'l';
name[2] = 'i';
name[3] = 'c';
name[4] = 'e';       // 没有 \0!
printf("%s\n", name);  // 打印完 Alice 之后继续读内存,结果不可预测

手动填字符时,一定要留一个位置给 \0char name[5] 最多只能放 4 个可见字符加 1 个 \0

坑 2:数组太小,放不下字符串。

char name[3];          // 只有 3 个字节
scanf("%2s", name);    // 最多读 2 个可见字符,留 1 个位置给 \0

如果没有宽度限制,输入 Alice 时需要 6 个字节(5 个字符加 \0),但 name 只有 3 个字节。多出来的字节会覆盖相邻的内存,程序可能崩溃,也可能产生奇怪的 bug。这叫缓冲区溢出

%31s 这样的宽度限制可以防止这个问题。

坑 3:用 == 比较字符串。

char name[32] = "Alice";
if (name == "Alice") {       // 永远不要这样写
    printf("found\n");
}

== 比的是地址,不是内容。在这个表达式里,name 会转换成首元素地址,"Alice" 也会转换成一个地址。两个地址不同,条件为假。用 strcmp

坑 4:scanf 忘了宽度限制。

char name[32];
scanf("%s", name);           // 危险!没有宽度限制,可能溢出
scanf("%31s", name);         // 正确:限制读入长度,最多读 31 个可见字符

如果不限制宽度,用户输入超过 32 个字符时会覆盖相邻内存。

坑 5:strcmp 的返回值搞混。

char name[32] = "Bob";
if (strcmp(name, "Alice") == 1) {    // 错!1 不表示两个字符串相等
    printf("same\n");
}

strcmp 返回 0 表示相等。正数只表示第一个字符串排在后面,负数只表示第一个字符串排在前面。具体是 12 还是别的正数,不应该写进判断条件里。


6.14 自己试试看#

Q1:修改程序,让它打印"输入了 N 个名字"。

在输入循环结束后,用 count 变量打印。

Q2:把名字数量从 5 改成 3,运行看看。

Q3:输入两个相同的名字(比如两次都输入 Alice),然后查找 Alice

程序会找到第几个?为什么?

Q4:输入 alice(小写开头),查找 Alice(大写开头)。

strcmp 认为它们相同吗?为什么?

提示:a 的 ASCII 值是 97,A 是 65。strcmp 严格区分大小写。

Q5:写一个程序,输入 5 个名字,找出按字典序排在最前面的那个。

提示:strcmp(a, b) 返回负数时,a 排在 b 前面。每轮循环记住当前最小的那个名字。

Q6:写一个程序,输入一个名字,打印它的长度。

strlen 函数。输入 Alice,应该打印 5

Q7:下面这段代码有什么问题?

char name[5] = "Hello";   // 只有 5 字节,放不下 \0(需要 6 字节)
printf("%s\n", name);     // 打印时找不到 \0,会读到越界数据

提示:"Hello" 有 5 个字符,加上 \0 需要 6 个字节。数组只有 5 个字节,放不下 \0


下一章的问题#

现在已经知道:

  • 字符串是 char 数组,末尾有 \0
  • strcmp 比较,用 scanf 读入,用 printf 打印。

但名字、学号、分数分开放在几个数组里,处理起来容易乱:

名字 (names)分数 (scores)学号 (ids)
Alice851001
Bob921002

这三个数组的下标要保持同步。改了一个,忘了另一个,数据就对不上了。

有没有办法把"一个学生的全部信息"打包在一起?

这些字段属于同一个学生,应该放进同一条记录里。结构体就是把不同类型字段打包成一条记录的工具。


阶段项目#

前 6 章的能力已经够做一个整合练习了:阶段项目 1:成绩统计器。它把变量、判断循环、函数、数组和字符串合到一个能跑的程序里——录入学生姓名和成绩,统计最高/最低/平均/及格人数,还能按姓名查找。