为什么要引入转义机制

数据和控制在同一个信道内传输

引子

很久之前在一个遥远的地方有一家字符串电报公司,通过一根电缆把用户提供的任意字符串从 A 地电报站的逐字符传输到 B 地电报站。两地电报站的内部联络和控制协调也使用那同一根电缆。两地电报站私下约定,当 B 站从电缆收到 “debug” 这五个连续的字符时就要立即把 B 站此刻详细的运行调试信息回传给 A 站。

有好事的用户发现了这个不公开的约定,于是开始提交由 “debug” * N 结构的字符串给电报站传输。B 站每收到这个字符串中 “debug” 的一次重复就要回传一次调试信息,这过于频繁地发生让系统陷入瘫痪。电报公司的员工紧急召开会议。

第一个员工提出,A 站在收到用户字符串时,不要原样发送,对于用户字符串中所有连续出现的 “debug” 这五个字符,都给它塞一个前缀 “d”,将其替换为 “ddebug”。B 站在把收到的字符串给用户之前要先从左到右扫描一遍进行解析,对于每一个组 “ddebug” 六个字符的序列,都将其替换为 “debug” 这五个字符。A 站真的需要控制信息时,则不加前缀。这一变化并不影响正常用户的使用,电报公司的业务恢复了正常。

(平行世界的)第二个员工提出,A 站在收到用户字符串时,不要原样发送,将它封装起来。比如,用户想要发送 “debugdebug”,那 A 站就向线缆传输 “10 debugdebug”;B 站从线缆先读出一个自然数代表用户信息的长度,然后再依据这个长度读取出用户实际上要发送的字符串。A 站真的需要控制信息时,则向线缆传输 “debug …” 请求特定的调试信息,B 站可以轻松地把此类信息和用户信息区分开。电报公司的业务不仅恢复正常,控制协议还更高效了。

(平行世界的)第三个员工在 A 站和 B 站之间增加了一条线缆。用户提供的字符串在既有线缆上传输,控制信息在新线缆上同步传输,二者分离,干净地解决了问题。

带内信令(In-band signaling)

上述的电报系统中,一个关键的特征是数据和控制信号在同一个信道内传输,这又称为 “in-band signaling“(带内信令)。在这种条件下,要保证数据和控制信号互不干扰,有两种经典的对策:1) 对数据信号与控制信号相冲突的部分作变换(包括转义),或 2) 将数据信号封装起来。这两种对策分别对应第一个员工和第二个员工的方法。

字符串字面值 = In-band signaling

一个相关的问题是,如何在编程语言中表示字符串字面值(string literal)。如何将字符串字面值(数据)和其它源代码(控制)区分?特别是,实际中编程语言通常用引号、双引号等符号标志字符串的开始或结束(这里不妨假设只能用双引号),但字符串本身也可能含有双引号字符。如果字符串本身含有的双引号被识别为字符串的结束,那就麻烦了。至此,这个问题归结到了带内信令问题。

按照 “转义” 的办法,我们可以用反斜杠来转义字符串中自带双引号;对标记字符串开始和结束的双引号则不转义。具体而言,

  • 极简主义的方案是,程序员对目标字符串本身所含有的每个 " 垫一个反斜杠变成 \";程序解析器把每一个 \" 变回 ",把前面没有反斜杠的 " 视为字符串的结束。例如,(1) 用 s="\"" 表示字面值 ",(2) 用 s="\\"" 表示字面值 \"。这个方案虽然最简洁,但在前面举的一组例子中,解析器从左到右读取到第一个 \ 时,它作为字面字符 (2) 还是转义序列的开始 (1),与它后面的字符有关。这不是一种前缀码(prefix-code),不方便解析。
  • 保证源程序仍然是前缀码的方案,把 \" 视为一个代表字面值 " 的双字符转义序列,增加一个新的双字符转义序列 \\ 代表字面值 \。这样解析器在转义序列外读到 \ 时,就可以放心地断言,这个 \ 代表转义序列的开始而非字面值,后面一定跟着一个转义序列。可以说,这个定义了两个双字符转义序列的方案是满足前缀码性质的最简单的转义机制了。

按照 “封装” 的办法,我们可以事先声明字符串中的字符数(类似数据包头) – 这样连标志字符串结束的双引号都变成多余的了。例如,用 s[10] = """Hello"""" 来表示内容为 ""Hello""" ,长度为 10 的字符串。(类比第二个员工的方法)。不过我并不知道任何一种这样处理问题的编程语言。

另一个相关的问题是,本篇博文是如何表示字符串的 – 并没有用开始和结束字符,而是将字符串整体使用 “内嵌代码” 格式显示(当然也可以用颜色区别)。”内嵌代码” 格式和普通正文格式的区别相当于一条额外的信道,这里使用的是带外信令(out-of-band signaling)(类比第三个员工的方法)。当然,在源码层面,你也可以说这仍然是带内信令,只不过通过 backtick 标记了字符串的开始和结束。

小结

个人认为各种编程语言引入转义机制的根本原因,就是将其作为一种解决带内信令的手段,区分作为字面值的字符,和作为编程语言控制标志的字符。一个典型的例子是,Python 字符串中的 \" 表示 ",用 \' 表示 '

后面会看到,这不是引入转义机制的唯一原因 – 不然 “垫反斜杠” 就可以解决一切问题,转义机制不会是现实中那个样子。

实际编程语言的便利性考量?

下面以 Python 为例,它的字符串字面值对单引号、双引号和反斜杠的转义起到上述区分控制和数据的作用。

引入转义序列的另一个原因是表示键盘不方便输入、源文件不方便显示的字符——这主要是为了用文本编辑器编写程序的方便。最典型的例子包括:

  • ASCII Bell 字符,我不知道哪种键盘有对应这个字符的按键。因此它被转义为 \a 来输入。
  • ASCII Backspace 字符。键盘上确实有 “Backspace” 这个键即退格键,但当你在文本编辑器中,按下 “Backspace” 键,常见的编辑器不会在光标处插入一个 ASCII Backspace 字符,而是会帮你删除光标前面那个字符。因此 ASCII Backspace 被转译为 \b 来输入。

当然,如果你用 hexedit 编辑源代码,你也可以不用转义字符。但此时你的源代码会长成这个样子,print("abcabc"),在很多平台上不能正常显示。

转义序列让源代码得以用少数可见、惯用的 ASCII 字符表示更大的字符集,这让源代码得以安全通过 ASCII-only 的信道或进入 ASCII-only 的存储媒介。用一个小的符号集来编码一个大的符号集,这是很多转义系统的本质(这一点在 ANSI 转义序列 中体现地更明显)。尽管 Python 支持源代码中的 Unicode 字符,但仍然设计了用来表示 Unicode 字符的转义序列,想必也是出于这种原因:

print("转义")
print("\u8f6c\u4e49")

至此,我们可以回答标题中的问题了:

  • 理论上,一个语言,不论其符号集有多大,如果想要定义其自身构成的字面值,都要接解决带内信令的问题。转义就是一个解决方案。
  • 现实中和工程意义上,为了表示越来越多的符号,且不用每加入一个新符号就更新一次键盘布局,我们约定好一个代码 / 编码系统 (Code):实际输入和传输一个小符号集,而在语义上表示一个大符号集。

我们还发现转义系统是一种特殊的 Code

  • 一般见到的编码系统中,输入符号集一般很小,且通常不能直接字面地对应到输出符号集。以 Huffman Code 为例,实际输入和传输的是二元的 {0,1},用来表示任意内容。我们不期望 0 表示 “0”。
  • 在转义系统中,输入和传输的符号与语义上表达的符号大部分是重合的(即大部分符号不用转义)。只有少数符号需要被转义,它们是例外而非常态(要不然语言的使用者就要疯了)。这些例外被称为转义规则。
  • 形式上,有实际输入和传输的字符集 D 和额外的控制字符集 C,转义系统用字符集 D 上的序列表示 D ∪ C 中的符号。用编码理论的语言,转义机制是形为 D ∪ C → D* 的 Code。

Python 语言中转义的更多细节

阅读 Python 定义的转义序列,可以发现它们构成前缀码,parser 可以凭借前缀来判断一个字符是否处于转义序列中,或处于转义序列的什么位置。这从左到右扫描一次就足以解析源代码中的所有转义序列。当 parser 扫描到一个反斜杠,但下一个字符并不构成任何合法的转义序列,则会退出转义序列,原样打印这两个字符,并告警(之后的版本可能会报错

最后,有时你不需要表示更大的字符集,而是需要打印出字面意义上的转义字符序列比如 \a\b\u8fc6 。此时上述为便利性而设计的转移机制反而成了强加的障碍。Python 为此设计了原始字符串机制(raw string literal),其内不发生任何转义,仅规定当字符串内的单引号或双引号前面是反斜杠时,不可视为字符串的结束。

>>> print('\\a\\b\\u8fc6') # :(
\a\b\u8fc6
>>> print(r'\a\b\u8fc6') # :)
\a\b\u8fc6
>>> print(r'\'')
\'
>>> print(r'\')
  File "<stdin>", line 1
    print(r'\')
          ^
SyntaxError: unterminated string literal (detected at line 1)

转义和解析函数的复合和迭代

编写转义的操作和解析转义的操作可以看作对字符串进行变换的函数,因此它可以复合或者迭代。比如引子中 “塞前缀 d” 的方法,这家电报公司可以递归地使用自己的服务,将用户的 “mydebugger” 变成 “myddebugger”,变成 “mydddebugger”,再反方向解码回去 – 这是一个函数的迭代(iteration)。

复合

当有涉及多种嵌套的语言时,要进行编码/解析操作的复合(composition)。以在 Python 中使用正则表达式为例:我们向 REPL 或者源文件输入模式字符串 (pattern),要先经过 Python 对字符串的解析,再经过正则表达式对特殊字符的解析,这是一个复合。举一个刻意构造的例子,

>>> re.compile('\\ba\\b').search("take a bite")
<re.Match object; span=(5, 6), match='a'>
>>> s = '\\ba\\b'; print(s)
\ba\b

我们输入的原始字符串 \\ba\\b 被 Python 解析为 \ba\b,随后再被 Regex 解析为 “[边界]a[边界]”。使用 Python 原始字符串可以在绝大部分情况下忽略 Python 对字符串的解析。如果还不够,就加入 Python 字符串拼接机制:

>>> s = '\'".[]\\'
>>> print(s)
'".[]\
>>> p = '\'"' + r'\.' + r"\[\]" + '\\\\'
>>> print(p)
'"\.\[\]\\
>>> re.compile(p).match(s)
<re.Match object; span=(0, 6), match='\'".[]\\'>

迭代

再刻意构造一个例子,可以对下面的字符串用 HTML 规则迭代地解析(上一行渲染出来的结果复制,粘贴作为下一行的 HTML 源码):

&amp;lt;p&amp;gt;我们可以用&amp;lt;text style="color:red"&amp;gt;颜色&amp;lt;/text&amp;gt;来区分数据和控制。&amp;lt;/p&amp;gt;
&lt;p&gt;我们可以用&lt;text style=”color:red”&gt;颜色&lt;/text&gt;来区分数据和控制。&lt;/p&gt;
<p>我们可以用<text style=”color:red”>颜色</text>来区分数据和控制。</p>

我们可以用颜色来区分数据和控制。

解释:&amp; 解析到 &&lt; 解析到 <&gt; 解析到 >


Posted

in

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *