C语言字符判断避坑:ASCII、ctype.h与大小写转换
写 C 语言里的大小写判断,几乎每个人入门时都写过一行自认为天衣无缝的代码,然后在某个深夜盯着输出发呆:明明输入的是小写 y,程序却像没看见一样继续往下走。更让人抓狂的是,把这段代码原封不动抄给同桌,他那边跑得好好的。这类问题八成不是编译器的问题,而是踩在了字符判断的两个经典雷区上:一个是区间比较的语法陷阱,另一个是对 ASCII 码与ctype.h库的边界理解不到位。下面我按照排查这类问题的真实顺序,把 ASCII 码表的布局、大小写转换的位运算原理、ctype.h各函数的参数约定、输入循环的缓冲区处理,以及几种方案的取舍一次讲透。刚接触 C 语言的同学可以照着代码抄,已经写过几个小项目但总觉得字符处理"差口气"的朋友,也能从中找到几处平时文档里不太提的细节。
1. 那个永远为真的区间判断:'a' <= ch <= 'z' 骗了太多人
1.1 从编译器的视角拆开连续比较
先看一段被写烂的代码:
char ch = 'A'; if ('a' <= ch <= 'z') { printf("是小写字母\n"); }运行结果会打印"是小写字母",哪怕变量里装的是大写 A。这不是编译器算错了,而是这行代码在语法上根本不是"判断 ch 是否落在 a 到 z 之间"。
C 语言的比较运算符是左结合的二元运算符,'a' <= ch <= 'z'会被解析成('a' <= ch) <= 'z'。先算括号里的部分,'a' <= ch的结果是一个逻辑值,成立得 1,不成立得 0,类型是int。然后拿这个 0 或 1 去和'z'比较,也就是判断1 <= 122或0 <= 122。这两个条件都成立,所以整个表达式恒为真,跟ch存了什么完全没关系。
同样的问题在0 < x < 10这种数学写法上更隐蔽,因为数学课本里它就是合法的连写,而 C 语言里它是合法的语法、错误的语义。编译器不会报错,连警告都不一定给,因为从语法角度这行代码完全合法。这也是为什么这个坑能存活几十年,每年都有新人栽进去。
1.2 用 ASCII 码把区间判断写对
正确的写法必须把两个比较拆开,用逻辑与连接起来:
if (ch >= 'a' && ch <= 'z') { printf("是小写字母\n"); }也可以反过来写ch <= 'z' && ch >= 'a',效果一样。如果用字符常量的方式看着不直观,换成十进制数字也行,小写字母是 97 到 122:
if ((unsigned char)ch >= 97 && (unsigned char)ch <= 122) { ... }但我更建议保留字符常量写法,因为'a'和'z'一眼就能看出意图,而97和122需要脑子里过一遍对照表。可读性在字符处理代码里格外重要,因为后面还要和'A'、'0'、'\n'这些值混着用,数字写多了根本分不清谁是谁。
还有一点值得强调:ch如果是char类型,且在某些平台上char默认是有符号的,那么当字节值大于等于 128 时,ch会变成负数,此时ch >= 'a'依然能正常工作(因为负数字节确实不是小写字母),但只要后面拿它去索引数组或者传给ctype.h函数,就会出问题。所以养成一个习惯:处理原始字节时先转成unsigned char再比较,成本为零,收益很大。
1.3 为什么新手特别容易在这里翻车
因为这个错误的"外观"太好了。'a' <= ch <= 'z'从左往右读,语义自然,逻辑通顺,甚至像伪代码一样漂亮,任何人第一眼都会觉得它应该是对的。相比之下ch >= 'a' && ch <= 'z'显得啰嗦,重复了变量名,视觉上不优雅。新手在追求"代码看起来简洁"的时候,很容易被前者吸引。
另外,错误不会立刻暴露。如果程序后面正好有个默认分支,if恒真反而让程序"看起来能跑",只是走进了错误的分支。等到某天输出结果不对,回头看才发现判断条件根本没起作用。我在带新人的时候,第一件事就是让他们把这类区间比较全部改成显式逻辑与,然后自己写三组测试:'A'、'a'、'0',看三条路是不是各走各的。三组测试能过,这个坑基本就绕过去了。
还有一种变体是用strcmp比较单个字符,比如strcmp(ch, "a") >= 0。这直接把char当指针传了进去,属于更严重的类型错误,轻则崩溃重则玄学。单字符比较永远用字符常量加比较运算符,别碰字符串函数。
2. 大小写字母在 ASCII 码表上的排列:只差一个比特的巧妙设计
2.1 三张对照表看清数值关系
想彻底搞明白大小写转换,得把 ASCII 码表里跟字母相关的部分背下来——不用全背,记住四组就够了:
| 字符类别 | 范围 | 十进制 | 十六进制 | 二进制低 8 位 |
|---|---|---|---|---|
| 大写字母 | A - Z | 65 - 90 | 0x41 - 0x5A | 0100 0001 - 0101 1010 |
| 小写字母 | a - z | 97 - 122 | 0x61 - 0x7A | 0110 0001 - 0111 1010 |
| 数字 | 0 - 9 | 48 - 57 | 0x30 - 0x39 | 0011 0000 - 0011 1001 |
| 空格、换行等 | 控制与空白 | 0 - 32 | 0x00 - 0x20 | 低位密集 |
把大写 A 和小写 a 的二进制并排放:
A = 0100 0001 a = 0110 0001 ^ 只有这一位不同'a' - 'A'恰好等于 32,也就是 2 的 5 次方,对应二进制的第 5 位(从 0 开始计数)。这个设计不是巧合,早期字符编码设计者故意把大小写字母排成只差一个比特的形式,好让硬件做大小写转换时只需要一条按位操作指令。A 到 Z 连续排列,a 到 z 连续排列,中间隔着 6 个非字母符号([、\、]、^、_、`),这 6 个符号的存在让"区间判断"变得必要——不能用ch > 64之类的粗糙条件,必须两头都卡住。
2.2 0x20 这个掩码位控制了什么
既然只差第 5 位,那就可以用位运算直接改这一位:
- 把大写转成小写:
ch | 0x20,把第 5 位置 1; - 把小写转成大写:
ch & ~0x20,把第 5 位清 0; - 大小写互换:
ch ^ 0x20,把第 5 位取反。
写成函数:
static inline int to_lower_bits(int c) { return (c >= 'A' && c <= 'Z') ? (c | 0x20) : c; } static inline int to_upper_bits(int c) { return (c >= 'a' && c <= 'z') ? (c & ~0x20) : c; } static inline int flip_case_bits(int c) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) return c ^ 0x20; return c; }这三个函数的共同点:先判断再运算。这一步不能省。如果不做区间检查就直接c | 0x20,那'0'(0x30)会被变成 0x30 | 0x20 = 0x30?不对,0x30 的第 5 位本来就是 1,| 0x20之后仍是 0x30,看起来没事。但'!'(0x21)会变成 0x21 | 0x20 = 0x21?也不对,0x21 的第 5 位是 1,还是 0x21。真正出问题的是第 5 位为 0 的非字母字符,比如'@'(0x40),0x40 | 0x20变成 0x60,也就是反引号`,凭空造出一个符号出来。所以位运算方案必须配上完整的前置判断,否则就是在做数据污染。
2.3 位运算方案能跑,但别忽略它的适用范围
很多人学完位运算之后就再也不用库函数了,理由是"快"和"酷"。这两个理由在他们自己的机器上确实成立,但换个环境就可能翻车。
第一,这套位运算的合法性完全建立在一个前提上:字符集是 ASCII 或者与 ASCII 兼容的编码(比如 Latin-1、UTF-8 的单字节部分)。在 ASCII 系编码里,大小写差值恒为 32,第 5 位就是大小写标志。但在 EBCDIC 这类字符集上,字母不是连续排列的,大小写之间的差值也不是 32,位运算直接失效。我当年在一门讲可移植性的选修课上见过这个例子,当时觉得"反正我不用主机",但实际跨平台移植代码时,这类硬编码就是最早爆的雷。
第二,位运算只解决大小写切换,不解决"这个字符到底是不是字母"。完整的判断逻辑还是要写区间比较,一行变两行,代码量反而上去了。而ctype.h提供一个函数名就搞定了,语义还更清晰。
第三,也是最重要的一点:位运算方案里那两次区间比较,本质上就是在重新实现isupper和islower。既然都要判断了,为什么不直接调库?除非你明确知道当前环境对性能有极端要求,且已经做过 profile 验证这里确实是瓶颈,否则没有任何理由手写。位运算方案更适合当成理解工具,用来搞明白ctype.h内部大致在做什么,而不是日常项目里的首选。
3. ctype.h 到底替你挡了什么:参数类型与返回值的双重陷阱
3.1 参数必须是 unsigned char 或 EOF
ctype.h里所有函数的参数类型都是int,接收的是一个"字符值"或者EOF。标准里写得很明确:参数的值必须能表示为unsigned char,或者等于EOF(通常是 -1)。如果传进去一个负数且不是 EOF,行为是未定义的。
为什么强调这一点?因为char在不同平台上默认是否有符号是不一样的。x86 上常见的是有符号char,取值范围 -128 到 127。如果一个字节的值是 0xE4(比如某个多字节字符的一个字节),存进char之后它变成 -28。把这个 -28 传给isalpha,就踩进了未定义行为的区域。在多数实现里它会去查一张表,负索引越界,可能读到奇怪的数据,返回什么都说不准。
正确的写法是显式转换:
char ch = ...; if (isalpha((unsigned char)ch)) { ... }或者更稳妥的做法,让字符从读取开始就以int的形式存在:
int c; while ((c = getchar()) != EOF) { if (isalpha(c)) { ... } }getchar返回int,值域是unsigned char能表示的所有值加上EOF,正好满足ctype.h的参数要求,不需要转换。这也是 C 语言里getchar返回int而不是char的根本原因——它要留出一个额外的值来表示"没有更多输入了"。每当我看到有人写char c = getchar();,就知道后面大概率要出 EOF 判断的 bug。
3.2 返回值不是布尔值,别拿它和 1 比较
isupper、islower、isalpha这些函数返回的是int,表示"真"或"假",但标准只保证"非零表示真",不保证真值一定等于 1。某些实现会返回一个非零的位掩码,某些实现返回任意正数。
所以下面这种写法是错的:
if (isupper(ch) == 1) { ... } /* 不保证成立 */应该写:
if (isupper(ch)) { ... } if (!isupper(ch)) { ... }这个约定在 C 语言里非常普遍,strcmp返回的也不是 1 和 -1,只保证正、零、负。新手习惯了别的语言里"布尔函数返回 true/false",到 C 里就会下意识地做等值比较。这个坑不会让程序崩溃,只会让某个分支死活走不到,排查起来极其费神,因为你会反复怀疑逻辑,唯独没想到是返回值比较出了问题。
还有一个容易被忽略的点:标准允许这些函数被实现成宏。如果某个实现确实用了宏,宏体里可能对参数求值多次。所以千万不要传带副作用的表达式:
if (isalpha(*p++)) { ... } /* 危险:p 可能被加多次 */正确的做法是先取值再判断:
int c = *p++; if (isalpha(c)) { ... }3.3 一组函数的分工与最容易混淆的几对
ctype.h里的函数可以按用途分成几组,混着用的后果通常是逻辑错位而不是编译错误:
| 函数 | 判断内容 | 常见误用场景 |
|---|---|---|
isalpha | 是否为字母(大写或小写) | 想判断"是否为可见字符"时错用它 |
isupper/islower | 是否为大写 / 小写字母 | 对数字也返回假,被误以为函数坏了 |
isdigit | 是否为十进制数字 0-9 | 误以为能识别负数或小数点 |
isalnum | 是否为字母或数字 | 用它校验标识符,忘了下划线不是字母数字 |
isspace | 是否为空白(空格、制表、换行等) | 以为只认空格,结果\r也算 |
ispunct | 是否为标点符号 | 和"非字母数字"混淆,实际不含空格 |
isxdigit | 是否为十六进制数字 | 以为只认 0-9,忘了 A-F 和 a-f |
toupper/tolower | 转换大小写 | 以为它们会检查输入,实际对非字母原样返回 |
特别说一下toupper和tolower的行为:它们对非字母字符原样返回,不做任何改动,也不会报错。这意味着toupper('1')返回'1',tolower('\n')返回'\n'。这个设计很贴心,让"整段文本转大写"这种操作可以无脑对每个字符调用,不用担心破坏数字和标点。但也正因为如此,它不能用来判断字符是不是字母——返回值不变不代表它是字母,只能说明转换没有发生。
另外,toupper和tolower返回的是int,如果直接赋给char,在极端情况下(返回值超出char范围)会有实现定义的行为。通常我们处理的都是 ASCII 范围内的字符,不会出问题,但严谨写法还是用int接住:
int upper = toupper(c); putchar(upper);4. 把判断逻辑装进真实程序:输入循环、缓冲区与 EOF
4.1 getchar 返回 int 这件事必须养成肌肉记忆
判断单个字符的代码片段谁都会写,难的是把它放进一个能真正处理输入的循环里。最常见的骨架是这样的:
#include <stdio.h> #include <ctype.h> int main(void) { int c; long upper = 0, lower = 0, digit = 0, other = 0; while ((c = getchar()) != EOF) { if (isupper(c)) upper++; else if (islower(c)) lower++; else if (isdigit(c)) digit++; else other++; putchar(toupper(c)); } fprintf(stderr, "\n大写:%ld 小写:%ld 数字:%ld 其他:%ld\n", upper, lower, digit, other); return 0; }这段代码里有两处值得停下来看。
第一处是int c。如果写成char c,getchar返回的 EOF(-1)赋给char之后,在无符号char的平台上会变成 255,c != EOF永远成立,循环永远不结束。在有符号char的平台上,恰好 255 这个字节值也会被当成 EOF,导致读取二进制文件时提前停止。这两种 bug 都不报错,只是行为诡异。用int接住,问题一次性消失。
第二处是putchar(toupper(c))放在循环末尾,对每个字符都执行。这里c的值域是 0 到 255,符合ctype.h的参数要求,不需要转换。如果c是从char变量里来的,就必须写toupper((unsigned char)c)。
4.2 行缓冲和换行符残留:菜单程序的经典事故
比 EOF 更常见的,是换行符把下一次输入吃掉。想一下这个场景:
char ans; printf("继续吗?(Y/N): "); scanf("%c", &ans); if (ans == 'y' || ans == 'Y') { /* 继续 */ }用户输入y然后按回车。scanf("%c")读走了y,缓冲区里还剩一个\n。如果后面还有一次scanf("%c"),它立刻读到这个换行符,不会等用户输入。程序看起来"跳过"了一次询问。
这类问题的根源是对输入流的理解:键盘输入先进缓冲区,scanf和getchar从缓冲区里逐个取,不是直接从键盘取。回车键产生的\n也是一个字符,它在那里等着,谁先来读谁拿走。
处理方式有几种,按场景选:
第一种,读完字符后主动清掉缓冲区里剩下的内容:
int ch; while ((ch = getchar()) != '\n' && ch != EOF) ;这个循环会一直读到换行为止,把残留吃掉。注意必须保留ch != EOF的判断,否则在输入流结束时死循环。
第二种,在读字符前加一个空格跳过空白:
scanf(" %c", &ans); /* 注意前面的空格 */格式串里的空格表示"跳过所有前导空白字符",包括空格、制表、换行。这个技巧对单字符输入特别有效,一行改动解决问题。
第三种,干脆整行读进来再取第一个字符:
char line[128]; if (fgets(line, sizeof line, stdin) != NULL) { char ans = line[0]; if (isupper((unsigned char)ans)) ans = (char)tolower((unsigned char)ans); }fgets会把整行连同换行一起读走,缓冲区不会残留,逻辑最干净,代价是多占一点栈空间。对交互式程序,我更倾向这一种,因为它把"读一行"和"解析一行"分开了,后续要支持多字符输入也不用重写。
4.3 拿真实需求练手:密码强度校验里的大小写判断
把上面这些零件拼起来,做一个能跑的小功能:校验一个口令是否同时包含大写字母、小写字母和数字。这个需求足够真实,也把所有判断函数都用上了。
#include <stdio.h> #include <ctype.h> static int check_password(const char *s) { int has_upper = 0, has_lower = 0, has_digit = 0; if (s == NULL) return 0; for (const unsigned char *p = (const unsigned char *)s; *p; ++p) { if (isupper(*p)) has_upper = 1; else if (islower(*p)) has_lower = 1; else if (isdigit(*p)) has_digit = 1; } return has_upper && has_lower && has_digit; } int main(void) { char buf[128]; printf("请输入口令: "); if (fgets(buf, sizeof buf, stdin) == NULL) return 1; /* 去掉行尾换行,避免它被算进字符统计 */ size_t len = 0; while (buf[len] != '\0' && buf[len] != '\n') len++; buf[len] = '\0'; printf("强度检查: %s\n", check_password(buf) ? "通过" : "不通过"); return 0; }这段代码里有一个细节值得单独说:循环变量声明成const unsigned char *而不是char *。这样每次解引用拿到的值天然落在 0 到 255 范围内,直接传给ctype.h函数完全安全,不用在每个调用点写强制转换。很多代码规范里都会强调这一点,原因就是前面提到的负数越界问题。
另一个细节是去换行的循环。fgets读进来的字符串会带着\n,如果不处理,口令长度统计会多一个字符,虽然对"是否包含大写"这种布尔判断没影响,但如果后续加长度限制就会出错。所以养成习惯,fgets之后先处理行尾。
4.4 scanf 的 %s 和 %c 在处理字符上的差别
顺带把scanf的两个格式符说清楚,因为新手经常混:
| 格式符 | 读入内容 | 是否跳过前导空白 | 是否自动加结束符 |
|---|---|---|---|
%c | 单个字符 | 否 | 是(作为独立字符,不加\0) |
%s | 一串非空白字符 | 是 | 是(自动补\0) |
%d | 十进制整数 | 是 | 不涉及 |
%c不跳过空白,所以回车、空格都会被它当成有效输入,这就是换行符残留问题的来源。%s会跳过前导空白,但遇到空白就停,不能读带空格的一整行。要读整行只能用fgets,别用gets(已从标准中移除,且没有长度限制)。
判断单个字符时,如果目标是"用户按了一个键",用scanf(" %c", &ch)是最省事的。如果目标是"用户输入了一行命令",用fgets更稳。两种方式别混着用,同一份代码里一会儿scanf一会儿fgets,缓冲区状态会变得很难推理。
5. 手写判断、库函数与查表:三种方案怎么选
5.1 三种实现方式横向对照
同样是判断一个大写字母,至少有三条路可走,性能、可读性、可移植性各有取舍:
| 方案 | 示例写法 | 优点 | 缺点 |
|---|---|---|---|
| 区间比较 | c >= 'A' && c <= 'Z' | 零依赖,一眼看懂 | 重复代码多,容易漏掉unsigned char转换 |
| 位运算 | (c & ~0x20) == c配合判断 | 单指令,理论最快 | 依赖 ASCII 位布局,可读性差,易被误用 |
| 库函数 | isupper(c) | 语义清晰,实现方处理边界 | 可能有函数调用开销(视实现) |
很多人看到第三列"可能有开销"就倾向手写,这个判断在二十年前也许成立,现在基本不成立。主流编译器在处理isupper时,要么把它内联成一次查表,要么在编译期就把常量参数算出来。真正需要担心的不是这几个周期,而是手写代码里漏掉的那次unsigned char转换——一次未定义行为带来的损失,远大于省下的几个机器周期。
5.2 性能在真实项目里到底占多少权重
我做过一个粗略的对比实验:对一个几十兆的文本文件逐字节做大小写统计,分别用isupper和手写区间比较,在同一台机器上各跑十次取平均。差距在测量噪声范围内,两者的耗时几乎无法区分。原因很简单,瓶颈在 I/O,不在判断本身。即使把数据全读进内存再跑,isupper版本也就慢几个百分点,因为现代实现里的查表非常快,而且编译器会把循环里的判断优化得很好。
真正能拉开差距的场景是:字符判断在极内层循环里,且每次迭代做的其他工作极少。比如写一个词法分析器,每个字节都要过一遍分类逻辑。这种时候如果确实测出热点,再考虑用手写的分类表替代。注意是"分类表"而不是位运算——一个 256 字节的静态数组,把每个字节的类别预先算好,一次索引就出结果,比任何分支判断都快,而且是数据驱动的,不会因为分支预测失败而抖动。
/* 预计算分类表,类别用位标志表示 */ enum { CH_UPPER = 1, CH_LOWER = 2, CH_DIGIT = 4, CH_SPACE = 8 }; static unsigned char class_table[256]; static void init_class_table(void) { for (int i = 0; i < 256; ++i) { unsigned char v = 0; if (i >= 'A' && i <= 'Z') v |= CH_UPPER; if (i >= 'a' && i <= 'z') v |= CH_LOWER; if (i >= '0' && i <= '9') v |= CH_DIGIT; if (i == ' ' || i == '\t' || i == '\n' || i == '\r') v |= CH_SPACE; class_table[i] = v; } } /* 使用: if (class_table[(unsigned char)c] & CH_UPPER) ... */这个方案把判断逻辑和业务逻辑彻底分开,初始化一次,之后每字节一次数组索引加一次按位与,没有分支。代价是启动时多花几十微秒建表,以及对缓存多一点点压力。对于需要处理海量文本的程序,这是我个人最推荐的写法。对于普通的命令行小工具,ctype.h就够了,没必要上表。
5.3 locale 和非 ASCII 输入带来的现实问题
还有一个容易被忽略的维度:ctype.h的行为受当前 locale 影响。默认的 "C" locale 下,isalpha只对 ASCII 字母返回真,这通常是我们想要的行为。但如果程序调用了setlocale(LC_ALL, "")跟着系统环境走,某些 locale 下isalpha可能会对扩展字符返回真,导致统计结果和预期不一致。
这不算 bug,是标准允许的行为,但会让"同一份程序在不同机器上结果不同"这种现象出现。如果你的程序只处理 ASCII 文本,最稳妥的做法是不要调用setlocale,让它保持在 "C" locale。如果确实需要处理本地化字符,那就得接受ctype.h的行为差异,并且在文档里写清楚。
另一个现实问题是多字节编码。处理一段包含中文的文本时,如果按字节遍历,中文字符的每个字节值都大于 0x7F,在 "C" locale 下isalpha对它们一律返回假,会被计入"其他字符"。这不是判断逻辑错了,而是遍历的粒度不对——按字节遍历本来就无法正确处理多字节字符。如果需求是"统计有多少个字母",且文本里混有中文,那么先用合适的库把文本解码成宽字符再判断,才是正确的做法。这一点我在处理日志分析脚本时踩过一次,统计出来的字符总数比文件大小还小,排查半天才发现是把多字节序列的续字节当成了独立字符。
6. 一段统计程序为什么少数了几个字母:完整排查链路
6.1 症状描述与第一轮怀疑
有个同学写了个统计程序,读入一个文本文件,输出大写字母、小写字母和其他字符的数量,要求三个数加起来等于文件总字节数。他跑了一段纯英文文本,发现大写加小写加其他,比wc -c的结果少了几十个。他的第一反应是判断函数写错了,把isupper换成手写区间比较,结果一样少。于是开始怀疑是不是文件读取有问题。
这段代码大致是这样:
FILE *fp = fopen("input.txt", "r"); char ch; while ((ch = fgetc(fp)) != EOF) { if (isupper(ch)) upper++; else if (islower(ch)) lower++; else other++; }我让他先把总数打印出来,看是不是读取阶段就少了。加一行total++,结果总数确实比文件大小少。到这一步可以确定:问题不在字符分类,而在读取循环提前结束了。
6.2 逐层定位:从 char 到 EOF 的那次隐式转换
问题出在char ch和fgetc的返回值上。fgetc返回int,正常字节值范围是 0 到 255,文件结束时返回 EOF,通常是 -1。把返回值赋给char类型的变量时,会发生截断和符号转换。
假设文件里有一个字节的值是 0xFF(比如某种编码下的特殊符号,或者文件本身就是二进制)。在char默认为有符号的平台上,0xFF 赋给char之后变成 -1。接下来判断ch != EOF,而此时ch的值恰好也是 -1,条件不成立,循环在这里就退出了,后面的内容全部没读到。
这解释了为什么"少的数量"和文件内容有关——少掉的是从第一个 0xFF 字节开始往后的所有内容,而不是固定值。
验证方法很直接:把char ch改成int ch,重新编译运行,总数立刻对上了。整个过程不需要调试器,只需要意识到char和int在这里不是可以随便互换的类型。
6.3 修复与回归验证
修复后的版本:
int c; while ((c = fgetc(fp)) != EOF) { total++; if (isupper(c)) upper++; else if (islower(c)) lower++; else other++; }改完之后要做三组回归验证,不能只看一组数据通过就收工:
第一组,纯 ASCII 文本,包含大写、小写、数字、标点、空格、换行,手工数一遍各有多少个,和程序输出对照。这一组验证分类逻辑。
第二组,包含高位字节的文件,比如一张小图片或者一段 UTF-8 编码的中文文本。验证总数是否和文件大小一致。这一组验证读取循环不再提前退出。
第三组,空文件。验证程序不会卡住也不会有奇怪的输出,各计数都是零。这一组验证边界条件。
三组都过,这段代码才算稳。我在实际项目里见过太多"改完主逻辑就提交"的情况,结果边界条件在线上环境炸出来,排查成本比一开始就测高三倍。
6.4 顺带踩到的几个小坑
在排查这段程序的过程中,还发现了几处小问题,值得一并记下来。
第一处是isupper(c)的调用。修复后的版本里c是int,值域是 0 到 255 或 EOF,直接传参是安全的。但如果有人后续把循环改成从char数组里遍历,比如for (char *p = buf; *p; ++p) if (isupper(*p)),那就又把负数传进去了。所以我在代码评审时,看到isupper的参数是解引用的char *,一律要求改成unsigned char *或者加显式转换。
第二处是文件的打开模式。用"r"打开在某些平台上会做换行符转换,导致统计的字节数和实际文件大小对不上。如果要按字节精确统计,应该用"rb"以二进制模式打开。这个问题在 Windows 上尤其明显,因为\r\n会被转换成一个\n,总数自然就少了。这一点不影响字符分类的正确性,但会影响总数校验。
第三处是fopen的返回值没有检查。如果文件不存在,fp是空指针,fgetc直接崩溃。加三行判断就能避免:
FILE *fp = fopen("input.txt", "rb"); if (fp == NULL) { perror("input.txt"); return 1; }这几行代码看着琐碎,但它是"程序能不能给出有用错误信息"和"直接段错误"的分界线。
最后一处,是关于toupper和tolower的返回值。如果程序要做整段文本的大小写转换并写回文件,用fputc(toupper(c), out)是安全的,因为toupper返回int,fputc也接受int。但如果中间用char存了一下,比如char u = toupper(c);,那在极端情况下会被截断。虽然 ASCII 范围内不会出问题,但既然能写成不用中间变量的形式,何必给自己留个隐患。
排查这类问题的经验可以浓缩成一句话:凡是接getchar或fgetc返回值的地方,变量类型必须是int;凡是往ctype.h函数里传字符的地方,参数必须是unsigned char转出来的值或者EOF。这两条守住了,字符处理代码里八成的诡异问题都不会出现。剩下的两成,多半是缓冲区里残留的换行符在捣乱,而这个只要在读字符前多想一步"上一次输入有没有留下什么",也能避开。