| 目录 |

7 字符串

终于!字符串!还有什么比这更简单的呢?

嗯,事实证明在 C 里字符串其实并不是“字符串”。没错!它们是指针!它们当然是指针!

和数组很像,C 里的字符串勉强算是存在。

不过还是来看看吧——其实真没什么大不了的。

7.1 字符串字面量

开始之前,先说说 C 里的字符串字面量。它们是包在双引号(")里的一串字符。(单引号括起来的是字符,完全是另一回事。)

例如:

"Hello, world!\n"
"This is a test."
"When asked if this string had quotes in it, she replied, \"It does.\""

第一个末尾有一个换行符——这是很常见的情况。

最后一个里嵌入了引号,但你能看到每个引号前面都有一个反斜杠(\)——我们说它被“转义”了——表示此处字符串里应当有一个字面意义上的引号。C 编译器正是靠这一点来区分“要打印的双引号”和“字符串结尾的双引号”。

7.2 字符串变量

既然已经知道怎么创建字符串字面量,我们就把它赋给一个变量,好拿它做点什么。

char *s = "Hello, world!";

注意那个类型:指向 char 的指针。字符串变量 s 实际上是指向该字符串第一个字符的指针,也就是那个 H。

我们可以用 %s(代表“string”)格式说明符把它打印出来:

char *s = "Hello, world!";

printf("%s\n", s);  // 输出 "Hello, world!"

7.3 把字符串变量当作数组

另一种写法是这样,与上面的 char* 用法几乎等价:

char s[14] = "Hello, world!";

// 或者,如果我们够懒,让编译器
// 替我们算出长度:

char s[] = "Hello, world!";

这意味着你可以用数组写法来访问字符串里的字符。我们就这么做,把字符串里的所有字符打印在同一行上:

#include <stdio.h>

int main(void)
{
    char s[] = "Hello, world!";

    for (int i = 0; i < 13; i++)
        printf("%c", s[i]);
    printf("\n");
}

注意我们用的是 %c 格式说明符来打印单个字符。

另外看看这个。如果我们把 s 的定义改成 char* 类型,程序依然能正常工作:

#include <stdio.h>

int main(void)
{
    char *s = "Hello, world!";   // 这里用 char*

    for (int i = 0; i < 13; i++)
        printf("%c", s[i]);    // 但这里仍然用数组……?
    printf("\n");
}

而且打印时我们照样能用数组写法把活干完!这挺让人意外,但说到底还是因为我们还没讲过数组与指针的等价性。不过这也再次暗示:数组和指针在骨子里是同一回事。

7.4 字符串初始化器

我们已经见过一些用字符串字面量初始化字符串变量的例子:

char *s = "Hello, world!";
char t[] = "Hello, again!";

但这两个初始化有细微的差别。字符串字面量和整数字面量一样,其内存由编译器自动为你管理!对于整数这种大小固定的数据,编译器管理起来相当容易。但字符串是字节数不定的怪物,编译器只好把它丢进一块内存,再给你一个指向它的指针,以此来驯服它。

这种写法指向该字符串被放置的位置。出于性能与安全方面的原因,那个位置通常远在程序其余内存之外的“远方”——只读内存。

char *s = "Hello, world!";

所以,如果你用下面这段代码去改动那个字符串:

char *s = "Hello, world!";

s[0] = 'z';  // 坏消息:试图改动字符串字面量!

行为是未定义的。视你的系统而定,很可能导致崩溃。

但把它声明为数组就不一样了。编译器不会把那些字节存到城市的另一头,它们就在同一条街上。这一份是字符串可变的副本——我们可以随意改动:

char t[] = "Hello, again!";  // t 是该字符串的数组副本
t[0] = 'z'; // 没问题

printf("%s\n", t);  // 输出 "zello, again!"

所以要记住:如果你有一个指向字符串字面量的指针,别试图去改动它!另外,如果用双引号里的字符串来初始化数组,那其实不算字符串字面量。

7.5 获取字符串长度

你做不到,因为 C 不会替你记录长度。而我说“做不到”时,其实意思是“做得到”63。<string.h> 里有一个 strlen() 函数,可以用它来计算任何字符串的字节长度64。

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

int main(void)
{
    char *s = "Hello, world!";

    printf("The string is %zu bytes long.\n", strlen(s));
}

strlen() 函数返回 size_t 类型,这是一种整数类型,所以你可以拿它做整数运算。我们用 %zu 打印 size_t。

上面的程序会打印:

The string is 13 bytes long.

太好了!所以获取字符串长度确实是可能的!

可是……如果 C 根本没在任何地方记录字符串的长度,它怎么知道字符串有多长呢?

7.6 字符串终止

C 处理字符串的方式和许多编程语言略有不同,事实上和几乎所有现代编程语言都不同。

在设计一门新语言时,要把字符串存进内存,基本上有两种选择:

  1. 把字符串的各个字节存下来,再附上一个表示字符串长度的数字。

  2. 只存字符串的字节,并用一个叫作终止符的特殊字节来标记字符串的结尾。

如果想得到长度超过 255 个字符的字符串,方案 1 至少需要两个字节来存长度。而方案 2 只需一个字节就能终止字符串。这样就能省下一点空间。

当然,如今还为省下一个字节(或者 3 个——很多语言都乐意让你拥有长达 4 GB 的字符串)而操心,显得有点可笑。但在当年,这可是件大事。

于是 C 选择了方案 2。在 C 里,“字符串”由两个基本特征来定义:

在 C 代码里,NUL 字符可以写成 \0,不过你通常不必这么做。

当你在代码里写上双引号括起来的字符串时,NUL 字符会被自动、隐式地包含进去。

char *s = "Hello!";  // 实际上幕后是 "Hello!\0"

明白了这一点,我们就自己写一个 strlen() 函数,它数一数字符串里的 char,直到遇到 NUL 为止。

做法是沿着字符串往下找那个 NUL 字符,一边找一边计数66:

int my_strlen(char *s)
{
    int count = 0;

    while (s[count] != '\0')  // 单个字符用单引号
        count++;

    return count;
}

内置的 strlen() 基本上就是这么干的。

7.7 复制字符串

你不能用赋值运算符(=)来复制字符串。它做的只是复制指向第一个字符的指针……结果你就得到了两个指向同一个字符串的指针:

#include <stdio.h>

int main(void)
{
    char s[] = "Hello, world!";
    char *t;

    // 这复制的是指针,不是字符串!
    t = s;

    // 我们改动 t
    t[0] = 'z';

    // 但打印 s 却显示出改动!
    // 因为 t 和 s 指向同一个字符串!

    printf("%s\n", s);  // 输出 "zello, world!"
}

如果你想复制一个字符串,就得一个字节一个字节地复制——也就是说,你要把字符串的各个字节从内存中的一处取出来,再复制到内存中的另一处。用 strcpy() 函数会方便一些。67

复制字符串之前,先确认你有足够的空间放它,也就是说,用来存放字符的目标数组至少要和你复制的字符串一样长。

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

int main(void)
{
    char s[] = "Hello, world!";
    char t[100];  // 每个 char 占一个字节,所以空间足够

    // 这次复制的是字符串!
    strcpy(t, s);

    // 我们改动 t
    t[0] = 'z';

    // 而 s 不受影响,因为它是另一个字符串
    printf("%s\n", s);  // 输出 "Hello, world!"

    // 但 t 已经被改过了
    printf("%s\n", t);  // 输出 "zello, world!"
}

注意 strcpy() 的第一个实参是目标指针,第二个是源指针。我记这事的口诀是:如果赋值 = 对字符串管用,你本来就会按这个顺序写 t 和 s——源在右,目标在左。


| 目录 |