在 C 语言的老代码里,经常能看到这样一行:c = c | 0x20;。没有任何函数调用,也没有查表,一个位运算就把大写字母变成了小写。第一次见到这种写法的人往往会愣一下,然后意识到一件事——字母的大小写关系,被编码设计者安排得相当精巧。
一个位运算就能完成转换
先看两个具体的数值:大写 A 的 ASCII 码是 65,小写 a 是 97。两者相差 32。这个差值对大写 Z(90)和小写 z(122)同样成立,也对所有 26 个字母成立。
32 是 2 的 5 次方,也就是二进制里的第 6 位。把 65 和 97 写成二进制就一目了然:
65 = 0100 0001 (A)
97 = 0110 0001 (a)
↑
只有这一位不同
两个数的区别只在第 6 位,其他位完全相同。这个观察带来三种等价的操作方式:
c = c | 0x20; // 置第 6 位为 1,转小写
c = c & ~0x20; // 置第 6 位为 0,转大写
c = c ^ 0x20; // 翻转第 6 位,大小写互换
最后一种特别实用:它不需要知道当前是大写还是小写,直接翻转即可,两个方向共用一段代码。
为什么设计者要这样安排
这不是巧合,而是刻意的工程取舍。
在 ASCII 制定之前,字符编码的常见载体是打孔卡片和纸带。同一个字母的大小写如果在编码上相距很远,机械装置和译码电路就要额外增加判断逻辑。把大小写放在只差一位的位置上,等于把「切换大小写」这件事简化成了一次位翻转,硬件成本立刻降下来。
还有一层考虑与排序有关。把大写字母放在 65 到 90、小写字母放在 97 到 122,两个区间各自连续,使得按码值排序时同类字符能聚在一起。同时大写整体排在小写之前,也符合当时常见的展示约定。
顺便说一句,差值选 32 而不是别的数,也和空格有关:空格恰好是 32。在早期的终端操作里,大写字母的码值加上 32 就能得到小写字母,这个 32 与空格共用同一个数值,让一些字符处理程序写起来更紧凑。
实际代码里该怎么用
理解了原理,回到日常开发,这里有几个值得注意的点。
第一,如果只是做「忽略大小写的比较」,不要自己用位运算去处理,直接用语言提供的比较函数。原因在下一个问题里。
第二,不要用加减 32 处理非 ASCII 字符。这个规律只对 A—Z 与 a—z 成立。带音标的字母、西里尔字母、希腊字母、汉字都没有这种简单关系。对它们做位运算,结果不是大小写转换,而是产生一个完全不相干的字符。
第三,注意语言和地区的差异。土耳其语里字母 I 的小写不是 i 而是 ı(无点 i),i 的大写不是 I 而是 İ。如果用「先统一转小写再比较」的写法处理土耳其语环境下的字符串,会出现意料之外的匹配失败。涉及用户可见文本的大小写转换,应该使用带 locale 参数的转换函数。
第四,做字符分类时,可以用码值范围快速判断,但要注意区间边界:A 是 65,Z 是 90,a 是 97,z 是 122。写成 c >= 65 && c <= 90 这样的判断比正则更快,但可读性差,建议加注释或定义常量。
32 这个数字还出现在别处
如果留意 ASCII 表,会发现 32 这个数字出现的频率很高,而这些出现之间是有关联的。
空格本身的码值就是 32。大写字母加 32 得到小写字母。0 到 31 这 32 个位置留给控制字符,恰好构成一个整齐的区间。可打印字符从 32 开始,也正好接在控制字符之后。
这种「同一数值在不同位置复用」的现象,是 ASCII 设计紧凑性的体现:它在只有 128 个位置的预算里,尽量让各种关系变得规整,从而让实现更简单。
检查清单
- 大写与小写字母相差 32,即二进制第 6 位的差异
- 转小写用
| 0x20,转大写用& ~0x20,互换用^ 0x20 - 这个规律只适用于 ASCII 的 26 个字母,不要用于非 ASCII 字符
- 忽略大小写比较优先用语言内置函数,注意土耳其语等 locale 差异
- 用码值范围做字符判断时,记住边界是 65、90、97、122
- 空格码值同为 32,控制字符共 32 个,这些数字的重复不是偶然