9. 字符与字符串
本章从字符、基本字符集和字符串字面量出发,再学习 <ctype.h> 与 <string.h>。单个字符、以空字符结尾的字符串和指定长度的字节序列是三种不同的数据,标准库分别为它们提供了不同的函数。
1. 本章内容
- 字符串字面量
- ASCII 与基本字符集
<ctype.h>:判断字符类别并转换大小写。<string.h>:处理空终止字符串和指定长度的字节序列。
2. 两条基本规则
传给字符分类函数的值必须是 EOF,或能表示为 unsigned char 的值。使用字符串函数时,必须保证源字符串以空字符结尾,并且目标数组有足够的空间;处理可能含有空字符的任意字节序列时,应使用带显式长度的内存函数(如 memcpy、memcmp)。
3. 习题
阅读下面的程序,写出输出,并说明每一行结果从哪里来。
#include <stdio.h>
#include <string.h>
int main(void) {
const char raw[] = "ab\0cd";
char word[4] = "abc";
char shifted[] = "abcdef";
printf("%d %d\n", (int)sizeof raw, (int)strlen(raw));
printf("%s\n", raw);
printf("%d %d\n", (int)strlen(word), (int)sizeof word);
memmove(shifted + 2, shifted, 4);
printf("%s\n", shifted);
printf("%d\n", (int)((const char *)memchr(raw, 'c', sizeof raw) - raw));
printf("%d\n", strcmp(raw, "ab"));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
问题:
sizeof raw与strlen(raw)为什么相差 4?printf("%s", raw)只输出ab,raw里\0之后的cd去哪了?strlen(word)与sizeof word分别是多少?相差的那个字节是什么,由谁写入?把word的声明改成char word[] = "abc";后两者又各是多少,为什么仍然差 1?memmove那一行输出ababcd。如果换成memcpy,结果有保证吗?这里为什么必须用memmove?
本机输出(逐行):6 2、ab、3 4、ababcd、3、0。
只用 <ctype.h>,实现就地修改的函数:
#include <stddef.h>
void title_case(char *s);2
3
要求:
- 每个单词的首字母大写,同一单词内其余字母小写;单词由
isspace识别的空白分隔; - 只处理基本字符集中的字母,其他字符(数字、标点、
>= 0x80的字节)原样保留,也不把它们当作分隔单词的空白; - 判断字母一律用
isalpha、islower、isupper,不得写成c >= 'a' && c <= 'z'这类形式。写一小段程序验证'9' - '0'等于 9,并说明这一条为什么可以依赖,而「'a'到'z'连续」不能依赖; - 调用
isspace、toupper、tolower前先把char值转换成unsigned char,说明不转换在什么情况下会出错; - 不改动字符串长度。
验收(本机输出,每行给出输入与结果):
"" -> []
"hello" -> [Hello]
"HELLO WORLD" -> [Hello World]
" mixed CASE " -> [ Mixed Case ]
"a1b 2c" -> [A1b 2c]
"don't STOP" -> [Don't Stop]2
3
4
5
6
最后一例的 ' 既不是字母也不是空白。本机这份结果对它的处理是:不重置「处于单词中」的状态,于是 t 之后只做小写化。先自己决定规则,再对答案,说明你选的理由;只要规则写清楚、实现与自己声明的规则一致,[Don't Stop] 与 [Don'T Stop] 都算通过。
strncpy 与 strlcpy 处理目标空间的方式不同。先不查文档,根据 9.4 的说明预测下面三次调用在缓冲区里留下的字节,再运行验证。
char buf[16];
memset(buf, '#', sizeof buf);
strncpy(buf, "hello", 8);
strncpy(buf, "hello, world", 8);
strncpy(buf, "hello", 4);2
3
4
5
6
(每次调用前都把缓冲区重新填满 #,三次调用互不影响。)
(1) [2.2] 用十六进制逐字节打印前 10 个字节(00 表示空字符),并给出每次调用后缓冲区里是否存在空字符。解释为什么第二次和第三次调用之后缓冲区里都没有空字符。
(2) [3.3] 实现签名与 9.4 节给出的 strlcpy 一致的有界复制函数。要求返回源字符串的长度,最多向 dest 写 size - 1 个字符并保证空字符结尾;size 为 0 时不得写 dest。
#include <stddef.h>
size_t mdr_strlcpy(char *dest, const char *src, size_t size);2
3
用同样的三次调用验证,并说明 size = 8 时第二次调用后缓冲区里有什么。
(3) [4.3] 再把 mdr_strlcat 实现出来,签名对应 9.4 节的 strlcat:
size_t mdr_strlcat(char *dest, const char *src, size_t size);要求 size 是 dest 数组的总大小,返回值是「原本想拼成的长度」(strlen(dest) + strlen(src)),调用方据此判断是否截断。用 dest = "abcde"、src = "XYZ"、size = 8 验证:返回 8,缓冲区变成 abcdeXY。
本机 strncpy 的前 10 个字节:第一次 68 65 6C 6C 6F 00 00 00 23 23,第二次 68 65 6C 6C 6F 2C 20 77 23 23,第三次 68 65 6C 6C 23 23 23 23 23 23。
下面的程序有四处缺陷。先按原样编译运行,再逐条定位。编译器的诊断并不足以把它们都拦下来。
#include <ctype.h>
#include <stdio.h>
#include <string.h>
static void shout(char *s) {
for (size_t i = 0; i < strlen(s); i++) {
s[i] = (char)toupper(s[i]);
}
}
static void copy_name(char *dest, const char *src) {
strncpy(dest, src, 8);
}
static int count_fields(char *line, const char *delim) {
int count = 0;
for (char *tok = strtok(line, delim); tok != NULL; tok = strtok(NULL, delim)) {
count++;
}
return count;
}
int main(void) {
char name[8];
char csv[] = "a,b,c";
char accented = (char)0xE9;
char *text = "one,two,three";
copy_name(name, "a-very-long-name");
printf("name: [%s]\n", name);
for (char *tok = strtok(csv, ","); tok != NULL; tok = strtok(NULL, ",")) {
printf("tok: %s\n", tok);
}
printf("field count on second pass: %d\n", count_fields(csv, ","));
printf("isalpha: %d\n", isalpha(accented));
printf("isalpha(unsigned char): %d\n", isalpha((unsigned char)accented));
shout(text);
printf("text: %s\n", text);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
程序最后一行 shout(text) 会崩溃。在终端里直接运行时,崩溃之前打印的内容仍然可见;把输出接进管道时标准输出是全缓冲的,这些内容可能来不及写出,这时可以先用 setvbuf(stdout, NULL, _IONBF, 0) 关掉缓冲,或者用调试器在崩溃处查看。
(1) [2.1] 先只看编译器给了什么。用 -std=c17 -Wall -Wextra 编译,记下全部诊断。这四处缺陷里有几处被报告了?只靠 -Wall -Wextra 能不能发现全部四处?
(2) [4.2] 逐条定位其余三处缺陷。四处分别属于:strncpy 的截断与空字符、向只读的字面量写入、strtok 修改被分割的字符串、ctype 函数收到负的 char 值。指出哪一处让程序崩溃、哪三处只让结果不符合预期。让程序在崩溃前把输出刷出来的办法有两种:setvbuf(stdout, NULL, _IONBF, 0),或直接重定向到文件后看文件内容。运行时会看到 field count on second pass: 1,解释这个结果为什么说明 strtok 已经改动了 csv。
(3) [4.3] 逐一修正:copy_name 增加容量参数并用 memcpy 加空字符;shout 改为遍历到空字符为止;拆分时先把 csv 复制到一份临时数组再交给 strtok,使 csv 保持原样;isalpha 前先转 unsigned char。修正后编译零警告,输出依次为 name: [a-very-]、三行 tok:、field count on second pass: 3、两行 isalpha: 0、text: ONE,TWO,THREE。再改用 9.4 节提到的可重入版本 strtok_r 拆同一行,说明它多出的那个参数在做什么。
(4) [6.4*] 不用 strtok,实现一个不修改原串的字段遍历:
#include <stddef.h>
/* 按 delims 中任意字符分割 line,把各字段的起点和长度写入 out,
最多写 max_fields 个,返回实际字段数;line 不被修改。 */
size_t split_fields(const char *line, const char *delims,
const char **starts, size_t *lengths, size_t max_fields);2
3
4
5
6
用 strcspn 找下一个分隔符的位置。相邻分隔符之间产生空字段(长度为 0),末尾分隔符之后也产生一个空字段,空行产生一个长度为 0 的字段。验收:
split("a,,b,", ",") -> 4 个字段:[a] [] [b] []
split("", ",") -> 1 个字段:[]
split("x;y,z", ";,") -> 3 个字段:[x] [y] [z]2
3
再写 size_t count_byte(const char *data, size_t n, char value),用 memchr 统计任意字节序列中某字节出现的次数,用 const char raw[] = "a\0b\0c"; 与 sizeof raw 验证 count_byte(raw, 6, '\0') 为 3。说明为什么这两个接口必须带长度参数,而不能只靠空字符判断结尾。