理解Lua中的模式匹配(Pattern Matching)

11,533 阅读4分钟

最近在学习Lua的过程中,发现Lua中并没有内置正则表达式的功能,而是发明了一种类正则表达式的规则——模式匹配(Pattern Matching)来支持类似功能。其主要原因是出于程序大小的考虑:一个符合POSIX标准的正则表达式实现要大于4000行代码,比整个Lua的标准库都要大,而相比之下,Lua中模式匹配的实现只有不到500行。模式匹配的功能虽然没有正则表达式那般强大,但也足以应对大多数场景。这也算是一个“空间换时间”的思路——程序员们的学习时间。本篇将通过一些示例来帮助理解Lua中的模式匹配。

快速预览

以下示例程序从字符串s中提取日期部分并打印输出:

s = "Deadline is 30/05/1999, firm"
date = "%d%d/%d%d/%d%d%d%d"
print(string.match(s, date))   --> 30/05/1999

其中字符串data就是用作匹配的“模式”(pattern)。不难发现,%d表示一个数字,告诉string.match:把在字符串s中形如dd/mm/yyyy格式的日期匹配出来。

模式的组成

character classes

类似%d的用法,在Lua中称之为character classes,列举如下:

character class匹配
%a字母
%c控制字符 (例如回车:\n)
%d0-9的数字
%l小写字母
%p标点符号
%s空白字符
%u大写字母
%w字母和数字
%x十六进制
%z空字符"\0"

使用character class的大写可以表示该类型的补充。例如%A代表所有非字母字符:

s, _ = string.gsub("hello, up-down!", "%A", ".")
print(s) --> hello..up.down. 

string.gsub函数过程:匹配"hello, up-down!"中所有非字符字符(%A),并用"."替代这些符。

magic characters

至此,我们发现character class只能匹配一个字符,能力十分有限。不过Lua还引入了类似正则表达式语法,可以通过一些特殊字符实现复杂匹配,这些特殊字符在Lua中称为magic characters,列举如下:

magic character描述备注
()标记一个子模式,供后续使用,跟正则的用法类似
.匹配所有字符类似正则表达式,只不过正则表达式的.不包括回车"\n"
%1. 可用作转义符;
2. 声明character classess;
3. 跟()结合用于子模式匹配:%N,N是一个数字,表示匹配第N个子串,例如%1,%2 ...,跟正则表达式的1,1,2...类似。
+匹配前面的模式1次或多次
-匹配前面的模式0次或多次,返回最短的匹配结果,模式匹配中的“非贪婪模式”
*匹配前面的模式0次或多次
?匹配前面的模式0次或1次
如果处于模式开头,则表示匹配输入字符串的开始位置,如果放在[]中,表示取补集, 跟正则表达式的用法基本一致
$表示匹配输入字符串的结尾位置,跟正则表达式的用法基本一致
[ ]表示一个字符集合(char-set),跟正则用法类似例如[%w_]表示匹配字母数字和下划线,[a-f]表示匹配字母a到f

示例

在Lua中,用到模式匹配的函数主要有:

函数签名基本用途
string.find(string, pattern [, init, plain])在字符串中查找符合pattern模式的第一个实例,并返回其起止位置
string.match(string, pattern [, init])返回字符串中符合pattern模式的第一个实例
string.gmatch(string, pattern)返回一个迭代函数,该函数每次调用会返回下一个匹配的实例
string.gsub(string, pattern,repl [, n])把字符串中所有被pattern匹配的字符串都替换为repl,返回替换后的字符串

[]中的参数为可选参数,详尽的函数说明请参阅官方文档。
www.lua.org/manual/5.1/…

接下来我们将通过几个示例程序来演示如何使用模式匹配。

基本示例

匹配多次

-- + 匹配1次或多次
print(string.match("hello world 123","%w+ %w+")) -->  hello world
print(string.match("helle world 123","[%w %d]+")) --> hello world 123
-- ? 匹配0或1次 (匹配一个有符号数)
print(string.match("the number is: +123", "[+-]?%d+")) --> +123
print(string.match("the number is: 123", "[+-]?%d+")) --> 123
print(string.match("the number is: -123", "[+-]?%d+")) --> -123
-- * 匹配0次或多次
print(string.match("abc123abc", "%a+%d*%a+")) --> abc123abc
print(string.match("abcabc", "%a+%d*%a+")) --> abcabc

使用[]组合

-- 匹配所有字母数字和  "."
print(string.match("a1.a2+a3","[%w.]+")) --> a1.a2
-- 匹配所有数字,等同于%d+,这里只是作为示例,实际编码使用%d+即可
print(string.match("abc123","[0-9]+")) --> 123
-- 匹配所有字母和数字,等同于%w+,这里只是作为示例,实际编码中使用%w+即可
print(string.match("abc123","[a-fA-F0-9]+")) --> abc123
-- 匹配0和1
print(string.match("1010345","[01]+")) --> 1010
-- ^取反, 匹配除了字母以外的所有字符
print(string.match("abc123","[^%a]+")) --> 123

使用()%N来捕获子模式

以下这个例子使用()来捕获子模式,并且返回对应子模式的匹配结果:

date = "17/7/1990"
_, _, d, m, y = string.find(date, "(%d+)/(%d+)/(%d+)")
print(d, m, y)  --> 17  7  1990

这个例子很好理解:我们在模式中使用()定义了三个子模式,string.find将子模式的匹配结果作为返回值分别返回。
使用()搭配%N,还可以直接在模式中使用被捕获的子模式的值,例如提取一个字符串中被双引号"或单引号'包裹的部分,可以这样做:

s = [[then he said: "it's all right"!]]
a, b, c, quotedPart = string.find(s, "([\"'])(.-)%1")
print(quotedPart)   --> it's all right
print(c)            --> "

模式([\"'])(.-)%1包含两个子模式,%1则表示匹配到的第一个子模式(也就是返回值c),也就是([\"'])匹配到的值,在这个例子中,是双引号"。在这个示例中,不能简单的使用类似[\"'].-[\"']这样的模式,因为显然这条模式只能匹配到it

非贪婪匹配

+,*,?的匹配规则跟正则表达式中的贪婪匹配类似,总是尽可能多的匹配。正则表达式使用?来表示非贪婪限定符。在Lua中,?不具备这样的功能,而是使用-来表示非贪婪匹配,例如:

print(string.match("<span>hello</span><span>world</span>","<span>.+</span>"))
--> <span>hello</span><span>world</span>
print(string.match("<span>hello</span><span>world</span>","<span>.-</span>")) 
--> <span>hello</span>

我们希望匹配HTML元素span之间的内容,在第一条模式中,由于.+是贪婪匹配,所以直接匹配到了结尾的</span>之前,也就是hello</span><span>world。第二条模式中,.-会匹配尽可能少的字符,也就是非贪婪匹配模式(懒惰模式),匹配到第一个</span>就停止匹配了。
通过string.gusb来观察匹配结果:

-- 期望将<span>xxx</span>中的xxx替换成lua
-- 贪婪匹配,替换了整个字符串
print(string.gsub("<span>hello</span><span>world</span>","<span>.+</span>","<span>lua</span>"))
--> <span>lua</span> 

-- 非贪婪匹配,结果符合预期
print(string.gsub("<span>hello</span><span>world</span>","<span>.-</span>","<span>lua</span>"))
--> <span>lua</span><span>lua</span>

要注意,-用在模式的开头是没有意义的,因为-表示匹配0次或多次,如果放在开头,那意味着永远都表示匹配0次。

string.gmatch

string.match只能匹配字符串中第一个符合pattern模式的字符串,如果我们想得到字符串中所有符合pattern模式的子串,则可以使用string.gmatch,以上述贪婪匹配为例,打印所有span标签中的元素:

for i  in string.gmatch("<span>hello</span><span>world</span>", "<span>(.-)</span>") do
    print(i)
end

输出结果:

hello
world

可以发现,我们对pattern做了一点小手脚,从<span>.-</span>改成了<span>(.-)</span>。这样使得string.gmatch返回的迭代器会直接迭代捕获的子模式,也就是(.-)。如果沿用原本的pattern,则输出为:

<span>hello</span>
<span>world</span>

这是gmatch的特性:如果在pattern中出现了(),则迭代器只会迭代()所匹配的子串,如果没有,则返回所有匹配的字符串。利用这个特性,还可以实现读取类似key=value键值对的功能:

s = "from=world, to=Lua"
for k, v in string.gmatch(s, "(%w+)=(%w+)") do
    print("key="..k)
    print("value="..v)
end

输出结果:

key=from
value=world
key=to
value=Lua

string.gsub

string.gsub用于字符串的匹配-替换,使用参数repl替换字符串中的所有(或者前n个,如果有传入第三个参数来给定)符合pattern的字符串,其中repl可以是字符串,方法或者是table:

x = string.gsub("hello world", "(%w+)", "%1 %1")
--> x="hello hello world world"
--%1 表示(%w+)所匹配的字符串,也就是"hello world"。

x = string.gsub("hello world", "%w+", "%0 %0", 1)
--> x="hello hello world"
--第三个参数表示替换前1一个,也就是第一个,匹配到的字符串,也就是"hello"。
--对于第一次匹配,$0 $0 = "hello hello"。
--因此得到结果为"hello hello world"。

x = string.gsub("hello world", "%w+", "%0 %0", 2)
--> x="hello hello world world"
--第三个参数表示替换前2个匹配到的字符串,也就是"hello"和"world"。
--对于第一次匹配,$0 $0 = "hello hello".
--对于第二次匹配,$0 $0 = "world world"
--因此得到结果为"hello hello world world"

x = string.gsub("hello world from Lua", "(%w+)%s*(%w+)", "%2 %1")
--> x="world hello Lua from"

x = string.gsub("home = $HOME, user = $USER", "%$(%w+)", os.getenv)
--> x="home = /home/roberto, user = roberto"
--%$中的%是转义,表示匹配$
--当第二个参数是函数的时候,这个函数会在每次匹配到模式的时候调用,入参为()所匹配的子模式
--函数返回值则则作为替换的字符串。
--在这个示例中,会调用 os.getenv("HOME") 和 os.getenv("USER"),并根据返回值替换原字符串。

x = string.gsub("4+5 = $return 4+5$", "%$(.-)%$", function (s)
     return loadstring(s)()
   end)
--> x="4+5 = 9"

local t = {name="lua", version="5.1"}
x = string.gsub("$name-$version.tar.gz", "%$(%w+)", t)
--> x="lua-5.1.tar.gz"

执行过程拆解

string.gsub玩法十分丰富,但万变不离其宗。理解了其执行过程,再理解上面的示例就很轻松了。
string.gsub的执行过程就是一个模式匹配+字符串替换的循环,直到模式匹配停止。过程伪代码表示如下:

repeat 
    执行模式匹配
    根据repl规则,对本次循环模式匹配到的字符串进行替换
until (模式匹配结束)

如果不传递第三个参数,string.gsub默认对字符串进行完全匹配,如果我们不需要对字符串进行完全匹配(例如像是string.matchstring.find,只匹配第一个结果就结束了),我们可以通过第三个参数来设定要匹配几次——也就是设定模式匹配什么时候结束。在当前匹配-替换循环中,repl只会感知到本次模式匹配的结果:例如%N所捕获的子串的是当次匹配的子串,如果repl是一个函数,那么函数的入参也是当次匹配的字符串,返回值也只替换当次匹配的字符串。

参考

www.lua.org/manual/5.1/…
www.lua.org/pil/20.1.ht…
www.fhug.org.uk/wiki/wiki/d…
www.jianshu.com/p/f141027e1…