LaTeX 的环境其实没有任何特殊机制。\begin{quote} 调用一个叫 \quote 的命令,\end{quote} 调用一个叫 \endquote 的命令,仅此而已;用来自定义环境的 \newenvironment,做的也不过是一次性写下这两个宏。抓住这一对结构,其余的便顺藤摸瓜:为什么环境名会和命令名撞车,为什么参数在结束代码里用不了,为什么 \begin 与 \end 不匹配时的报错是那副样子。本页就沿着这条线索,讲 \newenvironment 与 \renewenvironment、参数与可选参数、白送的自动分组,直到现代的 \NewDocumentEnvironment。
\newenvironment 怎么写:开始代码与结束代码
在导言区写下 \newenvironment{name}{开始代码}{结束代码},正文里就能使用 \begin{name}…\end{name} 了。第一个参数是环境名,不带反斜杠;第二个参数是 LaTeX 遇到 \begin{name} 时执行的代码,第三个参数是遇到 \end{name} 时执行的代码。夹在中间的正文本身不受任何改动,照常排版。所以设计一个自定义环境,归根结底就是回答一个问题:进门时要准备什么,出门时要收拾什么。
% preamble: define a warning environment
\newenvironment{warning}{%
\par\noindent\textbf{Warning:}\itshape
}{%
\par
}
% body: use it
\begin{warning}
This operation cannot be undone.
\end{warning}这个例子里结束代码只有一个 \par,并不是偷懒。开始代码打开的 \itshape(斜体)没有任何地方去关闭它,可环境之外的文字却老老实实变回了正体。原因在接下来两节揭晓,先给结论:环境会自动形成一个分组。写 \newenvironment 的诀窍就是 不要去还原本就不必还原的东西——结束代码写成空的 {} 完全没问题。
环境的真面目:\name 与 \endname 这一对宏
\begin{name} 调用 \name,\end{name} 调用 \endname。这不是猜测:latex.ltx 里 \end 的定义原原本本写着 \csname end#1\endcsname。若不愿只凭信任,TeX 提供了 \show。对标准的 quote 环境执行 \show\quote 和 \show\endquote,你会看到一个是打开 \list 的宏,另一个不过是 \endlist。所谓环境的「语法」根本不存在,存在的只是按名字配对的两个宏。
% ask LaTeX what the quote environment is actually made of
\show\quote
% > \quote=\long macro:
% -> \list {}{\rightmargin \leftmargin }\item \relax .
\show\endquote
% > \endquote=\long macro:
% -> \endlist .
% so \begin{quote} ... \end{quote} is, in effect:
% \begingroup \quote ... \endquote \endgroup这个事实很快就会以报错的形式找上门。环境名会占用同名的命令名。 试着写 \newenvironment{alpha}{...}{...},编译便停在 ! LaTeX Error: Command \alpha already defined.——因为希腊字母 \alpha 早就存在了。环境的名字空间和命令的名字空间从来就是同一个。\newenvironment{quote} 报出 Command \quote already defined. 也是同样的道理,而这项检查是有意为之,防止你不小心覆盖既有定义。给自定义环境起名时,选 mywarning、thmbox 这类不易与现成命令撞车的词最稳妥。
同一逻辑的另一面,就是 \newcommand 那边那条著名的限制:写 \newcommand{\endnotes}{...} 会被拒绝,尽管这个命令根本不存在。若谁都能随意创造以 end 开头的名字,就可能与 \end{...} 所调用的 \endname 撞车,于是整个前缀都被保留了。这项检查的细节留给宏那一页;这里要说的是,那边的禁令正是为了保护本页所构建的环境名字空间。
环境自动成组:什么会还原,什么会漏出去
\begin 在运行开始代码 之前 发出 \begingroup,\end 在运行结束代码 之后 发出对应的 \endgroup。于是开始代码、正文、结束代码三者统统待在同一个分组里。这正是上面那个 warning 环境无需关闭 \itshape 的原因。用宏做同样的事,就得自己用 { … } 把内容括起来;而在环境里,\begin…\end 本身就是这对括号。要把格式改动关在一段固定范围内,环境比宏更直白,道理全在这里。
不过这并不意味着「离开分组就全部还原」。TeX 的赋值分局部与全局两种,而 LaTeX 的计数器操作是 有意做成全局的:latex.ltx 里的 \addtocounter 用的是 \global\advance,所以在环境内部用 \stepcounter 加过的编号,出了 \end 依然保留。章节号、图号在环境里递增后不会凭空消失,靠的正是这个设计。反过来,在环境里用 \newcommand 定义的宏会随 \end 一起消失,到外面使用就得到 ! Undefined control sequence.
| 在环境内部执行 | 离开 \end 之后 | 原因 |
|---|---|---|
\itshape | 还原 | 字体切换是局部赋值 |
\setlength | 还原 | \setlength 是普通的局部赋值 |
\newcommand | 消失 | 定义是局部的;在外面使用会得到 ! Undefined control sequence. |
\stepcounter | 保留 | 计数器操作是用 \global 写的 |
\gdef | 保留 | 这是显式的全局定义 |
\label | 保留 | 写入 .aux 文件的动作不会被分组撤销 |
带参数的环境,以及带默认值的可选参数
若要每次调用都改变内容,就在名称后的方括号里写上参数个数,并在开始代码中用 #1、#2 等引用:\newenvironment{name}[⟨个数⟩]{开始代码}{结束代码},从 #1 到 #9,最多九个。再加一组方括号——\newenvironment{name}[⟨个数⟩][⟨默认值⟩]{...}{...}——#1 就变成可选参数:\begin{name} 使用默认值,\begin{name}[x] 则把 x 放进 #1。这套写法与 \newcommand 完全一致,连 ⟨个数⟩ 要写 含可选参数在内的总数 这一条也一样。
% one mandatory argument
\newenvironment{point}[1]{%
\par\noindent\textbf{#1}\quad
}{%
\par
}
\begin{point}{Conclusion}
Back up early.
\end{point}
% first argument optional, default "Note"
\newenvironment{callout}[1][Note]{%
\par\noindent\textbf{#1:}\itshape
}{%
\par
}
\begin{callout} % label is "Note"
Nothing to configure.
\end{callout}
\begin{callout}[Warning] % #1 becomes "Warning"
This cannot be undone.
\end{callout}为什么在结束代码里写 #1 会报错,以及标准的绕法
参数 #1、#2 等只能在开始代码里使用。把它写进结束代码,LaTeX 甚至不等到使用时,而是 在定义它的那一行 就报出 ! Illegal parameter number in definition of \enddemo. 请注意错误点名的 \enddemo——它印证了前面所有的说法:\newenvironment 造出的是一个接收参数的宏 \demo,和一个 一个参数也不接收 的宏 \enddemo。在没有参数文本的宏体里出现 #1,对 TeX 而言不过是语法错误。并不是「运行时参数消失了」,而是根本就没有接收它的位置。
如果结束时确实需要参数的值,标准做法是 趁还在开始代码里先把值存起来。文本的话,最稳妥的是用盒子:\newsavebox 分配,\sbox 装入;让宏通过 \def 或 \newcommand 记住它也可以。由于整个环境是一个分组,开始代码里存下的内容会完好地活到结束代码。下面的 citequote 就是在引文末尾右对齐地打出出处:用 #1(默认 Shakespeare)接收出处,塞进盒子 \quoteauthor,再在结束代码里用 \usebox 取出来。
\newsavebox{\quoteauthor}
\newenvironment{citequote}[1][Shakespeare]{%
\sbox\quoteauthor{#1}% save the argument while we still have it
\begin{quotation}%
}{%
\hspace{1em plus 1fill}---\usebox{\quoteauthor}% retrieve it here
\end{quotation}%
}
\begin{citequote}
To be, or not to be.
\end{citequote}
\begin{citequote}[Knuth]
Premature optimization is the root of all evil.
\end{citequote}\renewenvironment 与带星号的 \newenvironment*
要改造已经存在的环境,就用 \renewenvironment。它的参数写法——包括 [⟨个数⟩][⟨默认值⟩]——与 \newenvironment 一字不差,不同的只是前提。\newenvironment 只在名字尚未被占用时成功,\renewenvironment 则只在名字已被占用时成功;用在不存在的名字上会停在 ! LaTeX Error: Environment nosuch undefined. 它适合做全局性的改动,例如把文档里所有 quote 都改成斜体。不过要记住,重新定义类或宏包提供的环境,可能会连累依赖它的其他代码。
% italicise every quote in the document
\renewenvironment{quote}{%
\list{}{\rightmargin\leftmargin}\item\relax\itshape
}{%
\endlist
}\newenvironment 和 \renewenvironment 都还有在名字后加 * 的星号形式。星号改变的是 参数里能否出现空行。不带星号的参数可以跨段落(\par);带星号的参数是「短」参数,中间夹了空行就会停在 ! Paragraph ended before \shortenv was complete. 乍看像是限制,其实是好意:当漏写右花括号导致参数失控时,星号让它在下一个空行处停住,而不是一路跑到文档末尾。另外,标准里并没有对应 \providecommand 的「只在不存在时定义」的环境版;需要的话,请用下一节的工具。
\NewDocumentEnvironment:结束代码里也能用参数
改用 \NewDocumentEnvironment{name}{⟨参数说明⟩}{开始代码}{结束代码},保存盒子那道手续就免了,因为在这套接口里,开始代码和结束代码 两边 都能引用同一批参数。它不写参数个数,而是写一串参数说明字母:m(必需)、o(可选)、O{默认值}(带默认值的可选)、s(是否有星号)等。这原本是 xparse 宏包的功能,但 2020 年 10 月 1 日的版本把它并入了 LaTeX 内核,如今无需 \usepackage 即可使用(说明符清单见 xparse 页面)。
% O{...} is an optional argument with a default; #1 works in both halves
\NewDocumentEnvironment{citequote}{O{Shakespeare}}{%
\begin{quotation}%
}{%
\hspace{1em plus 1fill}---#1%
\end{quotation}%
}
\begin{citequote}[Knuth]
Premature optimization is the root of all evil.
\end{citequote}同一个版本还带来了 \RenewDocumentEnvironment(重做)、\ProvideDocumentEnvironment(仅在不存在时定义)和 \DeclareDocumentEnvironment(无论如何都定义)。上一节说标准里没有的、对应 \providecommand 的环境版,到这里终于有了。写新代码时,凭三点理由可以把这一族当作默认选择:能接收多个可选参数、能正规地处理星号变体、结束代码看得见参数。\newenvironment 则退居为阅读与维护既有文档时需要认得的东西。
三种模式覆盖了大多数自定义环境
实际写出来的自定义环境,几乎都落在下面三种模式之一。骨架都一样:在开始代码里做准备,在结束代码里收尾。
- 用格式包住正文 — 在开始代码里设定字体、字号或对齐,让正文继承这个样子。有分组撑腰,结束代码可以是空的。
- 在上下加入空白 — 在开始代码开头和结束代码末尾放上
\par\medskip之类的竖直间距,用上下留白框住正文。 - 建立在已有环境之上 — 在开始代码里用
\begin{...}打开另一个环境,在结束代码里关掉对应的\end{...}。这正是给quote、center、list稍加调味的做法。
% 1. wrap the body in formatting
\newenvironment{aside}{\par\small\itshape}{\par}
% 2. add vertical space above and below
\newenvironment{spaced}{\par\medskip\noindent}{\par\medskip}
% 3. build on an existing environment
\newenvironment{smallquote}{%
\small\begin{quotation}%
}{%
\end{quotation}%
}第三种是出场次数最多的一型。底层环境的缩进和边距会原样继承,所以要补写的内容少得出奇。只是要记住:开始代码里打开的东西,务必在结束代码里关掉,别让配对断裂。另外,上面那些行末的 % 不是装饰。不写 % 的话,行尾的换行会作为一个空格混进正文,环境前后就冒出莫名其妙的空隙。写跨多行的定义时,把行末的 % 变成习惯,能省掉一整类事故。
\begin 与 \end 对不上时会遇到的报错
\end{name} 不只是调用 \endname,它还会核对 name 是否与当前打开的环境一致。一旦对不上,就会看到类似 ! LaTeX Error: \begin{sidenote} on input line 5 ended by \end{remarkbox}. 的信息,它告诉你 打开的那个环境名,以及打开它的行号。这个行号往往比报错所在的行更有用。下面四种是实际最常碰到的面孔。
! LaTeX Error: Environment nosuchenv undefined.— 不存在这个名字的环境。要么是拼错了,要么是忘了给定义它的宏包写\usepackage。! LaTeX Error: \begin{sidenote} on input line 5 ended by \end{remarkbox}.— 打开的名字和关闭的名字不一样。! LaTeX Error: \begin{sidenote} on input line 4 ended by \end{document}.— 漏写了\end{sidenote},环境一直开着直到\end{document}。! LaTeX Error: \begin{document} ended by \end{nosuchenv}.— 关闭了一个从未打开的环境。前面的\begin因未定义而报错时,紧接着也会出现这一条。
这些都是同一个症状——「没有配成对」——的不同说法。如果自定义环境在开始代码里打开了另一个环境,请先怀疑结束代码里的 \end{...}。另外要提醒的是,这类错误往往会拖着 ! Missing $ inserted. 之类看似毫不相干的惨叫一起出现。次生伤害常常比病因更醒目,所以原则是 从日志里最早的那条错误开始读。