TeX 中的宏展开

本文主要介绍 $\TeX$以及$\LaTeX{} 3$中的宏及其展开相关内容,主要参考了高德纳的 $\TeX{}book$以及$\LaTeX{} 3$手册。

TeX 编译原理

我们首先介绍一下 $\TeX$ 是如何从输入产生文章的。 作为基于宏定义的“排版”语言,和常见的指令式语言不同,其编译过程更像是 LISP 等函数式编程语言。 它的编译过程大概分为以下两步:

  • 词法分析:将字符输入转化为词元(Token)。这个过程经常被称作其“眼睛”。
  • 宏展开:将可展开的宏按一定规则进行展开,直到所有宏都不能继续展开。这个过程有时被称作“嘴巴”。
  • 产生输出:不能展开的宏通常具有一定的语义(比如\vskip),这些宏将在这一步指导全文件的排版。这个过程经常被称作“胃”。

TeX 中的词法分析

首先介绍一下 $\TeX$ 中的词法分析。 $\TeX$将任何输入的字符分为 16 类,其中每一类都具有不同的含义。 我们目前主要关心的是以下几类:

  • 转义符(第零类):标记接下来的字符具有特殊的含义,默认为反斜杠(\)。
  • 组开始(第一类)和组结束(第二类):标记分组的开始与结束。默认为正反花括号。
  • 参数标记(第六类):标记宏替换中的参数,默认为井号(#)。
  • 空白符(第十类):空格等空白符。
  • 字母(第十一类):英文所用的拉丁字母,包括(a-zA-Z)。
  • 其他字符(第十二类):没有特殊含义,也不是字母的字符,包括数字和没有语义的标点等。

在$\TeX$中,输入的语义是由其字符类别确定的。 这就意味着,只要通过一定的方法设定字符的类别,那么这些字符就能发挥对应字符类别的作用。 譬如,如果将字符/的类别设为零,那么这个字符也可以起到和默认的反斜杠一样的作用。 因此,$\TeX$中的词元分析的中心并非字符,而是字符的类别码。 当然,出于简洁考虑,之后我们都使用默认的字符指代该类别码的词元。

词元分析的另一个重点是处理转义符和控制序列(Control sequence)。 控制序列是以转义符(默认为\)开始的、以一系列字母或一个其他字符组成的单个词元。 如果该控制序列含有多个字符,那么也称为控制词。 控制词一般以空白结束,因此控制词后面的所有空白都会被词法分析消去,因此\TeX 产生$\TeX $ (注意没有空格)。 以单个字符组成的词元称为控制符,这种控制序列一般会取其后一个字符作为参数。 一些控制序列比较特殊,是由$\TeX$编译器内建并直接支持的,这些控制序列称为原语(Primitive)。

考虑以下 TeXBook 中的经典例子,若用户的输入为:

{\hskip 36 pt}

那么词法分析后会变为: \(\{_{1} \; \boxed{\text{hskip}} \quad 3_{12} \quad 6_{12} \quad \underline{\ \ }_{10} \quad p_{11} \quad t_{11} \; \}_{2}\) 其中$\text{hskip}$作为单个控制词出现。 这种被处理为词元的列表称为词元列表(Token list),作为 $\TeX$ 的基本数据结构之一被广泛使用。 词法分析将所有字符转化为词元,因此这一步也叫词元化或者分词(Tokenization)。

和大部分编译器一样,词法分析其后的阶段不会保存控制序列的完整字符信息,而是将其保存在符号表中,然后用索引(一般是该字符串的散列值)替代整个字符串。 不过这种实现细节不需要我们考虑。

之前中频繁提到“宏”这一概念,所谓的“宏”究竟是什么? 根据语境不同,“宏”可以指代下列事物:

  • 控制序列。如上文所述,任何以转义符(默认是反斜杠\)开头的单个词元都是控制序列,包括控制词(如\hskip\TeX)和控制符(如\')。
  • \def系列原语定义的一系列词元。在进行展开时,这些词元的某一部分可能会被参数替换。

这里我们主要使用第一种说法,即将其作为控制序列的同义词使用。

TeX 的宏展开

在通过词法分析发现控制序列之后,$\TeX$编译器就会尝试进行一系列特殊处理,包括主要包括查阅控制序列的定义、解析宏的参数和进行展开几个部分,本文将这一系列操作统称为宏展开。

这里值得注意的是这一系列操作发生的时机:词元化发生在宏展开之前。 $\TeX$编译器会先进行词元化(“眼睛”)将所有字符转化为词元,然后再从词元列表中进行宏展开(“嘴巴”),而宏展开本身又会修改词元列表,从而影响“胃”中的执行。 而某些宏的执行(例如新定义宏或设置字符的类别码)又会反过来影响词元化过程。 从这个意义上看,对整个文章的词元化、宏展开和执行三个步骤其实是“并发”执行的,只有在小的单元内部,这三个步骤才是有序的。

这三个步骤的顺序意味着这种宏

\def\docat #1{\catcode`\$=11 #1}
I paid \docat{$90} for that book.

无法通过编译: $\TeX$首先扫描到\docat,并决定其参数是$90。 但是这里$的类别码还没有被修改,因此编译器认为这是一个不完整的公式,并拒绝进一步编译。

宏的参数定义

在$\TeX$中,一个宏按以下方法定义:

\def <控制序列> <形式参数文本> {<替换文本>}

其中要求参数文本中不能含有花括号,而替换文本中的花括号必须成对出现。 形式参数文本可以含有从#1开始的参数,参数之间可指定分隔符。 在进行宏展开时,替换文本中对应的参数会被实际参数替代。 特别地,替换文本中的##会被替换为单个井号。

关于宏展开时参数的解析,$\TeX$遵守以下规则:

  • 若形式参数文本中,该参数后面紧接另一个参数或左花括号,那么说明该参数没有指定间界定符。这种情况下,后一个非空词元(若非左花括号)或左花括号包括的整个组(不包括包围的花括号)都将视为该展开的实际参数。
  • 若形式参数文本中,该参数后面还有其他文本,那么这些文本被视为该参数的结束符。此时实际参数被解析为以该结束符结束的最短的、花括号匹配的词元序列(不含结束符)。

特别地,除非通过\long\def特别指定,否则段落结束符\par不能出现在参数中,而通过\outer\def定义的宏则永远不能出现在参数中。 这是为了避免未输入界定符而使参数解析花费过多的内存,导致程序崩溃。

因此,对于宏定义

\def\cs AB#1#2C$#3\$ {#3{ab#1}#1 c##\x #2}

调用

\cs AB {\Look}C${And\$ }{look}\$ 5.

将被解析为:

  • #1:\Look(一个词元);
  • #2:(空);
  • #3:{And\$ }{look}(十三个词元)。

关于参数解析有一个特别的规则:若该参数是最后一个参数,并且结束符是#,那么实际上选择的结束符是左花括号{。 例如宏定义

\def\A#1#{\hbox to #1}

对于调用

\A3pt{x}

将被解析为

\hbox to 3pt{x}

注意两点:

  1. 控制词只能含有字母,因此数字3会被解析为参数
  2. 结束符选择为{,因此解析的参数是A到左花括号之间的3pt

宏的展开时机

$\TeX$中对可展开的宏的展开做了一些规定,我们主要关注以下几点:

  • 若是由\def等定义的带参数宏,那么首先确定其参数,然后将宏替换为替换文本,过程中不展开参数,也不展开替换文本。
  • 若是条件文本,那么进行对应的展开以计算条件,并将其替换为对应的\else\or\fi等包裹的词元,其余词元被忽略。
  • \csname ...\endcsname之中的所有宏都被展开至字符词元(十一类或十二类),然后变为单个控制序列词元;如果不能如此展开(比如完全展开后还有不能展开的宏),则发生错误。以这种方式创建的控制序列不受没有空白符或只能有一个十二类词元的限制。
  • \expandafter<词元>:读入并保存此词元,然后读入该词元后面的词元,并可能进行展开;其后的词元完成处理后,将保存的词元插入前方,然后重新开始宏展开以及其后的处理。若两个词元均是可展开的控制序列,那么效果等同于先展开后面的控制序列,再展开前面的控制序列。
  • \noexpand<词元>:读入此词元,若该词元是要展开的控制序列,那么不进行展开,而是等同于\relax,即一个不可展开的控制序列。

同时,若处于以下情形中,可展开的宏不会进行任何展开:

  • 读入宏的参数时。如上文所述,参数不进行展开。
  • 读入\let\def等定义的控制序列时。
  • 读入\def等的形式参数文本时。
  • 读入\def\gdef的替换文本时,但\edef的替换文本会被展开。
  • 读入\expandafter\noexpand等后面的词元时。

譬如,考虑以下文件

\def\a{aaa}
\uppercase{\a}

其输出为

aaa

这是因为\a作为参数不进行展开。 \uppercase是一个$\TeX$原语,只会将其后的花括号括起的词元列表中的字母转化为大写字母。 由于\a没有展开,因此其词元列表中不包含任何小写字母,这就不会发生转换。 正确的做法是:

\def\a{aaa}
\uppercase\expandafter{\a}

另一方面,若我们需要控制\edef中的展开顺序,让其延迟展开,则可使用\noexpand

\def\prefix{pg.}
\edef\page{\noexpand\bf\prefix\noexpand\thepage}
\def\prefix{}

LaTeX 的展开顺序控制

重新考虑上面一个例子,若我们希望实现

\def\d{d}
\uppercase{abc\d}

输出

ABCD

要如何实现? 使用\expandafter,我们不得不这样写:

% 插入了换行以提高可读性
\uppercase\expandafter{
    \expandafter a
    \expandafter b
    \expandafter c
    \d
}

如果我们有特别的需求,需要让\a\b\c\d三个宏逆序展开,正确的写法是:

\expandafter\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter\a
\expandafter\expandafter\expandafter\b
\expandafter\c
\d

显然这种方法并不实用。

Expl3 语法

$\LaTeX 3$提供了一系列机制来简化宏的展开顺序问题。 首先,其将控制序列分为变量和函数,后者可取参数,而前者不能。 为控制宏展开的顺序,其中最中心的机制是函数的变体(variants)。

在$\LaTeX 3$中,每个函数除了名字之外还有参数说明,比较常见的有以下几种:

  • Nn:不进行任何展开。前者取一个词元作为参数,后者取花括号中的词元列表作为参数。
  • c:控制序列名。这个参数将被转换为单个控制序列词元,类似\csname
  • Vv:将变量展开至其值。前者取一个控制序列词元(如\Variable),后者取花括号中的词元列表,并将其转化为控制序列词元(如{Variable})。
  • o:展开一次。
  • x:完全展开,如同\edef一样。含有这种参数的函数是不可展开的。推荐使用e而非x
  • e:完全展开,如同\expanded(这是一个$\epsilon\TeX$原语)一样。含有这种参数的宏可能可以展开。

$\LaTeX 3$中的类别代码经过了修改,因此控制序列中可以出现下划线,且空白符不再影响词法分析。

这些变体可配合以下函数使用:

  • \exp_not:N及其变体:即使出现在xe指定的参数列表中,也停止展开。等同于$\TeX$原语\noexpand
  • \exp_args:Nn...n及其变体:取下一个控制序列名,让后将其参数按指定方式展开。
  • \cs_generate_variant:Nn:为指定函数生成变体。该函数的被修改的参数必须具有N或者n的形式。

这些函数配合使用,几乎可以解决所有$\TeX$中的问题。 以之前的大写问题为例,现在可以写:

\ExplSyntaxOn
% 定义一个新的词元列表
\tl_const:Nn \abcd {abcd}
% 定义新的函数及其变体
\cs_new:Npn \my_uppercase:n #1 {\uppercase{#1}}
\cs_generate_variant:Nn \my_uppercase:n {e}

\exp_args:NV \uppercase \abcd
\exp_args:Ne \uppercase {abcd\abcd}
\my_uppercase:e {abcd\abcd}

更新时间: