字符串
- 1. 字符串字面量
- 1.1 字符串字面量中的转义序列
- 1.2 延续字符串字面量
- 1.3 如何存储字符串字面量
- 1.4 字符串字面量的操作
- 1.5 字符串字面量与字符常量
- 2. 字符串变量
- 2.1 初始化字符串变量
- 2.2 字符数组与字符指针
- 3. 字符串的读和写
- 3.1 用 `printf` 函数和 `puts` 函数写字符串
- 3.2 用 `scanf` 函数和 `gets` 函数读字符串
- 3.3 逐个字符读字符串
- 4. 访问字符串中的字符
- 5. 使用 C 语言的字符串库
- 5.1 `strcpy` 函数
- 5.2 `strlen` 函数
- 5.3 `strcat` 函数
- 5.4 `strcmp` 函数
- 6. 字符串惯用法
- 6.1 搜索字符串的结尾
- 6.2 复制字符串
- 7. 字符串数组
本专题介绍 C 语言中的字符串字面量和字符串变量,讨论用于处理字符串的函数,并通过描述如何创建数组元素是指向不同长度字符串的指针的数组,说明 C 语言如何使用这种数组为程序提供命令行支持。
参考资料:《C 语言程序设计 · 现代方法 第 2 2 2 版》
1. 字符串字面量
字符串字面量( string literal \text{string literal} string literal)是用一对双引号括起来的字符序列:
"When you come to a fork in the road, take it."
字符串字面量常常作为格式串出现在 printf
函数和 scanf
函数的调用中。
1.1 字符串字面量中的转义序列
字符串字面量可以像字符常量一样包含转义序列。例如,字符串
"Candy\nIs dandy\nBut liquor\nIs quicker.\n --Ogden Nash\n"
中每一个字符 \n
都会导致光标移到下一行:
Candy
Is dandy
But liquor
Is quicker.--Ogden Nash
虽然字符串字面量中的八进制数和十六进制数的转义序列也都是合法的,但是它们不像字符转义序列那样常见。
在字符串字面量中要小心使用八进制数和十六进制数的转义序列。八进制数的转义序列在 3 3 3 个数字之后结束,或者在第一个非八进制数字符处结束。例如,字符串 \1234
包含两个字符(\123
和 4
),而字符串 \189
包含 3 3 3 个字符(\1
、8
和 9
)。而十六进制数的转义序列则不限制为 3 3 3 个数字,而是直到第一个非十六进制数字符截止。但是大部分编译器会把十六进制数的转义序列通常范围限制在 \x0
∼ \sim ∼ \xff
。
1.2 延续字符串字面量
如果字符串字面量太长而无法放置在单独一行内,只要把第一行用字符 \
结尾,C 语言就允许在下一行延续字符串字面量。除了末尾的换行符,在同一行不可以有其他字符跟在 \
后面:
printf("When you come to a fork in the road, take it. \
--Yogi Berra");
字符 \
可以用来把两行或更多行的代码连接成一行,这一过程称为 “拼接”( splicing \text{splicing} splicing)。使用 \
有一个缺陷:字符串字面量必须从下一行的起始位置继续。但是这就破坏了程序的缩进结构。根据下面的规则,处理长字符串字面量有一种更好的方法:当两条或更多条字符串字面量相邻时(仅用空白字符分割),编译器会把它们合并成一条字符串。这条规则允许把字符串分割放在两行或者更多行中:
printf("When you come to a fork in the road, take it. ""--Yogi Berra");
1.3 如何存储字符串字面量
从本质而言,C 语言把字符串字面量作为字符数组来处理。当 C 语言编译器在程序中遇到长度为 n n n 的字符串字面量时,它会为字符串字面量分配长度为 n + 1 n + 1 n+1 的内存空间。这块内存空间将用来存储字符串字面量中的字符,以及一个用来标志字符串末尾的额外字符(空字符)。空字符是一个所有位都为 0 0 0 的字节,因此用转义序列 \0
来表示。注意不要混淆空字符 '\0'
和零字符 '0'
。空字符的码值为 0 0 0,而零字符则有不同的码值,在 ASCII 中为 48 48 48。
例如,字符串字面量 "abc"
是作为有 4 4 4 个字符的数组来存储的(a
、b
、c
和 \0
):
字符串字面量可以为空,空字符串 ""
作为单独一个空字符来存储。
既然字符串字面量是作为数组来存储的,那么编译器会把它看作是 char *
类型的指针。例如,printf
函数和 scanf
函数都接收 char *
类型的值作为它们的第一个参数。例如 printf("abc");
,当调用 printf
函数时,会传递 "abc"
的地址,即指向存储字母 a
的内存单元的指针。
1.4 字符串字面量的操作
通常情况下可以在任何 C 语言允许使用 char *
指针的地方使用字符串字面量。例如,字符串字面量可以出现在赋值运算符的右边:
char *p;
p = "abc";
这个赋值操作不是复制 "abc"
中的字符,而是使 p
指向字符串中的第一个字符。
C 语言允许对指针取下标,因此可以对字符串字面量取下标:
char ch;
ch = "abc"[1];
ch
的新值将是字母 b
。其他可能的下标是 0 0 0(a
)、 2 2 2(c
)和 3 3 3(空字符)。字符串字面量的这种特性并不常用,但有时也比较方便 。思考下面的函数,这个函数把 0 ∼ 15 0 \sim 15 0∼15 的数转换成等价的十六进制的字符形式:
char digit_to_hex_char(int digit)
{return "0123456789ABCDEF"[digit];
}
试图改变字符串字面量会导致未定义的行为:
char *p = "abc";
*p = 'd'; /*** WRONG ***/
1.5 字符串字面量与字符常量
只包含一个字符的字符串字面量不同于字符常量。字符串字面量 "a"
是用指针来表示的,这个指针指向存放字符 "a"
的内存单元。字符常量 'a'
是用整数来表示的。
不要在需要字符串的时候使用字符,反之亦然。例如函数调用 printf("\n");
是合法的,而 printf('\n');
是非法的。
2. 字符串变量
只要保证字符串是以空字符结尾的,任何一维的字符数组都可以用来存储字符串。这种方法很简单,但使用起来有很大难度。有时很难辨别是否把字符数组作为字符串来使用。如果编写自己的字符串处理函数,请千万注意要正确地处理空字符。
假设需要用一个变量来存储最多有 80 80 80 个字符的字符串。由于字符串在末尾处需要有空字符,我们把变量声明为含有 81 81 81 个字符的数组:
#define STR_LEN 80
...
char str[STR_LEN + 1];
这里把 STR_LEN
定义为 80 80 80 而不是 81 81 81,强调的是 str
可以存储最多有 80 80 80 个字符的字符串;然后才在 str
的声明中对 STR_LEN
加 1 1 1。这是 C 程序员常用的方式。
当声明用于存放字符串的字符数组时,要始终保证数组的长度比字符串的长度多一个字符,因为 C 语言规定每个字符串都要以空字符结尾。如果没有给空字符预留位置,可能会导致程序运行时出现不可预知的结果,因为 C 函数库中的函数假设字符串都是以空字符结束的。
声明长度为 STR_LEN + 1
的字符数组并不意味着它总是用于存放长度为 STR_LEN
的字符串。字符串的长度取决于空字符的位置,而不是取决于用于存放字符串的字符数组的长度。有 STR_LEN + 1
个字符的数组可以存放长度范围从空字符串到 STR_LEN
的字符串。
2.1 初始化字符串变量
字符串变量可以在声明时进行初始化:
char date1[8] = "June 14";
编译器将把字符串 "June 14"
中的字符复制到数组 date1
中,然后追加一个空字符从而使 date1
可以作为字符串使用。date1
将如下所示:
"June 14"
看起来是字符串字面量,但其实不然。C 编译器会把它看成是数组初始化式的缩写形式。实际上,我们可以写成:
char date1[8] = {'J', 'u', 'n', 'e', ' ', '1', '4', '\0'};
不过原来的方式更便于阅读。
如果初始化式太短以致于不能填满字符串变量,编译器会添加空字符。因此,在声明
char date2[9] = "June 14";
之后,date2
将如下所示:
这与 C 语言处理数组初始化式的方法一致。当数组的初始化式比数组本身短时,余下的数组元素会被初始化为 0 0 0。在把字符数组额外的元素初始化为 \0
这点上,编译器对字符串和数组遵循相同的规则。
如果初始化式比字符串变量长,这对字符串而言是非法的。然而 C 语言允许初始化式(不包括空字符)与变量有完全相同的长度:
char date3[7] = "June 14";
由于没有给空字符留空间,所以编译器不会试图存储字符:
如果计划对用来放置字符串的字符数组进行初始化,一定要确保数组的长度要长于初始化式的长度,否则,编译器将忽略空字符,这将使得数组无法作为字符串使用。
字符串变量的声明中可以省略它的长度。这种情况下,编译器会自动计算长度:
char date4[] = "June 14";
编译器为 date4
分配 8 8 8 个字符的空间,这足够存储 "June 14"
中的字符和一个空字符。不指定 date4
的长度并不意味着以后可以改变数组的长度,一旦编译了程序,date4
的长度就固定是 8 8 8 了。如果初始化式很长,那么省略字符串变量的长度是特别有效的,因为手工计算长度很容易出错。
2.2 字符数组与字符指针
一起来比较一下下面这两个看起来很相似的声明:
char date[] = "June 14";
char *date = "June 14";
前者声明 date
是一个数组,后者声明 date
是一个指针。正因为有了数组和指针之间的紧密关系,才使上面这两个声明中的 date
都可以用作字符串。尤其是,任何期望传递字符数组或字符指针的函数都能够接收这两种声明的 date
作为参数。
然而,需要注意,不能错误地认为上面这两种 date
可以互换。两者之间有很大的差异:
- 在声明为数组时,就像任意数组元素一样,可以修改存储在
date
中的字符。在声明为指针时,date
指向字符串字面量,而字符串字面量是不可以修改的。 - 在声明为数组时,
date
是数组名。在声明为指针时,date
是变量,这个变量可以在程序执行期间指向其他字符串。
如果希望可以修改字符串,那么就要建立字符数组来存储字符串,声明指针变量是不够的。下面的声明使编译器为指针变量分配了足够的内存空间:
char *p;
可惜的是,它不能为字符串分配空间,因为我们没有指明字符串的长度。在使用 p
作为字符串之前,必须把 p
指向字符数组。一种可能是把 p
指向已经存在的字符串变量:
char str[STR_LEN + 1], *p;
p = str;
现在 p
指向了 str
的第一个字符,所以可以把 p
作为字符串使用了。另一种可能是让 p
指向一个动态分配的字符串。
使用未初始化的指针变量作为字符串是非常严重的错误。考虑下面的例子,它试图创建字符串 "abc"
:
char *p;
p[0] = 'a'; /*** WRONG ***/
p[1] = 'b'; /*** WRONG ***/
p[2] = 'c'; /*** WRONG ***/
p[3] = '\0'; /*** WRONG ***/
因为 p
没有被初始化,所以我们不知道它指向哪里。用指针 p
把字符 a
、b
、c
和 \0
写入内存会导致未定义的行为。
3. 字符串的读和写
3.1 用 printf
函数和 puts
函数写字符串
转换说明 %s
允许 printf
函数写字符串。例如:
char str[] = "Are we having fun yet?";
printf("%s\n", str);
输出会是
Are we having fun yet?
printf
函数会逐个写字符串中的字符,直到遇到空字符才停止。如果空字符丢失,printf
函数会越过字符串的末尾继续写,直到最终在内存的某个地方找到空字符为止。
如果只想显示字符串的一部分,可以使用转换说明 %.ps
,这里 p p p 是要显示的字符数量。语句 printf("%.6s\n", str);
会显示 Are we
。
字符串跟数一样,可以在指定字段内显示。转换说明 %ms
会在大小为 m m m 的字段内显示字符串。对于超过 m m m 个字符的字符串,printf
函数会显示出整个字符串,而不会截断。如果字符串少于 m m m 个字符,则会在字段内右对齐输出。如果要强制左对齐,可以在 m m m 前加一个减号。 m m m 值和 p p p 值可以组合使用:转换说明 %m.ps
会使字符串的前 p p p 个字符在大小为 m m m 的字段内显示。
C 函数库还提供了 puts
函数:puts(str);
。puts
函数只有一个参数,即需要显示的字符串。在写完字符串后,puts
函数总会添加一个额外的换行符,从而前进到下一个输出行的开始处。
3.2 用 scanf
函数和 gets
函数读字符串
转换说明 %s
允许 scanf
函数把字符串读入字符数组:
scanf("%s", str);
在 scanf
函数调用中,不需要在 str
前添加运算符 &
,因为 str
是数组名,编译器在把它传递给函数时会把它当作指针来处理。
调用时,scanf
函数会跳过空白字符,然后读入字符并存储到 str
中,直到遇到空白字符为止。scanf
函数始终会在字符串末尾存储一个空字符。
用 scanf
函数读入字符串永远不会包含空白字符。因此,scanf
函数通常不会读入一整行输入。换行符会使 scanf
函数停止读入,空格符或制表符也会产生同样的结果。为了一次读入一整行输入,可以使用 gets
函数:gets(str);
。类似于 scanf
函数,gets
函数把读入的字符放到数组中,然后存储一个空字符。然而,在其他方面 gets
函数有些不同于 scanf
函数。
gets
函数不会在开始读字符串之前跳过空白字符,scanf
函数会跳过。gets
函数会持续读入直到找到换行符才停止,scanf
函数会在任意空白字符处停止。此外,gets
函数会忽略掉换行符,不会把它存储到数组中,用空字符代替换行符。
在把字符读入数组时,scanf
函数和 gets
函数都无法检测数组何时被填满。因此,它们存储字符时可能越过数组的边界,这会导致未定义的行为。通过用转换说明 %ns
代替 %s
可以使 scanf
函数更安全。这里的数字 n n n 指出可以存储的最多字符数。可惜的是,gets
函数天生就是不安全的,fgets
函数则是一种好得多的选择。
3.3 逐个字符读字符串
因为对许多程序而言,scanf
函数和 gets
函数都有风险且不够灵活,C 程序员经常会自己编写输入函数。通过每次一个字符的方式来读入字符串,这类函数可以提供比标准输入函数更大程度的控制。
如果决定设计自己的输入函数,那么就需要考虑下面这些问题。
- 在开始存储字符串之前,函数应该跳过空白字符吗?
- 什么字符会导致函数停止读取:换行符、任意空白字符还是其他某种字符?需要存储这类字符还是忽略掉?
- 如果输入的字符串太长以致无法存储,那么函数应该做些什么:忽略额外的字符还是把它们留给下一次输入操作?
假定我们所需要的函数不会跳过空白字符,在第一个换行符处停止读取,换行符不存储到字符串中,并且忽略额外的字符。函数将有如下原型:
int read_line(char str[], int n);
str
表示用来存储输入的数组,而 n
是读入字符的最大数量。如果输入行包含多于 n n n 个的字符,read_line
函数将忽略多余的字符。read_line
函数会返回实际存储在 str
中的字符数量( 0 0 0 到 n n n 之间的任意数)。我们不可能总是需要 read_line
函数的返回值,但是有这个返回值也没问题。
read_line
函数主要由一个循环构成。只要 str
中还有空间,此循环就会调用 getchar
函数逐个读入字符并把它们存储到 str
中。在读入换行符时循环终止。严格地说,如果 getchar
函数读入字符失败,也应该终止循环,但是这里暂时忽略这种复杂情况。下面是 read_line
函数的完整定义:
int read_line(char str[], int n)
{int ch, i = 0;while ((ch = getchar()) != '\n')if (i < n)str[i++] = ch;str[i] = '\0'; /* terminates string */return i; /* number of characters stored */
}
注意,ch
的类型为 int
而不是 char
,因为 getchar
把它读取的字符作为 int
类型的值返回。
返回之前,read_line
函数在字符串的末尾放置一个空字符。scanf
和 gets
等标准函数会自动在输入字符串的末尾放置一个空字符;然而,如果要自己写输入函数,必须手工加上空字符。
4. 访问字符串中的字符
字符串是以数组的方式存储的,因此可以使用下标来访问字符串中的字符。例如,为了对字符串 s
中的每个字符进行处理,可以设定一个循环来对计数器 i
进行自增操作,并通过表达式 s[i]
来选择字符。
假定需要一个函数来统计字符串中空格的数量。利用数组取下标操作可以写出如下函数:
int count_spaces(const char s[])
{int count = 0, i;for (i = 0; s[i] != '\0'; i++)if (s[i] == ' ')count++;return count;
}
在 s
的声明中加上 const
表明 count_spaces
函数不会改变数组。许多 C 程序员不会像例子中那样编写 count_spaces
函数,他们更愿意使用指针来跟踪字符串中的当前位置。这种方法对于处理数组来说一直有效,在处理字符串方面尤其方便。
下面用指针算术运算代替数组取下标来重新编写 count_spaces
函数。这次不再需要变量 i
,而是利用 s
自身来跟踪字符串中的位置。通过对 s
反复进行自增操作,count_spaces
函数可以逐个访问字符串中的字符。下面是 count_spaces
函数的新版本:
int count_spaces(const char *s)
{int count = 0;for ( ; *s != '\0'; s++)if (*s == ' ')count++;return count;
}
注意,const
没有阻止 count_spaces
函数对 s
的修改,它的作用是阻止函数改变 s
所指向的字符。而且,因为 s
是传递给 count_spaces
函数的指针的副本,所以对 s
进行自增操作不会影响原始的指针。count_spaces
函数示例引出了一些关于如何编写字符串函数的问题。
- 用数组操作或指针操作访问字符串中的字符,哪种方法更好一些呢?只要使用方便,可以随意使用任意一种方法,甚至可以混合使用两种方法。从传统意义上来说,C 程序员更倾向于使用指针操作来处理字符串。
- 字符串形式参数应该声明为数组还是指针呢?
count_spaces
函数的两种写法说明了这两种选择:第 1 1 1 种写法把s
声明为数组,而第 2 2 2 种写法则把s
声明为指针。实际上,这两种声明之间没有任何差异,因为编译器会把数组型的形式参数视为指针。 - 形式参数的形式(
s[]
或者*s
)是否会对实际参数产生影响呢?不会的。当调用count_spaces
函数时,实际参数可以是数组名、指针变量或者字符串字面量。count_spaces
函数无法说明差异。
5. 使用 C 语言的字符串库
在 C 语言中把字符串当作数组来处理,因此对字符串的限制方式和对数组的一样,特别是,它们都不能用 C 语言的运算符进行复制和比较操作。
直接复制或比较字符串会失败。例如,假定 str1
和 str2
有如下声明:
char str1[10], str2[10];
利用 =
运算符来把字符串复制到字符数组中是不可能的:
str1 = "abc"; /*** WRONG ***/
str2 = str1; /*** WRONG ***/
从专题十一中可知,把数组名用作 =
的左操作数是非法的。但是,使用 = 初始化
字符数组是合法的:char str1[10] = "abc";
,这是因为在声明中,=
不是赋值运算符。
试图使用关系运算符或判等运算符来比较字符串是合法的,但不会产生预期的结果:
if (str1 == str2) ... /*** WRONG ***/
这条语句把 str1
和 str2
作为指针来进行比较,而不是比较两个数组的内容。因为 str1
和 str2
有不同的地址,所以表达式 str1 == str2
的值一定为 0 0 0。
C 语言的函数库为完成对字符串的操作提供了丰富的函数集,这些函数的原型驻留在 <string.h>
头中。在 <string.h>
中声明的每个函数至少需要一个字符串作为实际参数。字符串形式参数声明为 char *
类型,这使得实际参数可以是字符数组、char *
类型的变量或者字符串字面量。然而,要注意那些没有声明为 const
的字符串形式参数,这些形式参数可能会在调用函数时发生改变,所以对应的实际参数不应该是字符串字面量。
5.1 strcpy
函数
strcpy
函数的原型如下:
char *strcpy(char *s1, const char *s2);
strcpy
函数把字符串 s2
复制给字符串 s1
,准确地讲,应该说成是 “strcpy
函数把 s2
指向的字符串复制到 s1
指向的数组中”。也就是说,strcpy
函数把 s2
中的字符复制到 s1
中直到遇到 s2
中的第一个空字符为止,该空字符也需要复制。strcpy
函数返回 s1
,即指向目标字符串的指针。这一过程不会改变 s2
指向的字符串,因此将其声明为 const
。strcpy
函数的存在弥补了不能使用赋值运算符复制字符串的不足。
大多数情况下我们会忽略 strcpy
函数的返回值,但有时候 strcpy
函数调用是一个更大的表达式的一部分,这时其返回值就比较有用了。例如,可以把一系列 strcpy
函数调用连起来:
strcpy(str1, strcpy(str2, "abcd")); /* both str1 and str2 now contain "abcd" */
在 strcpy(str1, str2)
的调用中,strcpy
函数无法检查 str2
指向的字符串的大小是否真的适合 str1
指向的数组。假设 str1
指向的字符串长度为 n n n,如果 str2
指向的字符串中的字符数不超过 n − 1 n - 1 n−1,那么复制操作可以完成。但是,如果 str2
指向更长的字符串,那么结果就无法预料了,因为 strcpy
函数会一直复制到第一个空字符为止,所以它会越过 str1
指向的数组的边界继续复制。
尽管执行会慢一点,但是调用 strncpy
函数仍是一种更安全的复制字符串的方法。strncpy
类似于 strcpy
,但它还有第三个参数可以用于限制所复制的字符数。为了将 str2
复制到 str1
,可以使用如下的 strncpy
调用:
strncpy(str1, str2, sizeof(str1));
只要 str1
足够装下存储在 str2
中的字符串,包括空字符,复制就能正确完成。当然,strncpy
本身也不是没有风险。如果 str2
中存储的字符串的长度大于 str1
数组的长度,strncpy
会导致 str1
中的字符串没有终止的空字符。下面是一种更安全的用法:
strncpy(str1, str2, sizeof(str1) - 1);
str1[sizeof(str1) - 1] = '\0';
5.2 strlen
函数
strlen
函数的原型如下:
size_t strlen(const char *s);
定义在 C 函数库中的 size_t
类型是一个 typedef
名字,表示 C 语言中的一种无符号整型。除非是处理极长的字符串,否则不需要关心其技术细节。我们可以简单地把 strlen
的返回值作为整数处理。
strlen
函数返回字符串 s
的长度:s
中第一个空字符之前的字符个数,不包括空字符。下面是几个示例:
int len;
...
len = strlen("abc"); /* len is now 3 */
len = strlen(""); /* len is now 0 */
strcpy(str1, "abc");
len = strlen(str1); /* len is now 3 */
最后一个例子说明了很重要的一点:当用数组作为实际参数时,strlen
不会测量数组本身的长度,而是返回存储在数组中的字符串的长度。
5.3 strcat
函数
strcat
函数的原型如下:
char *strcat(char *s1, const char *s2)
strcat
函数把字符串 s2
的内容追加到字符串 s1
的末尾,并且返回字符串 s1
。下面列举了一些使用 strcat
函数的例子:
strcpy(str1, "abc");
strcat(str1, "def"); /* str1 now contains "abcdef" */
strcpy(str1, "abc");
strcpy(str2, "def");
strcat(str1, str2); /* str1 now contains "abcdef" */
同使用 strcpy
函数一样,通常忽略 strcat
函数的返回值。下面的例子说明了可能使用返回值的方法:
strcpy(str1, "abc");
strcpy(str2, "def");
strcat(str1, strcat(str2, "ghi");/* str1 now contains "abcdefghi"; str2 contains "defghi" */
如果 str1
指向的数组没有大到足以容纳 str2
指向的字符串中的字符,那么调用 strcat(str1, str2)
的结果将是不可预测的。strncat
函数比 strcat
更安全,但速度也慢一些。与 strncpy
一样,它有第三个参数来限制所复制的字符数。下面是调用的形式:
strncat(str1, str2, sizeof(str1) - strlen(str1) - 1);
strncat
函数会在遇到空字符时终止 str1
,第三个参数没有考虑该空字符。在上面的例子中,第三个参数计算 str1
中的剩余空间,然后减去 1 1 1 以确保为空字符留下空间。
5.4 strcmp
函数
strcmp
函数的原型如下:
int strcmp(const char *s1, const char *s2);
strcmp
函数比较字符串 s1
和字符串 s2
,然后根据 s1
是小于、等于或大于 s2
,函数返回一个小于、等于或大于 0 0 0 的值。例如,为了检查 str1
是否小于 str2
,可以写
if (strcmp(str1, str2) < 0) /* is str1 < str2 */
...
通过选择适当的关系运算符或判等运算符,可以测试 str1
与 str2
之间任何可能的关系。
类似于字典中单词的编排方式,strcmp
函数利用字典顺序进行字符串比较。更精确地说,只要满足下列两个条件之一,那么 strcmp
函数就认为 s1
是小于 s2
的。
s1
与s2
的前 i i i 个字符一致,但是s1
的第 i + 1 i + 1 i+1 个字符小于s2
的第 i + 1 i + 1 i+1 个字符。例如,"abc"
小于"bcd"
,"abd"
小于"abe"
。s1
的所有字符与s2
的字符一致,但是s1
比s2
短。例如,"abc"
小于"abcd"
。
当比较两个字符串中的字符时,strcmp
函数会查看字符对应的数值码。一些底层字符集的知识可以帮助预测 strcmp
函数的结果。例如,下面是 ASCII 字符集的一些重要性质。
A
∼ \sim ∼Z
、a
∼ \sim ∼z
、0
∼ \sim ∼9
这几组字符的数值码都是连续的。- 所有的大写字母都小于小写字母。在 ASCII 码中, 65 ∼ 90 65 \sim 90 65∼90 的编码表示大写字母, 97 ∼ 122 97 \sim 122 97∼122 的编码表示小写字母。
- 数字小于字母。 45 ∼ 57 45 \sim 57 45∼57 的编码表示数字。
- 空格符小于所有打印字符。ASCII 码中空格符的值是 32 32 32。
程序
remind.c
:显示一个月的提醒列表
下面的程序会显示一个月的每日提醒列表。用户需要输入一系列提醒,每条提醒都要有一个前缀来说明是一个月中的哪一天。当用户输入的是 0 0 0 而不是有效日期时,程序会显示出录入的全部提醒列表,按日期排序。
总体策略不是很复杂。把字符串存储在二维的字符数组中,数组的每一行包含一个字符串。在程序读入日期以及相关的提醒后,通过使用 strcmp
函数进行比较来查找数组从而确定这一天所在的位置。然后,程序会使用 strcpy
函数把此位置之后的所有字符串往后移动一个位置。最后,程序会把这一天复制到数组中,并且调用 strcat
函数来把提醒附加到这一天后面。
当然,总会有少量略微复杂的地方。例如,希望日期在两个字符的字段中右对齐以便它们的个位可以对齐。有很多种方法可以解决这个问题。这里选择用 scanf
函数把日期读入到整型变量中,然后调用 sprintf
函数把日期转换成字符串格式。sprintf
是个类似于 printf
的库函数,不同之处在于它会把输出写到字符串中。函数调用
sprintf(day_str, "%2d", day);
把 day
的值写到 day_str
中。因为 sprintf
在写完后会自动添加一个空字符,所以 day_str
会包含一个由空字符结尾的字符串。
另一个复杂的地方是确保用户没有输入两位以上的数字,为此将使用下列 scanf
函数调用:
scanf("%2d", &day);
即使输入有更多的数字,在 %
和 d
之间的数 2 2 2 也会通知 scanf
函数在读入两个数字后停止。
/* Prints a one-month reminder list */#include <stdio.h>
#include <string.h>#define MAX_REMIND 50 /* maximum number of reminders */
#define MSG_LEN 60 /* max length of reminder message */int read_line(char str[], int n);int main(void)
{char reminders[MAX_REMIND][MSG_LEN+3];char day_str[3], msg_str[MSG_LEN+1];int day, i, j, num_remind = 0;for (;;) {if (num_remind == MAX_REMIND) {printf("-- No space left --\n");break;}printf("Enter day and reminder: ");scanf("%2d", &day);if (day == 0)break;sprintf(day_str, "%2d", day);read_line(msg_str, MSG_LEN);for (i = 0; i < num_remind; i++)if (strcmp(day_str, reminders[i]) < 0)break;for (j = num_remind; j > i; j--)strcpy(reminders[j], reminders[j-1]);strcpy(reminders[i], day_str);strcat(reminders[i], msg_str);num_remind++;}printf("\nDay Reminder\n");for (i = 0; i < num_remind; i++)printf(" %s\n", reminders[i]);return 0;
}int read_line(char str[], int n)
{int ch, i = 0;while ((ch = getchar()) != '\n')if (i < n)str[i++] = ch;str[i] = '\0';return i;
}
这个程序的运行过程如下(用户的输入用下划线标注):
Enter day and reminder:
24 Susan’s birthday ‾ \underline{\text{24 Susan's birthday}} 24 Susan’s birthday
Enter day and reminder:
5 6:00 - Dinner with Marge and Russ ‾ \underline{\text{5 6:00 - Dinner with Marge and Russ}} 5 6:00 - Dinner with Marge and Russ
Enter day and reminder:
26 Movie - “Chinatown” ‾ \underline{\text{26 Movie - “Chinatown”}} 26 Movie - “Chinatown”
Enter day and reminder:
7 10:30 - Dental appointment ‾ \underline{\text{7 10:30 - Dental appointment}} 7 10:30 - Dental appointment
Enter day and reminder:
12 Movie - “Dazed and Confused” ‾ \underline{\text{12 Movie - “Dazed and Confused”}} 12 Movie - “Dazed and Confused”
Enter day and reminder:
5 Saturday class ‾ \underline{\text{5 Saturday class}} 5 Saturday class
Enter day and reminder:
12 Saturday class ‾ \underline{\text{12 Saturday class}} 12 Saturday class
Enter day and reminder:
0 ‾ \underline{0} 0
Day Reminder5 Saturday class5 6:00 - Dinner with Marge and Russ7 10:30 - Dental appointment12 Saturday class12 Movie - "Dazed and Confused"24 Susan's birthday26 Movie - "Chinatown"
6. 字符串惯用法
6.1 搜索字符串的结尾
许多字符串操作需要搜索字符串的结尾。strlen
函数就是一个重要的例子。下面的 strlen
函数搜索字符串参数的结尾,并且使用一个变量来跟踪字符串的长度:
size_t strlen(const char *s)
{size_t n;for (n = 0; *s != '\0'; s++)n++;return n;
}
指针 s
从左至右扫描整个字符串,变量 n
记录当前已经扫描的字符数量。当 s
最终指向一个空字符时,n
所包含的值就是字符串的长度。
下面的版本运行速度可能会更高一些:
size_t strlen(const char *s)
{const char *p = s;while (*s)s++;return s - p;
}
这个版本的 strlen
函数通过定位空字符位置的方式来计算字符串的长度,然后用空字符的地址减去字符串中第一个字符的地址。运行速度的提升得益于不需要在 while
循环内部对 n
进行自增操作。请注意,在 p
的声明中出现了单词 const
,如果没有它,编译器会注意到把 s
赋值给 p
会给 s
指向的字符串造成一定风险。
下面两个语句都是 “搜索字符串结尾的空字符” 的惯用法。第一个版本最终使 s
指向了空字符。第二个版本更加简洁,但是最后使 s
正好指向空字符后面的位置。
while (*s)s++;
while (*s++);
6.2 复制字符串
char *strcat(char *s1, const char *s2)
{char *p = s1;while (*p != '\0')p++;while (*s2 != '\0') {*p = *s2;p++;s2++;}*p = '\0';return s1;
}
strcat
函数的这种写法采用了两步算法:
- 确定字符串
s1
末尾空字符的位置,并且使指针p
指向它; - 把字符串
s2
中的字符逐个复制到p
所指向的位置。
函数中的第一个 while
语句实现了第 1 1 1 步。程序中先把 p
设定为指向 s1
的第一个字符,接着 p
开始自增直到指向空字符为止。循环终止时,p
指向空字符。
第二个 while
语句实现了第 2 2 2 步。循环体把 s2
指向的一个字符复制到 p
指向的地方,接着 p
和 s2
都进行自增,当 s2
指向空字符时循环终止。接下来,程序在 p
指向的位置放置空字符,然后 strcat
函数返回。
类似于对 strlen
函数的处理,也可以简化 strcat
函数的定义,得到下面的版本:
char *strcat(char *s1, const char *s2)
{char *p = s1;while (*p)p++;while (*p++ = *s2++);return s1;
}
改进的 strcat
函数的核心是 “字符串复制” 的惯用法:
while (*p++ = *s2++);
如果忽略了两个 ++
运算符,那么圆括号中的表达式会简化为普通的赋值:*p = *s2
。这个表达式把 s2
指向的字符复制到 p
所指向的地方。正是由于有了这两个 ++
运算符,赋值之后 p
和 s2
才进行了自增。重复执行此表达式所产生的效果就是把 s2
指向的一系列字符复制到 p
所指向的地方。
由于圆括号中的主要运算符是赋值运算符,所以 while
语句会测试赋值表达式的值,也就是测试复制的字符。除空字符以外的所有字符的测试结果都为真,因此,循环只有在复制空字符后才会终止。而且由于循环是在赋值之后终止,所以不需要单独用一条语句来在新字符串的末尾添加空字符。
7. 字符串数组
存储字符串数组的最明显的解决方案是创建二维的字符数组,然后按照每行一个字符串的方式把字符串存储到数组中。考虑下面的例子:
char planets[][8] = {"Mercury", "Venus", "Earth","Mars", "Jupiter", "Saturn","Uranus", "Neptune", "Pluto"};
下面给出了 planets
数组的可能形式。并非所有的字符串都足以填满数组的一整行,所以 C 语言用空字符来填补。因为只有 3 3 3 个行星的名字需要用满 8 8 8 个字符(包括末尾的空字符),所以这样的数组有一点浪费空间。
因为大部分字符串集都是长字符串和短字符串的混合,所以这些例子所暴露的低效性是在处理字符串时经常遇到的问题。我们需要的是参差不齐的数组( ragged array \text{ragged array} ragged array),即每一行有不同长度的二维数组。C 语言本身并不提供这种 “参差不齐的数组类型”,但它提供了模拟这种数组类型的工具。秘诀就是建立一个特殊的数组,这个数组的元素都是指向字符串的指针。
下面是 planets
数组的另外一种写法,这次把它看成是指向字符串的指针的数组:
char *planets[] = {"Mercury", "Venus", "Earth","Mars", "Jupiter", "Saturn","Uranus", "Neptune", "Pluto"};
看上去改动不是很大,只是去掉了一对方括号,并且在 planets
前加了一个星号。但是,这对 planets
存储方式产生的影响却很大:
planets
的每一个元素都是指向以空字符结尾的字符串的指针。虽然必须为 planets
数组中的指针分配空间,但是字符串中不再有任何浪费的字符。
为了访问其中一个行星名字,只需要对 planets
数组取下标。由于指针和数组之间的紧密关系,访问行星名字中的字符的方式和访问二维数组元素的方式相同。例如,为了在 planets
数组中搜寻以字母 M
开头的字符串,可以使用下面的循环:
for (i = 0; i < 9; i++)if (planets[i][0] == 'M')printf("%s begins with M\n", planets[i]);
命令行参数
运行程序时经常需要提供一些信息 —— 文件名或者是改变程序行为的开关。考虑 UNIX 的 ls
命令。如果我们运行 ls
,将会显示当前目录中的文件名;但是,如果键入 ls -l
,那么会显示一个详细的文件列表,包括每个文件的大小、文件的所有者、文件最后改动的日期和时间等。为了进一步改变 ls
的行为,可以指定只显示一个文件的详细信息:ls -l remind.c
。
命令行信息不仅对操作系统命令可用,它对所有程序都是可用的。为了能够访问这些命令行参数(C 标准中称为程序参数),必须把 main
函数定义为含有两个参数的函数,这两个参数通常命名为 argc
和 argv
:
int main(int argc, char *argv[])
{...
}
argc
(参数计数)是命令行参数的数量(包括程序名本身),argv
(参数向量)是指向命令行参数的指针数组,这些命令行参数以字符串的形式存储。argv[0]
指向程序名,而从 argv[1]
到 argv[argc-1]
则指向余下的命令行参数。
argv
有一个附加元素,即 argv[argc]
,这个元素始终是一个空指针。空指针是一种不指向任何地方的特殊指针,用宏 NULL
表示。如果用户输入命令行 ls -l remind.c
,那么 argc
将为 3 3 3,argv[0]
将指向含有程序名的字符串,argv[1]
将指向字符串 "-l"
,argv[2]
将指向字符串 "remind.c"
,而 argv[3]
将为空指针:
此处没有详细说明程序名,因为根据操作系统的不同,程序名可能会包括路径或其他信息。如果程序名不可用,那么 argv[0]
会指向空字符串。
因为 argv
是指针数组,所以访问命令行参数非常容易。常见的做法是,期望有命令行参数的程序将会设置循环来按顺序检查每一个参数。设定这种循环的方法之一就是使用整型变量作为 argv
数组的下标。例如,下面的循环每行一条地显示命令行参数:
int i;
for (i = 1; i < argc; i++)printf("%s\n", argv[i]);
另一种方法是构造一个指向 argv[1]
的指针,然后对指针重复进行自增操作来逐个访问数组余下的元素。因为 argv
数组的最后一个元素始终是空指针,所以循环可以在找到数组中一个空指针时停止:
char **p;
for (p = &argv[1]; *p != NULL; p++)printf("%s\n", *p);
因为 p
是指向字符的指针的指针,所以必须小心使用。设置 p
等于 &argv[1]
是有意义的,因为 argv[1]
是一个指向字符的指针,所以 &argv[1]
就是指向指针的指针。因为 *p
和 NULL
都是指针,所以测试 *p != NULL
是没有问题的。对 p
进行自增操作看起来也是对的 —— 因为 p
指向数组元素,所以对它进行自增操作将使 p
指向下一个元素。显示 *p
的语句也是合理的,因为 *p
指向字符串中的第一个字符。
程序
planet.c
:核对行星的名字
下面的程序说明了访问命令行参数的方法。设计此程序的目的是为了检查一系列字符串,从而找出哪些字符串是行星的名字。程序执行时,用户将把待测试的字符串放置在命令行中,程序会指出每个字符串是否是行星的名字。如果是,程序还将显示行星的编号。
我们把最靠近太阳的行星编号为 1 1 1。并且规定除非字符串的首字母大写并且其余字母小写,否则程序不会认为字符串是行星的名字。
/* Checks planet names */#include <stdio.h>
#include <string.h>#define NUM_PLANETS 9int main(int argc, char *argv[])
{char *planets[] = {"Mercury", "Venus", "Earth","Mars", "Jupiter", "Saturn","Uranus", "Neptune", "Pluto"};int i, j;for (i = 1; i < argc; i++) {for (j = 0; j < NUM_PLANETS; j++)if (strcmp(argv[i], planets[j]) == 0) {printf("%s is planet %d\n", argv[i], j + 1);break;}if (j == NUM_PLANETS)printf("%s is not a planet\n", argv[i]);}return 0;
}
这个程序的运行过程如下(用户的输入用下划线标注):
planet Jupiter venus Earth fred ‾ \underline{\text{planet Jupiter venus Earth fred}} planet Jupiter venus Earth fred
Jupiter is planet 5
venus is not a planet
Earth is planet 3
fred is not a planet