# 字符串 ## 简介 C 语言没有单独的字符串类型,字符串被当作字符数组,即`char`类型的数组。比如,字符串“Hello”是当作数组`{'H', 'e', 'l', 'l', 'o'}`处理的。 编译器会给数组分配一段连续内存,所有字符储存在相邻的内存单元之中。在字符串结尾,C 语言会自动添加一个全是二进制`0`的字节,写作`\0`字符,表示字符串结束。字符`\0`不同于字符`0`,前者的 ASCII 码是0(二进制形式`00000000`),后者的 ASCII 码是48(二进制形式`00110000`)。所以,字符串“Hello”实际储存的数组是`{'H', 'e', 'l', 'l', 'o', '\0'}`。 所有字符串的最后一个字符,都是`\0`。这样做的好处是,C 语言不需要知道字符串的长度,就可以读取内存里面的字符串,只要发现有一个字符是`\0`,那么就知道字符串结束了。 ```c char localString[10]; ``` 上面示例声明了一个10个成员的字符数组,可以当作字符串。由于必须留一个位置给`\0`,所以最多只能容纳9个字符的字符串。 字符串写成数组的形式,是非常麻烦的。C 语言提供了一种简写法,双引号之中的字符,会被自动视为字符数组。 ```c {'H', 'e', 'l', 'l', 'o', '\0'} // 等价于 "Hello" ``` 上面两种字符串的写法是等价的,内部存储方式都是一样的。双引号里面的字符串,不用自己添加结尾字符`\0`,C 语言会自动添加。 注意,双引号里面是字符串,单引号里面是字符,两者不能互换。如果把`Hello`放在单引号里面,编译器会报错。 ```c // 报错 'Hello' ``` 另一方面,即使双引号里面只有一个字符(比如`"a"`),也依然被处理成字符串(存储为2个字节),而不是字符`'a'`(存储为1个字节)。 如果字符串内部包含双引号,则该双引号需要使用反斜杠转义。 ```c "She replied, \"It does.\"" ``` 反斜杠还可以表示其他特殊字符,比如换行符(`\n`)、制表符(`\t`)等。 ```c "Hello, world!\n" ``` 如果字符串过长,可以在需要折行的地方,使用反斜杠(`\`)结尾,将一行拆成多行。 ```c "hello \ world" ``` 上面示例中,第一行尾部的反斜杠,将字符串拆成两行。 上面这种写法有一个缺点,就是第二行必须顶格书写,如果想包含缩进,那么缩进也会被计入字符串。为了解决这个问题,C 语言允许合并多个字符串字面量,只要这些字符串之间没有间隔,或者只有空格,C 语言会将它们自动合并。 ```c char greeting[50] = "Hello, ""how are you ""today!"; // 等同于 char greeting[50] = "Hello, how are you today!"; ``` 这种新写法支持多行字符串的合并。 ```c char greeting[50] = "Hello, " "how are you " "today!"; ``` `printf()`使用占位符`%s`输出字符串。 ```c printf("%s\n", "hello world") ``` ## 字符串变量的声明 字符串变量可以声明成一个字符数组,也可以声明成一个指针,指向字符数组。 ```c // 写法一 char s[14] = "Hello, world!"; // 写法二 char* s = "Hello, world!"; ``` 上面两种写法都声明了一个字符串变量`s`。如果采用第一种写法,由于字符数组的长度可以让编译器自动计算,所以声明时可以省略字符数组的长度。 ```c char s[] = "Hello, world!"; ``` 上面示例中,编译器会将数组`s`的长度指定为14,正好容纳后面的字符串。 字符数组的长度,可以大于字符串的实际长度。 ```c char s[50] = "hello"; ``` 上面示例中,字符数组`s`的长度是`50`,但是字符串“hello”的实际长度只有6(包含结尾符号`\0`),所以后面空出来的44个位置,都会被初始化为`\0`。 字符数组的长度,不能小于字符串的实际长度。 ```c char s[5] = "hello"; ``` 上面示例中,字符串数组`s`的长度是`5`,小于字符串“hello”的实际长度6,这时编译器会报错。因为如果只将前5个字符写入,而省略最后的结尾符号`\0`,这很可能导致后面的字符串相关代码出错。 字符指针和字符数组,这两种声明字符串变量的写法基本是等价的,但是有两个差异。 第一个差异是,指针指向的字符串,在 C 语言内部被当作常量,不能修改字符串本身。 ```c char* s = "Hello, world!"; s[0] = 'z'; // 错误 ``` 上面代码使用指针,声明了一个字符串变量,然后修改了字符串的第一个字符。这种写法是错的,会导致难以预测的后果,执行时很可能会报错。 如果使用数组声明字符串变量,就没有这个问题,可以修改数组的任意成员。 ```c char s[] = "Hello, world!"; s[0] = 'z'; ``` 为什么字符串声明为指针时不能修改,声明为数组时就可以修改?原因是系统会将字符串的字面量保存在内存的常量区,这个区是不允许用户修改的。声明为指针时,指针变量存储的值是一个指向常量区的内存地址,因此用户不能通过这个地址去修改常量区。但是,声明为数组时,编译器会给数组单独分配一段内存,字符串字面量会被编译器解释成字符数组,逐个字符写入这段新分配的内存之中,而这段新内存是允许修改的。 为了提醒用户,字符串声明为指针后不得修改,可以在声明时使用`const`说明符,保证该字符串是只读的。 ```c const char* s = "Hello, world!"; ``` 上面字符串声明为指针时,使用了`const`说明符,就保证了该字符串无法修改。一旦修改,编译器肯定会报错。 第二个差异是,指针变量可以指向其它字符串。 ```c char* s = "hello"; s = "world"; ``` 上面示例中,字符指针可以指向另一个字符串。 但是,字符数组变量不能指向另一个字符串。 ```c char s[] = "hello"; s = "world"; // 报错 ``` 上面示例中,字符数组的数组名,总是指向初始化时的字符串地址,不能修改。 同样的原因,声明字符数组后,不能直接用字符串赋值。 ```c char s[10]; s = "abc"; // 错误 ``` 上面示例中,不能直接把字符串赋值给字符数组变量,会报错。原因是字符数组的变量名,跟所指向的数组是绑定的,不能指向另一个地址。 为什么数组变量不能赋值为另一个数组?原因是数组变量所在的地址无法改变,或者说,编译器一旦为数组变量分配地址后,这个地址就绑定这个数组变量了,这种绑定关系是不变的。C 语言也因此规定,数组变量是一个不可修改的左值,即不能用赋值运算符为它重新赋值。 想要重新赋值,必须使用 C 语言原生提供的`strcpy()`函数,通过字符串拷贝完成赋值。这样做以后,数组变量的地址还是不变的,即`strcpy()`只是在原地址写入新的字符串,而不是让数组变量指向新的地址。 ```c char s[10]; strcpy(s, "abc"); ``` 上面示例中,`strcpy()`函数把字符串`abc`拷贝给变量`s`,这个函数的详细用法会在后面介绍。 ## strlen() `strlen()`函数返回字符串的字节长度,不包括末尾的空字符`\0`。该函数的原型如下。 ```c // string.h size_t strlen(const char* s); ``` 它的参数是字符串变量,返回的是`size_t`类型的无符号整数,除非是极长的字符串,一般情况下当作`int`类型处理即可。下面是一个用法实例。 ```c char* str = "hello"; int len = strlen(str); // 5 ``` `strlen()`的原型在标准库的`string.h`文件中定义,使用时需要加载头文件`string.h`。 ```c #include #include int main(void) { char* s = "Hello, world!"; printf("The string is %zd characters long.\n", strlen(s)); } ``` 注意,字符串长度(`strlen()`)与字符串变量长度(`sizeof()`),是两个不同的概念。 ```c char s[50] = "hello"; printf("%d\n", strlen(s)); // 5 printf("%d\n", sizeof(s)); // 50 ``` 上面示例中,字符串长度是5,字符串变量长度是50。 如果不使用这个函数,可以通过判断字符串末尾的`\0`,自己计算字符串长度。 ```c int my_strlen(char *s) { int count = 0; while (s[count] != '\0') count++; return count; } ``` ## strcpy() 字符串的复制,不能使用赋值运算符,直接将一个字符串赋值给字符数组变量。 ```c char str1[10]; char str2[10]; str1 = "abc"; // 报错 str2 = str1; // 报错 ``` 上面两种字符串的复制写法,都是错的。因为数组的变量名是一个固定的地址,不能修改,使其指向另一个地址。 如果是字符指针,赋值运算符(`=`)只是将一个指针的地址复制给另一个指针,而不是复制字符串。 ```c char* s1; char* s2; s1 = "abc"; s2 = s1; ``` 上面代码可以运行,结果是两个指针变量`s1`和`s2`指向同一字符串,而不是将字符串`s1`的内容复制给`s2`。 C 语言提供了`strcpy()`函数,用于将一个字符串的内容复制到另一个字符串,相当于字符串赋值。该函数的原型定义在`string.h`头文件里面。 ```c strcpy(char dest[], const char source[]) ``` `strcpy()`接受两个参数,第一个参数是目的字符串数组,第二个参数是源字符串数组。复制字符串之前,必须要保证第一个参数的长度不小于第二个参数,否则虽然不会报错,但会溢出第一个字符串变量的边界,发生难以预料的结果。第二个参数的`const`说明符,表示这个函数不会修改第二个字符串。 ```c #include #include int main(void) { char s[] = "Hello, world!"; char t[100]; strcpy(t, s); t[0] = 'z'; printf("%s\n", s); // "Hello, world!" printf("%s\n", t); // "zello, world!" } ``` 上面示例将变量`s`的值,拷贝一份放到变量`t`,变成两个不同的字符串,修改一个不会影响到另一个。另外,变量`t`的长度大于`s`,复制后多余的位置(结束标志`\0`后面的位置)都为随机值。 `strcpy()`也可以用于字符数组的赋值。 ```c char str[10]; strcpy(str, "abcd"); ``` 上面示例将字符数组变量,赋值为字符串“abcd”。 `strcpy()`的返回值是一个字符串指针(即`char*`),指向第一个参数。 ```c char* s1 = "beast"; char s2[40] = "Be the best that you can be."; char* ps; ps = strcpy(s2 + 7, s1); puts(s2); // Be the beast puts(ps); // beast ``` 上面示例中,从`s2`的第7个位置开始拷贝字符串`beast`,前面的位置不变。这导致`s2`后面的内容都被截去了,因为会连`beast`结尾的空字符一起拷贝。`strcpy()`返回的是一个指针,指向拷贝开始的位置。 `strcpy()`返回值的另一个用途,是连续为多个字符数组赋值。 ```c strcpy(str1, strcpy(str2, "abcd")); ``` 上面示例调用两次`strcpy()`,完成两个字符串变量的赋值。 另外,`strcpy()`的第一个参数最好是一个已经声明的数组,而不是声明后没有进行初始化的字符指针。 ```c char* str; strcpy(str, "hello world"); // 错误 ``` 上面的代码是有问题的。`strcpy()`将字符串分配给指针变量`str`,但是`str`并没有进行初始化,指向的是一个随机的位置,因此字符串可能被复制到任意地方。 如果不用`strcpy()`,自己实现字符串的拷贝,可以用下面的代码。 ```c char* strcpy(char* dest, const char* source) { char* ptr = dest; while (*dest++ = *source++); return ptr; } int main(void) { char str[25]; strcpy(str, "hello world"); printf("%s\n", str); return 0; } ``` 上面代码中,关键的一行是`while (*dest++ = *source++)`,这是一个循环,依次将`source`的每个字符赋值给`dest`,然后移向下一个位置,直到遇到`\0`,循环判断条件不再为真,从而跳出循环。其中,`*dest++`这个表达式等同于`*(dest++)`,即先返回`dest`这个地址,再进行自增运算移向下一个位置,而`*dest`可以对当前位置赋值。 `strcpy()`函数有安全风险,因为它并不检查目标字符串的长度,是否足够容纳源字符串的副本,可能导致写入溢出。如果不能保证不会发生溢出,建议使用`strncpy()`函数代替。 ## strncpy() `strncpy()`跟`strcpy()`的用法完全一样,只是多了第3个参数,用来指定复制的最大字符数,防止溢出目标字符串变量的边界。 ```c char* strncpy( char* dest, char* src, size_t n ); ``` 上面原型中,第三个参数`n`定义了复制的最大字符数。如果达到最大字符数以后,源字符串仍然没有复制完,就会停止复制,这时目的字符串结尾将没有终止符`\0`,这一点务必注意。如果源字符串的字符数小于`n`,则`strncpy()`的行为与`strcpy()`完全一致。 ```c strncpy(str1, str2, sizeof(str1) - 1); str1[sizeof(str1) - 1] = '\0'; ``` 上面示例中,字符串`str2`复制给`str1`,但是复制长度最多为`str1`的长度减去1,`str1`剩下的最后一位用于写入字符串的结尾标志`\0`。这是因为`strncpy()`不会自己添加`\0`,如果复制的字符串片段不包含结尾标志,就需要手动添加。 `strncpy()`也可以用来拷贝部分字符串。 ```c char s1[40]; char s2[12] = "hello world"; strncpy(s1, s2, 5); s1[5] = '\0'; printf("%s\n", s1); // hello ``` 上面示例中,指定只拷贝前5个字符。 ## strcat() `strcat()`函数用于连接字符串。它接受两个字符串作为参数,把第二个字符串的副本添加到第一个字符串的末尾。这个函数会改变第一个字符串,但是第二个字符串不变。 该函数的原型定义在`string.h`头文件里面。 ```c char* strcat(char* s1, const char* s2); ``` `strcat()`的返回值是一个字符串指针,指向第一个参数。 ```c char s1[12] = "hello"; char s2[6] = "world"; strcat(s1, s2); puts(s1); // "helloworld" ``` 上面示例中,调用`strcat()`以后,可以看到字符串`s1`的值变了。 注意,`strcat()`的第一个参数的长度,必须足以容纳添加第二个参数字符串。否则,拼接后的字符串会溢出第一个字符串的边界,写入相邻的内存单元,这是很危险的,建议使用下面的`strncat()`代替。 ## strncat() `strncat()`用于连接两个字符串,用法与`strcat()`完全一致,只是增加了第三个参数,指定最大添加的字符数。在添加过程中,一旦达到指定的字符数,或者在源字符串中遇到空字符`\0`,就不再添加了。它的原型定义在`string.h`头文件里面。 ```c char* strncat( const char* dest, const char* src, size_t n ); ``` `strncat()`返回第一个参数,即目标字符串指针。 为了保证连接后的字符串,不超过目标字符串的长度,`strncat()`通常会写成下面这样。 ```c strncat( str1, str2, sizeof(str1) - strlen(str1) - 1 ); ``` `strncat()`总是会在拼接结果的结尾,自动添加空字符`\0`,所以第三个参数的最大值,应该是`str1`的变量长度减去`str1`的字符串长度,再减去`1`。下面是一个用法实例。 ```c char s1[10] = "Monday"; char s2[8] = "Tuesday"; strncat(s1, s2, 3); puts(s1); // "MondayTue" ``` 上面示例中,`s1`的变量长度是10,字符长度是6,两者相减后再减去1,得到`3`,表明`s1`最多可以再添加三个字符,所以得到的结果是`MondayTue`。 ## strcmp() 如果要比较两个字符串,无法直接比较,只能一个个字符进行比较,C 语言提供了`strcmp()`函数。 `strcmp()`函数用于比较两个字符串的内容。该函数的原型如下,定义在`string.h`头文件里面。 ```c int strcmp(const char* s1, const char* s2); ``` 按照字典顺序,如果两个字符串相同,返回值为`0`;如果`s1`小于`s2`,`strcmp()`返回值小于0;如果`s1`大于`s2`,返回值大于0。 下面是一个用法示例。 ```c // s1 = Happy New Year // s2 = Happy New Year // s3 = Happy Holidays strcmp(s1, s2) // 0 strcmp(s1, s3) // 大于 0 strcmp(s3, s1) // 小于 0 ``` 注意,`strcmp()`只用来比较字符串,不用来比较字符。因为字符就是小整数,直接用相等运算符(`==`)就能比较。所以,不要把字符类型(`char`)的值,放入`strcmp()`当作参数。 ## strncmp() 由于`strcmp()`比较的是整个字符串,C 语言又提供了`strncmp()`函数,只比较到指定的位置。 该函数增加了第三个参数,指定了比较的字符数。它的原型定义在`string.h`头文件里面。 ```c int strncmp( const char* s1, const char* s2, size_t n ); ``` 它的返回值与`strcmp()`一样。如果两个字符串相同,返回值为`0`;如果`s1`小于`s2`,`strcmp()`返回值小于0;如果`s1`大于`s2`,返回值大于0。 下面是一个例子。 ```c char s1[12] = "hello world"; char s2[12] = "hello C"; if (strncmp(s1, s2, 5) == 0) { printf("They all have hello.\n"); } ``` 上面示例只比较两个字符串的前5个字符。 ## sprintf(),snprintf() `sprintf()`函数跟`printf()`类似,但是用于将数据写入字符串,而不是输出到显示器。该函数的原型定义在`stdio.h`头文件里面。 ```c int sprintf(char* s, const char* format, ...); ``` `sprintf()`的第一个参数是字符串指针变量,其余参数和`printf()`相同,即第二个参数是格式字符串,后面的参数是待写入的变量列表。 ```c char first[6] = "hello"; char last[6] = "world"; char s[40]; sprintf(s, "%s %s", first, last); printf("%s\n", s); // hello world ``` 上面示例中,`sprintf()`将输出内容组合成“hello world”,然后放入了变量`s`。 `sprintf()`的返回值是写入变量的字符数量(不计入尾部的空字符`\0`)。如果遇到错误,返回负值。 `sprintf()`有严重的安全风险,如果写入的字符串过长,超过了目标字符串的长度,`sprintf()`依然会将其写入,导致发生溢出。为了控制写入的字符串的长度,C 语言又提供了另一个函数`snprintf()`。 `snprintf()`只比`sprintf()`多了一个参数`n`,用来控制写入变量的字符串不超过`n - 1`个字符,剩下一个位置写入空字符`\0`。下面是它的原型。 ```c int snprintf(char*s, size_t n, const char* format, ...); ``` `snprintf()`总是会自动写入字符串结尾的空字符。如果你尝试写入的字符数超过指定的最大字符数,`snprintf()`会写入 n - 1 个字符,留出最后一个位置写入空字符。 下面是一个例子。 ```c snprintf(s, 12, "%s %s", "hello", "world"); ``` 上面的例子中,`snprintf()`的第二个参数是12,表示写入字符串的最大长度不超过12(包括尾部的空字符)。 `snprintf()`的返回值是写入格式字符串的字符数量(不计入尾部的空字符`\0`)。如果`n`足够大,返回值应该小于`n`,但是有时候格式字符串的长度可能大于`n`,那么这时返回值会大于`n`,但实际上真正写入变量的还是`n-1`个字符。如果遇到错误,返回一个负值。因此,返回值只有在非负并且小于`n`时,才能确认完整的格式字符串写入了变量。 ## 字符串数组 如果一个数组的每个成员都是一个字符串,需要通过二维的字符数组实现。每个字符串本身是一个字符数组,多个字符串再组成一个数组。 ```c char weekdays[7][10] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; ``` 上面示例就是一个字符串数组,一共包含7个字符串,所以第一维的长度是7。其中,最长的字符串的长度是10(含结尾的终止符`\0`),所以第二维的长度统一设为10。 因为第一维的长度,编译器可以自动计算,所以可以省略。 ```c char weekdays[][10] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; ``` 上面示例中,二维数组第一维的长度,可以由编译器根据后面的赋值,自动计算,所以可以不写。 数组的第二维,长度统一定为10,有点浪费空间,因为大多数成员的长度都小于10。解决方法就是把数组的第二维,从字符数组改成字符指针。 ```c char* weekdays[] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; ``` 上面的字符串数组,其实是一个一维数组,成员就是7个字符指针,每个指针指向一个字符串(字符数组)。 遍历字符串数组的写法如下。 ```c for (int i = 0; i < 7; i++) { printf("%s\n", weekdays[i]); } ```