14. 进阶处理与输入输出
本章涵盖:
- 使用指针
- 格式化输入
- 处理扩展字符集
- 使用二进制流进行输入和输出
- 检查错误并清理
既然已经了解指针及其工作方式,现在可以从新的角度审视 C 库的一些功能。C 的文本处理离不开指针,因此本章在第 14.1 节先考察一个更详尽的示例。随后介绍格式化输入函数(第 14.2 节);这些函数要求以指针作为实参,所以只能推迟到这里。接着引入一整套处理扩展字符集(第 14.3 节)和二进制流(第 14.6 节)的新函数。本章以及整个层级的最后,将讨论如何干净利落地处理错误(第 14.6 节)。
14.1 文本处理
先来看下面这个程序。它读取一系列文本行,每行都含有若干来自 stdin 的数,然后以规范化形式把相同的数写到 stdout:各数表示成十六进制,并以逗号分隔。
numberline.c
int main (void) {
char lbuf[256];
for (;;) {
if (fgetline(sizeof lbuf, lbuf, stdin)) {
size_t n;
size_t* nums = numberline(strlen(lbuf)+1, lbuf, &n, 0);
if (nums) {
int ret = fprintnumbers(stdout, "%#zX", ",\t", n, nums);
if (ret < 0) return EXIT_FAILURE;
free(nums);
}
} else {
if (lbuf[0]) { /* a partial line has been read */
for (;;) {
int c = getc(stdin);
if (c == EOF) return EXIT_FAILURE;
if (c == '\n') {
fprintf(stderr, "line too long: %s\n", lbuf);
break;
}
}
} else break; /* regular end of input */
}
}
}253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
这个程序把工作分成三项不同任务:
- 用
fgetline读取一行文本; - 用
numberline把这样一行拆成一系列size_t类型的数; - 用
fprintnumbers打印这些数。
核心是函数 numberline。它把收到的 lbuf 字符串拆成若干数,分配一个数组来存储它们;如果提供了指针形参 np,还会通过该形参返回这些数的数量。
接口:numberline
把字符串 lbuf 解释为一系列以 base 为基数表示的数。
返回: 一个新分配的数组,其中存放在 lbuf 中找到的数。
形参:
lbuf应当是字符串;- 如果
np非空,数的数量存入*np; base是从 0 到 36 的值,其解释与strtoul及类似函数相同。
备注: 调用方负责释放返回的数组。
size_t* numberline(size_t size,
char const lbuf[restrict static size],
size_t* restrict np, int base);2
3
该函数本身又分成两个承担截然不同任务的部分。numberline_inner 负责解释文本行;numberline 本身只是前者外面的一层封装,用来核实或保证前者的先决条件。函数 numberline_inner 在循环中使用 C 库函数 strtoull,收集各个数并返回其数量。
现在可以看到 strtoull 第二个形参的用途。这里传入的是对象 next 的地址,next 用来追踪字符串中数结束的位置。由于 next 是指向 char 的指针,传给 strtoull 的实参就是指向 char 指针的指针:
numberline.c
static
size_t numberline_inner(char const* restrict act,
size_t numb[restrict static 1],
int base) {
size_t n = 0;
for (char* next = nullptr; act[0]; act = next) {
numb[n] = strtoull(act, &next, base);
if (act == next) break;
++n;
}
return n;
}101
102
103
104
105
106
107
108
109
110
111
假设调用形式是 strtoull("0789a", &next, base)。根据形参 base 的值,同一个字符串会得到不同解释。例如,base 的值为 10 时,第一个非数字字符就是末尾的 'a':
base | 数字个数 | 得到的数 | *next |
|---|---|---|---|
| 8 | 2 | 7 | '8' |
| 10 | 4 | 789 | 'a' |
| 16 | 5 | 30,874 | '\0' |
| 0 | 2 | 7 | '8' |
请记住基数为 0 时的特殊规则。实际基数由字符串的第一个字符(或前两个字符)推导得出。这里第一个字符是 '0',所以字符串按八进制解释;解析会在该基数下的第一个非数字字符 '8' 处停止。
numberline_inner 收到的文本行,其解析过程可能由两个条件终止:
act指向字符串终止位置,也就是字符0;- 函数
strtoull没有找到数,此时next会设为act的值。
这两个条件分别出现在 for 循环的控制表达式,以及循环内部的 if-break 条件中。
请注意,C 库函数 strtoull 有一项历史遗留弱点:第一个实参的类型是 char const*,第二个实参的类型却是没有 const 限定的 char**。正因如此,我们不得不把 next 的类型写成 char*,不能使用 char const*。调用 strtoull 之后,我们可能会在无意间修改只读字符串,导致程序崩溃。
要点 14.1 #1
字符串 strto... 转换函数不具备 const 安全性。
函数 numberline 为 numberline_inner 提供外围衔接:
- 如果
np为空,就把它设为指向一个辅助对象; - 检查输入字符串是否有效;
- 分配一个元素数量足够的数组来存储各值,并在得知正确长度后把数组调整到适当大小。
这里使用了三个 C 库函数:memchr、malloc 和 realloc。与先前的示例一样,malloc 与 realloc 的组合确保数组具有必要长度。
如果存在值为 0 的字节,memchr 调用就返回第一个此类字节的地址;如果不存在,则返回空指针。这里,它只用来检查前 size 个字节中是否确实存在字符 0。这样便能保证底层使用的所有字符串函数(特别是 strtoull)都作用于以 0 终止的字符串。
C23 以前,memchr 是一个存在问题的接口。当时只有一个同名函数,它返回的 void* 可能指向只读对象。
要点 14.1 #2
memchr 和 strchr 搜索函数的函数接口不具备 const 安全性。
C23 新增的类型泛型宏接口修复了这项缺陷。第 18.1.7 节将更详细地讨论这些类型泛型接口。
要点 14.1 #3
memchr 和 strchr 搜索函数的类型泛型接口具备 const 安全性。
相比之下,返回字符串内部索引位置的函数则是安全的。
要点 14.1 #4
strspn 和 strcspn 搜索函数具备 const 安全性。
numberline.c
size_t* numberline(size_t size,
char const lbuf[restrict static size],
size_t* restrict np, int base) {
size_t* ret = nullptr;
size_t n = 0;
/* Check for validity of the string, first. */
if (memchr(lbuf, 0, size)) {
/* The maximum number of integers encoded.
To see that this may be as much look at
the sequence 08 08 08 08 ... and suppose
that base is 0. */
ret = malloc(sizeof(size_t[1+(2*size)/3]));
if (!ret) return nullptr;
n = numberline_inner(lbuf, ret, base);
size_t len = n ? n : 1;
size_t* ret2 = realloc(ret, sizeof(size_t[len]));
if (ret2) ret = ret2;
}
if (np) *np = n;
return ret;
}114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
遗憾的是,这些返回索引的函数不能用来检查一个 char 数组实际上是不是字符串。这正是它们无法用于此处的原因。
现在来看示例中的第二个函数。
接口:fgetline
读取一个至多包含 size-1 个字节的文本行。
字符 '\n' 会替换为 0。
返回: 如果成功读入了完整的一行,就返回 s。否则返回 0,而 s 中包含能够读入的最长不完整文本行。s 以空字符终止。
char* fgetline(size_t size,
char s[restrict size],
FILE* restrict stream);2
3
它与 C 库函数 fgets 十分相似。第一项差别在接口上:形参顺序不同,而且 size 形参的类型是 size_t 而不是 int。和 fgets 一样,如果从流中读取失败,它会返回空指针。这样就很容易检测 stream 上的文件结束条件。
更重要的是,fgetline 能更从容地处理另一种关键情形:它可以检测下一输入行是否过长,也能检测流的最后一行是否没有以 '\n' 字符结束。
numberline.c
char* fgetline(size_t size,
char s[restrict size],
FILE* restrict stream) {
s[0] = 0;
char* ret = fgets(s, size, stream);
if (ret) {
/* Uses non-const variant of strchr */
char* pos = strchr(s, '\n');
if (pos) *pos = 0;
else ret = nullptr;
}
return ret;
}137
138
139
140
141
142
143
144
145
146
147
148
函数的前两行保证 s 始终以空字符终止:如果 fgets 调用成功,终止由它完成;否则就强制把 s 设为空字符串。随后,如果确实读入了内容,就把 s 中找到的第一个 '\n' 字符替换为 0。如果找不到,就说明只读入了不完整的一行。此时,调用方可以检测到这种情形,再次调用 fgetline,尝试读取该行的剩余部分,或者检测文件结束条件。[1]
除 fgets 外,这里还使用了 C 库中的 strchr。自 C23 起,函数接口缺乏 const 安全性不再构成问题;类型泛型接口会提供适当保证。
函数 fprintnumbers 涉及大量细致的错误处理,因此第 15.6 节会更深入地讨论它。这里仅讨论稍微简单一些的 sprintnumbers:它只向字符串而不是流写入,而且直接假定收到的缓冲区 buf 提供了足够空间。
接口:sprintnumbers
使用 printf 格式 form,把一系列数 nums 打印到 buf 中。各数之间用 sep 字符分隔,末尾添加换行字符。
返回: 打印到 buf 的字符数。
这里假定 tot 和 buf 足够大,而且 form 是适合打印 size_t 的格式。
int sprintnumbers(size_t tot, char buf[restrict tot],
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict static len]);2
3
4
5
函数 sprintnumbers 使用了一个尚未见过的 C 库函数:sprintf。它的格式化能力与 printf 和 fprintf 相同,只是不向流打印,而是写入 char 数组。
numberline.c
int sprintnumbers(size_t tot, char buf[restrict tot],
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[const restrict static len]) {
char* p = buf; /* next position in buf */
size_t const seplen = strlen(sep);
if (len) {
size_t i = 0;
for (; i < len;) {
p += sprintf(p, form, nums[i]);
++i;
if (i >= len) break;
memcpy(p, sep, seplen);
p += seplen;
}
}
memcpy(p, "\n", 2);
return (p-buf)+1;
}156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
函数 sprintf 始终保证在字符串末尾放置字符 0。它还会返回该字符串的长度,也就是所写字符 0 之前的字符数。示例利用这个返回值更新指向缓冲区当前位置的指针。不过,sprintf 仍有一项重要漏洞。
要点 14.1 #5
sprintf 没有采取任何防止缓冲区溢出的措施。
也就是说,如果把空间不足的缓冲区作为第一个实参传入,糟糕的事情就会发生。在 sprintnumbers 内部,就像在 sprintf 本身中一样,我们假定缓冲区大到足以容纳结果。如果不能确定缓冲区是否容得下结果,可以改用 C 库函数 snprintf:
int snprintf(char* restrict s, size_t n,
char const* restrict form, ...);2
该函数进一步保证,写入 s 的字节数绝不会超过 n。如果返回值大于或等于 n,字符串就会截短到能够容纳的程度。尤其是当 n 为 0 时,不会向 s 写入任何内容。
要点 14.1 #6
格式化长度未知的输出时,请使用 snprintf。
总而言之,snprintf 有许多优良性质:
- 缓冲区不会溢出;
- 调用成功后,
s是字符串; - 把
n设为0、把s设为空指针来调用时,snprintf返回原本会写入的字符串长度。
利用这一点,只需一个简单的 for 循环,就可以计算一行中打印的所有数共需多少字符:
numberline.c
/* Count the chars for the numbers. */
for (size_t i = 0; i < len; ++i)
tot += snprintf(nullptr, 0, form, nums[i]);189
190
后面会看到,这段代码如何用于 fprintnumbers。
挑战 15:字符串中的文本处理
关于文本处理,我们已经介绍了相当多的内容。现在来看看能否真正把它们用起来。
- 能否在字符串中搜索给定单词?
- 能否替换字符串中的一个单词,并返回包含新内容的副本?
- 能否为字符串实现一些正则表达式匹配函数?例如,寻找
[A-Q]或[^0-9]这样的字符类,并支持用*表示“任意内容”、用?表示“任意字符”的匹配? - 能否为
[[:alpha:]]、[[:digit:]]等 POSIX 字符类实现正则表达式匹配函数? - 能否把这些功能全部结合起来,在字符串中搜索正则表达式?
- 能否用正则表达式对特定单词进行查询替换?
- 能否为正则表达式增加分组?
- 能否为查询替换增加分组?
14.2 格式化输入
与用于格式化输出的 printf 函数族相似,C 库也提供了一系列格式化输入函数:fscanf 从任意流输入,scanf 从 stdin 输入,sscanf 则从字符串输入。例如,下面的代码会从 stdin 读取一行三个 double 值:
double a[3];
/* Read and process an entire line with three double values. */
if (scanf(" %lg %lg %lg ", &a[0], &a[1], &a[2]) < 3) {
printf("not enough input values!\n");
}2
3
4
5
表 14.1 到表 14.3 概览了格式说明。不幸的是,这些函数比 printf 更难使用,其中一些约定还以细微方式偏离 printf。
- 为了能够返回各格式对应的值,实参必须是指向所扫描类型的指针。
表 14.1 scanf 及类似函数的格式说明,一般句法为 [XX][WW][LL]SS
| 名称 | 记号 | 说明 |
|---|---|---|
XX | * | 抑制赋值 |
WW | 字段宽度 | 最大输入字符数 |
LL | 修饰符 | 选择目标类型的宽度 |
SS | 说明符 | 选择转换 |
表 14.2 scanf 及类似函数的格式说明符。 二进制支持由 C23 引入。字符或字符集合说明符('c'、's'、'[')带有 'l' 修饰符时,会把输入中的多字节字符序列转换到宽字符 wchar_t 实参中;参见第 14.3 节。
SS | 转换 | 指向 | 跳过空白 | 类似函数 |
|---|---|---|---|---|
'd' | 十进制 | 有符号类型 | 是 | strtol,基数 10 |
'i' | 二进制、八进制、十进制或十六进制 | 有符号类型 | 是 | strtol,基数 0 |
'b' | 二进制 | 无符号类型 | 是 | strtoul,基数 2 |
'o' | 八进制 | 无符号类型 | 是 | strtoul,基数 8 |
'u' | 十进制 | 无符号类型 | 是 | strtoul,基数 10 |
'x' | 十六进制 | 无符号类型 | 是 | strtoul,基数 16 |
'aefg' | 浮点 | 浮点类型 | 是 | strtod |
'%' | 字符 '%' | 不赋值 | 否 | |
'c' | 字符 | char | 否 | memcpy |
's' | 非空白字符 | char | 是 | 使用 " \f\n\r\t\v" 的 strcspn |
'[' | 扫描集 | 字符串 | 否 | strspn 或 strcspn |
'p' | 地址 | void | 是 | |
'n' | 字符计数 | 有符号类型 | 否 |
- 空白处理非常微妙,有时还会出人意料。格式中的空格字符
' '会匹配任意空白序列:空格、制表符和换行符。这样的序列可以为空,也可以包含多个换行符。 - 字符串处理有所不同。由于
scanf函数的实参本来就是指针,格式"%c"和"%s"都指向char*类型的实参。"%c"读取长度固定(默认为1)的字符数组;"%s"则匹配任意非空白字符序列,并在末尾添加字符0。 - 格式中的类型说明与
printf存在细微差异,浮点类型尤其如此。为了让两者保持一致,最好在printf和scanf中都用"%lg"或类似格式表示double,用"%Lg"表示long double。 - 还有一种识别字符类的初步工具。例如,可以用格式
"%[aeiouAEIOU]"扫描拉丁字母表中的元音。如果脱字符^出现在字符类说明的开头,就会对该字符类取反。因此,"%[^\n]%*[\n]"会扫描完整的一行(该行不得为空),随后丢弃行末的换行字符。
这些特殊之处让 scanf 函数族难以使用。例如,前面那个看似简单的示例有一项缺陷(或者说功能):它不限于读取单个输入行,而会欣然接受分布在多行中的三个 double 值。[2] 对于一系列数之类具有规则模式的输入,大多数情况下最好避免这些函数。
表 14.3 scanf 及类似函数的格式修饰符。 请注意,float 和 double 的处理方式与 printf 不同。
| 修饰符 | 类型 |
|---|---|
"L" | long double |
"hh" | char 类型 |
"h" | short 类型 |
"" | signed、unsigned、float、char 数组和字符串 |
"l" | long 整数类型、double、wchar_t 字符和字符串 |
"ll" | long long 整数类型 |
"j" | intmax_t、uintmax_t |
"z" | size_t |
"t" | ptrdiff_t |
"wN" | uintN_t、intN_t、uint_leastN_t 或 int_leastN_t(自 C23 起,N 通常为 8、16、32、64 或 128) |
"wfN" | uint_fastN_t 或 int_fastN_t(自 C23 起) |
14.3 扩展字符集
到目前为止,我们在说明程序或控制台所打印的字符串字面量内容中,只使用了有限的一组字符:拉丁字母、阿拉伯数字和一些标点字符。这项限制是历史偶然,一方面源于美国计算机产业在早期市场上的主导地位,另一方面则源于最初只能用极少量位编码字符的需要。[3] 正如基本数据单元采用类型名 char 所体现的那样,文本字符与不可分割的数据组成部分这两个概念,在一开始并没有得到很好的区分。
拉丁语作为口语早已消亡。它的字符集不足以编码其他语言在语音上的特点。在欧洲语言中,英语的一项特殊之处,是会用 ai、ou、gh(fair enough)之类的字母组合来编码缺失的语音,而不像大多数近亲语言那样使用附加符号、特殊字符或连字(fär ínóff)。因此,即使对于同样使用拉丁字母表的其他语言,可用手段也已经受到很大限制;至于使用完全不同文字体系(希腊语、俄语),甚至采用全然不同文字观念(日语、汉语)的语言和文化,这套受限的美式字符集显然远远不够。
市场向全球扩张的最初几年中,不同的计算机制造商、国家和组织近乎随意地为各自社群提供母语支持;它们还在毫无协调的情况下,加入了图形字符、数学排版、乐谱等方面的专门支持。那是一片彻底的混乱。其结果是,不同系统、国家和文化之间交换文本信息往往十分困难,甚至根本不可能;编写能在不同语言和不同计算平台环境中使用的可移植代码,简直如同黑魔法。
幸运的是,这些延续多年的困难如今大都得到解决。在现代系统上,可以用统一方式编写使用“扩展”字符的可移植代码。下面的代码片段展示了理想的做法:
mbstrings-main.c
setlocale(LC_ALL, "");
/* Multibyte character printing only works after the locale
has been switched. */
draw_sep(TOPLEFT " © 2014 jɛnz 'gʊz,tɛt ", TOPRIGHT);88
89
90
也就是说,在程序靠近开头的位置切换到“本地”区域设置,之后就可以使用并输出包含扩展字符的文本——这里使用的是语音字符(所谓 IPA)。从调用 setlocale 开始至关重要;否则,把扩展集中的字符输出到终端时,很可能只会看到乱码。但只要已经调用 setlocale,而且系统安装妥当,把这类字符放进多字节字符串 "fär ínóff",效果就应当不会太糟,详情见下文。
输出大致如下:
┌ © 2014 jɛnz 'gʊz,tɛt ────────────────────────────────────────────┐实现手段相当简单。先用一些宏,为竖线、横线、左上角和右上角提供带有魔法字符串字面量的定义:
mbstrings-main.c
#define VBAR "\u2502" /**< a vertical bar character */
#define HBAR "\u2500" /**< a horizontal bar character */
#define TOPLEFT "\u250c" /**< topleft corner character */
#define TOPRIGHT "\u2510" /**< topright corner character */44
45
46
再提供一个临时编写的函数,用漂亮的形式排出一行输出:
接口:draw_sep
绘制多字节字符串 start 和 end,中间用一条横线分隔。
void draw_sep(char const start[static 1],
char const end[static 1]) {
fputs(start, stdout);
size_t slen = mbsrlen(start, 0);
size_t elen = 90 - mbsrlen(end, 0);
for (size_t i = slen; i < elen; ++i) fputs(HBAR, stdout);
fputs(end, stdout);
fputc('\n', stdout);
}2
3
4
5
6
7
8
9
这里使用一个函数 mbsrlen 来计算多字节字符串中的可打印字符数,还用到了文本输出方面的老朋友 fputs 和 fputc。
接口:mbsrlen
把 mbs 中的多字节字符串解释为宽字符串,并返回其长度。
返回: 多字节字符串的长度;如果发生编码错误,则返回 mbinvalid。
只要传给本函数的 state 实参与从 mbs 开头的多字节字符一致,就可以把本函数纳入对一个字符串进行的一系列搜索。函数不会修改状态本身。
备注: state 为空,表示扫描 mbs 时无须考虑任何上下文。
size_t mbsrlen(char const* restrict mbs,
mbstate_t const* restrict state);2
多字节字符是一个字节序列,它被解释为扩展字符集中的单个字符;多字节字符串则是包含这类多字节字符的字符串。所幸,这些奇特事物与我们迄今处理的普通字符串兼容。
要点 14.3 #1
多字节字符不包含空字节。
要点 14.3 #2
多字节字符串以空字符终止。
因此,许多标准字符串函数(例如 strcpy)无需修改就能用于多字节字符串。不过,多字节字符串带来了一项重大困难:此时无法再根据 char 数组的元素数或函数 strlen,直接推导可打印字符数。正因如此,前面的代码使用了(非标准)函数 mbsrlen。
从接口说明可以看出,要从多字节字符串中解析出各个多字节字符,过程可能稍微复杂一些。尤其是,我们通常需要用 C 标准在头文件 <wchar.h> 中提供的类型 mbstate_t 保存解析状态。[4] 这个头文件既提供处理多字节字符串与多字节字符的工具,也提供宽字符类型 wchar_t。相关函数通常可能返回多个不同代码,用来表示当前解析状态。
mbcode:多字节转换函数的返回代码
这些函数通常返回 size_t 类型的值。因此,各个特殊代码必须位于该类型范围的上端。
下面用枚举类型为这些常量提供名称。只有自 C23 起才能这样做,因为此前枚举常量仅限于 signed int。
枚举项:
mbinvalid:遇到了无效编码;mbincomplete:编码不完整;mbstored:存储了此前检测到的一个输出字符。
enum mbcode {
//! An invalid encoding was encountered.
mbinvalid = (size_t)-1,
//! The encoding was incomplete.
mbincomplete = (size_t)-2,
//! A previously detected output character was stored.
mbstored = (size_t)-3,
};2
3
4
5
6
7
8
不过首先还要介绍另一项国际标准:ISO 10646(Unicode [2017])。它试图为字符代码提供统一框架(参见 Joel Spolsky 的文章 The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets),为人类迄今构想出的几乎所有字符概念建立了一张庞大表格。[5]
这里,“概念”二字极其重要。必须明白,在特定字体中看到某个字符的印刷形式或字形时,例如“拉丁大写字母 A”在本书正文中可能显示成 A、𝐀、𝑨 或 𝒜。其他概念字符,例如“希腊大写字母 Alpha”,甚至可能用相同或相似的字形“A”印出。
Unicode 把每个字符概念——用它自己的行话说,就是每个码点——置于语言或技术上下文中。除定义字符外,Unicode 还会为它分类,例如归类为大写字母;也会建立它与其他码点的关系,例如说明 A 是 a 的大写形式。
如果特定语言需要特殊字符,那么键盘上很可能已经有这些字符,可以原样输入 C 代码中的多字节字符串。也就是说,系统可能配置为直接把 ä 的整个字节序列插入文本,并替你完成所有必要的魔法。如果键盘没有这些字符,或者不想采用这种方式,可以使用前面为宏 HBAR 所用的技巧。那里使用了 C11 新增的一种转义序列(参见 The Interesting State of Unicode in C):反斜杠加 u,再跟四个十六进制数字,用来编码 Unicode 码点。例如,“带分音符的拉丁小写字母 a”的码点是 228,也就是 0xE4。在多字节字符串中,它写作 "\u00E4"。四个十六进制数字只能寻址 65,536 个码点,因此还可以用反斜杠和大写 U 引入八个十六进制数字;不过,只有非常专业的环境才会遇到后一种写法。
前一个示例就用这种 Unicode 说明编码了四个图形字符,而它们很可能不在任何键盘上。有多个在线网站可以帮助你查找所需字符的码点。
如果要对多字节字符和字符串执行比简单输入输出更多的操作,事情就会变得复杂一些。仅仅统计字符数就已经并非易事:strlen 给不出正确答案,strchr、strspn 和 strstr 等其他字符串函数也不会按预期工作。所幸,C 标准提供了一组替代函数,它们通常用前缀 wcs 取代 str,作用于宽字符串。前面介绍的 mbsrlen 函数可以写成:
mbstrings.c
size_t mbsrlen(char const* s, mbstate_t const* restrict state) {
state = state ? state : MBSTATE;
mbstate_t st = *state;
size_t mblen = mbsrtowcs(nullptr, &s, 0, &st);
if (mblen == mbinvalid) errno = 0;
return mblen;
}31
32
33
34
35
36
函数的核心是使用库函数 mbsrtowcs,其名称表示“多字节字符串(mbs),可重启(r),转为宽字符串(wcs)”。它是 C 标准为处理多字节字符串所提供的基本操作之一:
size_t mbsrtowcs(wchar_t* restrict dst,
char const** restrict src,
size_t len, mbstate_t* restrict ps);2
3
因此,只要拆解这个缩写名称,就知道该函数会把多字节字符串 src 转换为宽字符串 dst。这里,wchar_t 类型的宽字符(wc)用于恰好编码扩展字符集中的一个字符。宽字符组成宽字符串的方式,与 char 组成普通字符串的方式基本相同:宽字符串是由宽字符构成、以空字符终止的数组。
如果 state 为空,mbsrlen 还会使用宏 MBSTATE(这里没有给出),该宏提供一个 mbstate_t 类型的辅助缓冲区。此外,如果提供给函数的源字符串无效,该函数可能把 errno 设为某个错误代码。我们对此不感兴趣,所以返回前把它重置为零。
C 标准没有对 wchar_t 采用的编码施加太多限制,但如今任何正常环境都应当使用 Unicode 作为内部表示。可以用下面两个宏检查这一点:
mbstrings.h
#ifndef __STDC_ISO_10646__
# warning "wchar_t wide characters have to be Unicode code points"
#endif
#ifdef __STDC_MB_MIGHT_NEQ_WC__
# warning "basic character codes must agree on char and wchar_t"
#endif25
26
27
28
29
现代平台通常用 16 位或 32 位整数类型实现 wchar_t。只要仅使用 \uXXXX 记法中四个十六进制数字能够表示的码点,一般无须关心平台采用哪一种。实际采用 16 位的平台无法使用 \UXXXXXXXX 记法中的其他码点,不过这通常不会给你造成太多困扰。
宽字符和宽字符串字面量遵循与 char 和普通字符串相似的规则。两者都用前缀 L 表示宽字符或宽字符串。例如,L'ä' 与 L'\u00E4' 是同一个字符,两者的类型都是 wchar_t;L"b\u00E4" 则是一个含有三个 wchar_t 元素的数组,其中包含宽字符 L'b'、L'ä' 和 0。
宽字符的分类方式与普通 char 相似。头文件 <wctype.h> 提供所需的函数和宏。
回到 mbsrtowcs。该函数把多字节字符串 src 解析成与多字节字符(mbc)相对应的片段,并把相应码点赋给 dst 中的宽字符。形参 len 说明所得宽字符串可以具有的最大长度。形参 state 指向保存多字节字符串可能存在的解析状态的对象;稍后会简要讨论这个概念。
可以看到,函数 mbsrtowcs 有两项特别之处。第一,如果以空指针作为 dst 调用,它不会存储宽字符串,只会返回该宽字符串应有的大小。第二,如果多字节字符串编码不正确,它会产生编码错误。此时函数返回 mbinvalid,并把 errno 设为值 EILSEQ(参见 <errno.h>)。mbsrlen 的部分代码会再次把 errno 设为 0,用以修补这种错误处理策略。
现在来看另一个帮助我们处理多字节字符串的函数。
接口:mbsrdup
把 s 中的字节序列解释为多字节字符串,并将其转换为宽字符串。
返回: 用 malloc 新分配、长度适当的宽字符串;如果发生编码错误,则返回空指针。
备注: 只要传给本函数的 state 实参与从 c 开头的多字节字符一致,就可以把本函数纳入对一个字符串进行的一系列同类搜索。函数不会修改状态本身。
state 为空,表示扫描 s 时无须考虑任何上下文。
wchar_t* mbsrdup(char const* s, mbstate_t* restrict state);该函数返回一个新分配的宽字符串,其内容与输入的多字节字符串 s 相同。除了状态形参外,实现十分直接:
mbstrings.c
wchar_t* mbsrdup(char const* s, mbstate_t* restrict state) {
mbstate_t st = state ? *state : *MBSTATE;
size_t mblen = mbsrlen(s, &st);
if (mblen == mbinvalid) return nullptr;
wchar_t* S = malloc(sizeof(wchar_t[mblen+1]));
/* We know that s converts well, so no error check */
if (S) mbsrtowcs(S, &s, mblen+1, state);
return S;
}39
40
41
42
43
44
45
46
确定目标字符串长度后,使用 malloc 分配空间,再用 mbsrtowcs 复制数据。
为了更精细地控制多字节字符串的解析,标准提供了函数 mbrtowc:
size_t mbrtowc(wchar_t* restrict pwc,
char const* restrict s, size_t len,
mbstate_t* restrict ps);2
3
在该接口中,形参 len 表示为了得到一个多字节字符而在 s 中扫描的最远位置。一般来说,我们不知道目标机器上的多字节编码如何工作,因此必须作出一些猜测来帮助确定 len。为了封装这种启发式办法,我们设计下面这个接口。它的语义与 mbrtowc 相似,却无须说明 len。
接口:mbrtow
把 c 中的字节序列解释为多字节字符,并通过 C 将其作为宽字符返回。
返回: 多字节字符的长度;如果发生编码错误,则返回 mbinvalid。
只要对本函数或类似函数的所有调用都传入同一个 state 实参,就可以把本函数纳入对一个字符串进行的一系列同类搜索。
备注: state 为空,表示扫描 c 时无须考虑任何上下文。
size_t mbrtow(wchar_t* restrict C,
char const c[restrict static 1],
mbstate_t* restrict state);2
3
该函数返回在字符串开头识别出的第一个多字节字符的字节数,出错时返回 mbinvalid。如果 len 不够大,mbrtowc 还可能返回另一个值 mbincomplete。实现利用这个返回值检测相应情形,并调整 len 直至足够大:
mbstrings.c
size_t mbrtow(wchar_t* restrict C,
char const c[restrict static 1],
mbstate_t* restrict state) {
if (!state) state = MBSTATE;
size_t len = -2;
for (size_t maxlen = MB_LEN_MAX; len >= mbincomplete; maxlen *= 2)
len = mbrtowc(C, c, maxlen, state);
if (len == mbinvalid) errno = 0;
return len;
}15
16
17
18
19
20
21
22
23
这里,MB_LEN_MAX 是一个标准值,在大多数情形下都是 len 的良好上界。请注意,即使字符串正确地以空字符结束,这个过程也能正常工作。
下面来看一个函数,它利用 mbrtow 识别多字节字符的能力,在多字节字符串中进行搜索。
接口:mbsrwc
把 s 中的字节序列解释为多字节字符串,并搜索宽字符 C。
返回: s 中第 occurrence 次出现的位置,该位置是与 C 相对应的多字节序列的起点;如果发生编码错误,则返回空指针。
如果出现次数少于 occurrence,就返回最后一个这样的位置。因此,使用 SIZE_MAX(或 -1)一定会返回最后一次出现的位置。
备注: 只要对本函数或类似函数的所有调用都传入同一个状态实参,而且后续搜索从本函数返回的位置开始,就可以把本函数纳入对一个字符串进行的一系列同类搜索。
state 为空,表示扫描 s 时无须考虑任何上下文。
char const* mbsrwc(char const s[restrict static 1],
mbstate_t* restrict state,
wchar_t C, size_t occurrence);2
3
mbstrings.c
char const* mbsrwc(char const s[restrict static 1],
mbstate_t* restrict state,
wchar_t C, size_t occurrence) {
if (!C || C == WEOF) return nullptr;
state = state ? state : MBSTATE;
char const* ret = nullptr;
mbstate_t st = *state;
for (size_t len = 0; s[0]; s += len) {
mbstate_t backup = st;
wchar_t S = 0;
len = mbrtow(&S, s, &st);
if (!S) break;
if (C == S) {
*state = backup;
ret = s;
if (!occurrence) break;
--occurrence;
}
}
return ret;
}69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
14.4 UTF 字符编码
正如前面提到的,只要环境保持一致(即源代码内部、其他文本文件和终端都使用相同级别的多字节编码),那么针对多字节字符串的日常编码工作,以及简单的输入/输出操作就能顺利进行。遗憾的是,目前世界上并非所有的环境都统一使用某种特定的编码。因此,在不同的环境之间传输文本文件(包括源代码文件)或者可执行程序时,就可能会遇到困难。除了定义庞大繁复的字符映射表之外,Unicode 标准还定义了三种目前被广泛使用的编码格式(它们有望最终取代所有其他编码):UTF-8、UTF-16 和 UTF-32,这三种格式分别代表使用 8 位、16 位和 32 位存储单元的 Unicode 转换格式。
自 C11 起,C 语言无需依赖区域设置,就能为这些编码提供初步的直接支持。采用这些编码的字符和字符串字面量可以用前缀编写。例如,u8"text"、u"text" 和 U"text" 的类型分别是 char8_t[5]、char16_t[5] 和 char32_t[5]。
现代平台上的多字节编码很可能就是 UTF-8。如果确实如此,就不需要这些特殊字面量和类型。它们主要用于必须保证采用特定编码的环境,例如网络通信。在遗留平台上,日子可能会更难过;这篇文章 概览了 Windows 平台上的情况。
在比较简单的情形中,UTF 与基本字符类型之间具有表 14.4 所示的对应关系。
表 14.4 UTF 与基本字符类型之间的常见对应关系
| 字面量 | typedef | 类型 | 编码 | 对应关系 |
|---|---|---|---|---|
u8'a' | uchar8_t | unsigned char | UTF-8 | char 中的 ASCII |
u'π' | uchar16_t | uint_least16_t | UTF-16 | |
U'𝒜' | uchar32_t | uint_least32_t | UTF-32 | wchar_t 中的“wc” |
u8"𝒜" | uchar8_t[5] | unsigned char[5] | UTF-8 | char[] 中的“mbs” |
u"𝒜" | uchar16_t[3] | uint_least16_t[3] | UTF-16 | |
U"𝒜" | uchar32_t[2] | uint_least32_t[2] | UTF-32 | wchar_t[] 中的“wcs” |
UTF-16 的情况尤其复杂。u"𝒜" 这样的字符串含有三个元素,因为码点 0x1D49C 至少需要 17 位来编码;依照定义,它无法装入 16 位编码。UTF-16 借助所谓的代理对来解决这个问题,也就是用两个 16 位字编码这类字符(再加一个字保存末尾的空字符)。有一种已经过时的编码称为 UCS-2,它包含那些可以由 UTF-16 用单个 16 位字编码的码点。这些码点组成的集合称为基本多文种平面(BMP)。其他字符,例如示例中的 u'𝒜',不属于这个集合,因而无法用 UCS-2 表示。请尽可能避免 UTF-16(以及 UCS-2)。UTF-8 是效率很高的多字节编码,UTF-32 则可以用一个字完整编码整个 Unicode 集合;能选择时,应当优先使用它们。
与名称中含有 wcs 或 mbs 的函数相似,还有一些函数会用 c8、c16 和 c32 分别表示源或目标采用 UTF-8、UTF-16 和 UTF-32 编码。遗憾的是,这套函数并不完整;现有函数中的一部分也直到 C23 才姗姗来迟。因此,如果使用较旧的平台,或者有特定需求,可能要费些力气才能把它们组装成实用功能。
C23 还带来另一项保证:char16_t 和 char32_t 实际使用的编码确实是 UTF-16 和 UTF-32;此前的标准允许实现偏离这一点。过去可以用功能测试宏 __STDC_UTF_16__ 和 __STDC_UTF_32__ 检验这项性质。现在它们固定为值 1,所以下面的预处理代码在现代编译器上绝不应触发:
mbstrings.h
#if (__STDC_UTF_16__ != 1) || (__STDC_UTF_32__ != 1)
# error "wide character strings should use UTF encodings"
#endif6
7
14.5 可重启的文本转换
前面见过的那些名称古怪、形如 XXXrtoYYY 的函数还有一项性质:名称中的 r 表示“可重启”(restartable)。也就是说,这些函数可能收到不完整的多字节字符串作为输入,把迄今所见输入的状态收集到状态对象中(由 state 形参指向),并在检测到完整的多字节字符后开始产生输出。C23 支持下面这些编码组合:
mbrtowc mbrtoc8 mbrtoc16 mbrtoc32
wcrtomb c8rtomb c16rtomb c32rtomb2
各编码的缩写和补充信息见表 14.5。请注意,wc 和 c32 都是单字符编码,所以两端都无须缓冲。一般来说,其他编码需要缓冲,因为一个码点可能需要多个字符。这样一来,唯一令人意外的项目就是 mb 出现在输出端的情况。
表 14.5 形如 XXXrtoYYY 的文本转换函数
| 缩写 | 编码 | 用作 XXX | 用作 YYY |
|---|---|---|---|
mb | 区域设置的多字节编码 | 有缓冲 | 无缓冲 |
wc | 区域设置的宽字符编码 | 无缓冲 | 无缓冲 |
c8 | UTF-8 | 有缓冲 | 有缓冲 |
c16 | UTF-16 | 有缓冲 | 有缓冲 |
c32 | UTF-32 | 无缓冲 | 无缓冲 |
要点 14.5 #1
一个码点的多字节 mb 编码会一次性写入输出字符串。
输入端是否正在缓冲很容易检测:只要输入尚未完整,函数就返回 mbincomplete。在输出端,检测一次写操作是否结束稍微微妙一些:
- 较小的数表示输入产生或补全了一个码点,而且输出的第一个字符已经存储。此时还不知道输出是否需要额外字符。
- 后续对相同函数的调用,只要又存储了编码中的一个字符,就会返回
mbstored。
为了观察实际效果,假设要扫描从 stdin 读取的多字节输入序列。代码由两个嵌套循环组成,在它们之前还有若干缓冲区声明和语句。
analyze-utf8.c
int main (void) {
// Make sure to have the platform's mb encoding on input.
setlocale(LC_CTYPE, "");
// Holds the state of input/output buffering.
mbstate_t st = { };
// collects the input mb sequence
char ib[23];
// collects the current UTF-8 mb sequence
char8_t ob[5] = { };
// the number of input characters for the current code point
size_t in = 0;
while (fgets(ib, sizeof(ib), stdin)) {
// Run through the current line. The last character is
// always reserved for the string terminator.
for (char* p = ib; (p-ib) < sizeof(ib)-1;) {
size_t const n = sizeof(ib)-1-(p-ib);7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里,调用 setlocale 实际上保证了我们确实把输入看作多字节字符串。如果没有这项调用,就只会采用 "C" 区域设置逐字节扫描。
在内层循环体中,输入通过指针 p 提供;我们把 UTF-8 输出序列写入输出缓冲区 ob。
analyze-utf8.c
size_t const r = mbrtoc8(ob, p, n, &st);
switch (r) { // Handle the special cases.
case mbincomplete: p += n; in += n; continue;
case 0: case mbstored: case mbinvalid: goto INVAL;
}
p += r; in += r;
char8_t* cont = ob+1; // first character is already stored
while (mbrtoc8(cont, "", 1, &st) == mbstored) {
cont++;
}23
24
25
26
27
28
29
30
31
目标是转换这个序列,使我们能够看清所有完整码点的边界。为此,代码多次调用 C 库函数 mbrtoc8,所有调用都使用同一个 mbstate_t 对象 st。第一次调用应当返回从输入中读取的字节数,以此表明码点是否已经补全;所得值累加到名为 in 的对象中。随后,循环尝试把空字符存入目标缓冲区,直到成功为止,也就是直到构成 UTF-8 序列的所有字节都已写出。
switch 语句处理各种例外情形,尤其会跳到标签 INVAL 处的错误处理代码(见下文);如果多字节输入序列尚未完整,它就会继续寻找输入。例如,输入缓冲区恰好在一个多字节字符中间结束时,就会发生这种情况。
要点 14.5 #2
一个码点的多字节 mb 编码可以从输入中分段收集。
现在,输出缓冲区 ob 中已经有了码点的 UTF-8 编码,可以分析结果,并辨别只含一个 ASCII 字符的情形。这里必须特别处理输入行末,因为我们知道 fgets 会在这些位置停止。比较时也必须采用 UTF-8 中正确的行末字符,因为原则上它可能不同于平台的本机编码:
analyze-utf8.c
// Now we have the whole UTF-8. Analyze the result.
printf("%s", ((cont-ob) == 1) ? "ASCII\t" : "UTF-8\t");
for (char8_t* o = ob; o < cont; ++o) {
printf("|%02hhx", *o);
}
// fgets stopped at an end of a line
if (*ob == u8'\n') {
puts("|\t~ eol");
in = 0;
break;
} else if (in == (cont-ob)) {
printf("|\t~ '%s'\n", ob);
} else {
printf("|\t%zu→→→%tu\n", in, (cont-ob));
}
in = 0;33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
请注意,我们没有管理那种必须为长输入行不断调整大小的缓冲区。输入缓冲区 ib 长度固定,而 mbrtoc8 能够保存解析状态。重启机制负责解决读取不完整输入行的问题。这个示例中,ib 的长度 23 小得近乎荒谬;这样做是为了确保测试时能够轻易覆盖多字节序列被从中间一分为二的情形。在生产代码中,所选值通常会大得多。
随后,函数代码以两个嵌套循环的末尾结束;错误处理可以通过标签 INVAL 到达:
analyze-utf8.c
}
if (*ob != u8'\n') {
fputs("incomplete line\n", stderr);
}
}
return EXIT_SUCCESS;
INVAL:
fputs("input error, exiting\n", stderr);
return EXIT_FAILURE;
}49
50
51
52
53
54
55
56
57
14.6 二进制流
第 8.4 节曾简要提到,与迄今一直使用的普通文本模式相比,流的输入和输出还可以在二进制模式下进行。要理解两者的区别,请记住:文本模式输入输出不会把传给 printf 或 fputs 的字节逐一原样写到目标文件或设备。
- 根据目标平台不同,一个
'\n'字符可能编码成一个或多个字符。 - 换行符之前的空格可能受到抑制。
- 多字节字符可能从执行字符集(程序的内部表示)转写到文件所依托的文件系统字符集。
从文本文件读取数据时也有类似情况。
如果所处理的数据确实是供人阅读的文本,这一切都没有问题;输入输出函数与 setlocale 会共同让这种机制尽可能透明,我们大可乐享其成。但如果想按某些 C 对象中的原貌读写二进制数据,这套机制就可能成为沉重负担,并造成严重困难。尤其是,二进制数据可能恰好隐式映射到文件的行末约定,写入这样的数据便可能改变文件的内部结构。
如前所述,流可以用二进制模式打开。对于这样的流,文件中外部表示与内部表示之间的一切转换都会跳过,每个字节都会原样写入或读取。在迄今见过的接口中,只有 fgetc 和 fputc 能以可移植方式处理二进制文件。其他接口都可能依赖某种行末转换。
为了更方便地读写二进制流,C 库提供了几个更合适的接口:
size_t fread(void* restrict ptr, size_t size, size_t nmemb,
FILE* restrict stream);
size_t fwrite(void const* restrict ptr, size_t size, size_t nmemb,
FILE* restrict stream);
int fseek(FILE* stream, long int offset, int whence);
long int ftell(FILE* stream);2
3
4
5
6
fread 和 fwrite 的用法相对直接。每个流都有一个当前文件位置,用于读写。如果调用成功,这两个函数会从该位置开始读取或写入 size*nmemb 个字节,然后把文件位置更新为新值。两个函数的返回值都是已经读取或写入的字节数,通常是 size*nmemb;因此,如果返回值小于这个数,就发生了错误。
函数 ftell 和 fseek 可以操纵该文件位置:ftell 返回以文件起点为基准、按字节表示的位置;fseek 则根据实参 offset 和 whence 定位文件。这里,whence 可以取以下值之一:SEEK_SET 指文件起点,SEEK_CUR 指调用前的当前文件位置。[6]
借助这四个函数,我们可以在表示文件的流中有效地前后移动,并读写其中任何字节。例如,可以用这种方式把大型对象的内部表示写入文件,随后再由另一个程序读回,而不作任何修改。
不过,这套接口也有一些限制。为了实现可移植性,必须用二进制模式打开流。在某些平台上,因为实际上不存在任何需要执行的转换,输入输出始终是二进制的。遗憾的是,不采用二进制模式的程序在这些平台上可能一直可靠运行,移植到其他平台后却会失败。
要点 14.6 #1
凡是使用 fread 或 fwrite 的流,都应当以二进制模式打开。
由于这里处理的是对象的内部表示,所以只在采用相同表示、相同字节序的平台和程序执行之间具有可移植性。不同平台、操作系统,甚至不同程序执行,都可能采用不同的表示。
要点 14.6 #2
以二进制模式写出的文件不能跨平台移植。
文件位置使用 long 类型,这会把 ftell 和 fseek 可以轻松处理的文件大小限制在 LONG_MAX 字节以内。在大多数现代平台上,这相当于 2 GiB。[7]
要点 14.6 #3
fseek 和 ftell 不适合非常大的文件偏移量。
在某些情况下,应用需要访问一块在程序每次执行中都相同的二进制数据。例如,弹出窗口中显示的图形标志,或者需要包含在每个目标文件中的二进制签名。自 C23 起,可以用一项简单工具可靠地把这种二进制数据纳入源代码:#embed 指令。
unsigned char logo[] = {
#if defined (__has_embed) && __has_embed("picture.dat")
# embed "picture.dat"
#else
"embed directive is missing"
#endif
};2
3
4
5
6
7
从表面上看,它的工作方式与 #include 指令十分相似:指令后面的文件名指出要纳入哪个文件。与 __has_include 类似,还有一项可用于测试的 __has_embed 功能。首先,如果 __has_embed 本身已经定义,就说明实现支持 #embed 指令。然后,如果 __has_embed("picture.dat") 在预处理条件中返回 1,就说明该文件已知存在,可以嵌入。
文件按二进制数据读取,效果如同在相应位置插入一长串由逗号分隔的数。每个字节都用表示其内容的十进制数表示。因此可以想见,对于比较大的文件,这会形成一张很长而又难以理解的数字列表。在前一个示例中,这张数字列表直接用作数组对象 logo 的初始化器。尽管如此,成熟的编译器在把 #embed 指令用于初始化器时,应当能极其高效地实现它;编译器应当把二进制数据直接放入可执行文件,而不是先在二进制数据与十进制字面量之间来回编码和解码。下面来看一个更详尽的示例。
embed.c
// define a character array that will contain the entire
// source file
static char const here[] = {
// Cedro does not work with blanks between the # and any directive
#pragma Cedro 1.0 embed
#embed "embed.c"
};
// define another character array that has the same size
static char there[sizeof here];26
27
28
29
30
31
32
33
34
35
36
这段代码定义两个字符数组 here 和 there。与前面一样,如果编译器实现了 #embed 指令,就把一个文件作为二进制数据提供,用于初始化数组 here。此外,这里还展示了一种可能的后备方案:使用名为 Cedro 的工具;在编译器尚未跟上进度时,它可以帮你弥合差距。更多信息参见附录 B.2。
预期语义由相应的 main 展示:
embed.c
int main (int argc, char* argv[static argc+1]) {
size_t ibytes = 0;
int cmp = 1000;
// Open the file in binary mode.
FILE* inp = fopen(__FILE__, "rb");
if (inp) {
// read the file as binary
ibytes = fread(there, 1, sizeof there, inp);
cmp = memcmp(here, there, sizeof here);
fclose(inp);
} else {
printf("could not open %s\n", __FILE__);
printf("+++++++++++++++++++++++++++++++++++++++++++++++\n");
}
size_t obytes = fwrite(here, 1, sizeof here, stdout);
printf("+++++++++++++++++++++++++++++++++++++++++++++++\n");
printf("in %zu, out %zu, bytes are %s\n",
ibytes, obytes,
cmp < 0 ? "smaller" : (cmp > 0 ? "greater" : "equal"));
}39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
这里以二进制模式把(希望是)同一个文件读入第二个数组 there。预期 here 与 there 的内容完全相同,所以 memcmp 调用应当返回 0。
挑战 16:流中的文本处理
对于流中的文本处理,你能否从 stdin 读取,把修改后的文本输出到 stdout,并在 stderr 上报告诊断?能否统计一组单词各自出现的次数?统计正则表达式出现的次数?把一个单词的所有出现位置替换成另一个单词?
挑战 17:更精密的文本处理器
你能否扩展自己的文本处理器(挑战 12),使其使用多字节字符?
能否进一步扩展它,使其执行正则表达式处理,例如搜索单词、进行简单的单词查询替换、使用正则表达式对特定单词进行查询替换,以及应用正则表达式分组?
小结
- C 库提供多个文本处理接口,但必须留意 const 限定与缓冲区溢出。
- 使用
scanf(及类似函数)进行格式化输入时,在指针类型、字符串的空字符终止、空白和换行分隔方面存在微妙问题。如果可能,应当把fgets与strtod或类似的更专门函数结合使用。 - 处理扩展字符集的最佳方式是使用多字节字符串。只要稍加谨慎,就可以像普通字符串一样使用它们进行输入和输出。
- 二进制数据应当用
fwrite和fread写入二进制文件。这类文件依赖平台。 - 编译期二进制数据可以用
#embed指令高效初始化。
练习 51 改进示例的
main,使它能够处理任意长度的输入行。 ↩︎练习 52 修改示例中的格式字符串,使其只接受同一行中由空格分隔的三个数,并跳过末尾的换行字符以及换行前可能存在的空格。 ↩︎
基本字符集占主导地位的字符编码称为 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)。 ↩︎
头文件
<uchar.h>也提供该类型。 ↩︎如今 Unicode 大约有 110,000 个码点。 ↩︎
还有用于文件结束位置的
SEEK_END,但它可能带有由平台定义的瑕疵。 ↩︎练习 57 编写函数
fseekmax,让它用intmax_t取代long,并通过组合调用fseek来实现很大的定位值。 ↩︎