21. 本地化与扩展文本
本地化 (localization) 指程序按地区的习惯处理数字格式、货币符号、字符串排序和字符分类等。C 标准库通过区域设置 (locale) 支持本地化:头文件 <locale.h> 提供查询和修改区域设置的接口,<ctype.h>、<string.h>、<wchar.h> 等头文件中的部分函数,其行为受当前区域设置影响。
1. 核心接口 setlocale
setlocale 用于查询或修改当前的区域设置。设置失败时返回空指针,因此每次修改都应检查返回值。
#include <locale.h>
#include <stdio.h>
int main(void) {
const char *result = setlocale(LC_ALL, "");
if (result == NULL) {
puts("setlocale failed");
return 1;
}
puts(result);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
成功时,程序打印实现用于标识当前区域设置的字符串;失败时打印固定的错误信息。例如:
en_US.UTF-8setlocale failed成功时打印的名称由操作系统和 C 库决定,不能假定它在各平台上一致。传入空字符串 "" 表示按运行环境(通常是操作系统的环境变量)选择区域设置。
2. 数字与货币格式
localeconv 返回指向 struct lconv 对象的指针,该对象描述当前区域设置下的数字和货币格式,例如 decimal_point(小数点字符)和 currency_symbol(货币符号)。这些信息用于输出给人看的文本;程序内部的计算逻辑应与区域设置无关,只在输入输出时按地区习惯处理。
3. 排序与比较
字符串排序若需要遵循地区规则,应使用 strcoll 和 strxfrm,而不是用 strcmp 直接按字节比较。按字节比较只适合协议字段和机器可读数据,不适合人类语言文本。
4. 区域设置是全局状态
setlocale 修改的是整个程序的区域设置,而不是某个函数或模块私有的状态。程序的一部分修改区域设置后,其他部分的格式化输出、字符分类和排序结果都会随之改变。
5. 与多字节字符和宽字符的关系
区域设置还决定多字节字符的编码方式,从而影响 mbtowc、wcrtomb 等多字节与宽字符之间的转换函数,以及 <wctype.h> 中宽字符分类函数的结果。处理 UTF-8 等多字节文本前,通常需要先调用 setlocale(LC_ALL, "") 或选择一个具体的区域设置。
6. 多线程程序中的区域设置
在多线程程序运行期间调用 setlocale,可能与其他线程中受区域设置影响的函数调用产生数据竞争。应在创建其他线程之前完成区域设置,此后不再修改。
7. 习题
7.1 巩固
下面的程序先切换到 C 区域设置,再试图切换到一个不存在的名字。先写出两次 printf 和最后一次 setlocale 查询的输出,再实际运行对照。
#include <locale.h>
#include <stdio.h>
int main(void) {
if (setlocale(LC_ALL, "C") == NULL) {
puts("cannot set C locale");
return 1;
}
struct lconv *lc = localeconv();
printf("[%s][%s]\n", lc->decimal_point, lc->thousands_sep);
if (setlocale(LC_ALL, "no_such_locale") == NULL) {
puts("switch failed");
}
printf("[%s][%s]\n", localeconv()->decimal_point, localeconv()->thousands_sep);
printf("current: %s\n", setlocale(LC_ALL, NULL));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
除了输出,还要回答:
setlocale返回空指针时,程序当前的区域设置是保持原样、变成未定义,还是被重置成别的值?- 把最后一行改成两步:先用
const char *saved = setlocale(LC_ALL, NULL);保存返回值,再调用一次setlocale(LC_ALL, "C"),最后打印saved。在你的机器上这一步打印的还是原来那个名字吗?saved指向的存储归谁所有?把这段代码交给另一个实现编译,结果是否一定相同?
写程序验证 setlocale 的分类参数:只改 LC_NUMERIC 时,数字格式改变,排序规则不变。
要求:
- 先
setlocale(LC_ALL, "C"),打印setlocale(LC_NUMERIC, NULL)、setlocale(LC_COLLATE, NULL)和localeconv()->thousands_sep,再打印strcmp("a", "B")与strcoll("a", "B")的正负号。 - 只调用
setlocale(LC_NUMERIC, "en_US.UTF-8"),重复上面的打印。 - 再用
setlocale(LC_ALL, "en_US.UTF-8")重复一次。
验收(区域设置名按本机 locale -a 的输出调整):
- 第 1 步:两个类别都是
C,thousands_sep为空串,strcmp与strcoll同号。 - 第 2 步:
LC_NUMERIC变成en_US.UTF-8,LC_COLLATE仍是C,thousands_sep变成,,两个比较仍然同号。 - 第 3 步:两个类别都变。
strcmp("a", "B")仍为正,strcoll("a", "B")变为负,语言规则把a排在B前面。第 3 步的结果由实现决定,MinGW 与 glibc 上都能观察到。
7.2 编程
struct lconv 的 grouping 成员说明从右往左每多少位数字插入一次 thousands_sep:grouping[0] 是最右一组的位数,后面的字节依次给出向左各组的位数;取 0 表示重复前一个字节,取 CHAR_MAX 表示不再分组;grouping[0] 为 0 或 CHAR_MAX 时整个数不分隔。
写一个函数:
int format_amount(long cents, char *buf, size_t size);金额以分为单位。函数按当前区域设置输出整数部分带分组、小数点后两位、负号在最前的结果,返回写入的字节数(不含结尾的 '\0'),缓冲区放不下时返回 -1。
要求:
- 不要用
printf的'标志做分组。它不是 ISO C 的一部分,MinGW 上会把%'d原样打印出来。 - 小数点用
decimal_point,分隔符用thousands_sep,都从localeconv取。 - 缓冲区放不下时返回 -1,此时
buf的内容不作要求,调用方不应使用。 - 按需包含
<locale.h>、<limits.h>、<string.h>和<stddef.h>。
验收,en_US.UTF-8 下依次输出 0.00、7.07、999.99、1,000.00、1,234,567.67、-1,234,567.67、-5.05、20,000,000.00,对应输入 0、707、99999、100000、123456767、-123456767、-505、2000000000。同一程序在 C 区域设置下输出 0.00、7.07、999.99、1000.00、1234567.67、-1234567.67、-5.05、20000000.00。
取 8 个单词:
static const char *words[] = {
"banana", "Apple", "apple", "Banana",
"cherry", "Cherry", "date", "Date",
};2
3
4
分别完成三种排序,各打印一行:
- 用
strcmp作qsort的比较函数。 - 用
strcoll作比较函数。 - 先用
strxfrm(NULL, s, 0)求出每个词需要的字节数(返回值不含结尾的'\0'),按这个长度加 1 分配缓冲区并调用strxfrm生成键,再用strcmp比较键排序。
验收(en_US.UTF-8):
strcmp顺序为Apple Banana Cherry Date apple banana cherry date。strcoll顺序为apple Apple banana Banana cherry Cherry date Date。- 第 3 步的顺序与第 2 步完全相同。
再把 setlocale 改成 "C" 跑一遍,说明三次结果为什么都变成按字节的顺序。
7.3 综合与挑战
下面这段库代码有两处问题:它擅自改写了调用方的区域设置,还把 setlocale 返回的指针存了起来。
#include <locale.h>
#include <stdio.h>
static const char *g_locale;
void library_format(double value) {
g_locale = setlocale(LC_ALL, "");
if (g_locale == NULL) {
return;
}
printf("%.2f\n", value);
}2
3
4
5
6
7
8
9
10
11
12
要求:
- 写一个测试程序:主程序先把区域设置设为
"C",记录localeconv()->thousands_sep,调用library_format(1234.5),再记录一次。把library_format里的setlocale改成setlocale(LC_ALL, "en_US.UTF-8")(或本机存在的英文区域设置)以便复现,此时返回后thousands_sep会从空串变成,,主程序之后的格式化输出随之改变。 - 修改
library_format,使调用返回后主程序的区域设置与调用前相同:进入时保存当前的LC_ALL字符串,退出前用它恢复。保存时必须把字符串复制到自己的缓冲区,不能只存指针。 - 打印切换前后
g_locale指向的内容。标准允许后续的setlocale调用覆盖上一次返回的字符串,因此旧指针不能依赖;localeconv返回的struct lconv也一样,下一次调用可能改写其中的内容。MinGW 上能直接看到旧指针指向的内容已经变成乱码,glibc 上可能仍然读到旧值。无论观察到哪一种,结论都是同一个:要长期保存就复制。结合这一点说明第 2 步为什么要求复制。 - 再写一个不调用
setlocale的版本:库函数只按当前区域设置格式化,由程序在启动时设置一次。在注释里写明多线程程序为什么只能采用这一版。 - 验收:测试程序打印的区域设置字符串在调用前后相同,
thousands_sep也相同。
写一个账单报表程序:从标准输入读入若干行,每行是名称、一个制表符和以分为单位的金额(可能为负),输出一张按名称排序的报表。
要求:
- 程序接受一个可选参数作为区域设置名:
./report en_US.UTF-8 < data.txt;省略时传空串,即按运行环境选择。在main开头只调用这一次setlocale,把返回的区域设置名字复制保存;此后任何函数都不再调用setlocale。用参数指定而不是只靠环境变量,是因为 Windows 上的setlocale(LC_ALL, "")不看LC_ALL和LANG,会直接选中系统的区域设置,测试结果随机器而变。 - 报表按名称的
strcoll顺序排列。 - 名称按字符截断到 12 个字符显示,不能截出半个多字节字符。字符边界用
mbtowc或mbrtowc判断,MB_CUR_MAX由当前区域设置决定。 - 金额按 12103 的方式格式化,合计行放在最后。
- 另外把原始数据按字节顺序(
strcmp)写到标准错误流,每行是名称、制表符和金额,供其他程序处理。 - 程序结束前用保存的区域设置名字恢复,并用
setlocale(LC_ALL, NULL)验证恢复成功。 - 区域设置名不存在时,向标准错误流报告并返回非零退出码。
测试数据(保存为 data.txt,两列之间是制表符):
Alice 1234567
bob -250000
Carol 99999
dave 100000
Christopher-Anderson 42002
3
4
5
验收。./report en_US.UTF-8 < data.txt 的标准输出(本机用 %-14s %14s 对齐,下表去掉了多余空格):
| 名称(截断后) | 金额 |
|---|---|
| Alice | 12,345.67 |
| bob | -2,500.00 |
| Carol | 999.99 |
| Christopher- | 42.00 |
| dave | 1,000.00 |
| TOTAL | 11,887.66 |
标准错误流按字节顺序:
Alice 1234567
Carol 99999
Christopher-Anderson 4200
bob -250000
dave 1000002
3
4
5
./report C < data.txt 下标准输出的金额依次为 12345.67、-2500.00、999.99、42.00、1000.00,合计 11887.66,行顺序变为 Alice、Carol、Christopher-、bob、dave;标准错误流按字节排序,所以两次运行的内容相同。