美元符号本身并没有开启数学模式的能力。把这个职责交给 !,!x_1! 就会被当成公式排出来、一个错误也不报——因为决定角色的不是字符本身,而是它的 类别码(catcode),即TeX 给每个读入字符所附的 0 到 15 的编号。这一个编号就能解释:为什么 LaTeX 的内部命令里到处是 @,\makeatletter 究竟做了什么,以及 % 为什么会一声不吭地吞掉整行的剩余部分。本页梳理全部十六个类别码、改写它们的原语 \catcode、\makeatletter 只有一行的真身,以及如何用 \string、\meaning、\detokenize 看清一个记号的本来面目。
十六个类别码,以及默认哪个字符属于哪一个
类别码共有 十六种,编号 0 到 15,TeX 在把源码切成 记号(token) 的阶段使用它们——那是词法分析,远早于任何排版工作。一个字符是命令的开头、是打开分组,还是仅仅一点墨迹,全由这个编号决定。日常会留意的是:开始命令的 0、分组的 1 和 2、数学的 3、表格单元格分隔的 4、构成段落的行尾 5、宏参数标记 6、上下标的 7 和 8、组成正文的 10/11/12,以及注释的 14。
| catcode | 角色 | 默认字符(LaTeX 文档内) |
|---|---|---|
0 | 转义(命令的开始) | 反斜杠 \ |
1 | 开始分组 | 左花括号 { |
2 | 结束分组 | 右花括号 } |
3 | 数学模式切换 | 美元符号 $ |
4 | 对齐制表(表格单元格分隔) | 和号 & |
5 | 行尾(空行形成新段落) | 回车(字符 13) |
6 | 参数(宏的参数) | 井号 # |
7 | 上标 | 插入符 ^ |
8 | 下标 | 下划线 _ |
9 | 被忽略(当作不存在跳过) | 在 LaTeX 中默认没有任何字符属于它 |
10 | 空格 | 空格与制表符(字符 9) |
11 | 字母(可组成命令名) | a–z, A–Z |
12 | 其他(纯粹的墨迹) | 数字、标点、@ 及其余全部 |
13 | 活动字符(字符本身即命令) | 波浪号 ~ 与控制字符 1–31 |
14 | 注释(丢弃该行剩余部分) | 百分号 % |
15 | 无效字符(会报错) | 空字符(0)与删除字符(127) |
请留意第 9 行。多数资料写「catcode 9 是空字符」,但那说的是 裸 TeX(IniTeX)。在 TeX Live 2024 的 LaTeX 中扫描全部 256 个字符,会发现 没有任何一个字符的 catcode 是 9:latex.ltx 把空字符移到了 15(无效),而把 9 空着——它是十六个类别中唯一没有默认住户的一个。只有自己动手分配字符,它才开始工作。listings 宏包正是这么做的:在读取自身源码期间把制表符、换页符和回车降到 catcode 9,从而直接跳过它们。另一端,控制字符 1–31 被设为 活动(13),则是现代 LaTeX 处理 UTF-8 输入的机制。
用 \catcode 改写字符的角色
字符与类别的对应并非固定,原语 \catcode 可以改写它。写法是「\catcode 之后跟一个反引号、目标字符、=、新的编号」;读取则只需在 \the\catcode 后接同样的反引号和字符。下面这段代码把数学模式切换的职责(catcode 3)交给了 !:错误为零,!x_1! 和 !\frac{a}{b}! 都被正确地排成公式。$ 之所以能开启数学模式,不是因为它是美元符号,而是因为它的 catcode 是 3——这套机制的妙处,正在于一行代码就能证明这一点。
\documentclass{article}
\begin{document}
\catcode`\!=3 % hand the math-shift job to "!"
!x_1! and !\frac{a}{b}! % typeset as mathematics, zero errors
\catcode`\!=12 % give it back to "other"
Back to normal!
% read a catcode back
\the\catcode`\$ % 3
\the\catcode`\% % 14
\the\catcode`A % 11
\end{document}不过,这是一件 过于有效 的工具。类别在分词时就被冻结,因此从改写的那一刻起,之后读入的一切含义都会改变;忘记恢复,坏掉的往往是毫无关联的地方。而且这种改写在离开分组({ } 或环境)时会自动还原,于是又会产生「导言区有效、正文却无效」的困惑。请把裸写 \catcode 当作最后手段,只在无法套用下文 \makeatletter 这类固定包装时才用。当然,宏包本身经常这么做:载入 babel 的德语选项后," 会变成 catcode 13(活动字符),于是 "a 得到 ä,双引号加反引号得到 „。德语文档里引号行为忽然改变,原因就在这里。
% 为何一声不吭地吃掉一行,& 为何报错
答案就在 catcode 的数值本身。% 的 catcode 是 14——意思是「从这里到行尾全部丢弃」——而且它 从不报告自己丢弃了什么。所以写下 Only 50% of the sample survived.,下一行接 The rest did not.,输出会是 Only 50The rest did not.,错误和警告都是零。正文少了一行,日志却一片寂静。这是整个 LaTeX 中最难发现的事故之一;正文里的百分号务必转义为 \%。相比之下,& 的 catcode 是 4(表格单元格分隔),写在表格之外会立刻得到 ! Misplaced alignment tab character &.。而 $ 的 catcode 是 3,忘记闭合就会得到 ! Missing $ inserted。
% catcode 14: everything after % on this line is discarded, silently
Only 50% of the sample survived.
The rest did not.
% output: "Only 50The rest did not." -- no error, no warning
% catcode 4 outside a table:
Smith & Sons % ! Misplaced alignment tab character &.
% the fix in running text:
Only 50\% of the sample survived.catcode 11 决定命令名在哪里结束
TeX 的 控制词(control word),也就是 \section 这样的具名命令,被识别为 一个 catcode 0 的字符(通常是 \)后接一串 catcode 11 的字符。通常只有 catcode 11 的字符能组成命令名,这正是 \section 的名字在 section 处结束的原因:紧随其后的空格或 { 不是 catcode 11。数字和标点是 catcode 12(其他),所以 \a2 会被读成「命令 \a 加上字符 2」。反过来利用这一点,就得到了关键的一步:把某个字符的 catcode 改成 11,它就能进入命令名。下一节 @ 的故事正是从这一步开始的。(控制词后空格消失、\LaTeXlogo 变成未定义命令等用户层面的后果,由「语法规则」页面负责。)
\makeatletter 是什么——真身只有一行
\makeatletter 所做的仅仅是把 @ 的 catcode 设为 11,配对的 \makeatother 也仅仅是把它设回 12。在 latex.ltx 中,两条定义各自就是一行,名字已经说明了一切:让 @ 成为字母,让 @ 成为其他。为什么需要它?因为 LaTeX 的内核与宏包里满是名字含 @ 的 内部命令:\@startsection(组装节标题)、\@ifnextchar(查看下一个记号以分支)、\@maketitle(标题区域),还有数以百计的其他命令。\usepackage 与 \documentclass 在读取 .sty 或 .cls 时会把 @ 切换为 catcode 11,所以在宏包内部这些名字能被解析成单个命令。
% the whole of \makeatletter and \makeatother, in the LaTeX kernel
\DeclareRobustCommand\makeatletter{\catcode`\@11\relax}
\DeclareRobustCommand\makeatother{\catcode`\@12\relax}
% two abbreviations you will meet in internal code (note the values)
\newdimen\p@ \p@=1pt % 1pt -- "this saves macro space and time"
\newdimen\z@ \z@=0pt % 0pt, and doubles as the integer 0在普通文档中,@ 的 catcode 是 12(其他)。因此在正文或导言区直接写 \p@,TeX 会读成「命令 \p 加字符 @」,并以 ! Undefined control sequence. 停下。错误显示在 l.3 Value: \p 处断开,下一行以 @ 开头——命令名在哪里被截断,是肉眼可见的,记住这个症状能大大加快诊断。顺带一提,\p@ 是 1pt,表示 0pt 的是 \z@,把两者弄混是常见的失误。latex.ltx 中至今保留的注释说这个缩写「节省宏空间和时间」,那是内存珍贵年代的遗物,却原封不动地一直运行到今天。
! Undefined control sequence.
l.3 Value: \p
@
The control sequence at the end of the top line
of your error message was never \def'ed.什么时候需要 \makeatletter,什么时候绝不能写
实用的判断只需一句话:只有在 .tex 文档中(几乎总是导言区)书写名字含 @ 的内部命令时才需要它。此外一律不写。尤其 不要写在 .sty/.cls 里面——读取这些文件期间 @ 已经是 catcode 11,因此没有必要,而多余的 \makeatother 还可能破坏后续处理。典型用途是在导言区对类定义的内部宏做小幅调整。下面的例子在包装内部重新定义了排布标题区域的内部宏 \@maketitle。
\documentclass{article}
\makeatletter % @ becomes a letter here
\renewcommand{\@maketitle}{% % redefine the internal title block
\begin{center}
{\LARGE\bfseries \@title}\par
\vspace{1ex}{\large \@author}\par
\end{center}%
}
\makeatother % ... and goes back to "other" here
\title{Category codes}
\author{A. Author}
\begin{document}
\maketitle
\end{document}陷阱有两个。第一个是 忘记写 \makeatother:漏掉它,@ 会以字母状态延续到后面的一切,破坏正文中的邮箱地址或任何特殊处理 @ 的宏包。请把它变成机械动作——用一个开,就用另一个关。第二个是没有先确认 能否完全不碰内部命令。如果可以用 \renewcommand 覆盖公开命令,或者已有正规宏包提供所需功能,那条路总是更安全。内部命令会随宏包更新而不打招呼地改变,你在 \makeatletter 里写下的重定义会在下一个版本悄悄失效。
为什么 \verb 与 listings 必须动 catcode
要原样打印源码,只有一条路:让特殊字符不再特殊。\verb 的定义正是这么做的,verbatim 环境则对整块内容做同样的事。它对 \dospecials 列出的十一个字符——空格、\、{、}、$、&、#、^、_、%、~——施加 \@makeother,把它们统统降为 catcode 12(其他)。接着 \@noligs 反其道而行,把另外六个字符设为 catcode 13(活动字符):反引号、<、>、,、撇号和 -,以免两个反引号被悄悄合成弯的开引号。也就是说,\verb 同时在降低和抬高 catcode;由于整体包在 \bgroup 中,结束时一切会自动还原。
\def\@makeother#1{\catcode`#1=12\relax}
\def\dospecials{\do\ \do\\\do\{\do\}\do\$\do\&%
\do\#\do\^\do\_\do\%\do\~}
\def\verb{\relax\ifmmode\hbox\else\leavevmode\null\fi
\bgroup % everything below is local
\verb@eol@error \let\do\@makeother \dospecials % all 11 -> catcode 12
\verbatim@font\@noligs % ` < > , ' - -> catcode 13
\language\l@nohyphenation
\@ifstar\@sverb\@verb}由此可以推出 \verb 那条著名的限制:\verb 不能用在其他命令的参数里。参数在宏被调用之前就已分词,等 \verb 去改 catcode 时早已太迟。\footnote{code: \verb|\foo_bar|} 会先给出 ! Undefined control sequence.(因为 \foo 被读成了真正的命令),再给出 ! Missing $ inserted.(因为 _ 被读成了下标);放进宏参数里还会附送 ! Extra }, or forgotten $.。出路是 listings 的 \lstinline:\section{A \lstinline|x_1| heading} 能零错误编译,甚至能原样进入目录。listings 内部四十多处 catcode 操作,正是为了承担这一类问题。
% BREAKS: the argument is tokenized before \verb can act
\footnote{code: \verb|\foo_bar|}
% ! Undefined control sequence. <argument> ... \verb |\foo
% ! Missing $ inserted.
% WORKS: \lstinline survives inside a moving argument
\usepackage{listings}
\section{A \lstinline|x_1| heading} % zero errors, reaches the ToC看清记号的真身 — \string、\meaning、\detokenize
如果类别码听上去仍显抽象,最快的解药就是 直接看一眼。\meaning 会把 catcode 用词语说出来:\meaning A 给出 the letter A(catcode 11,故称 letter),\meaning 7 给出 the character 7(catcode 12),\meaning\bgroup 给出 begin-group character {。一句话就说清了某个字符此刻的身份。\string 把命令拆成包含反斜杠的字符,\detokenize 把整个参数变成可打印的文本——输出中 # 变成 ##,是为了作为记号列表保持忠实。调试时 \show 也很好用:它把定义写进日志并暂停。
| 命令 | 作用 | TeX Live 2024 下的实测输出 |
|---|---|---|
\the\catcode | 以数值读出字符的 catcode | \the\catcode + 反引号 + $ → 3 |
\meaning | 用词语描述一个记号的身份 | \meaning A → the letter A;\meaning 7 → the character 7 |
\string | 把命令拆成字符(含 \) | \string\frac → \frac |
\detokenize | 把整个参数变成可打印文本 | \detokenize{\frac{1}{2} #1} → \frac {1}{2} ##1 |
\show | 把定义写入日志并暂停 | \show\LaTeX → macro:->\protect \LaTeX |
最后说一点实务上的直觉。遇到「偏偏只有这个字符不对劲」的问题,先用 \the\catcode 查一下它的编号。babel、csquotes、listings、hyperref 这类宏包都会为了各自的目的把特定字符设为活动字符或降到 catcode 12。如果症状局限在某一个字符上,十有八九是类别码在作怪。 一旦确认,修法就收敛为三选一:改变载入顺序、避开该字符,或使用宏包已提供的正规出口(\%、给 \verb 换一个定界符、\lstinline)。