C语言判断大小写:ASCII码与ctype.h的避坑实践
判断一个字符是不是大写字母,这件事在 C 语言里的分量,大概相当于学骑车时的辅助轮——看着不起眼,但几乎每个写 C 的人都在这里绊过一下。我最近连着看了几份新手交上来的代码,有人拿 ASCII 码硬比区间,有人从标准头文件里翻出 ctype.h 直接调 isupper,两种写法都能跑,但问到“为什么这么写”“边界在哪”“换台机器还准不准”的时候,能答上来的人不多。用 ASCII 码判断字母大小写和用 ctype.h 判断字母大小写,这两条路线看似只差几行代码,背后却牵出了 char 的符号性、getchar 的返回值类型、库函数参数范围、locale 默认值这一连串东西。这篇就围绕这个具体问题摊开讲:怎么判断、为什么这么判断、哪些写法看着聪明其实是定时炸弹、完整代码怎么写才能既跑得通又经得起看。刚摸 C 语言的同学可以照着抄,写过一阵子但一直靠试错的人,应该能从里面捡到几个之前没注意的细节。
1. 为什么一个“判断大小写”的小需求值得单独拎出来讲
1.1 从一次代码评审现场说起
前阵子帮一个学弟看他写的一个命令行小工具,功能是把输入文本里的单词首字母统一转成大写。代码不长,六十来行,逻辑也能跑通。但我在里面看到了这么一句:if (ch >= 65 && ch <= 90)。我问他 65 和 90 分别是什么,他愣了一下说“大写字母的范围吧,教程里抄的”。再往下翻,另一处又写成了if (ch >= 'A' && ch <= 'Z')。同一个文件里,同一个判断,出现了两种表达。
这就是我想写这篇的直接动机。65和'A'在编译器眼里完全等价,但在读代码的人眼里完全是两回事。前者需要你在脑子里查表,后者一眼就懂。更麻烦的是,一旦哪天要处理的不是字母而是别的字符,用数字硬编码的人往往记不住对应关系,只能再去搜一次 ASCII 表。判断大小写这个动作本身很简单,但它牵扯到“字符在 C 里到底是什么”这个根问题,把这个根问题捋顺了,后面字符串、输入输出、编码处理一大串东西都会顺。
另一个让我警觉的点是,那个学弟在另一处写了char ch = getchar();,然后用ch != EOF做循环条件。我跟他说这段在特定输入下会提前结束,他一脸不信,直到我用一个含0xFF字节的文件喂进去,程序读到一半直接退出了。这类问题不会在常规测试里暴露,但一旦上线就是事故。
1.2 这个判断在真实项目里出现在哪儿
不用把场景想得太高大上,判断字母大小写在日常代码里的出现频率比想象中高得多。做输入校验的时候,要保证用户名只含字母,那就得逐个字符判断是不是字母;做密码强度检查的时候,要统计大写字母、小写字母、数字、符号各出现了几类;做文本统计工具的时候,要分别数出大写和小写的数量;做配置解析的时候,要把KEY=value这种写法的键统一转成小写再比较,避免大小写不一致导致查不到;写终端界面的时候,要把用户敲进来的小写命令字母转成大写去匹配菜单项。
再往底层走一点,命令行参数解析、简单的词法分析器、日志格式化、CSV 字段清洗,这些地方都会用到同一组判断。它们的共同点是:输入的字符集不受你控制,可能是任意字节。这时候“判断得准不准”和“判断得安全不安全”就是两个问题了。准不准指的是逻辑对不对,安全不安全指的是遇到意料之外的字节会不会越界、会不会触发未定义行为。
1.3 先把边界划清楚
需要先说清楚这篇覆盖什么。这里讨论的是单字节字符环境下的判断,也就是默认的"C"locale,字母就是 ASCII 里的 A 到 Z 和 a 到 z,各 26 个。中文、日文、带重音的拉丁字母这些多字节或者扩展字符不在讨论范围内,它们在 C 里需要另外一套处理方式,跟这篇讲的不是一回事。
需要说明的是,ctype.h 这一族函数在标准里的行为是跟 locale 绑定的。默认情况下只认 ASCII 字母,但如果程序调用了setlocale切到别的语言环境,isupper认定的“大写字母”集合可能变大。这个差异在纯英文环境下感觉不到,但在做国际化的时候会突然冒出来,后面第 3 章会专门说。
另外,这篇的代码都以 C99 或更新的标准为目标,用 gcc 或 clang 都能编。老式的 C89 写法里变量声明必须在块开头,跟这里讲的核心内容无关,不展开。
2. 走ASCII码这条路:把字符当整数看
2.1 字母区间为什么是65到90和97到122
C 语言的char本质上是一个小整数类型,占一个字节。你在代码里写'A',编译器把它翻译成对应的整数值,在 ASCII 编码下就是 65。所以'A'和65是同一个东西的两种写法,前者可读,后者可算。
ASCII 表把可打印字符按顺序排了下来。大写字母占了 65 到 90 这一段,小写字母占了 97 到 122 这一段,中间隔着 91 到 96 这六个字符:[、\、]、^、_、`。这两段的长度都是 26,正好对应英文字母表。数字0到9落在 48 到 57,在字母段之前。
有个细节值得知道:大写和小写之间差了整整 32,也就是 0x20,二进制上是第 6 位(从 0 数起)的差别。'A'是 0x41,'a'是 0x61,只差这一位。这不是巧合,ASCII 设计时刻意这么安排,目的是让电传打字机上切换大小写只需要翻转一个比特位,硬件实现最简单。这个设计决策过了六十年还在影响我们写代码的方式,后面讲的位运算技巧就是从这儿来的。
| 字符区间 | 十进制 | 十六进制 | 数量 |
|---|---|---|---|
0到9 | 48 - 57 | 0x30 - 0x39 | 10 |
A到Z | 65 - 90 | 0x41 - 0x5A | 26 |
a到z | 97 - 122 | 0x61 - 0x7A | 26 |
2.2 最稳的区间比较写法
区间比较是最直白的做法,也是我推荐新手先掌握的写法:
static int is_upper_ascii(int c) { return c >= 'A' && c <= 'Z'; } static int is_lower_ascii(int c) { return c >= 'a' && c <= 'z'; }注意参数类型我写的是int不是char。这不是随手写的,是为了跟后面 ctype.h 的接口保持一致的调用习惯,也顺便避开了传负数的问题。函数内部只做比较,不涉及下标访问,所以即便传进来一个负数,结果也只是“返回 0”,不会出事。这一点很重要,等下讲 ctype.h 的时候你会看到,那边的函数就没这么宽容了。
为什么推荐用字符字面量而不是数字?因为c >= 'A'这句话本身就在解释代码意图,不需要注释。而c >= 65你得在旁边补一句“65 是大写 A”,补了注释还是比字面量多一道翻译。而且字符字面量在非 ASCII 的执行字符集上是可移植的,数字不是。虽然现在几乎所有平台都是 ASCII 兼容的,但既然有免费的午餐,没必要不吃。
有个小陷阱要提醒:不要写成'A' <= c <= 'Z'。C 的关系运算符是左结合的,这个表达式会先算'A' <= c,得到一个 0 或 1,再拿这个 0/1 去跟'Z'比较,结果永远是真(因为 0 和 1 都小于 90)。这个错误编译器一般不给警告,-Wall也不管,只能靠人眼抓。我自己刚学的时候在这上面栽过,写了个永远返回真的校验函数,测了半天才发现输入校验根本没生效。
2.3 位运算技巧:快,但别单独用
知道了大小写只差 0x20 这一位,就能写出很短的转换代码:
/* 前提:c 已经确认是字母 */ int to_lower_bits(int c) { return c | 0x20; } int to_upper_bits(int c) { return c & ~0x20; }'A' | 0x20得到'a','a' & ~0x20得到'A',确实成立。但问题来了:能不能用位运算来判断大小写?
/* 危险示范:对非字母字符会误判 */ if ((c & 0x20) != 0) { /* 作者以为这里是小写字母 */ }不行。反引号`的 ASCII 码是 0x60,0x60 & 0x20等于 0x20,不等于 0,于是它会被误判成“小写字母”。{是 0x7B,0x7B & 0x20也是 0x20,同样误判。原因很简单:第 6 位为 1 的字符远不止小写字母,0x60 到 0x7F 这一整段都满足。所以位运算只能用在“已经确认是字母,现在要转换”的场景,判断本身必须靠区间比较。
那如果非要提速呢?可以两个条件一起用:
/* 先转小写,再看是否落在 a..z,等价于大小写判断 */ if (((c | 0x20) >= 'a') && ((c | 0x20) <= 'z')) { /* 是大写或小写字母 */ }这个写法的原理是:把第 6 位强制置 1 之后,大写和小写字母都会变成小写形态,非字母字符要么不在 a..z 区间,要么(比如`)虽然在这一段附近但不满足上界。验证一下:'A'|0x20 = 'a',落在区间;'z'|0x20 = 'z',落在区间;'@'是 0x40,0x40|0x20是 0x60 即`,小于'a',不命中;'{'是 0x7B,本身第 6 位已经是 1,还是 0x7B,大于'z',不命中。所以这个写法确实是正确的。
但我不建议在业务代码里这么写。它省下的几个时钟周期,在现代处理器上完全感知不到,而它给读者带来的认知负担是实实在在的。这类技巧适合放在底层库或者性能敏感的解析器里,普通业务代码老老实实写两行比较就行。真想要简洁,直接调isalpha(c)比什么都清楚。
2.4 char到底有没有符号,这件事必须确认
这是 ASCII 路线上最容易翻车的地方,也是很多人一直没搞明白的地方。
C 标准里,char、signed char、unsigned char是三个不同的类型。char到底等价于有符号还是无符号,标准没规定,由编译器实现决定。在 x86 平台的 gcc 和 clang 上,默认是有符号的,取值范围 -128 到 127;但在不少 ARM 平台上,默认是无符号的,范围 0 到 255。同一份代码,换个平台编译,行为可能就不一样。
为什么这件事重要?因为 ASCII 只占 0 到 127,正好落在有符号char的正数区间里,所以只要你处理的都是标准 ASCII 字符,有没有符号感觉不到差别。但一旦输入里出现了 0x80 以上的字节——比如 UTF-8 编码的中文、二进制文件、图像数据——有符号char会把它解释成负数,后续任何拿它当下标的操作都会越界。
确认自己平台上char符号性的方法有两个。一个是查宏:
#include <stdio.h> #include <limits.h> int main(void) { printf("CHAR_MIN = %d, CHAR_MAX = %d\n", CHAR_MIN, CHAR_MAX); printf("char 在本平台上是 %s\n", CHAR_MIN < 0 ? "有符号" : "无符号"); return 0; }在典型的 x86_64 Linux 上会输出CHAR_MIN = -128, CHAR_MAX = 127。另一个办法是看编译器预定义的宏:
gcc -dM -E - < /dev/null | grep -i char_un如果输出了#define __CHAR_UNSIGNED__ 1,说明当前配置下char是无符号的。也可以用-fsigned-char和-funsigned-char两个编译选项强制指定,但这属于改变整个编译单元的行为,除非有明确理由,我不建议动。
实操结论:只要你的代码可能接触到非 ASCII 字节,就在传给库函数或者做下标之前显式转成unsigned char。这个习惯养成之后,跨平台问题会少一大半。具体怎么写,第 3 章和第 4 章的代码里会有完整示例。
3. 走ctype.h这条路:把边界交给标准库
3.1 那几个函数的职责分工
ctype.h里跟字母判断相关的主要是这几个:isupper、islower、isalpha、toupper、tolower。前三个返回真假,后两个做转换。它们的关系是:isalpha(c)为真,当且仅当isupper(c)或islower(c)为真(在同一个 locale 下)。
除了这几个,头文件里还有一批经常一起用的:
| 函数 | 判断内容 | 典型用途 |
|---|---|---|
isupper | 是否大写字母 | 统计、转换前判断 |
islower | 是否小写字母 | 统计、转换前判断 |
isalpha | 是否字母(大小写都算) | 用户名校验 |
isdigit | 是否十进制数字 | 数字解析 |
isalnum | 是否字母或数字 | 标识符校验 |
isspace | 是否空白(空格、制表、换行等) | 分词 |
ispunct | 是否标点符号 | 文本清洗 |
isprint | 是否可打印字符 | 输出前过滤 |
iscntrl | 是否控制字符 | 输入净化 |
选哪个函数,取决于你的语义。要判断“是不是字母”,用isalpha;要分大小写统计,用isupper和islower。别用isalpha(c) && !isupper(c)去推“是小写”,虽然结果对,但多绕了一圈,读的人也得多想一步。
3.2 参数到底该传什么类型
这三个判断函数的签名都是int isupper(int c);,参数是int不是char。标准对参数的规定非常明确:传入的值必须是EOF,或者是能表示成unsigned char的值。除此之外的值,行为未定义。
这条规定不是摆设。glibc 这类实现通常用查表的方式,内部有个 384 字节左右的数组,索引由参数算出来。传一个负数进去,索引就跑到数组前面去了,读到的是内存里的垃圾数据。程序不一定当场崩,但返回值是随机的,这种 bug 排查起来能把人逼疯。
那什么情况下会传进非法值?两种典型场景。
第一种,从char变量直接传:
char ch = (char)0xC3; /* 有符号 char 下,ch 的值是 -61 */ if (isupper(ch)) { ... } /* 未定义行为 */第二种,从getchar来但中间存进了char:
char ch; while ((ch = getchar()) != EOF) { /* 这里有双重问题 */ if (isupper(ch)) { ... } /* ch 可能是负数 */ }正确写法是把中间变量声明成int:
int c; while ((c = getchar()) != EOF) { if (isupper(c)) { ... } /* 安全 */ }这里有个反直觉的点值得说清楚:getchar返回的是int,C 标准保证这个返回值要么是EOF,要么是一个能用unsigned char表示的值(也就是 0 到 255 之间)。这两种值都在isupper允许的参数范围内,所以从getchar直接拿到、中间不经过char变量的值,传给 ctype 函数是完全安全的,不需要额外强转。很多教程不加区分地说“必须强转”,其实是从char变量传的时候才需要。搞清楚这个区别,你就比抄教程的人多懂一层。
从char变量传的时候,标准写法是:
char ch = ...; if (isupper((unsigned char)ch)) { ... }强转的目的不是改变值,而是让负数值重新解释成 128 到 255 之间的正数。比如(char)0xC3转成unsigned char是 195,落在合法范围内,函数会正确返回 0(不是字母)。
3.3 返回值为什么不能当布尔用
isupper的返回值类型是int。标准只保证两件事:不是字母时返回 0,是字母时返回某个非零值。它没有承诺这个非零值一定是 1。
实际实现里,glibc 返回的可能是 0x100、0x200 这类位掩码值,也可能是 1。这两种情况都符合标准。所以下面这种写法有隐患:
int flag = isupper(c); /* 可能是 256 */ printf("%d\n", flag); /* 打印出来是 256,不是 1 */如果只是拿它当条件判断,if (isupper(c))完全没问题,因为非零即真。但如果你要把它存起来、比较、或者传给别的接口,就得自己归一化:
int is_upper = isupper(c) != 0; /* 明确变成 0 或 1 */这个细节知道的人不多,但一旦踩上,症状会很诡异——比如把多个判断结果加起来统计数量,结果大得离谱,因为每个“真”贡献的不是 1 而是 256。我自己就见过一次统计结果比文件字节数还大的情况,查了半天才定位到这儿。
3.4 locale会悄悄改变结果
ctype.h这族函数的行为是受 locale 影响的。默认情况下,程序启动时的 locale 是"C",此时字母就是 ASCII 的 52 个,跟手写区间比较完全一致。但如果程序里调用了:
#include <locale.h> setlocale(LC_ALL, "");或者指定了某个具体语言环境,isupper认定的“大写字母”集合就可能扩大到包含带重音符号的字母等。这时候isupper和你的手写区间比较就会产生分歧,同一个字符两边给出的答案不一样。
这带来两个实践建议。第一,如果你的程序是纯 ASCII 处理逻辑,又想保证行为绝对可控,可以在main开头显式写一句setlocale(LC_CTYPE, "C");,把行为锁死。第二,如果你在做对比测试,发现两个实现对同一个输入给出了不同结果,第一件事就是检查 locale,别急着改代码。
另外要注意,setlocale的效果是全局的,会影响进程内所有用到 locale 的函数。在库代码里调用它是个坏习惯,因为会污染调用方的环境。这个坑在多人协作的项目里挺常见的,表现是“单独跑没问题,集成进去就不对”。
4. 三份完整代码,从能跑到能看
4.1 先把编译环境摆好
代码用 gcc 或者 clang 都行,标准定在 C11。我习惯开这几个警告:
gcc -std=c11 -Wall -Wextra -Wconversion -Wshadow -O2 demo.c -o demo-Wall -Wextra是基本盘,能抓出大部分低级问题。-Wconversion值得特别说一下,它会警告隐式类型转换导致的精度损失,比如把int赋给char。判断大小写的代码里到处都是字符和整数的转换,这个警告能帮你抓出不少“我以为没问题”的地方。-Wshadow管变量遮蔽,在嵌套循环里特别有用。
第一次开-Wconversion可能会被警告数量吓到,但耐心一条条处理完,代码质量会有明显提升。这也是为什么我建议新手从第一份代码开始就开着它,等到代码量大了再改成本太高。
顺带提一句,如果你的代码要在多个平台上跑,建议在 CI 里同时用-fsigned-char和-funsigned-char各编译一遍,跑同一套测试。两边都过,才算真的可移植。
4.2 版本一:纯ASCII区间判断
这份代码完全不用ctype.h,所有判断手写:
#include <stdio.h> /* 返回 1 表示大写字母,0 表示否 */ static int is_upper_ascii(int c) { return c >= 'A' && c <= 'Z'; } static int is_lower_ascii(int c) { return c >= 'a' && c <= 'z'; } static int is_alpha_ascii(int c) { return is_upper_ascii(c) || is_lower_ascii(c); } int main(void) { int c; while ((c = getchar()) != EOF) { if (c == '\n') { continue; } if (is_upper_ascii(c)) { printf("字符 %c (0x%02X) 是大写字母\n", c, (unsigned)c); } else if (is_lower_ascii(c)) { printf("字符 %c (0x%02X) 是小写字母\n", c, (unsigned)c); } else if (is_alpha_ascii(c)) { printf("字符 %c 是字母\n", c); } else { printf("字符 %c (0x%02X) 不是字母\n", c, (unsigned)c); } } return 0; }几个细节解释一下。(unsigned)c是为了让%02X输出正确,如果c是负数,直接按%X打印会得到一个巨大的无符号数,看着莫名其妙。第三个分支is_alpha_ascii其实是死代码,因为前两个分支已经覆盖了所有字母情况,留着是为了展示分层的写法,实际项目里可以删掉。
这份代码的好处是零依赖,任何 C 编译器都能编,行为完全可预测,跟着 ASCII 表走。坏处是如果你要处理的不止字母,还得一个个手写is_digit、is_space,重复劳动。
4.3 版本二:ctype.h版本
#include <stdio.h> #include <ctype.h> #include <locale.h> int main(void) { int c; setlocale(LC_CTYPE, "C"); /* 锁死行为,避免环境差异 */ while ((c = getchar()) != EOF) { if (c == '\n') { continue; } if (isupper(c)) { printf("字符 %c 是大写字母,转小写是 %c\n", c, tolower(c)); } else if (islower(c)) { printf("字符 %c 是小写字母,转大写是 %c\n", c, toupper(c)); } else { printf("字符 %c 不是字母\n", c); } } return 0; }这份代码短,意图清晰,而且将来要扩展判断数字、标点时,加一行就行。tolower、toupper这两个函数有个特性:如果传入的字符本身不是对应的大小写形式,它们原样返回。所以toupper('a')得到'A',toupper('A')得到'A',toupper('5')得到'5',不需要先判断再转换。这个设计挺贴心,省了一层嵌套。
还有一点:tolower和toupper的返回值也是int,如果要存回char变量,得显式转换一下,否则-Wconversion会报:
char ch = 'a'; ch = (char)toupper((unsigned char)ch);内层的(unsigned char)是保险,外层的(char)是收窄。两层都不能省。
4.4 版本三:一个能直接拿去用的小工具
光判断单个字符不够过瘾,写个有点实际用途的:统计一段输入里各类字符的数量,并顺手把大写和小写的分布打印出来。
#include <stdio.h> #include <ctype.h> #include <locale.h> struct stats { unsigned long upper; unsigned long lower; unsigned long digit; unsigned long space; unsigned long other; }; static void feed(struct stats *s, int c) { if (isupper(c)) s->upper++; else if (islower(c)) s->lower++; else if (isdigit(c)) s->digit++; else if (isspace(c)) s->space++; else s->other++; } int main(void) { struct stats s = {0}; int c; setlocale(LC_CTYPE, "C"); while ((c = getchar()) != EOF) { feed(&s, c); } unsigned long total = s.upper + s.lower + s.digit + s.space + s.other; printf("总字符数: %lu\n", total); printf("大写字母: %lu\n", s.upper); printf("小写字母: %lu\n", s.lower); printf("数字 : %lu\n", s.digit); printf("空白 : %lu\n", s.space); printf("其他 : %lu\n", s.other); if (s.upper + s.lower > 0) { double ratio = (double)s.upper / (double)(s.upper + s.lower); printf("字母中大写占比: %.2f%%\n", ratio * 100.0); } return 0; }用unsigned long而不是int计数,是为了避免处理大文件时溢出。32 位int最大约 21 亿,处理超过 2GB 的输入就会翻车,这种 bug 在大文件场景下不算罕见。double的强制转换也别忘了写,两个整数相除在 C 里是整除,1/2得到 0 不是 0.5,这个坑从学 C 的第一天就有人踩,到第十年还有人踩。
这份代码也可以改造成 ASCII 纯手写版本,用来做对拍验证:
# 用随机数据喂两份实现,比较输出是否一致 for i in $(seq 1 100); do head -c 8192 /dev/urandom > case.bin ./demo_ctype < case.bin > out_ctype.txt ./demo_ascii < case.bin > out_ascii.txt if ! diff -q out_ctype.txt out_ascii.txt > /dev/null; then echo "第 $i 轮出现差异" break fi done随机字节里必然包含 0x80 以上的值,正好可以把前面说的符号性问题暴露出来。如果两边输出一致,说明你的 ASCII 版本正确处理了高字节;如果出现差异,八成就是char符号性没处理好。这种“两份独立实现互相对拍”的思路,比盯着代码看半天有效得多,也适合用在任何“我不确定这两个写法是不是等价”的场景里。
4.5 三份代码怎么选
选择标准其实很清楚,就看三个因素:可移植性要求、是否已经用了标准库、以及团队习惯。
如果项目已经大量使用ctype.h,跟着用isupper就行,别搞特殊。如果项目对依赖极度敏感,或者跑在没有完整标准库的裸机环境里(嵌入式很常见),那就手写区间比较,几十个字节的事儿。如果代码需要在 ASCII 和非 ASCII 环境之间来回切换,ctype.h配合 locale 控制会更灵活。
| 维度 | ASCII 手写 | ctype.h |
|---|---|---|
| 可读性 | 中,需要认识字符区间 | 高,函数名自解释 |
| 依赖 | 无 | 标准库 |
| 可移植性 | 强,行为完全可控 | 受 locale 影响 |
| 扩展性 | 差,每个判断都要手写 | 好,加函数调用即可 |
| 性能 | 略快,通常可被内联 | 略慢,但有优化空间 |
| 出错风险 | 区间写错、符号性处理 | 参数越界、返回值误用 |
性能那一栏我写的是“略快”,实际情况是两者的差距在-O2下基本被优化掉了。真有性能瓶颈,也不在这个判断上,先去做 profile 再说。
5. 踩坑实录与排查手册
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 判断结果总是真 | 写成了'A' <= c <= 'Z' | 检查是否有连续比较 |
| 判断结果总是假 | 字符区间写反了 | 确认下界小于上界 |
| 读到某个字节就提前结束 | 用char接getchar | 改用int接 |
| 高字节输入时行为异常 | 有符号char传给了库函数 | 加(unsigned char)转换 |
| 统计出的数量异常大 | 把isupper返回值当 1 用 | 改成!= 0归一化 |
| 换个平台结果就变 | char符号性不同 | 查CHAR_MIN |
| 集成后才出问题 | 调用方改了 locale | 检查setlocale调用 |
| 编译警告一堆 | 没开或没看-Wconversion | 逐条处理转换警告 |
5.2 四个真实的翻车现场
第一个是前面提过的while ((ch = getchar()) != EOF)用char接。这个坑的机理值得掰开说:getchar返回值范围是 -1 到 255,共 257 种可能,正好超出 8 位能表示的范围。用int接,257 种值全都能存下;用有符号char接,-1 和 255 都会变成 0xFF,也就是 -1,跟 EOF 撞车。结果就是输入里只要出现一个 0xFF 字节,循环就提前退了。修复很简单,把char改成int。
第二个是强转缺失。有一份代码从文件里按字节读数据,读进char数组,然后遍历数组调isupper。测试用的都是英文文本,一切正常。上线后处理某个包含二进制头的文件,程序直接段错误。原因就是数组里的高字节被解释成负数,传进isupper后越界读。修复方案是在调用处统一加(unsigned char)转换。这件事的教训是:测试数据的覆盖面,决定了你对代码的信心上限,只用英文文本测字符处理,等于没测。
第三个是返回值误用。有个统计模块把多个isupper的结果累加,想统计大写字母总数,结果得出的数字接近文件大小的四分之一(因为每次返回 256)。这个 bug 藏得深,因为不崩溃、不报错,只是数字不对,而且不看原始数据根本发现不了。修复就是加!= 0。后来我把“不要假设库函数返回 1”写进了团队的代码规范。
第四个是 locale 污染。一个库函数内部为了处理某种本地化格式,调用了setlocale(LC_ALL, ""),没恢复。调用方的代码本来依赖"C"locale 下的行为,集成之后判断结果全变了。这种问题最难查,因为单测跑的是库自己的用例,全过;集成之后才暴露。规范做法是:库代码里不要调setlocale,需要格式化就自己实现,或者显式保存、修改、恢复三步走。
5.3 几条我自己的习惯
写字符处理代码的时候,我有一条固定的检查动作:只要看到getchar,就条件反射地确认接收变量是int;只要看到isxxx系列函数,就确认参数来源会不会是负数。这两条检查覆盖了这类代码八成以上的问题,养成习惯之后基本一眼就能看出来。
另一个习惯是给字符判断函数统一用int参数、返回 0 或 1。这样不管底下是手写还是调库,接口层面是一致的,调用方不用关心实现细节,也顺便把返回值归一化的问题在封装层解决了。封装一层看着多余,但项目变大的时候,改一处比改二十处划算得多。
还有就是编译警告要当错误对待。我自己的项目里会加-Werror,让警告直接中断编译。刚开始会很痛苦,但坚持一段时间后,你会发现很多隐患在敲代码的时候就被拦下来了,比调试阶段找出来省事太多。-Wconversion报的那些字符和整数之间的转换警告,尤其值得认真看,它们往往指向真实的逻辑问题。
最后分享一个我常用的小技巧:调试字符处理代码的时候,不要只打印字符本身,把它的十六进制值一起打出来。printf("%c 0x%02X\n", c, (unsigned)c)这样一行,能同时看到“它长什么样”和“它的值是多少”,很多看起来莫名其妙的现象——比如某个字符明明显示成 A 却判断失败——一眼就能定位到是零宽字符、全角字符还是编码错位。纯靠肉眼看字符,这些细节是看不出来的。
字符判断这件事,本质上是在问“这个字节在编码表里的位置”,把这张表装在脑子里,比记住任何一段代码都有用。至于用不用ctype.h,用哪个封装层,都是表层选择,底下的那点原理就那么几条,捋顺了就不会再被绊倒。