Peter_Matthew的博客

字符组——从语言标准看计算机发展

2025-03-20

本文共3k字,大约需要阅读11分钟。

翻开2024年10月发布的C语言标准 ISO/IEC 9899:2024 和C++语言标准 ISO/IEC 14882:2024 ,对比1999年版的C语言标准 ISO/IEC 9899:1999 和1998年版的C++语言标准 ISO/IEC 14882:1998 ,我们会发现一个已经彻底从C语言和C++语言弃用的特性——三字符组(Trigraph Sequence)。

这是一个冷门的特性,也因冷门而退出了历史的舞台。

背景:硬件限制与国际化需求

在ASCII(美国信息交换标准代码)发布后,ISO(国际标准化组织)和IEC(国际电工委员会)便开始着手制定一份能够国际化推行的一套字符集标准。

1972年, ISO/IEC 646 标准发布。由于当时硬件的限制,字符集一般只能在一字节大小内编码。这是一个7比特的字符集标准,参考了许多国家的国家标准,但主要依照ASCII制定。采用7比特一是因为一字节的8个比特中,往往使用一位比特作为符号位;二是因为ASCII等许多国家的标准已经采用了7比特的字符集,而8比特的字符集尚未得到普遍接纳。

7比特字符集总共有128个码位可供使用。ISO/IEC 646 确定了33个用于控制的符号、10个数字、26个大写字母和26个小写字母对应的码位,一共95个普遍无争议的码位。在此基础上,ISO/IEC 646 又确定了21个符号的码位,但由于国家之间符号的形状差异,各国可以按照实际需要进行修改。

为了表示欧洲等地区各种语言的带附加符号的变音字母,ISO/IEC 646 选择不浪费码位,而是采用组合的方式输出:首先写一个普通字母,然后跟一个退格键符(控制符BS,码位0x08),然后再跟一个兼作附加符号的符号。在电传打字机上,可以利用该方法通过在一个位置叠加两个字符达成效果。这些兼作附加符号的符号是:

符号 码位 附加符号
双引号 码位0x22 兼作分音符
单引号/撇号 码位0x27 兼作尖音符
逗号 码位0x2C 兼作软音符
脱字号 国别用途码位0x5E 兼作扬抑符
反引号 国别用途码位0x96 兼作重音符
波浪号 国别用途码位0x7E 兼作颚化符

下面是使用组合方式输出的效果:
组合方式效果

最后, ISO/IEC 646 保留了12个码位作为国别用途码位。这12个码位在ASCII中对应的字符分别是 #$@[\]^`{|}~

也许你会注意到,这12个字符中有9个字符是编程常用的字符:

符号 名称 码位 功能
# 井号 国别用途码位0x23 常用于宏定义等功能
[ 左方括号 国别用途码位0x5B 常用于数组
\ 反斜杠号 国别用途码位0x5C 常用于字符转义
] 右方括号 国别用途码位0x5D 常用于数组
^ 脱字号 国别用途码位0x5E 常用于异或运算等
{ 左花括号 国别用途码位0x7B 常用于定义块来隔离
| 分隔符号 国别用途码位0x7C 常用于或运算、逻辑或运算等
} 右花括号 国别用途码位0x7D 常用于定义块来隔离
~ 波浪号 国别用途码位0x7E 常用于取反运算等

然而不是所有国家都会为了编程而将自己国家的字符集设定的和ASCII一致。

  • 英国标准 BSI 4730 、法国标准 AFNOR NF Z 62010-1982 、爱尔兰标准 NSAI 433:1996 等标准将0x23用于英镑符号 来同时使用美元符号和英镑符号。
  • 丹麦标准 DS 2089 、挪威标准 NS 4551 、瑞典标准 SEN 85 02 00 、加拿大标准 CSA Z243.4-1985 等标准将0x5B到0x5E视为大写字母的延续区、将0x7B到0x7E视为小写字母的延续区,把无法通过 ISO/IEC 646 提供的组合方式输出的字符(例如 ÆØÅ 和对应的小写 æøå )和经常使用而组合方式输出过于繁琐的字符(例如 ÉÄÖÅÜ 和对应的小写 éäöåü )放到这一区域。
  • 日本标准 JIS C 6220-1969 将0x5c用于人民币和日元符号 ,韩国标准 KS C 5636-1989 将0x5c用于韩元符号

上述国家的字符集设定由于和ASCII不一致,导致国家之间的键盘布局有较大差异。例如,英国、法国、爱尔兰等将0x23定义为其他字符的国家难以输入井号 #

起源:替代方法解决问题

为了解决该问题,C/C++标准定义了字符组。

三字符组

在 ISO/IEC 9899:1999 和 ISO/IEC 14882:1998 中,定义了九种三字符组,覆盖了上述常用而可能在一些国家难以输入的符号。这些三字符组是:

三字符组 替代为
??= #
??( [
??/ \
??) ]
??' ^
??< {
??! |
??> }
??- ~

同时,还给出了两个示例:

例子1:

1
??=define arraycheck(a, b) a??(b??) ??!??! b??(a??)

将会在编译时被替换为:

1
#define arraycheck(a, b) a[b] || b[a]

例子2:

1
printf("Eh???/n");

将会在编译时被替换为:

1
printf("Eh?\n");

如果需要使用连续的两个问号,可以用字符串自动连接 "...?""?..." 或者使用转义序列 "...?\?..."

虽然这种设计解决了符号的输入问题,但显然导致了代码可读性严重下降。大量使用三字符组会导致代码的逻辑结构难以直观理解。

此外,由于三字符组在源文件的任何位置出现都会被替换,还会产生编程陷阱。

1
printf("Code:1(Do you forget it???)\n");

原计划,该语句应该输出的是 Code:1(Do you forget it???) 然而实际上却会输出 Code:1(Do you forget it?]

1
2
a+=b; //Why add this???/
b+=a;

原计划,这段应该执行两行语句,而由于 ??/ 会被替换为 \ 导致一行注释变成多行注释,使得第二行的语句不会被执行。

双字符组

为了解决可读性的问题,ISO和IEC引入了双字符组。

双字符组 替代为
<: [
:> ]
<% {
%> }
%: #
%:%: ##

此时,第一个例子就可以修改为:

1
%:define arraycheck(a, b) a<:b:> ??!??! b<:a:>

将会在编译时被替换为:

1
#define arraycheck(a, b) a[b] || b[a]

相较于三字符组来说,双字符组的可读性显然提升。

同时,双字符组不同于三字符组。双字符组如果出现之字符串字面值、字符常量、程序注释中(如: "Symbol %:Used In Modulo Operation." ),则不会被替换。此外,双字符组替换仅在编译器对源程序的tokenization阶段(即识别出关键字、标识符等,类似于自然语言的“断词”)发生,仅当双字符组作为一个token或者token的组成部分时(如 %:%: 被替换为预处理运算符 ## )双字符组才会被替换。

C++的增补关键字

C++除了支持上述的三字符组与双字符组外,还提供了下列内置的关键字:

关键字 等价于
and &&
bitor |
or ||
xor ^
compl ~
bitand &
and_eq &=
or_eq |=
xor_eq ^=
not !
not_eq !=

此时,第一个例子就可以修改为:

1
%:define arraycheck(a, b) a<:b:> or b<:a:>

将会在编译时被替换为:

1
#define arraycheck(a, b) a[b] or b[a] //or 与 || 等价

通过使用关键字,程序的可读性显然又能提升。

发展:万国码和兼容性差异

随着时代的发展,一些原有的硬件限制已不再是问题,而字符组的问题也随之暴露。

万国码的发展

ISO和IEC后来发布了 ISO/IEC 8859 用于替代 ISO/IEC 646 ,这是一系列8比特的字符集。ISO/IEC 8859 统一了此前各国各语言的单独编码的混乱局面,将ASCII的全部可打印字符放入通ASCII的码位(因此 ISO/IEC 8859 完全兼容7位的ASCII码),同时废弃了 ISO/IEC 646 使用的字母、退格键符和符号组合来表示变音字母的方法,而是在单独区域直接编码表示变音字母。

后来,该标准被 ISO/IEC 10646 (等价于Unicode) 替代。这是一个31比特的字符集。自此,由于编码不统一造成的编程字符无法输入的问题就被彻底解决了。

兼容性问题

由于一些跨平台的项目同时涉及GCC/G++编译器和Microsoft Visual C++编译器,但这两个编译器在字符组上的行为不一致,这会引起一些编译的问题。

三字符组

GCC/G++自C++17标准开始,编译器不再自动替换三字符组。如果需要使用三字符组替换,需要手动设定编译器命令行选项 -trigraphs

Microsoft Visual C++ 2010版开始,编译器不再自动替换三字符组。如果需要使用三字符组替换,需要手动设定编译器命令行选项 /Zc:trigraphs

同时,这两个编译器还提供了一个头文件 <iso646.h> 。这个头文件实际是一个空文件,如果编译器读到了源文件使用该头文件,则会自动进行三字符组替换。

双字符组

GCC/G++支持双字符组的替换,但是Microsoft Visual C++不支持双字符组替换。

C++的增补关键字

GCC/G++支持C++的增补关键字,但是Microsoft Visual C++编译器默认不支持C++的增补关键字。如果需要使用C++增补关键字,需要使用头文件 <ciso646> 。这个头文件实际是一个空文件,如果Microsoft Visual C++编译器读到了源文件使用该头文件,则会开启支持C++的增补关键字。

弃用:语言简化

GCC/G++和Microsoft Visual C++编译器都纷纷弃用了三字符组,减少了历史包袱,简化了编程语言。

这一变化,反映了编程语言设计范式的转变,即从“机器友好”到“开发者友好”。随着时代的发展,硬件限制已经几乎不存在了,一些入门困难的语言也随之退出了舞台,只有抛弃历史包袱,提升代码可维护性的语言才能在如今的时代继续焕发光芒。正是如此,抛弃三字符组是必然的。

此外,键盘不存在编程所需的字符的问题也随着越来越智能化的有代码补全功能的IDE所解决。

在开发者社群中,三字符组的弃用也引发了讨论,有部分开发者反对抛弃三字符组。对此,激进的C++语言选择抛弃来推动语言现代化;保守的C语言选择保留来支持遗留系统(比如一些嵌入式设备)。

总结:技术演进的启示

字符组的兴衰其实揭示了一个技术发展中的核心矛盾: 短期实用性长期可持续性 之间的平衡。其兴于早期硬件限制下的务实选择,其衰于现代语言进化的必然选择。

技术标准的价值不在于永恒正确,而在于动态适应人类认知与生产力的发展需求。

参考

ISO/IEC 标准文件 实体出版物:
ISO/IEC 9899:1999

ISO/IEC 9899:1999
ISO/IEC 14882:1998
ISO/IEC 14882:1998
ISO/IEC 9899:2024
ISO/IEC 9899:2024
ISO/IEC 14882:2024
ISO/IEC 14882:2024

中文维基百科:
ISO/IEC 646
ISO/IEC 8859
ASCII
通用字符集
C替代标记
三字符组与双字符组


知识共享许可协议

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏