C 语言重拾【六】字符串和字符串函数

之前的文章介绍过,字符串是以空字符(\0)结尾的char类型数组。常用的输出字符串的方式有两种 printf()puts(),他们不同之处在于,printf()是格式化输出字符串,而puts()只显示字符串。

在程序中定义字符串

用双引号括起来的内容称为字符串字面量,也叫字符串常量。字符串常量属于静态存储类别,这说明如果在函数中使用字符串常量,该字符串只会被存储一次,在整个程序的生命期内存在,即使函数被调用多次。用双引号括起来的内容被视为指向该字符串存储位置的指针。这类似于把数组名作为指向该数组位置的指针。如果确实如此,下面的程序会输出什么?

1
2
3
4
5
6
#include <stdio.h>

int main(void) {
printf("%s, %p, %c\n", "we", "are", *"space farers");
return 0;
}

输出如下:

1
we, 0x100003fa7, s

字符串数组与指针

1
const char ar1[] = "Something is pointing at me.";

数组形式和指针形式有何不同?以上面的声明为例,数组形式(ar1[])在计算机的内存中分配为一个内含 29 个元素的数组(每个元素对应一个字符,还加上一个末尾的空字符 '\0'),每个元素被初始化为字符串字面量对应的字符。

通常,字符串都作为可执行文件的一部分储存在数据段中。当把程序载入内存时,也载入了程序中的字符串。字符串储存在静态存储区(static memory)中。但是,程序在开始运行时才会为该数组分配内存。此时,才将字符串拷贝到数组中(接下来的章节将详细讲解)。注意,此时字符串有两个副本。一个是在静态内存中的字符串字面量,另一个是储存在 ar1 数组中的字符串。

此后,编译器便把数组名 ar1 识别为该数组首元素地址(&ar1[0])的别名。这里关键要理解,在数组形式中,ar1 是地址常量。不能更改 ar1,如果改变了 ar1,则意味着改变了数组的存储位置(即地址)。可以进行类似 ar1+1 这样的操作,标识数组的下一个元素。但是不允许进行 ++ar1 这样的操作。递增运算符只能用于变量名前(或概括地说,只能用于可修改的左值),不能用于常量。

指针形式(*pt1)也使得编译器为字符串在静态存储区预留 29 个元素的空间。另外,一旦开始执行程序,它会为指针变量 pt1 留出一个储存位置,并把字符串的地址储存在指针变量中。该变量最初指向该字符串的首字符,但是它的值可以改变。因此,可以使用递增运算符。例如,++pt1 将指向第 2 个字符(o)。

字符串字面量被视为 const 数据。由于 pt1 指向这个 const 数据,所以应该把 pt1 声明为指向 const 数据的指针。这意味着不能用 pt1 改变它所指向的数据,但是仍然可以改变 pt1 的值(即,pt1 指向的位置)。如果把一个字符串字面量拷贝给一个数组,就可以随意改变数据,除非把数组声明为 const

总之,初始化数组把静态存储区的字符串拷贝到数组中,而初始化指针只把字符串的地址拷贝给指针。程序清单11.3演示了这一点。

程序清单 11.3 addresses.c 程序

1
2
3
4
5
6
7
8
9
10
11
12
13
// addresses.c -- 字符串的地址
#define MSG "I'm special"
#include <stdio.h>
int main() {
char ar[] = MSG;
const char *pt = MSG;
printf("address of \"I'm special\": %p \n", "I'm special");
printf(" address ar: %p\n", ar);
printf(" address pt: %p\n", pt);
printf(" address of MSG: %p\n", MSG);
printf("address of \"I'm special\": %p \n", "I'm special");
return 0;
}

下面是在我们的系统中运行该程序后的输出:

1
2
3
4
5
address of "I'm special":  0x100000f10
address ar: 0x7fff5fbff858
address pt: 0x100000f10
address of MSG: 0x100000f10
address of "I'm special": 0x100000f10

该程序的输出说明了什么?

  1. ptMSG 的地址相同,而 ar 的地址不同,这与我们前面讨论的内容一致。
  2. 虽然字符串字面量 "I'm special" 在程序的两个 printf() 函数中出现了两次,但是编译器只使用了一个存储位置,而且与 MSG 的地址相同。编译器可以把多次使用的相同字面量储存在一处或多处。另一个编译器可能在不同的位置储存 3 个 "I'm special"
  3. 静态数据使用的内存与 ar 使用的动态内存不同。不仅值不同,特定编译器甚至使用不同的位数表示两种内存。

指针初始化字符串

我们来看一下未使用 const 限定符的指针初始化:

1
char * word = "frame";

是否能使用该指针修改这个字符串?

1
word[1] = 'l'; // 是否允许?

编译器可能允许这样做,但是对当前的 C 标准而言,这样的行为是未定义的。例如,这样的语句可能导致内存访问错误。原因前面提到过,编译器可以使用内存中的一个副本来表示所有完全相同的字符串字面量。例如,下面的语句都引用字符串 "Klingon" 的一个内存位置:

1
2
3
4
char * p1 = "Klingon";
p1[0] = 'F'; // ok?
printf("Klingon");
printf(": Beware the %ss!\n", "Klingon");

也就是说,编译器可以用相同的地址替换每个 "Klingon" 实例。如果编译器使用这种单次副本表示法,并允许 p1[0] 修改 'F',那将影响所有使用该字符串的代码。所以以上语句打印字符串字面量 "Klingon" 时实际上显示的是 "Flingon"

Flingon: Beware the Flingons!

实际上在过去,一些编译器由于这方面的原因,其行为难以捉摸,而另一些编译器则导致程序异常中断。因此,建议在把指针初始化为字符串字面量时使用 const 限定符:

1
const char * pl = "Klingon";  // 推荐用法

然而,把非 const 数组初始化为字符串字面量却不会导致类似的问题。因为数组获得的是原始字符串的副本。

总之,如果不修改字符串,不要用指针指向字符串字面量。

字符串数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define SLEN 40
#define LIM 5

const char *mytalents[LIM] = {
"Adding numbers swiftly",
"Multiplying accurately", "Stashing data",
"Following instructions to the letter",
"Understanding the C language"
};
char yourtalents[LIM][SLEN] = {
"Walking in a straight line",
"Sleeping", "Watching television",
"Mailing letters", "Reading email"
};

从某些方面来看,mytalentsyourtalents 非常相似。两者都代表 5 个字符串。使用一个下标时都分别表示一个字符串,如 mytalents[0]yourtalents[0] 使用两个下标时都分别表示一个字符,例如 mytalents[1][2] 表示 mytalents 数组中第 2 个指针所指向的字符串的第 3 个字符 'l'yourtalents[1][2] 表示youttalentes 数组的第 2 个字符串的第 3 个字符 'e'。而且,两者的初始化方式也相同。

但是,它们也有区别。mytalents 数组是一个内含 5 个指针的数组,在我们的系统中共占用 40 字节。而 yourtalents 是一个内含 5 个数组的数组,每个数组内含 40 个 char 类型的值,共占用 200 字节。所以,虽然 mytalents[0]yourtalents[0] 都分别表示一个字符串,但 mytalentsyourtalents 的类型并不相同。mytalents 中的指针指向初始化时所用的字符串字面量的位置,这些字符串字面量被储存在静态内存中;而 yourtalents 中的数组则储存着字符串字面量的副本,所以每个字符串都被储存了两次。此外,为字符串数组分配内存的使用率较低。yourtalents 中的每个元素的大小必须相同,而且必须是能储存最长字符串的大小。

我们可以把 yourtalents 想象成矩形二维数组,每行的长度都是 40 字节;把 mytalents 想象成不规则的数组,每行的长度不同。图 11.2 演示了这两种数组的情况(实际上,mytalents 数组的指针元素所指向的字符串不必储存在连续的内存中,图中所示只是为了强调两种数组的不同)。

综上所述,如果要用数组表示一系列待显示的字符串,请使用指针数组,因为它比二维字符数组的效率高。但是,指针数组也有自身的缺点。mytalents 中的指针指向的字符串字面量不能更改;而 yourtalentsde 中的内容可以更改。所以,如果要改变字符串或为字符串输入预留空间,不要使用指向字符串字面量的指针。

空字符与空指针

从概念上看,两者完全不同。空字符(或 '\0')是用于标记 C 字符串末尾的字符,其对应字符编码是 0。由于其他字符的编码不可能是 0,所以不可能是字符串的一部分。

空指针(或 NULL)有一个值,该值不会与任何数据的有效地址对应。通常,函数使用它返回一个有效地址表示某些特殊情况发生,例如遇到文件结尾或未能按预期执行。

空字符是整数类型,而空指针是指针类型。两者有时容易混淆的原因是:它们都可以用数值 0 来表示。但是,从概念上看,两者是不同类型的 0。另外,空字符是一个字符,占 1 字节;而空指针是一个地址,通常占 4 字节

字符串函数

ANSI C 库有 20 多个用于处理字符串的函数,下面总结了一些常用的函数。

1
char *strcpy(char * restrict s1, const char * restrict s2);

该函数把 s2 指向的字符串(包括空字符)拷贝至 s1 指向的位置,返回值是 s1。

1
char *strncpy(char * restrict s1, const char * restrict s2, size_t n);

该函数把 s2 指向的字符串拷贝至 s1 指向的位置,拷贝的字符数不超过 n,其返回值是 s1。该函数不会拷贝空字符后面的字符,如果源字符串的字符少于 n 个,目标字符串就以拷贝的空字符结尾;如果源字符串有 n 个或超过 n 个字符,就不拷贝空字符。

1
char *strcat(char * restrict s1, const char * restrict s2);

该函数把 s2 指向的字符串拷贝至 s1 指向的字符串末尾。s2 字符串的第 1 个字符将覆盖 s1 字符串末尾的空字符。该函数返回 s1

1
char *strncat(char * restrict s1, const char * restrict s2, size_t n);

该函数把 s2 字符串中的 n 个字符拷贝至 s1 字符串末尾。s2 字符串的第 1 个字符将覆盖 s1 字符串末尾的空字符。不会拷贝 s2 字符串中空字符和其后的字符,并在拷贝字符的末尾添加一个空字符。该函数返回 s1

1
int strcmp(const char * s1, const char * s2);

如果 s1 字符串在机器排序序列中位于 s2 字符串的后面,该函数返回一个正数;如果两个字符串相等,则返回 0;如果 s1 字符串在机器排序序列中位于s2字符串的前面,则返回一个负数。

1
int strncmp(const char * s1, const char * s2, size_t n);

该函数的作用和 strcmp() 类似,不同的是,该函数在比较 n 个字符后或遇到第 1 个空字符时停止比较。

1
char *strchr(const char * s, int c);

如果 s 字符串中包含 c 字符,该函数返回指向 s 字符串首位置的指针(末尾的空字符也是字符串的一部分,所以在查找范围内);如果在字符串 s 中未找到 c 字符,该函数则返回空指针。

1
char *strpbrk(const char * s1, const char * s2);

如果 s1 字符中包含 s2 字符串中的任意字符,该函数返回指向 s1 字符串首位置的指针;如果在 s1 字符串中未找到任何 s2 字符串中的字符,则返回空字符。

1
char *strrchr(const char * s, int c);

该函数返回 s 字符串中 c 字符的最后一次出现的位置(末尾的空字符也是字符串的一部分,所以在查找范围内)。如果未找到 c 字符,则返回空指针。

1
char *strstr(const char * s1, const char * s2);

该函数返回指向 s1 字符串中 s2 字符串出现的首位置。如果在 s1 中没有找到 s2,则返回空指针。

1
size_t strlen(const char * s);

该函数返回 s 字符串中的字符数,不包括末尾的空字符。

请注意,那些使用 const 关键字的函数原型表明,函数不会更改字符串。例如,下面的函数原型:

1
char *strcpy(char * restrict s1, const char * restrict s2);

表明不能更改 s2 指向的字符串,至少不能在 strcpy() 函数中更改。但是可以更改 s1 指向的字符串。这样做很合理,因为 s1 是目标字符串,要改变,而 s2 是源字符串,不能更改。

关键字 restrict 将在接下来的章节中介绍,该关键字限制了函数参数的用法。例如,不能把字符串拷贝给本身。

关键概念

许多程序都要处理文本数据。一个程序可能要求用户输入姓名、公司列表、地址、一种蕨类植物的学名、音乐剧的演员等。毕竟,我们用言语与现实世界互动,使用文本的例子不计其数。C 程序通过字符串的方式来处理它们。

字符串,无论是由字符数组、指针还是字符串常量标识,都储存为包含字符编码的一系列字节,并以空字符串结尾。C 提供库函数处理字符串,查找字符串并分析它们。尤其要牢记,应该使用 strcmp() 来代替关系运算符,当比较字符串时,应该使用 strcpy()strncpy() 代替赋值运算符把字符串赋给字符数组。

总结

C 字符串是一系列 char 类型的字符,以空字符('\0')结尾。字符串可以储存在字符数组中。字符串还可以用字符串常量来表示,里面都是字符,括在双引号中(空字符除外)。编译器提供空字符。因此,"joy" 被储存为 4 个字符 j、o、y 和 \0strlen() 函数可以统计字符串的长度,空字符不计算在内。

字符串常量也叫作字符串——字面量,可用于初始化字符数组。为了容纳末尾的空字符,数组大小应该至少比容纳的数组长度多 1。也可以用字符串常量初始化指向 char 的指针。

函数使用指向字符串首字符的指针来表示待处理的字符串。通常,对应的实际参数是数组名、指针变量或用双引号括起来的字符串。无论是哪种情况,传递的都是首字符的地址。一般而言,没必要传递字符串的长度,因为函数可以通过末尾的空字符确定字符串的结束。

fgets() 函数获取一行输入,puts()fputs() 函数显示一行输出。它们都是 stdio.h 头文件中的函数,用于代替已被弃用的 gets()

C 库中有多个字符串处理函数。在 ANSI C 中,这些函数都声明在 string.h 文件中。C 库中还有许多字符处理函数,声明在 ctype.h 文件中。

main() 函数提供两个合适的形式参数,可以让程序访问命令行参数。第 1 个参数通常是 int 类型的 argc,其值是命令行的单词数量。第 2 个参数通常是一个指向数组的指针 argv,数组内含指向 char 的指针。每个指向 char 的指针都指向一个命令行参数字符串,argv[0] 指向命令名称,argv[1]指向第 1 个命令行参数,以此类推。

atoi()atol()atof() 函数把字符串形式的数字分别转换成 intlongdouble 类型的数字。strtol()strtoul()strtod() 函数把字符串形式的数字分别转换成 longunsigned longdouble 类型的数字。

参考文献

  • C Primer Plus