自定义环境 (\newenvironment)

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} 时执行的代码。夹在中间的正文本身不受任何改动,照常排版。所以设计一个自定义环境,归根结底就是回答一个问题:进门时要准备什么,出门时要收拾什么。

latex
% 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。所谓环境的「语法」根本不存在,存在的只是按名字配对的两个宏。

latex
% 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. 也是同样的道理,而这项检查是有意为之,防止你不小心覆盖既有定义。给自定义环境起名时,选 mywarningthmbox 这类不易与现成命令撞车的词最稳妥。

同一逻辑的另一面,就是 \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 完全一致,连 ⟨个数⟩ 要写 含可选参数在内的总数 这一条也一样。

latex
% 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 取出来。

latex
\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 都改成斜体。不过要记住,重新定义类或宏包提供的环境,可能会连累依赖它的其他代码。

latex
% 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 页面)。

latex
% 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{...}。这正是给 quotecenterlist 稍加调味的做法。
latex
% 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. 之类看似毫不相干的惨叫一起出现。次生伤害常常比病因更醒目,所以原则是 从日志里最早的那条错误开始读