\setcounter 与 \setlength 看着像一对孪生兄弟,行为却截然相反。把两者放进同一个分组里执行,离开分组后 计数器保留新值,长度却悄悄还原——原因是内核定义中只有其中一个带着 \global 这个词。本页讨论的是 LaTeX 的两种数值寄存器:保存整数、驱动章节与图表公式编号的 计数器(\newcounter),以及保存尺寸的 长度(\newlength,其真身并非 dimen 而是 skip 寄存器)。沿途还会看到:为什么紧跟 \stepcounter 的 \label 会悄悄引用别人的编号,\value 究竟展开成什么,以及如何用 \dimexpr、calc 和 \fpeval 做计算(包括 1in = 72.27pt、1pt = 65536sp 这类换算)。
用 \newcounter 创建并推进计数器
\newcounter{foo} 创建一个新计数器。名称是不带反斜杠的纯字符串,创建后的初值是 0。它的真身是 TeX 的 \count 寄存器——用 \meaning 查看,会得到类似 \count196 的答案。改变值的命令有三条:\setcounter{foo}{3} 赋值,\addtocounter{foo}{2} 相加(传负数即相减),\stepcounter{foo} 恰好加 1。可选的第二个参数 \newcounter{foo}[⟨parent⟩] 会让 foo 从属于 parent,于是 每当 parent 前进一步,foo 就归 0。小节编号在节改变时重新开始,机制正在于此。
\newcounter{trial} % created with value 0
\setcounter{trial}{5} % trial = 5
\addtocounter{trial}{-2} % trial = 3
\stepcounter{trial} % trial = 4, and every child counter resets
\newcounter{item}[section] % "item" restarts at 0 whenever section steps
% errors you will actually see
\setcounter{nosuch}{3} % ! LaTeX Error: No counter 'nosuch' defined.
\newcounter{trial} % ! LaTeX Error: Command \c@trial already defined.
\setcounter{trial}{} % ! Missing number, treated as zero.错误的原话值得记住。触碰不存在的计数器会得到 ! LaTeX Error: No counter 'nosuch' defined.;对同一个名字执行两次 \newcounter 会得到 ! LaTeX Error: Command \c@trial already defined.。这里点名的 \c@trial 正是真正保存 trial 的内部寄存器——名字里含 @,所以要从导言区直接触碰它就需要 \makeatletter。重置由内部宏 \@stpelt 负责,它的内容是先把子计数器设为 −1,再对它调用 \stepcounter。用「再推进一步」而非直接赋 0,正是让重置能一路传递到孙计数器的关键。
\stepcounter 与 \refstepcounter 的区别——当 \label 指向了别人的编号
\refstepcounter{foo} 会执行与 \stepcounter 相同的递增与重置,并额外 把 foo 设为「当前可被引用的编号」。少了这一步,\label 就无所依附。实测下来,症状比想象的更恶劣。在一篇写了三个节的文档里执行 \stepcounter{demo}\label{bad},demo 的值是 1,可写进 .aux 的却是 \newlabel{bad}{{3}{1}{}{}{}}——\ref{bad} 打印出 3,也就是前一个节的编号。标签并不会退回该计数器的上一个值,而是抓住 最近一次被 \refstepcounter 触碰过的那个计数器,通常完全是另一个。而且没有错误,也没有警告。若在文档最开头做同样的事,得到的是 \newlabel{step}{{}{1}{}{}{}},\ref 什么也不打印。
\newcounter{demo}
\begin{document}
\section{First}\section{Second}\section{Third}
Here: \stepcounter{demo}\label{bad} (demo is now \thedemo)
Reference: \ref{bad}
\end{document}
% .aux -> \newlabel{bad}{{3}{1}{}{}{}}
% output -> "Here: (demo is now 1)" / "Reference: 3"
% log -> 0 errors, 0 warnings
% the fix -> \refstepcounter{demo}\label{good} gives \ref = 1机制在 latex.ltx 里一目了然。\refstepcounter 先调用 \stepcounter,再把 \thefoo 的值写入 \@currentlabel;而 \label 所做的不过是把 \@currentlabel 抄进 .aux 文件。也就是说,\label 保存的从来不是「紧邻的那个计数器」,而是一个全局变量的当前值。LaTeX 自己在推进 section、equation、figure 时用的也是 \refstepcounter,所以给标题或公式加的 \label 才能给出正确编号。实用规则也就随之而来:只需在内部推进数字时用 \stepcounter;要创建一个可以被 \label 引用的「带编号对象」时用 \refstepcounter。当自制的定理环境或习题编号无法正确交叉引用时,请先怀疑这里。
\value:把计数器取出为「能参与计算的数」
\value{foo} 展开成保存该计数器的那个 \count 寄存器本身——内核的定义就是 \csname c@#1\endcsname 这一行。所以它能用在 \setcounter 与 \addtocounter 的数值位置、\ifnum 比较中、\numexpr 表达式里,也就是 任何 LaTeX 期待整数的地方。反过来说,推论就是:\value 不是显示命令。把编号打进正文是下一节 \arabic 或 \thefoo 的职责,\value 只交回供算术使用的原始数字。把两个角色分开记,「编号不出现」「数字出现了两次」这类困惑就不会发生。
\setcounter{bar}{\value{foo}} % copy foo into bar
\addtocounter{bar}{\value{foo}} % bar = bar + foo
\ifnum\value{page}>10 \dots\fi % compare
\setcounter{bar}{\numexpr\value{foo}*2+1\relax} % arithmetic, no package
% \value is NOT for printing:
\value{foo} % wrong -- prints nothing useful
\arabic{foo} % right
\thefoo % right, and honours any format you defined改变编号的外观——从 \arabic 到 \thefoo
把计数器的值变成可打印字符的命令共有六条,每条都以计数器名为参数。但 实际排版中打印编号的,是每个计数器自动获得的专用宏 \thefoo:section 有 \thesection,figure 有 \thefigure;用 \renewcommand 重定义它,就能一举改变整个编号格式。\alph 与 \Alph 对应 26 个字母。边界比看上去更细:传入 0 什么也不输出,也不报错,而负值与 27 及以上会得到 ! LaTeX Error: Counter too large.。\fnsymbol 的有效范围同样是 1–9,传入 10 会得到同一句 ! LaTeX Error: Counter too large.。顺便纠正一处旧资料里的说法:在 TeX Live 2024 所带的 LaTeX2e 中,\@fnsymbol 的每一个分支都使用 \TextOrMath,因此 \fnsymbol 可以直接用在正文里,不再需要写成 $\fnsymbol{footnote}$(脚注仍以 \thefootnote 为常规途径)。
| 命令 | 输出 | 范围 / 注意 |
|---|---|---|
\arabic | 1, 2, 3 … | 默认;负值也能打印 |
\roman | i, ii, iii … | 小于 1 时不输出 |
\Roman | I, II, III … | 小于 1 时不输出 |
\alph | a, b, c … | 1–26。0 不输出;负值与 27 及以上报 Counter too large |
\Alph | A, B, C … | 1–26。0 不输出;负值与 27 及以上报 Counter too large |
\fnsymbol | ∗ † ‡ § ¶ ‖ ∗∗ †† ‡‡ | 1–9;10 会报 Counter too large。可直接用于正文,无需数学包裹 |
\thefoo | 该计数器的「正式」格式 | 实际打印的就是它;用 \renewcommand 修改 |
\renewcommand{\thesection}{\Roman{section}} % 1, 2, 3 -> I, II, III
\renewcommand{\thefigure}{\thesection.\arabic{figure}} % figure 2.3
\renewcommand{\thefootnote}{\fnsymbol{footnote}} % footnotes as * dagger ...
% appendix figures as A.1, A.2, ...
\appendix
\renewcommand{\thefigure}{\Alph{section}.\arabic{figure}}像第二个例子那样,把另一个计数器的显示织进 \the… 的定义,就能得到 “2.3” 这样的复合编号。由此显露出的是一种设计:编号的 外观(\thefigure 的定义)与决定何时归零的 联动(\newcounter 的父级参数)是完全分开决定的。所以单写 \renewcommand{\thefigure}{\thesection.\arabic{figure}} 并不会让图号在每节重新开始——若要重置,还必须建立父子关系,例如 \counterwithin{figure}{section}。
标准计数器,以及作为「配置值」的 secnumdepth 与 tocdepth
LaTeX 预先准备了一组计数器,并在读取文档的过程中自动推进它们。这些同样可以用 \setcounter 自由触碰,于是就能「在中途给章重新编号」或「让附录的图号显示为 A.1」。在空白的 article 类里新建一个计数器,其内部寄存器会落在 \count196 附近——这说明已经有几十个标准计数器在运转。顺带一提,经典 TeX 每种寄存器最多只有 256 个;e-TeX 抬高了这个上限,因此现在连续分配 400 个计数器也毫无怨言(最后一个落在 \count662)。
- 分节:
part、chapter、section、subsection、subsubsection、paragraph、subparagraph(有哪些取决于文档类) - 浮动体与公式:
figure、table、equation - 脚注:
footnote、mpfootnote(minipage内的脚注) - 列表:
enumi、enumii、enumiii、enumiv(enumerate的四层嵌套) - 页码:
page - 配置值:
secnumdepth(给到哪一层标题编号)与tocdepth(哪一层之前的条目进入目录)
最后两个是另一类东西:支配编号与目录「深度」的整数旋钮,并不用来计数。每条分节命令都有层级编号(section 为 1、subsection 为 2……),只有当该编号 小于或等于 secnumdepth 时才会编号。默认大约是 2,所以 \setcounter{secnumdepth}{1} 会去掉小节以下的编号,\setcounter{tocdepth}{1} 会把目录收缩到节为止。这两个很好地说明:计数器不只是计数器,也是 操纵排版的配置值。
\newlength 分配的是 skip 寄存器,而非 dimen
\newlength{\mylen} 的内里就是 \newskip。 内核的定义只有 \@ifdefinable#1{\newskip#1} 一行,用 \meaning 验证会得到类似 \skip50 的答案。这一点很关键,因为 skip 寄存器可以携带伸缩成分(glue),所以 \setlength{\parskip}{1em plus 2pt minus 1pt} 是完全合法的赋值。实测中 \the\parskip 会返回 10.00002pt plus 2.0pt minus 1.0pt,三个成分全部保留。与计数器不同,参数是 带反斜杠的命令名(\mylen),初值为 0pt。同名声明两次会得到 ! LaTeX Error: Command \mylen already defined.。而 \a、\b、\c、\d、\t 早已作为重音命令存在,这正是 \newlength{\a} 通不过的原因。
\newlength{\gap}
\setlength{\gap}{20pt plus 10pt minus 5pt} % a rubber length in a register
% watch the stretch and shrink actually being used
\showboxbreadth=5 \showboxdepth=5
\setbox0=\hbox to 70pt{\rule{20pt}{2pt}\hspace{\gap}\rule{20pt}{2pt}}\showbox0
\setbox1=\hbox to 55pt{\rule{20pt}{2pt}\hspace{\gap}\rule{20pt}{2pt}}\showbox1
% log: \hbox(2.0+0.0)x70.0, glue set 1.0 <- all 10pt of "plus" used
% \hbox(2.0+0.0)x55.0, glue set - 1.0 <- all 5pt of "minus" used用 \showbox 可以直接从日志里读出伸缩的实况。把自然宽度 60pt 的盒子撑到 70pt,得到 glue set 1.0——意思是 plus 10pt 被恰好用尽。压到 55pt 则得到 glue set - 1.0,minus 5pt 也用尽了。这个 glue set 数值是排查版面的有力帮手:若数值到了 2 或 3,就说明盒子被拉伸得远超舒适范围。plus/minus 在整页范围内如何发挥作用(\fill 与 \flushbottom 的故事),由「长度与空白」那一页详细讲解。
为什么 \setcounter 是全局的而 \setlength 是局部的
原因是 latex.ltx 定义中的一个词。\setcounter 与 \addtocounter 带着 \global,而 \setlength 与 \addtolength 没有。 就这一处差别,造就了截然不同的行为。在同一个分组内同时改变计数器和长度,离开分组后 计数器保留新值,长度却已回到旧值——实测中,从 1cm 改到 9cm 的长度,在分组结束的瞬间弹回 28.45274pt(即 1cm)。这种不对称是合理的:章节编号是不能在环境结束时被遗忘的「文档状态」,而对页边距的临时微调,本就该只在自身范围内生效。
\newcounter{cnt}\newlength{\len}
\setcounter{cnt}{1}\setlength{\len}{1cm}
{\setcounter{cnt}{99}\setlength{\len}{9cm}%
inside the group: cnt=\thecnt\ len=\the\len} % 99 / 256.0748pt
after the group: cnt=\thecnt\ len=\the\len % 99 / 28.45274pt
% latex.ltx:
% \def\setcounter#1#2{... {\global\csname c@#1\endcsname#2\relax}}
% \def\setlength#1#2{#1 #2\relax} <- no \global
% to make a length assignment survive a group:
\global\setlength{\len}{9cm}一旦你用 \newcommand 或 \newenvironment 编写自己的工具,这个差别就开始起作用。在环境内部写 \setlength{\parindent}{0pt},它会在 \end 处自动还原,无需善后。反过来,若环境内部数出的次数需要带到外面,就得用 \setcounter(或显式的 \global)。「设置在环境结束时消失了」和「页边距的改动漏到环境之外」这两类经典 bug,都能用这一个词的有无来解释。至于环境本身就会形成一个分组这一性质,由「创建环境」那一页负责讲解。
长度的计算——\dimexpr、calc、\fpeval 与单位换算
加、乘、除三者当中,完全不需要宏包就能写的是 e-TeX 的原语 \numexpr 与 \dimexpr。在默认的 article 中,\the\dimexpr\textwidth/3\relax 返回 115.0pt,\the\dimexpr 2cm+4pt\relax 得到 60.9055pt。有一点需注意:\numexpr 的除法是 四舍五入 而非截断——\the\numexpr 7/2\relax 为 4,-7/2 为 −4。若想要更有 LaTeX 味道的写法,就载入 calc 宏包,它让 \setlength{\x}{(\textwidth - 2em)/2} 这样的表达式可以直接写在 \setlength 里(实测 162.49998pt)。唯有按小数倍缩放写法不同:要插入 \real{},如 \widthof{Total:} * \real{0.5}。
| 写法 | 所需条件 | TeX Live 2024 下的实测结果 |
|---|---|---|
\dimexpr | 无(e-TeX 原语) | \the\dimexpr\textwidth/3\relax → 115.0pt |
\numexpr | 无(e-TeX 原语) | 7/2 → 4,-7/2 → -4(四舍五入,不是截断) |
calc | \usepackage{calc} | (\textwidth - 2em)/2 → 162.49998pt |
\real | \usepackage{calc} | \widthof{Total:} * \real{0.5} → 12.91669pt |
\fpeval | LaTeX 内核(无需 xfp) | \fpeval{345/7}pt → 49.28572pt;\fpeval{sqrt(2)} → 1.414213562373095 |
计算时了解单位的真实含义能减少事故。TeX 的基准是 pt(TeX 点),1in = 72.27pt。\the\dimexpr 1in\relax 返回 72.26999pt——之所以不是恰好 72.27,是因为 TeX 把每个尺寸都存为 sp(缩放点)的整数倍,且 1pt = 65536sp;\the\dimexpr 65536sp\relax 正好是 1.0pt。PostScript 家族的 bp 是 1/72 in,所以 1bp 为 1.00374pt,72bp 又回到 72.26999pt,恰是 1in。还有一点:em 在赋值那一刻被求值并随即冻结。在导言区写 \setlength{\x}{1em} 会存入 10.00002pt;之后进入 \Large 分组,\x 依然是 10.00002pt,而在同一处现测的 1em 则是 14.09984pt。单位一览本身以及 \hspace/\vspace,归「长度与空白」那一页所有。
测量排出来的结果——\settowidth、\settoheight、\settodepth
最强大的长度操作,是 先真正排一遍、量出结果,再存进长度里。\settowidth{\mylen}{text} 在内部排版给定文本,并把它的 宽度 赋给 \mylen。同理,\settoheight 测量基线 以上 的高度,\settodepth 测量基线 以下 的深度。实测数值让区别一目了然:Total: 宽 25.83339pt、高 6.94444pt、深 0.0pt;xyz 高 4.30554pt(即 x 高度)、深 1.94444pt;gjpqy 高 6.67859pt、深 1.94444pt。文本中一旦出现下伸部,深度立刻出现,数值上看得清清楚楚。
\usepackage{calc}
\newlength{\labelw}
\settowidth{\labelw}{Total:} % 25.83339pt -- the real typeset width
\noindent\makebox[\labelw][l]{Total:}42\par
\rule{\labelw}{0.4pt} % a rule exactly as wide as the label
% the same idea as a value inside an expression (needs calc):
\setlength{\labelw}{\widthof{Total:} + 1em}
\the\labelw % print the value to check it载入 calc 后,同样的思路就能用在表达式里:\widthof{text}、\heightof、\depthof 可以直接作为值书写。区别在于角色:\settowidth 是把结果赋给长度的命令,而 \widthof 是能放进表达式的值。 另外请记住忘记载入 calc 时的症状:\setlength{\mylen}{\textwidth/3} 一个错误也不会报。\mylen 只是拿到了 \textwidth 的值(默认 article 中为 345.0pt),而剩下的 /3 会 作为文字排进正文。要等到 PDF 里冒出「/3」才会发现——所以要做除法,要么载入 calc,要么用完全不需要宏包的 \dimexpr。
综合运用——自制可被引用的习题编号
最后,把前面的零件汇到一处。创建计数器 question,用 \thequestion 把显示形式定为「Q1.」,用 \settowidth 量出标签宽度让编号对齐,再用 \refstepcounter 推进计数器以便 \label 与 \ref 生效——这四点齐备,就得到了一个真正可用的带编号构件。由于格式集中在唯一一处(\thequestion 的定义),日后把「Q」改成别的字样也只是改一行。
\documentclass{article}
\newcounter{question}
\newlength{\qlabel}
\renewcommand{\thequestion}{Q\arabic{question}}
\newcommand{\question}{%
\refstepcounter{question}% <- \refstepcounter, so \label works
\settowidth{\qlabel}{\textbf{\thequestion.}}%
\par\noindent\makebox[\qlabel][l]{\textbf{\thequestion.}}\hspace{0.5em}}
\begin{document}
\question The first question.\label{q:first}
\question The second question.
See question~\ref{q:first}. % -> "See question Q1."
\end{document}这个小例子浓缩了把计数器与长度当作编程工具的诀窍:不要就地写死数值,给它们起名字并集中管理。 把编号格式放进 \the…,把尺寸放进 \newlength 命名的长度里,规格一旦变化,只需改一处。尺寸尽可能由既有长度搭建——写 0.8\textwidth 或 2\baselineskip,纸张尺寸或页边距变化时比例仍能保持。反之,一旦硬写下 8cm,这个值就预定了在下一次版面调整时崩坏。