科研宝典·工具篇 一份还算简短的LaTeX3编程介绍
发布时间: 2024-11-02 15:32:13作者: 小9官网直播足球
一些流程控制必要的接口可能存在于不同宏包,不同宏包可能具有高度相似的编程接口实现。
事实上,LaTeX2e的宏包规范确实存在一些混乱和不一致的问题,这主要是由于历史问题导致的。在LaTeX2e发布之前,已经有了一些非常受欢迎的宏包,如amsmath、graphicx和hyperref等。这些宏包采用了各自的命名方案和接口约定,但是在LaTeX2e发布后,它们并没有被纳入LaTeX2e的标准中,而是作为独立的宏包存在。
由于这些宏包的影响力和流行程度,其他开发者也开始模仿它们的命名和接口风格,这导致了许多宏包之间的命名和接口约定不一致,给使用者带来了一定的困扰。
L2e中变量和函数使用相同的命令(一般\newcommand甚至原生\def)来定义,且命名也没有规范的方法。
L2e归根结底是TeX写的,TeX终究是一个宏定义语言,有时不可避免地要控制宏展开的顺序。比如,如果要控制以下内容的宏展开顺序:ABCD
这代码可不是用来吓人的(的确是),可以用人脑模拟展开一下,最终的展开顺序的确是DCBA。总结两条使用\expandafter宏展开控制的规律:
尽管LaTeX2e(L2)的编程接口已经图灵完备,但其接口混乱。而LaTeX3(L3)接口旨在提供具有现代高级语言(如C++,Python)的特性,为逻辑流程控制、文件读写、正则表达式、数据类型等等提供标准的、统一的接口。
L3接口在语法和功能上都相对于L2e更强大和灵活,能够更好地满足复杂的排版需求,并提高代码的简洁性、可读性和可维护性。简而言之:人情冷暖可无问,不学L3吾自恨。
那么本文章的内容也基于一般高级语言学习的流程而展开,并尽可能忽略掉非高级语言没有的特性。可是,TeX终究还是宏定义语言,终究还是要先从控制序列(Control Sequence,cs)开始讲起。在L3中,控制序列可以认为就是函数。
这里主要是介绍如何开启L3的编程环境接口。即使expl3已经加入LaTeX内核,但使用L3的编程环境还是一定要通过两个宏命令\ExplSyntaxOn和\ExplSyntaxOff来开启和关闭,因为L3中的函数和变量命名加入了:和_,一定要通过这两个命令调整这两个符号的类别码Category Code,前者将这两个符号的类别码设为11(代表着字母),而后者恢复它们原来的类别码。这犹如在L2中我们常常成对使用\makeatletter和makeatother调整@的类别一样。因此,开启L3编程环境很简单,只需要将代码包在两个宏定义之间即可。
那么问题来了,将:和_设为字母类别后,在其它模式下我们应该这些符号表达特定含义时怎么办,目前以编者的经验是使用\c_colon_str代替冒号和使用\c_math_sub_token代替下划线。还有就是,在该模式中所有的空格和换行都会被忽略,能够正常的使用~和\来代替空格。
函数名字module_deion一般反应了其所属模块和该函数的说明,由下划线自带的函数都是这种形式。这只是一种命名规范,不遵守也没问题,编译也能通过,因为后面等命名不规范而导致代码混乱时,才会发现有大问题。因此建议养成良好的命名习惯,不过如果是初学者,那也就没必要刻意强制自己,在比较短的玩具代码中,简单的命名反而更好观察。
函数最重要的部分便是参数说明符(argument specifier)arg-spec。参数说明符一般为单个字符,申明函数的参数类型,能够理解为C++函数的形参类型申明或Python函数中的参数标注。实际上更加偏向前者,因为参数说明符在L3中是有意义的,控制着函数如何“吃”参数,而Python标注只是给开发者和IDE看,解释器并不做任何处理。下面列举几个常用的参数说明符:
N&n:原模原样地接收一个token,不过N一般接收单个token,通常用来接收控制序列,而n接收由花括号括起来的所有token
T&F:通常用于条件分支语句中,接收条件分支语句相 应true和false的代码
w:所谓魔法参数说明符,接收在终止符(\q_stop)之前出现的所有内容
L3终究还是宏定义语言,那也就是我们定义函数的工具也是函数。回想一下TeX中定义一个控制序列的方法:
那么思考L3中的\def应该长什么样。能够准确的看出,\def的参数可大致分为三个部分,\cmd,#1#2和\uppercase{#1,#2}。从上面的介绍可知,在L3中接收这三种文本的参数说明符分别为N,p和n。因此能推理出,L3中定义函数的命令名称应该是这种形式:
在L3中还引入了高级语言中的私有(private)变量和函数概念,私有变量和函数的命名很简单,类似Python语言的私有变量,在模块名module前面加下划线即可。
这种规范的命名可以很清晰地从名字看出一个变量的作用域和类型。变量的作用域分为三种:
值得注意的是,C++使用花括号,Python使用缩进控制作用域,而L3中使用一对\group_begin:和\group_end:来控制作用域。
变量的类型有很多,有基础数据类型,也有高级数据结构,本着简洁的原则,本文主要介绍**整数(int),浮点数(fp),字符串(str)和序列(seq)**。
定义一个变量名\l_my_lipsum_int为整数,与其同质的C++和Python代码也在注释里给出:
注意观察上面两个命令的参数说明符(冒号后面的字符),和其后面跟的参数是一一对应的。之后几乎所有的命令都会带有参数说明符,这是L3函数的标准形式。
输出是一个形象的说法,这里所谓的输出就是将其放到输出流,也就是显示在最终生成的pdf文件里面。如果仅仅是要输出一个单独变量的值,可以用\int_use:N。
从上面代码能够正常的看到,TeX的注释使用的是%。那如果要对变量取模(很多语言都是使用%作为取模运算符)怎么办。L3编程接口的目标既然是要提供现代语言特性,那自然少不了丰富的库函数(代码只列举了部分,后面内容皆是如此):
还有很多其它的运算符都支持,不在此阐述。还有一个重要的功能,就是在\fp_eval:n参数中可以直接用数学函数!
字符串变量可以直接丢进输出流而无须访问函数,L3也提供了一些操作字符串变量的函数:
指定分隔符以分割第三个参数的内容,生成含有多个元素的序列。如何将该序列扔到输出流?根据前面所学,可以猜得到应该是一个叫seq_use:Nn的命令。果不其然,我们大家可以使用该命令指定分隔符以输出序列:
可以指定更多的参数,读者可自行尝试。还能够正常的使用map函数(类似Python的map)进行输出:
上面的\seq_map_function:NN将seq中每个元素作为参数调用了\uppercase函数。仅仅一个\uppercase不能实现输出,那么可以自己定义一个函数,使用内联的map函数:
使用内联函数,序列中的每个元素都会自动地绑定到#1上。若是要想更加个性化地输出,可以等到后面学了循环来实现。
% 同理,在尾部删除一个元素,并将该元素放进token list变量\l_tmpa_tl中
如何根据下标获取序列元素(随机访问)?库函数有该api吗?肯定有。但是遇到问题就要去查文档吗?可不可以直接推理出来?
可以,先不看文档或教程,而直接推测出一个没接触过的api可能会是什么样。比如我们已经知道声明一个新变量基本是type_new:N这种形式。我们前面已经学过字符串的随机访问:
与字符串的api相比,只是把命令前面的类型名改了即可。再来一个例子,seq在首末删除一个元素不是保存到了一个tl中吗,看看这tl中有什么
现在看看\l_tmpa_tl有什么,举一反三,把seq的api名字里的seq替换成tl就成tl的api(不一定成功,但能减少繁琐的文档阅读),下面把seq的map函数改成tl的map函数:
成功运行!仔细观察输出内容,可以推断出\l_tmpa_tl中有5个元素,而我们只从seq里弹出了一个元素,那是因为seq里的一个元素(start)包含多个token(s, t, a, r, t)。
至此,数据类型部分讲完,该部分只涉及常用数据类型,若想要学习更多数据类型及其api,可以根据前面提到的模式进行推断没学过的api。
L3为大多数变量类型预定义了4个临时变量,无须定义便能够正常的使用它们。它们的名字一般为\⟨scope⟩_tmpa_⟨type⟩/和\⟨scope⟩_tmpb_⟨type⟩,因为作用域分为局部和全局,所以2乘以2等于4个。比如序列(seq)的4个临时变量:
这些预定义变量是所有代码共享的,因此,要谨慎使用这些变量,以免写出模块间高度耦合的代码。
仅仅学习数据类型是不够的,要想实现各种花里胡哨的逻辑,最核心的就是流程控制,比如条件语句,循环语句(前提是没有更花哨地使用布尔运算优化和递归实现条件判断和循环逻辑)。
条件判断其实就是判断布尔值表达式为真还是为假而执行不同分支。在L3中将布尔表达式转化为布尔值的函数成为断言函数(Predicate function),后面使用Predicates指代该函数。Predicates名字一般以_p结尾。一般数据类型都各自实现了自己的Predicates甚至自己的条件判断函数。但本文仅讲比较灵活的api—\bool_if:nTF+Predicates。
因为(2*5) = 10 = (4+6),所以输出Medium。若是没有任何一个匹配上,则输出No idea!。这里的输出比较简单,可以在代码块里面写更加复杂的逻辑,且case语句还有其它很多花哨的api,读者可自行去了解。
循环能让计算机不知疲倦地做同一件事情成千上万次。大多数其它编程语言都有for循环和while循环。L3中亦有相似功能的实现。
有一点注意,在L3中,很多东西默认都是从1开始,包括下标,循环初始值。还有就是,循环有如下api:
不过此时的#1是循环变量,而之前的是序列元素。循环也可以设置起始值,步长和终止值:
while循环仅仅根据布尔表达式的值而决定是否继续,能够正常的使用while循环实现上述逻辑:
L3中还有do-while循环,将上述代码循环命令中的do和while交换位置即可。
L3中甚至还有do-until和until-do及其变种(不同的参数说明符)。但这些都可以通过while-do循环实现。
该代码涵盖了条件判断,循环等等知识。值得注意的是,tikz画图中用的是极坐标,极坐标的表示需要冒号,而冒号在L3环境中被转义了,所以采用\c_colon_str代替冒号。最终得到的pdf为:
在L3中,定义函数其实就是定义命令,对函数操作的api基本都以cs_开头。在2.2节推测过,定义函数的命令具有如下形式:
在L3中参数的声明基本和L2一致,都是#打头加数字,但最多使用9个参数,具体详见\newcommand实现原理。调用并输出\my_add:nn:
函数和变量不同,无须提前声明也能赋值。所以也可以使用\cs_set:Npn创建函数:
但后者不会去判断是否已经定义,因此使用\cs_set:Npn可能会覆盖原函数。
函数带有了参数说明符之后,编译器能够自动推导参数个数,因此#1#2这种文本可以省略,如下代码:
注意参数说明符的变化,省略掉了#1#2同时,\cs_new的参数说明符也从Npn变成了Nn。
前面两种方法仅仅允许定义基本函数(base function),基本函数只能使用之前2.2节中介绍过的几个参数说明符N,n,T,F,p,w。而仅仅使用这一些参数说明符并不能很好地进行宏展开控制。下面增加几个参数的说明:
x&f&o:接收参数展开后的内容,x接收参数进行完全展开后的内容,f接收参数展开到第一个不能展开的token为止,而o接收对参数展开一次后的内容
使用命令\cs_generate_variant:Nn能够修改一个函数的参数说明符,产生一个函数的变种(variant),可以为基本函数定义个性化的参数描述符。其使用方式如下:
使用\cs_generate_variant:Nn可以很灵活的生成函数的变种以控制宏展开,甚至还可以修改L3内部函数的参数描述符定义。但其也有一些限制:
每适配一种情况就声明一个新函数变种何其繁琐。\exp_args也能够以一种更加简单的方式,且无须定义新函数变种来控制函数的参数说明符。上述例子用\exp_args命令一行便能解决:
其用法也很简单,只需要在其冒号后按自己需要的方式进行填写参数说明符即可,如下:
\exp_args的第一个参数描述符为N,表示接收要暂时修改的函数,自第二个开始便依次对应函数的参数该如何展开。而且可以在合适位置停止而只控制部分参数,即如下这种情况也是可以的。
但\clist_use:Nn第一个参数是接收一个命令,此时这样写显然是错误的。因此需要对[取第一个元素]的内容提取展开。因此,能够正常的使用\exp_args控制\clist_use:Nn第一个参数的展开:
使用函数可以简化代码的编写,将一些会重复使用的代码块封装成函数,避免代码冗余。LaTeX3中的函数也可以调用自身,实现递归。比如经典的汉诺塔问题,便可通过函数的递归轻松实现:
有点单调,不够直观,不够花哨。能够正常的使用beamer和tikz自动生成ppt动画,结果长这样: