第6章 字符串
6.1 问题从哪来#
上一章学了数组,能用 int scores[5] 存一组分数。
但学生的数据不只是分数。每个学生还有名字:
Alice 85
Bob 92
Charlie 78名字不是单个字符,而是一串字符连在一起。char c = 'A' 只能存一个字母,存不下 Alice。
用数组的思路想:Alice 是 5 个字符,排成一排。char name[5] 似乎刚好够用。但事情没那么简单——程序需要知道这串字符在哪里结束。如果数组里有多余的空间,程序怎么区分"有效字符"和"空白垃圾"?
这就是字符串要解决的核心问题。
怎么在 C 语言里表示"一串字符",并且让程序知道它在哪里结束?
6.2 先看一个例子#
假设要做这件事:
- 输入一个名字。
- 和预设的名字比较。
- 如果一样,打印"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
程序做了什么:
scanf("%31s", name)等待键盘输入,输入的字符依次放进name数组。strcmp(name, "Alice")逐字符比较name和"Alice"。- 比较结果是
0,说明相同,进入if分支,打印"Found"。 - 如果比较结果不是
0,进入else分支,打印"Not found"。
6.5 字符数组:名字在内存里怎么放#
char name[32] 声明了 32 个连续的 char 位置。每个位置占 1 个字节。
输入 Alice 后,内存里是这样的:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | … | 31 |
|---|---|---|---|---|---|---|---|---|---|---|
| 内容 | A | l | i | c | e | \0 | ? | ? | … | ? |
前 5 个位置放了 Alice 的 5 个字母。
第 6 个位置(下标 5)放了一个 \0。
scanf 自动在读到的字符串末尾加上 \0。这个字符的 ASCII 值是 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 的 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 个。
注意:
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"); // 比较两个字符串,返回比较结果
| 返回值 | 含义 |
|---|---|
0 | s1 和 s2 内容相同 |
| 负数 | s1 在字典序上排在 s2 前面 |
| 正数 | s1 在字典序上排在 s2 后面 |
strcmp 是逐字符比较的。它从两个字符串的第一个字符开始,一个一个往后比,直到遇到不同的字符或者 \0。
比较 "Alice" 和 "Alice":
| 位置 | 比较结果 | 下一步 |
|---|---|---|
| 0 | A == A | 继续 |
| 1 | l == l | 继续 |
| 2 | i == i | 继续 |
| 3 | c == c | 继续 |
| 4 | e == e | 继续 |
| 5 | \0 == \0 | 两个都结束了,完全相同,返回 0 |
比较 "Alice" 和 "Bob":
| 位置 | 比较结果 | 下一步 |
|---|---|---|
| 0 | A(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
strlen 和 sizeof 不一样。strlen 数有效字符的个数,sizeof 看数组占多少字节。
| 表达式 | 结果 | 含义 |
|---|---|---|
strlen("Alice") | 5 | 5 个有效字符 |
sizeof("Alice") | 6 | 5 个字符 + 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 之后继续读内存,结果不可预测
手动填字符时,一定要留一个位置给 \0。char 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 表示相等。正数只表示第一个字符串排在后面,负数只表示第一个字符串排在前面。具体是 1、2 还是别的正数,不应该写进判断条件里。
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) |
|---|---|---|
| Alice | 85 | 1001 |
| Bob | 92 | 1002 |
这三个数组的下标要保持同步。改了一个,忘了另一个,数据就对不上了。
有没有办法把"一个学生的全部信息"打包在一起?
这些字段属于同一个学生,应该放进同一条记录里。结构体就是把不同类型字段打包成一条记录的工具。
阶段项目#
前 6 章的能力已经够做一个整合练习了:阶段项目 1:成绩统计器。它把变量、判断循环、函数、数组和字符串合到一个能跑的程序里——录入学生姓名和成绩,统计最高/最低/平均/及格人数,还能按姓名查找。