类别码与 \makeatletter

美元符号本身并没有开启数学模式的能力。把这个职责交给 !!x_1! 就会被当成公式排出来、一个错误也不报——因为决定角色的不是字符本身,而是它的 类别码(catcode),即TeX 给每个读入字符所附的 0 到 15 的编号。这一个编号就能解释:为什么 LaTeX 的内部命令里到处是 @\makeatletter 究竟做了什么,以及 % 为什么会一声不吭地吞掉整行的剩余部分。本页梳理全部十六个类别码、改写它们的原语 \catcode\makeatletter 只有一行的真身,以及如何用 \string\meaning\detokenize 看清一个记号的本来面目。

十六个类别码,以及默认哪个字符属于哪一个

类别码共有 十六种,编号 0 到 15,TeX 在把源码切成 记号(token) 的阶段使用它们——那是词法分析,远早于任何排版工作。一个字符是命令的开头、是打开分组,还是仅仅一点墨迹,全由这个编号决定。日常会留意的是:开始命令的 0、分组的 12、数学的 3、表格单元格分隔的 4、构成段落的行尾 5、宏参数标记 6、上下标的 78、组成正文的 101112,以及注释的 14

catcode角色默认字符(LaTeX 文档内)
0转义(命令的开始)反斜杠 \
1开始分组左花括号 {
2结束分组右花括号 }
3数学模式切换美元符号 $
4对齐制表(表格单元格分隔)和号 &
5行尾(空行形成新段落)回车(字符 13)
6参数(宏的参数)井号 #
7上标插入符 ^
8下标下划线 _
9被忽略(当作不存在跳过)在 LaTeX 中默认没有任何字符属于它
10空格空格与制表符(字符 9)
11字母(可组成命令名)az, AZ
12其他(纯粹的墨迹)数字、标点、@ 及其余全部
13活动字符(字符本身即命令)波浪号 ~ 与控制字符 1–31
14注释(丢弃该行剩余部分)百分号 %
15无效字符(会报错)空字符(0)与删除字符(127)

请留意第 9 行。多数资料写「catcode 9 是空字符」,但那说的是 裸 TeX(IniTeX)。在 TeX Live 2024 的 LaTeX 中扫描全部 256 个字符,会发现 没有任何一个字符的 catcode 是 9latex.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——这套机制的妙处,正在于一行代码就能证明这一点。

document.tex
\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

document.tex
% 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,所以在宏包内部这些名字能被解析成单个命令。

latex.ltx
% 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 中至今保留的注释说这个缩写「节省宏空间和时间」,那是内存珍贵年代的遗物,却原封不动地一直运行到今天。

error
! 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

document.tex
\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 里写下的重定义会在下一个版本悄悄失效。

为什么 \verblistings 必须动 catcode

要原样打印源码,只有一条路:让特殊字符不再特殊\verb 的定义正是这么做的,verbatim 环境则对整块内容做同样的事。它对 \dospecials 列出的十一个字符——空格、\{}$&#^_%~——施加 \@makeother,把它们统统降为 catcode 12(其他)。接着 \@noligs 反其道而行,把另外六个字符设为 catcode 13(活动字符):反引号、<>,、撇号和 -,以免两个反引号被悄悄合成弯的开引号。也就是说,\verb 同时在降低和抬高 catcode;由于整体包在 \bgroup 中,结束时一切会自动还原。

latex.ltx
\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 操作,正是为了承担这一类问题。

document.tex
% 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 Athe letter A\meaning 7the character 7
\string把命令拆成字符(含 \\string\frac\frac
\detokenize把整个参数变成可打印文本\detokenize{\frac{1}{2} #1}\frac {1}{2} ##1
\show把定义写入日志并暂停\show\LaTeXmacro:->\protect \LaTeX

最后说一点实务上的直觉。遇到「偏偏只有这个字符不对劲」的问题,先用 \the\catcode 查一下它的编号。babelcsquoteslistingshyperref 这类宏包都会为了各自的目的把特定字符设为活动字符或降到 catcode 12。如果症状局限在某一个字符上,十有八九是类别码在作怪。 一旦确认,修法就收敛为三选一:改变载入顺序、避开该字符,或使用宏包已提供的正规出口(\%、给 \verb 换一个定界符、\lstinline)。