大多数语言都会给你一个知道自身长度的 string 类型,C 没有。在 C 中,字符串只是一个 char 数组,外加一条规则:文本后面跟着一个值为零的字节,写作 '\0',叫作空结束符。C 字符串的其他一切都源于这一条约定。
字符串就是 char 数组加上 '\0'
用字面量初始化一个 char 数组,就声明了一个字符串:
这个数组看起来有四个字节,而不是三个:
name: +-----+-----+-----+------+
| 'A' | 'd' | 'a' | '\0' |
+-----+-----+-----+------+
index: 0 1 2 3
sizeof(name) 是 4。编译器数了字符个数,为结束符加了一个,然后替你确定了数组大小。正因为有这个额外的字节,char name[3] = "Ada"; 就是一个迟早会出问题的 bug:文本放得下,结束符却放不下,后续的任何代码都无法知道字符串在哪里结束。
如果之后想修改内容,就自己指定大小并留出余量:
char name[32] = "Ada"; /* 3 characters, a terminator, 28 bytes spare */
初始化会把未使用的字节填为零,这没有坏处。
为什么结束符很重要
C 函数都不接收长度。printf("%s", s) 只拿到一个地址,然后一路向后打印字节,直到遇到 '\0'。所以没有结束符的 char 数组不是字符串,把它当作字符串使用,就会读到紧随其后的任意内存。
要记住的规则:sizeof 给出存储空间,strlen 给出文本长度。对于 char buf[32] = "Ada",sizeof buf 是 32,strlen(buf) 是 3。它们回答的是不同的问题,把两者搞混是最常见的 C bug 之一。
字符串字面量与 char 数组
下面两行看起来很像,行为却大不相同:
char a[] = "hello"; /* an array you own, initialized from the literal */
char *p = "hello"; /* a pointer aimed at the literal itself */
a 是一个 6 字节的数组,存放文本的一份私有副本,你可以修改它。p 指向程序的只读字符串数据;字面量是共享的,通过 p 写入是未定义行为,通常会在运行时而不是编译时崩溃。
只要指针指向字面量,就写成 const char *。这样编译器会在编译时拒绝写入,而不是让它变成一次崩溃。在两者之间做选择,本质上是指针和数组的问题,指针与数组一页中有详细介绍。
还有一个值得知道的区别:sizeof a 是 6,而 sizeof p 是指针的大小(在大多数系统上是 8),与文本长短无关。
打印和读取字符串
%s 打印字符串,%c 打印单个字符。
读取输入时,完全不要用 gets。它无法被安全地使用,已经从语言中移除了。应该用 fgets,它接收缓冲区大小,不会溢出:
fgets 会保留你按下的换行符,而字符串里几乎从来都不需要它。strcspn 那一行找到第一个 '\n' 并用结束符覆盖它,这是去掉换行符的一种简洁的标准写法。
逐个字符遍历字符串
因为结尾是用标记表示的,而不是用计数,惯用的循环直接检查字符本身:
条件 text[i] != '\0' 常被简写为 text[i],因为 '\0' 是零,也就是假。两种写法都正确;学习阶段,显式的写法更易读。
也可以用指针代替下标来遍历,这同样是地道的 C 写法:
调用 toupper 之前转换为 unsigned char 不是装饰:<ctype.h> 中的函数对负值是未定义的,而对于大于 127 的字节,普通 char 可能是负数。
不能用 = 复制字符串
这是每个新手都会撞上的墙:
char a[10];
a = "hello"; /* error: assignment to expression with array type */
char b[10] = "hi";
char c[10];
c = b; /* error - same reason */
数组名不是可修改的值,所以 = 无从下手。唯一能这样简写的地方是声明时的初始化,那是由编译器完成的另一种操作。
要在运行时复制,就用 <string.h> 中的 strcpy 复制字节:
同样的道理也解释了为什么 == 不能比较字符串。if (a == b) 比较的是两个地址,所以对于存着相同文本的两个不同数组,结果为假。应该改用 strcmp(a, b) == 0。这两个函数以及它们更安全的版本,都在字符串函数一页中介绍。
字符串数组
字符串列表可以是指针数组(用于只读的固定文本),也可以是二维 char 数组(每一项都需要可修改时):
指针数组存放三个指向只读字面量的地址,紧凑但不可修改。char[3][16] 的写法占用 48 字节你自己的内存,所以每一行都可以重写。
常见错误
- 忘了结束符的那个字节。
char s[5] = "hello"没有空间放'\0'。大小要按strlen + 1计算。 - 该用
strlen的地方用了sizeof。对指针用sizeof得到的是指针的大小,而不是文本的长度。 - 通过
char *写入字面量。这类指针要声明为const char *。 - 用
==比较。那比较的是地址。应该用strcmp。 - 用
scanf("%s", buf)读取。它没有大小限制,很容易溢出;优先用fgets。
常见问题
C 语言中如何声明字符串?
声明为 char 数组:char name[] = "Ada";。C 没有内置的字符串类型,这个数组存放 'A'、'd'、'a',以及隐藏的第四个字节 '\0',它标志着字符串的结尾。如果之后还想修改文本,就给数组一个明确的大小:char name[32] = "Ada";。
C 语言中的空结束符是什么?
标志字符串结尾的字节 '\0'(值为零)。每个标准函数,比如 printf("%s")、strlen、strcpy,都会一直向后读取,直到遇到这个字节。没有结束符的 char 数组不是字符串,把它传给这些函数会读到内存末尾之外。
C 语言中为什么不能用 = 给字符串赋值?
因为数组不是能用 = 复制的值。char a[10]; a = "hi"; 无法通过编译。应该用 <string.h> 中的 strcpy(a, "hi") 复制字符,或者在声明时初始化:char a[10] = "hi";,这是唯一可以这样简写的地方。
C 语言中 char * 和 char [] 有什么区别?
char s[] = "hi" 创建一个可修改的数组,里面是文本的一份私有副本。char *p = "hi" 创建一个指向字符串字面量的指针,字面量位于只读内存中:读取没问题,通过它写入就会崩溃。需要修改时用数组,只读时用指针。