与其他几种脚本语言不通,Lua语言既没有使用POSIX正则表达式,也没有使用Perl正则表达式来进行模式匹配。之所以这样做的主要原因在于大小问题:一个典型的POSIX正则表达式实现需要超过4000行代码,这比所有Lua语言标准库总大小的一半还大。相比之下,Lua语言模式匹配的实现代码只有不到600行。尽管Lua语言的欧式匹配做不到完整POSIX实现的所有功能,但是Lua语言的模式匹配仍然非常强大,同时还具有一些与标准POSIX不同但又可与之媲美的功能。
模式匹配的相关函数
字符串标准库提供了基于模式的4个函数。我们已经初步了解过函数find和gsub,其余两个函数分别是match和gmatch。
函数string.find
函数string.find用于在指定的目标字符串中搜索指定的模式。最简单的模式就是一个单词,它智慧匹配到这个单词本身。例如,模式’hello’会在目标自服装中所搜子串”hello”。函数string.find找到一个模式后,会返回两个值:匹配到模式开始位置的索引和结束位置的索引。如果没有找到任何匹配,则返回nil:
1 | s = "hello world" |
匹配成功后,可以以函数find返回的结果为参数调用函数string.sub来获取目标字符串匹配相应模式的子串。对于简单的模式来说,这一般就是模式本身。
函数string.find具有两个可选参数。第3个参数是一个索引,用于说明从目标字符串的哪个位置开始搜索。第4个参数是一个布尔值,用于说明是否进行简单搜索。所谓简单所有就是忽略模式而在目标字符串中进行单纯的“查找子字符串”的动作:
1 | > string.find("a[word]","[") |
由于’[‘在模式中具有特殊含义,因此第1个函数调用会报错。在第2个函数调用中,函数只是把’[‘当作简单字符串。请注意,如果没有第3个参数,是不能传入第4个可选参数的。
函数string.match
由于函数string.match也用于在一个字符串搜索模式,因此它与函数string.find非常相似。不过,函数string.match返回的是目标字符串中与模式相匹配的那部分子串,而非该模式所在的位置:
1 | print(string.match("hello world","hello")) -- hello |
对于诸如’hello’这样固定的模式,使用这个函数并没有什么意义。然而,当模式是变量时,这个函数的强大之处就显现出来了。例如:
1 | date = "Today is 15/4/2020" |
函数string.gmatch
函数string.gmatch返回一个函数,通过返回的函数可以遍历一个字符串中所有出现的指定模式。例如,以下示例可以找出字符串s中出现的所有单词:
1 | s = "some string" |
模式
大多数模式匹配库都是用反斜杠作为转义符。然后,这种方式可能会导致一些不良的后果。对于Lua语言的解析器而言,模式仅仅是普通的字符串。模式与其他的字符串一样遵循相同的规则,并不会被特殊对待;只有模式匹配相关的函数才会把它们当做模式进行解析。由于反斜杠是Lua语言中的转义符,所以我们应该避免它传递给任何函数。模式本身就难以阅读,到处把“\”换成“\”就更加火上浇油了。
我们可以使用双括号把模式括起来构成的长字符串来解决这个问题。然而,长字符串的写法对于通常比较短的模式而言又往往显得冗长。此外,我们还会失去在模式内进行转义的能力。
Lua语言的解决方案更加简单:Lua语言中的模式使用百分号作为转义符。总体上,所有被转义的字母都具有某些特殊含义,而所有被转移的非字母则代表其本身。
我们首先来学习字符分类的模式。所谓字符分类,就是模式中能够与一个特定集合中的任意字符相匹配的一项。例如,分类%d匹配的是任意数字。因此,可以使用模式’%d%d/%d%/d%d%d%d’来匹配dd/mm/yyyy格式的日期:
1 | s = "Deadline is 15/04/2020, firm |
下表列出了所有预置的字符串分类及其对应的含义:
. 任意字符
%a 字母
%c 控制字符
%d 数字
%g 除空格外的可打印字符
%l 小写字母
%p 标点符号
%s 空白字符
%u 大写字母
%w 字母和数字
%x 十六进制数字
这些类的大写形式表示类的补集。例如,%A
代表任意非字母的字符:
1 | print((string.gsub("hello, up-down!","%A","."))) -- hello..up.down. |
在输出函数gsub的返回结果时,我们使用了额外的括号来丢弃第二个结果,也就是特换发生的次数。
当在模式中使用时,还有一些被称为魔法字符的字符具有特殊含义。Lua语言的模式所使用的魔法字符包括:()
.
%
+
-
*
?
[
]
^
$
正如我们之前已经看到的,百分号同样可以用于这些魔法字符的转义。因此,%?
匹配一个问号,%%
匹配一个百分号。我们不仅可以用百分号对魔法字符进行转义,还可以将其用于其他所有字母和数字外的字符。当不确定是否需要转义时,为了保险起见就可以使用转义符。
可以使用字符集来创建自定义的字符分类,只需要在方括号内将单个字符和字符分类组合起来即可。例如,字符集[%w_]
匹配所有以下画线结尾的字母和数字,[01]
匹配二进制数字,[%[%]]
匹配方括号。如果想要统计一段文本中元音的数量,可以使用如下的代码:
1 | _,nvow = string.gsub(text,"[AEIOUaeiou]","") |
还可以在字符集中包含一段字符范围,做法是写出字符范围的第一个字符和最后一个字符并用横线将它们连接在一起。由于大多数常用的字符范围都被预先定义了,所以这个功能很少被使用。例如,%d
相当于[0,9]
,%x
相当于[0-9a-fA-F]
。不过,如果需要查找一个八进制的数字,那么使用[0-7]
就比显示地枚举[01234567]
强多了。
在字符集前加一个补字符^就可以得到这个字符集对应的补集:模式[^\n]
则代表除换行符以外的其他字符。尽管如此,我们还是要记得对于简单的分类来说可以使用大写形式来获得对应的补集:$S
显然要比[^%s]
更简单。
还可以通过描述模式中重复和可选部分的修饰符来让模式更加有用。Lua语言中的模式提供了4中修饰符:
+
重复一次或多次*
重复零次或多次-
重复零次或多次(最小匹配)?
可选(出现零次或一次)
修饰符 + 匹配原始字符串分类中的一个或多个字符,它总是获取与模式相匹配的最长序列。例如,模式’%a+’代表一个或多个字母:
1 | print((string.gusb("one , and two; and three", "%a+","word"))) |
模式’%d+’匹配一个或多个数字
1 | print(string.match("the number 1298 is event","%d+")) -- 1298 |
修饰符 * 类似于修饰符+,但是它还接受对应字符分类出现零次的情况。该修饰符一个典型的用法就是在模式的部分之间匹配可选的空格。例如,为了匹配像()或()这样的空括号对,就可以使用模式'%(%s*%)'
,其中的'%s*'
匹配零个或多个空格。另一个示例是用模式'[_%a][_%w]*'
匹配Lua程序中的标识符:标识符是一个由字母或下画线开头,并紧跟零个或多个由下画线、字母或数字组成的序列。
修饰符-和修饰符 * 类似,也是用于匹配原始字符分类的零次或多次出现。不过,跟修饰符 * 总是匹配能匹配的最长序列不同,修饰符-只会匹配最短序列。虽然有时它们两者并没有什么区别,但大多数情况下这两者会导致截然不同的结果。例如,当试图用模式'[_%a][_%w]-'
查找标识符时,由于[_%w]-
总是匹配空序列,所以我们只会找到第一个字母。又如,假设我们想要删掉某C语言程序中的所有注释,通常会首先尝试使用'/%*.*%*/'
。然而,由于'.*
会尽可能长地匹配,因此程序中的第一个"/*"
只会与最后一个"*/"
相匹配:
1 | test = "int x; /* x */ int y; /* y * /" |
相反,模式’.-‘则只会匹配到找到的第一个”*/“,这样就能得到期望的结果:
1 | test = "int x; /* x */ int y; /* y * /" |
最后一个修饰符?可用于匹配一个可选的字符。例如,假设我们想在一段文本中寻找一个证书,而这个证书可能包括一个可选的符号,那么就可以使用模式[+-]?%d+
来完成这个需求,该模式可以匹配像“-12”、“23”和“+1009”这样的数字。其中字符分类[+-]
匹配加号或减号,而其后的问号则代表这个符号是可选的。
与其他系统不同的是,Lua语言中的修饰符只能作用于一个字符模式,而无法作用于一组分类。例如,我们不能写出匹配一个可选的单词的模式。通常,可以使用一些高级技巧来绕开这个限制。
以补字符^开头的模式表示从目标字符串开头开始匹配。类似地,以$结尾的模式表示匹配到目标字符串的结尾。我们可以同时使用这两个标记来限制匹配查找和锚定模式。例如,如下的代码可以用来检查字符串s是否以数字开头:
1 | if string.find(s,"^%d") then ... |
如下的代码用来检查字符串是否为一个没有多余前缀字符和后缀字符的整数:
1 | if string.find(s."^[+-]?%d+$") then ... |
^和$字符只有位于模式的开头和结尾时才具有特殊含义;否则,它们仅仅就是与其身相匹配的普通字符。
模式%b
匹配成对的字符串,它的写法是%bxy
,其中x和y是任意两个不同的字符,x作为起始符而y作为结束字符。例如,模式%b()
匹配以左括号开始并以对应右括号结束的子串:
1 | s = "a (enclosed (in) parentheses line" |
通常,我们使用%b()
、%b[]
、%b{}
或%b<>
等作为模式,但实际上可以用任意不同的字符作为分隔符。
最后,模式%f[char-set]
代表前置模式。该模式只有在后一个字符位于char-set内而前一个字符不在时匹配一个空字符串:
1 | s = "the anthem is then theme" |
模式%f[%w]
匹配位于一个非字母或数字的字符和一个字母或数字的字符之间的前置,而模式%f[%W]
则匹配一个字母或数字的字符和一个非字母或数字的字符之间的前置。因此,指定的模式只会匹配完整的字符串”the”。请注意,即使字符集只有一个分类,也必须把它用括号括起来。
前置模式把目标字符串中第一个字符前和最后一个字符后的位置当成空字符。在前例中,第一个”the”在不属于集合[%W]
的空字符和属于集合[%W]
的t之间匹配了一个前置。
捕获
捕获机制允许根据一个模式从目标字符串中抽出与该模式匹配的内容来用于后续用途,可以通过把模式中需要捕获的部分放到一对圆括号内来指定捕获。
对于具有捕获的模式,函数string.match会将所有捕获到的值作为单独的结果返回;换句话说,该函数将字符串切分成多个被捕获的部分:
1 | pair = "name = Anna" |
模式%a+
表示一个非空的字母序列,模式%s*
表示一个可能为空的空白序列。因此,上例中的这个模式表示一个字母序列、紧跟着空白序列、一个等号、空白序列以及另一个字母序列。模式中的两个字母系列被分别放在圆括号中,因此在匹配时就能捕获到它们。下面是一个类似的示例:
1 | date = "Today is 15/4/2020" |
在这个示例中,使用了3个捕获,每个捕获对应一个数字序列。
在模式中,形如%n
的分类,表示匹配第n个捕获的副本。举一个典型的例子,假设想在一个字符串中寻找一个由单引号或双引号括起来的子串。那么可能会尝试使用模式’[“ ‘].-[“ ‘]’,它表示一个引号后面跟任意内容及另外一个引号;但是,这种模式在处理像”it’s all right”这样的字符串时会有问题。要解决这个问题,可以捕获第一个引号然后用它来指明第二个引号:
1 | s = [[then he said:"it's all right"!]] |
第1个捕获是引号本身,第2个捕获是引号中的内容。
下例是一个类似的示例,用于匹配Lua语言中的长字符串的模式:
1 | %[(=*)%[(.-)%]%1%] |
它所匹配的内容依次是:一个左方括号、零个或多个等号、另一个左方括号、任意内容(字符串的内容)、一个右方括号、相同数量的等号及另一个右方括号:
1 | p = "%[(=*)%[(.-)%]%1%]" |
第1个捕获是等号序列,第2个捕获是字符串内容。
被捕获对象的第3个用途是在函数gsub的替代字符串中。像模式一样,替代字符串同样可以包括像”%n”一样的字符分类,当发生替换时会被替换为相应的捕获。特别地,“%0”意味着整个匹配,并且替换字符串中的百分号必须被转义为“%%”。下面这个示例会重复字符串中的每个字母,并且在每个被重复的字母之间插入一个减号:
1 | print((string.gsub("hello Lua!","%a","%0-%0"))) -- h-he-el-ll-lo-o L-Lu-ua-a! |
下例交换了相邻的字符:
1 | print((string.gsub("hello Lua!","(.)(.)","%2%1"))) -- ehll ouLa |
替换
正如我们此前已经看到的,函数string.gsub的第3个参数不仅可以是字符串,还可以是一个函数或表。当第3个参数是一个函数时,函数string.gsub会在每次找到匹配时调用该函数,参数是捕获到的内容而返回值则被作为替换字符串。当第3个参数是一个表时,函数string.gsub会把第一个捕获到的内容作为建,然后将表中对应该键的值作为替换字符串。如果函数的返回值为nil或表中不包含这个键或表中键的对应值为nil,那么函数gsub不改变这个匹配。
先举一个例子,下述函数用于变量展开,它会把字符串中所有出现的$varname替换为全局变量varname的值:
1 | function expand(s) |
(_G是预先定义的包括所有全局变量的表)对于每个与$(%w_)
匹配到的地方,函数gsub都会在全局表_G中查找捕获到的名字,并用找到的结果替换字符串中相匹配的部分;如果表中没有对应的键,则不进行替换:
1 | print(expand("$othername is $status, isn't it?")) -- $othername is great,isn't it? |
如果不确定是否制定变量具有字符串值,那么可以对它们的值调用函数tostring。在这种情况下,可以用一个函数来返回要替换的值:
1 | function expand(s) |
在函数expand中,对于所有匹配$(%w+)
的地方,函数gsub都会调用给定的函数,传入捕获到的名字作为参数,并使用返回字符串替换匹配到的内容。
URL编码
我们的下一个示例中将用到URL编码,也就是HTTP所使用的在URL中传递参数的编码方式。这种编码方式会将特殊字符编码为”%xx”的形式,其中xx是对应字符的十六进制。此外,URL编码还会将空格转换为加号。例如,字符串a + b = c
的URL编码为a%2Bb+%3D+c
。最后,URL编码会将对参数名及其值用等号连接起来,然后将每对name = value用&连接起来。例如,值name = "al"
;query = "a+b = c"
;q = "yes or no"
对应的URL编码为name=al&query=a%2Bb+%3D+c&q=yes+or+no
。
现在,假设要将这个URL解码并将其中的键值对保存到一个表内,以相应的键作为索引,那么可以使用一下函数完成基本的解码:
1 | function unescape(s) |
第一个gsub函数将字符串中的所有加号替换为空格,第二个gsub函数则匹配所有以百分号开头的两位十六进制数,并对每处匹配调用一个匿名函数。这个匿名函数会将十六进制转换成一个数字并返回其对应的字符。
可以使用函数gmatch来对键值对name = value进行解码。由于键名和值都不能包含&或=,所以可以使用模式[^&=]+
来匹配它们:
1 | cgi = {} |
调用函数gmatch会匹配所有格式为name=value的键值对。对于每组键值对,迭代器会返回对应的捕获,捕获到的内容也就是name和value的值。循环体内只是简单地对两个字符串调用函数unescape,然后将结果保存到表cgi中。
对应的编码函数也很容易编写。先写一个escape函数,用它将所有的特殊字符编码为百分号紧跟对应的十六进制形式,然后把空格替换成加号:
1 | function escape(s) |
encode函数会遍历整个待编码的表,然后构造出最终的字符串:
1 | function encode(t) |
制表符展开
在Lua语言中,像()
这样的空白捕获具有特殊含义。该模式并不代表捕获空内容,而是捕获模式在目标字符串中的位置:
1 | print(strig.match("hello","()ll()")) -- 3 5 |
另一个关于位置捕获的良好示例是在字符串中进行制表符展开:
1 | function expandTabs(s,tab) |
函数gsub会匹配字符串中所有的制表符并捕获它们的位置。对于每个制表符,匿名函数会根据其所在位置计算出需要多少个空格才能恰好凑够一列(整数个tab):该函数先将位置减去1以从0开始计数,然后加上corr凑整之前的指标符(每一个被展开的指标符都会影响后续制表符的位置)。之后,该函数更新下一个制表符的修正量:为正在被去掉的制表符减1,再加上要增加的空格数sp。最后,这个函数返回由替代制表符的合适数量的空格组成的字符串。
为了完整起见,让我们再看下如何实现逆向操作,即将空格转换为制表符。第一种方法是通过空捕获来对位置进行操作,但还有一种更简单的方法:即在字符串中每隔8个字符插入一个标记,然后将前面有空格的标记替换为制表符。
1 | function unexpandTabs(s,tab) |
这个函数首先对字符串进行了制表符展开以移除其中所有的制表符,然后构造出一个用于匹配所有8个字符序列的辅助模式,再利用这个模式在每8个字符后添加一个标记。接着,它将所有以此标记结尾的空格序列都替换为制表符。最后,将剩下的标记删除。
诀窍
模式匹配是进行字符串处理的强大工具之一。虽然通过多次调用函数string.gsub就可以完成许多复杂的操作,但是还是应该谨慎地使用函数。
模式匹配替代不了传统的解析器。对于那些用后即弃的程序来说,我们确实可以在源代码中做一些有用的操作,但却很难构建出高质量的产品。
通常,在Lua程序中使用模式匹配时的效率是足够高的,但仍然需要注意,应该永远使用尽可能的精确的模式,不精确的模式会比精确的模式慢很多。一个极端的例子是模式(.-)%$
,它用于获取字符串中第一个$字符前的所有内容。如果目标字符串中有$符号,那么这个模式工作很正常;但是,如果字符串中没有$符号,那么匹配算法就会受限从字符串起始位置开始匹配,直至为了搜索$符号而遍历完整字符串。当到达字符串结尾时,这次从字符串起始位置开始的模式匹配就失败了。之后,模式匹配算法又从字符串的第二个位置开始第二次搜索,结果仍然是无法匹配这个模式。这个匹配过程会在字符串的每个位置上进行一次,从而导致O(n^2^)的时间复杂度。
此外,还要留心空模式,也就是那些匹配空字符串的模式。例如,如果试图使用模式%a*
来匹配名字,那么 就会发现到处都是名字:
1 | i,j = string.find(";$% **#$hello13","%a*") |
在这个示例汇中,函数string.find在字符串的开始位置正确地找到一个空的字母序列。
在模式的结束处使用修饰符-是没有意义的,因为这样只会匹配到空字符串。该修饰符总是需要在其后跟上其他的东西来限制扩展的范围。同样,含有”.*”的模式也非常容易出错,这主要是因为这种模式可能会匹配到超出我们预期范围的内容。
有时,用Lua语言来构造一个模式也很有用。我们已经在将空格转换为制表符的程序中使用过这个技巧。接下来再看另外一个示例,考虑如何找出一个文本中较长的行(比如超出70个字符的行)。较长的行就是一个具有70个或更多字符的序列,其中每个字符都不为换行符,因而可以使用字符分类[^\n]
来匹配除换行符以外的其他单个字符。这样,就能够通过把这个匹配单个字符的模式重复70次来匹配较长的行。除了手写以外,还可以使用函数string.rep来创建这个模式:
1 | pattern = string.ret("[^\n]",70 .. "+") |
再举一个例子,假设要进行大小写无关的查找。一种方法就是将模式中的所有字母x用[xX]
替换,即同时包含原字母大小写形式的字符分类。我们可以使用如下函数来自动地完成这种替换:
1 | function oncase(s) |
有时,我们可能需要将所有出现的s1替换为s2,而不管其中是否包含魔法字符。如果字符串s1和s2是常量,那么可以在编写字符串时对魔法字符进行合理的转义;但如果字符串是一个变量,那么就需要用另一个gsub函数来进行转义:
1 | s1 = string.gsub(s1,"(%W)","%%%1") |
在进行字符串搜索时,我们对所有字母和数字外的字符进行了转义(即大写的W)。而在替换字符串中,我们只对百分号进行了转义。
模式匹配的另一个有用的技巧就是,在进行实际工作前对目标字符串进行预处理。假设想把一个字符串中所有被双引号(“)引起来的内容改为大写,但又允许内容中包含转义的引号(“"“):
1 | follows a typical string:"This is \"great\"!". |
处理这种情况的方法之一就是先对文本进行预处理,将所有可能导致歧义的内容编码成别的内容。例如,可以讲"\""
编码成"\1"
。不过,如果原文中本身就含有"\1"
,那么就会遇到问题。另一种可以避免这个问题的简单做法是将所有"\x"
编码为"\ddd"
,其中ddd为字符x的十六进制表示形式:
1 | function code(s) |
这样,由于原字符串中所有的"\ddd"
都进行了编码,所以编码后字符串中的"\ddd"
序列一定都是编码造成的。这样,解码也就很简单了:
1 | function decode(s) |
现在我们就可以完成把一个字符串中被双引号(“)引起来的内容改为大写的需求。由于编码后的字符串中不包含任何转义的引号("\"")
,所以就可以直接使用"._"
来查找位于一对引号中的内容:
1 | s = [[follows a typical string:"This is \"great\"!".]] |
或者写成:
1 | print(decode(string.gsub(code(s),'".-"',string.upper))) |
是否能够将模式匹配函数用于UTF-8字符串取决于模式本身。由于UTF-8的主要特征之一就是任意字符的编码不会出现在别的字符的编码中,因此文本类的模式一般可以正常工作。字符分类和字符集只对ASCII字符有效。例如,可以对UTF-8字字符串使用模式’%s’,但它只能匹配ASCII空格,而不能匹配诸如HTML空格或蒙古文元音分隔符等其他的Uicode空格。
恰当的模式能够为处理Unicode带来额外的能力。一个优秀的例子是预定义模式utf8.charpattern,该模式只精确地匹配一个UTF-8字符。utf8标准库中就是按照下面的方法定义这个模式的:
1 | utf8.charpattern = [\0-\x7F\xC2-\xF4][\x80-\xBF]* |
该模式的第1部分匹配ASCII字符或多字节序列的其实字节。第2部分则匹配零个或多个后续的自己。