Lua程序设计 | 闭包、模式匹配、数据文件与序列化

From《Programming in Lua》 by Roberto Ierusalimschy

文章目录

  • 闭包
    • 函数是第一类值
    • 非全局函数
    • 词法定界
  • 模式匹配
    • 模式匹配的相关函数
      • 函数 string.find
      • 函数 string.match
      • 函数 string.gsub
      • 函数 string.gmatch
    • 模式
    • 捕获
    • 替换
  • 数据文件和序列化
    • 数据文件
    • 序列化
      • 保存不带循环的表
      • 保存带有循环的表


闭包

在Lua语言中,函数是严格遵循词法定界的第一类值。

“第一类值”意味着Lua语言中的函数与其他常见类型的值(例如数值和字符串)具有同等权限:一个程序可以将某个函数保存到变量中(全局变量和局部变量均可)或表中,也可以将某个函数作为参数传递给其他函数,还可以将某个函数作为其他函数的返回值返回。“词法定界”意味着Lua语言中的函数可以访问包含其自身的外部函数中的变量(也意味着Lua语言完全支持Lambda演算)。

上述两个特性联合起来为Lua语言带来了极大的灵活性。例如,一个程序可以通过重新定义函数来增加新功能,也可以通过擦除函数来为不受信任的代码(例如通过网络接收到的代码)创建一个安全的运行时环境。更重要的是,上述两个特性允许我们在Lua语言中使用很多函数式语言的强大编程技巧。

函数是第一类值

如前所述,Lua语言中的函数是第一类值。以下的示例演示了第一类值的含义:

a = (p = print}			--'a.p'指向'print'函数	
a.p("Hello World")		--> Hello World
print = math.sin		-- 'print1现在指向sine函数	
a.p(print(1))			--> 0.8414709848079	
math.sin = a.p			--'sin'现在指向print函数	
math.sin(10, 20)		--> 10	20

如果函数也是值的话,那么是否有创建函数的表达式呢?答案是肯定的。事实上,Lua语言中常见的函数定义方式如下:

function foo (x) return 2*x end

就是所谓的语法糖的例子,它只是下面这种写法的一种美化形式:

foo = function (x) return 2*x end

赋值语句右边的表达式(function (x) body end)就是函数构造器,与表构造器 {} 相似。因此,函数定义实际上就是创建类型为”function”的值并把它赋值给一个变量的语句。

请注意,在Lua语言中,所有的函数都是匿名的。像其他所有的值一样,函数并没有名字。当讨论函数名时,比如print,实际上指的是保存该函数的变量。虽然我们通常会把函数赋值给全局变量,从而看似给函数起了一个名字,但在很多场景下仍然会保留函数的匿名性。

表标准库提供了函数table.sort,该函数以一个表为参数并对其中的元素排序。这种函数必须支持各种各样的排序方式:升序或降序、按数值顺序或按字母顺序、按表中的键等。函数sort并没有试图穷尽所有的排序方式,而是提供了一个可选的参数,也就是所谓的排序函数,排序函数接收两个参数并根据第一个元素是否应排在第二个元素
之前返回不同的值。例如,假设有一个如下所示的表:

network = {
  {name = "grauna", 	IP = "210.26.30.34"},
  {name = "arraial", 	IP = "210.26.30.23"},
  {name = "lua", 			IP = "210.26.30.12"},
  {name = "derain", 	IP = "210.26.30.20"},
}

如果想针对name字段、按字母顺序逆序对这个表排序,只需使用如下语句:

table.sort(network, function (a, b) return (a.name > b.name) end)

可见,匿名函数在这条语句中显示出了很好的便利性。

像函数sort这样以另一个函数为参数的函数,我们称之为高阶函数。高阶函数是一种强大的编程机制,而利用匿名函数作为参数正是其灵活性的主要来源。

非全局函数

由于函数是一种“第一类值”,因此一个显而易见的结果就是:函数不仅可以被存储在全局变量中,还可以被存储在表字段和局部变量中。

我们已经在前面的博文中见到过几个将函数存储在表字段中的示例,大部分Lua语言的库就采用了这种机制(例如io.read和math.sin)。如果要在Lua语言中创建这种函数,只需将到目前为止我们所学到的知识结合起来:

Lib = {}

Lib.foo = function (x,y) return x + y end
Lib.goo = function (x,y) return x - y end

print(Lib.foo(2, 3), Lib.goo(2, 3))			--> 5  -1

当然,也可以使用表构造器:

Lib = {
  foo = function (x,y) return x + y end,
  goo = function (x,y) return x - y end
}

除此以外,Lua语言还提供了另一种特殊的语法来定义这类函数:

lib = {}
function Lib.foo (x,y) return x + y end
function Lib.goo (x,y) return x - y end

在表字段中存储函数是Lua语言中实现面向对象编程的关键要素。我们在后续博文中会继续讲解。

当把一个函数存储到局部变量时,就得到了一个局部函数,即一个被限定在指定作用域中使用的函数。局部函数对于包(package)而言尤其有用:由于Lua语言将每个程序段(chunk)作为一个函数处理,所以在一段程序中声明的函数就是局部函数,这些局部函数只在该程序段中可见。词法定界保证了程序段中的其他函数可以使用这些局部函数。

对于这种局部函数的使用,Lua语言提供了一种语法糖:

local function f (params)
	body
end

在定义局部递归函数时,由于原来的方法不适用,所以有一点是极易出错的。考虑如下的代码:

local fact = function (n)
	if n == 0 then return 1
	else return n*fact(n-1)		-- 有问题
	end
end

当Lua语言编译函数体中的fact(n-1)调用时,局部的fact尚未定义。因此,这个表达式会尝试调用全局的fact而非局部的fact。我们可以通过先定义局部变量再定义函数的方式来解决这个问题:

local fact
fact = function (n)
	if n == 0 then return 1
	else return n*fact(n-1)
	end
end

这样,函数内的fact指向的是局部变量。尽管在定义函数时,这个局部变量的值尚未确定,但到了执行函数时,fact肯定已经有了正确的赋值。

当Lua语言展开局部函数的语法糖时,使用的并不是之前的基本函数定义。

local function foo (params) body end
-- 上述函数会被展开成
local foo; foo = function (params) body end

因此,使用这种语法来定义递归函数不会有问题。

当然,这个技巧对于间接递归函数是无效的。在间接递归的情况下,必须使用与明确的前向声明等价的形式:

local f		-- "前向"声明

local function g()
  some code f() some code
end

function f()
  some code g() some code
end

请注意,不能在最后一个函数定义前加上local。否则,Lua语言会创建一个全新的局部变量f,从而使得先前声明的f (函数g中使用的那个)变为未定义状态。

词法定界

当编写一个被其他函数B包含的函数A时,被包含的函数A可以访问包含其的函数B的所有局部变量,我们将这种特性称为词法定界。虽然这种可见性规则听上去很明确,但实际上并非如此。词法定界外加嵌套的第一类值函数可以为编程语言提供强大的功能,但很多编程语言并不支持将这两者组合使用。

先看一个简单的例子。假设有一个表,其中包含了学生的姓名和对应的成绩,如果我们想基于分数对学生姓名排序,分数高者在前,那么可以使用如下的代码完成上述需求:

names = {"Peter", "Paul", "Mary"}
grades = {Mary = 10, Paul = 7, Peter = 0}
table.sort(names, function (n1, n2)
	return grades[n1] > grades[n2]
end)

现在,假设我们想创建一个函数来完成这个需求:

function sortbygrade (names, grades)
	table.sort(names, function (n1, n2)
		return grades[n1] > grades[n2]		--	比较分数
  end)
end

在后一个示例中,有趣的一点就在于传给函数sort的匿名函数可以访问grades,而grades是包含匿名函数的外层函数sortbygrade的形参。在该匿名函数中,grades既不是全局变量也不是局部变量,而是我们所说的非局部变量(由于历史原因,在Lua语言中非局部变量也被称为上值)。

这一点之所以如此有趣是因为,函数作为第一类值,能够逃逸出它们变量的原始定界范围。考虑如下的代码:

function newCounter ()
	local count = 0
	return function ()		-- 匿名函数
      count = count + 1
      return count
    end
end

c1 = newCounter()
print(c1())		--> 1
print(c1())		--> 2

在上述代码中,匿名函数访问了一个非局部变量(count)并将其当作计数器。然而,由于创建变量的函数(newCounter)己经返回,因此当我们调用匿名函数时,变量count似乎已经超出了作用范围。但其实不然,由于闭包(closure)概念的存在,Lua语言能够正确地应对这种情况。

简单地说,一个闭包就是一个函数外加能够使该函数正确访问非局部变量所需的其他机制。如果我们再次调用newCounter,那么一个新的局部变量count和一个新的闭包会被创建出来,这个新的闭包针对的是这个新变量:

c2 = newCounter()
print(c2()) --> 1
print(c1()) --> 3
print(c2()) --> 2

因此,c1和c2是不同的闭包。它们建立在相同的函数之上,但是各自拥有局部变量count的独立实例。

从技术上讲,Lua语言中只有闭包而没有函数。函数本身只是闭包的一种原型。不过尽管如此,只要不会引起混淆,我们就仍将使用术语“函数”来指代闭包。

闭包在许多场合中均是一种有价值的工具。正如我们之前已经见到过的,闭包在作为诸如sort这样的高阶函数的参数时就非常有用。同样,闭包对于那些创建了其他函数的函数也很有用,例如我们之前的newCounter示例。这种机制使得Lua程序能够综合运用函数式编程世界中多种精妙的编程技巧。

另外,闭包对于回调(callback)函数来说也很有用。对于回调函数而言,一个典型的例子就是在传统GUI工具箱中创建按钮。每个按钮通常都对应一个回调函数,当用户按下按钮时,完成不同的处理动作的回调函数就会被调用。

例如,假设有一个具有10个类似按钮的数字计算器(每个按钮代表一个十进制数字),我们就可以使用如下的函数来创建这些按钮:

function digitButton (digit)
	return Button{ label = tostring(digit),
                  action = function()
                  add_to_display(digit)
                  end
                  }
end

在上述示例中,假设Button是一个创建新按钮的工具箱函数,label是按钮的标签,action是当按钮按下时被调用的回调函数。回调可能发生在函数digitButton早已执行完后,那时变量digit已经超出了作用范围,但闭包仍可以访问它。

闭包在另一种场景下也非常有用。由于函数可以被保存在普通变量中,因此在Lua语言中可以轻松地重新定义函数,甚至是预定义函数。这种机制也正是Lua语言灵活的原因之一。通常,当重新定义一个函数的时候,我们需要在新的实现中调用原来的那个函数。

例如,假设要重新定义函数sin以使其参数以角度为单位而不是以弧度为单位。那么这个新函数就可以先对参数进行转换,然后再调用原来的sin函数进行真正的计算。代码可能形如:

local oldSin = math.sin
math.sin = function (x)
	return oldSin(x * (math.pi / 180))
end

另一种更清晰一点的完成重新定义的写法是:

do
  local oldSin = math.sin
  local k = math.pi / 180
  math.sin = function (x)
  	return oldSin(x * k)
	end
end

上述代码使用了 do代码段来限制局部变量oldSin的作用范围;根据可见性规则,局部变量oldSin只在这部分代码段中有效。因此,只有新版本的函数sin才能访问原来的sin函数,其他部分的代码则访问不了。

我们可以使用同样的技巧来创建安全的运行时环境,即所谓的沙盒(sandbox)。当执行一些诸如从远程服务器上下载到的未受信任代码时,安全的运行时环境非常重要。例如,我们可以通过使用闭包重定义函数io.open来限制一个程序能够访问的文件:

do
local oldOpen = io.open
local access_OK = function (filename, mode)
	-- check access
end
  
io.open = function (filename, mode)
    if access_OK(filename, mode) then
      return oldOpen(filename, mode)
    else
      return nil, "access denied"
    end
  end
end

上述示例的巧妙之处在于,在经过重新定义后,一个程序就只能通过新的受限版本来调用原来未受限版本的io.open函数。示例代码将原来不安全的版本保存为闭包的一个私有变量,该变量无法从外部访问。通过这一技巧,就可以在保证简洁性和灵活性的前提下在Lua语言本身上构建Lua沙盒。相对于提供一套大而全的解决方案,Lua语言提供的是一套“元机制”,借助这种机制可以根据特定的安全需求来裁剪具体的运行时环境。我们会在后续博文中继续深入的探讨这个话题。

模式匹配

模式匹配的相关函数

字符串标准库提供了基于模式(pattern)的4个函数。在之前的博文中已经初步了解过函数find和gsub,其余两个函数分别是match和gmatch (GlobalMatch的缩写)。接下来会对其进行详细介绍与分析。

函数 string.find

函数string.find用于在指定的目标字符串中搜索指定的模式。最简单的模式就是一个单词,它只会匹配到这个单词本身。例如,模式’hello’会在目标字符串中搜索子串"hello"。

函数string.find找到一个模式后,会返回两个值:匹配到模式开始位置的索引和结束位置的索引。如果没有找到任何匹配,则返回nil:

s = "hello world"
i, j = string.find(s, "hello")
print(i, j)							--> 1		5
print(string.sub(s, i, j))			--> hello
print(string.find(s, "world"))		--> 7		11
i, j = string.find(s, "l")
print(i, j)							--> 3		3
print(string.find(s, "lll"))		--> nil

匹配成功后,可以以函数find返回的结果为参数调用函数string.sub来获取目标字符串中匹配相应模式的子串。对于简单的模式来说,这一般就是模式本身。

函数string.find具有两个可选参数。

  • 第3个参数是一个索引,用于说明从目标字符串的哪个位置开始搜索。
  • 第4个参数是一个布尔值,用于说明是否进行简单搜索。简单搜索就是忽略模式而在目标字符串中进行单纯的“查找子字符串”的动作。
string.find("a [word]", "[")
-- stdin:1: malformed pattern (missing ']')

string.find("a [word]", "[", 1, true) 	--> 3	3

由于’[‘在模式中具有特殊含义,因此第1个函数调用会报错。在第2个函数调用中,函数只是把’['当作简单字符串。请注意,如果没有第3个参数,是不能传入第4个可选参数的。

函数 string.match

由于函数string.match也用于在一个字符串中搜索模式,因此它与函数string.find非常相似。不过,函数string.match返回的是目标字符串中与模式相匹配的那部分子串,而非该模式所在的位置:

print(string.match("hello world", "hello")) --> hello

对于诸如’hello’这样固定的模式,使用这个函数并没有什么意义。然而,当模式是变量时,这个函数的强大之处就显现出来了,例如:

date = "Today is 17/7/1990"
d = string.match(date, "%d+/%d+/%d+")
print(d)	 --> 17/7/1990

函数 string.gsub

函数string.gsub有3个必选参数:目标字符串、模式和替换字符串,其基本用法是将目标字符串中所有出现模式的地方换成替换字符串:

s = string.gsub("Lua is cute", "cute", "great")
print(s) --> Lua is great

s = string.gsub("all lii", "l", "x")
print(s) --> axx xii

s = string.gsub("Lua is great", "Sol", "Sun")
print(s) --> Lua is great

此外,该函数还有一个可选的第4个参数,用于限制替换的次数:

s = string.gsub("all lii", "l", "x", 1)
print(s) --> axl lii

s = string.gsub("all lii", "l", "x", 2)
print(s) --> axx lii

除了替换字符串以外,string.gsub的第3个参数也可以是一个函数或一个表,这个函数或表会被调用(或检索)以产生替换字符串。

函数string.gsub还会返回第2个结果,即发生替换的次数。

函数 string.gmatch

函数string.gmatch返回一个函数,通过返回的函数可以遍历一个字符串中所有出现的指定模式。例如,以下示例可以找出指定字符串s中出现的所有单词:

s = "some string"
words = {}
for w in string.gmatch(s, "a+") do
	words[#words + 1] = w
end

后续我们马上会学习到,模式’%a+'会匹配一个或多个字母组成的序列(也就是单词)。因此,for循环会遍历所有目标字符串中的单词,然后把它们保存到列表words中。

模式

大多数模式匹配库都使用反斜杠作为转义符。然而,这种方式可能会导致一些不良的后果。对于Lua语言的解析器而言,模式仅仅是普通的字符串。模式与其他的字符串一样遵循相同的规则,并不会被特殊对待;只有模式匹配相关的函数才会把它们当作模式进行解析。由于反斜杠是Lua语言中的转义符,所以我们应该避免将它传递给任何函数。模式本身就难以阅读,到处把"\“换成”\\"就更加火上浇油了。

我们可以使用双括号把模式括起来构成的长字符串来解决这个问题。然而,长字符串的写法对于通常比较短的模式而言又往往显得冗长。此外,我们还会失去在模式内进行转义的能力。

Lua语言的解决方案更加简单:Lua语言中的模式使用百分号作为转义符(C语言中的一些函数釆用的也是同样的方式,如函数printf)。总体上,所有被转义的字母都具有某些特殊含义(例如’%a’匹配所有字母),而所有被转义的非字母则代表其本身(例如’%.'匹配一个点)。

首先来讲解一下字符分类的模式。所谓字符分类,就是模式中能够与一个特定集合中的任意字符相匹配的一项。例如,分类%d匹配的是任意数字。因此,可以使用模式’%d%d/%d%d/%d%d%d%d’来匹配dd/mm/yyyy格式的日期:

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

下表列出了所有预置的字符分类及其对应的含义:

预置字符分类 含义
. 任意字符
%a 字母
%c 看着丈夫
%d 数字
%g 除空格外的可打印字符
%l 小写字母
%p 标点符号
%s 空白字符
%u 大写字母
%w 字母和数字
%x 十六进制数字

这些类的大写形式表示类的补集。例如,’%A’代表任意非字母的字符:

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

在输出函数gsub的返回结果时,我们使用了额外的括号来丢弃第二个结果,也就是替换发生的次数。

当在模式中使用时,还有一些被称为魔法字符的字符,它们具有特殊含义。Lua语言的模式所使用的魔法字符包括:

(	)	.	%	+	-	*	?	[	]	^	$

正如我们之前已经看到的,百分号同样可以用于这些魔法字符的转义。因此,’%?‘匹配一个问号,’%%'匹配一个百分号。我们不仅可以用百分号对魔法字符进行转义,还可以将其用于其他所有字母和数字外的字符。当不确定是否需要转义时,为了保险起见就可以使用转义符。

可以使用字符集来创建自定义的字符分类,只需要在方括号内将单个字符和字符分类组合起来即可。例如,字符集’[%w_]‘匹配所有以下画线结尾的字母和数字,’[01]‘匹配二进制数字,’[%[%]]'匹配方括号。如果想要统计一段文本中元音的数量,可以使用如下的代码:

_, nvow = string.gsub(text, "[AEIOUaeiou]", "")

还可以在字符集中包含一段字符范围,做法是写出字符范围的第一个字符和最后一个字符并用横线将它们连接在一起。由于大多数常用的字符范围都被预先定义了,所以这个功能很少被使用。例如,’%d’相当于’[0-9]’,’%x’相当于’[0-9a-fA-F]’。不过,如果需要查找一个八进制的数字,那么使用’[0-7]‘就比显式地枚举’[01234567]'强多了。

在字符集前加一个补字符^就可以得到这个字符集对应的补集:模式’[^0-7]‘代表所有八进制数字以外的字符,模式’[^\n]‘则代表除换行符以外的其他字符。尽管如此,我们还是要记得对于简单的分类来说可以使用大写形式来获得对应的补集:’%S’显然要比’[^%s]'更简单。

还可以通过描述模式中重复和可选部分的修饰符来让模式更加有用。Lua语言中的模式提供了4种修饰符:

  • +:重复一次或多次
  • *:重复零次或多次
  • -:重复零次或多次(最小匹配)
  • ?:可选(出现零次或一次)

修饰符+匹配原始字符分类中的一个或多个字符,它总是获取与模式相匹配的最长序列。例如,模式’%a+'代表一个或多个字母(即一个单词):

print((string.gsub("one, and two; and three", "%a+", "word")))
--> word, word word; word word

模式’%d+'匹配一个或多个数字(一个整数):

print(string.match("the number 1298 is even", "%d+"))	--> 1298

修饰符*类似于修饰符+,但是它还接受对应字符分类出现零次的情况。该修饰符一个典型的用法就是在模式的部分之间匹配可选的空格。例如,为了匹配像()或( )这样的空括号对,就可以使用模式其中的’%(%s*%)’,其中的’%s*'匹配零个或多个空格(括号在模式中有特殊含义,所以必须进行转义)。

另一个示例是用模式’[_%a][_%w]*'匹配Lua程序中的标识符:标识符是一个由字母或下画线开头,并紧跟零个或多个由下画线、字母或数字组成的序列。

修饰符-和修饰符*类似,也是用于匹配原始字符分类的零次或多次出现。不过,跟修饰符*总是匹配能匹配的最长序列不同,修饰符-只会匹配最短序列。虽然有时它们两者并没有什么区别,但大多数情况下这两者会导致截然不同的结果。

例如,当试图用模式’[_%a][_%w]-‘查找标识符时,由于’[_%w]-‘总是匹配空序列,所以我们只会找到第一个字母。又如,假设我们想要删掉某C语言程序中的所有注释,通常会首先尝试使用’/%*.*%/’,即"/*“和”*/“之间的任意序列,使用恰当的转义符对*进行转义)。然而,由于’.*'会尽可能长地匹配气 因此程序中的第一个”/*“只会与最后一个”*/"相匹配:

test = "int x; /* x */ int y; /* y */"
print((string.gsub(test, "/%*.*%*/", "")))
	--> int x;

相反,模式’.-'则只会匹配到找到的第一个这样就能得到期望的结果:

test = "int x; /* x */ int y; /* y */“
print((string.gsub(test, "/%*.-%*/", "")))
	--> int x; int y;

修饰符?可用于匹配一个可选的字符。例如,假设我们想在一段文本中寻找一个整数,而这个整数可能包括一个可选的符号,那么就可以使用模式’[±]?%(!+‘来完成这个需求,该模式可以匹配像"23"和"+1009"这样的数字。其中,字符分类’[±]'匹配加号或减号,而其后的问号则代表这个符号是可选的。

与其他系统不同的是,Lua语言中的修饰符只能作用于一个字符模式,而无法作用于一组分类。例如,我们不能写岀匹配一个可选的单词的模式(除非这个单词只由一个字母组成)。通常,可以使用一些将在本章最后介绍的高级技巧来绕开这个限制。

以补字符人开头的模式表示从目标字符串的开头开始匹配。类似地,以$结尾的模式表示匹配到目标字符串的结尾。我们可以同时使用这两个标记来限制匹配查找和锚定模式。例如,如下的代码可以用来检查字符串s是否以数字开头:

if string.find(s, "^%d") then ...

如下的代码用来检查字符串是否为一个没有多余前缀字符和后缀字符的整数:

if string.find(s, "^[+-]?%d+$") then ...

^和$字符只有位于模式的开头和结尾时才具有特殊含义;否则,它们仅仅就是与其自身相匹配的普通字符。

模式’%b’匹配成对的字符串,它的写法是’%bxy’,其中x和y是任意两个不同的字符,x作为起始字符而y作为结束字符。例如,模式’%b()'匹配以左括号开始并以对应右括号结束的子串:

s = "a (enclosed (in) parentheses) line"
print((string.gsub(s, "%b()", "")))	--> a line

通常,我们使用’%b()’、’%b[]’ 、’%b{}‘或’%b<>'等作为模式,但实际上可以用任意不同的字符作为分隔符。

最后,模式’%f[char-set]'代表前置模式。该模式只有在后一个字符位于char-set内而前一个字符不在时匹配一个空字符串:

s = "the anthem is the theme"
print((string.gsub(s, "%f[%w]the%f[%W]", "one")))
--> one anthem is one theme

模式’%f[%w]‘匹配位于一个非字母或数字的字符和一个字母或数字的字符之间的前置,而模式’%f[%W]'则匹配一个字母或数字的字符和一个非字母或数字的字符之间的前置。因此,指定的模式只会匹配完整的字符串"the"。请注意,即使字符集只有一个分类,也必须把它用括号括起来。

前置模式把目标字符串中第一个字符前和最后一个字符后的位置当成空字符(ASCII编码的\0)。在前例中,第一个"the"在不属于集合’[%w]‘的空字符和属于集合’[%w]'的t之间匹配了一个前置。

捕获

捕获(capture)机制允许根据一个模式从目标字符串中抽出与该模式匹配的内容来用于后续用途,可以通过把模式中需要捕获的部分放到一对圆括号内来指定捕获。对于具有捕获的模式,函数string.match会将所有捕获到的值作为单独的结果返回;换句话说,该函数会将字符串切分成多个被捕获的部分:

pair = "name = Anna"
key, value = string.match(pair, "(%a+)%s*=%s*(%a+)")
print(key, value) --> name Anna

模式’%a+‘表示一个非空的字母序列,模式’%s+'表示一个可能为空的空白序列。因此,上例中的这个模式表示一个字母序列、紧跟着空白序列、一个等号、空白序列以及另一个字母序列。模式中的两个字母序列被分别放在圆括号中,因此在匹配时就能捕获到它们。下面是一个类似的示例:

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

在这个示例中,使用了 3个捕获,每个捕获对应一个数字序列。

在模式中,形如’%n’的分类(其中n是一个数字),表示匹配第n个捕获的副本。举一个典型的例子,假设想在一个字符串中寻找一个由单引号或双引号括起来的子串。那么可能会尝试使用模式’[" '].-[" ‘]’,它表示一个引号后面跟任意内容及另外一个引号;但是,这种模式在处理像"it’s all right"这样的字符串时会有问题。要解决这个问题,可以捕获第一个引号然后用它来指明第二个引号:

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

第1个捕获是引号本身,第2个捕获是引号中的内容(与匹配的子串)。下例是一个类似的示例,用于匹配Lua语言中的长字符串的模式:

%[(=*)%[(.-)%]%1%]

它所匹配的内容依次是:一个左方括号、零个或多个等号、另一个左方括号、任意内容(即字符串的内容)、一个右方括号、相同数量的等号及另一个右方括号:

p = "%[(=*)%[(.-)%]%1%]"
s = "a = [=[[[ something ]] ]==] ]=]; print(a)"
print(string.match(s, p))	--> =       [[ something ]] ]==]

第1个捕获是等号序列(在本例中只有一个),第2个捕获是字符串内容。

被捕获对象的第3个用途是在函数gsub的替代字符串中。像模式一样,替代字符串同样可以包括像"%n"一样的字符分类,当发生替换时会被替换为相应的捕获。特别地,"%0"意味着整个匹配,并且替换字符串中的百分号必须被转义为"%%"。下面这个示例会重复字符串中的每个字母,并且在每个被重复的字母之间插入一个减号:

print((string.gsub("hello Lua!", "%a", "%0-%0")))
--> h-he-el-ll-lo-o L-Lu-ua-a!

下例交换了相邻的字符:

print((string.gsub ("hello Lua", "(.)(.)", "%2%1")))
--> ehll ouLa

以下是一个更有用的示例,让我们编写一个原始的格式转换器,该格式转换器能读取LATEX风格的命令(\example{text}),并将它们转换成XML风格(text):

\command{some text} --> some text

对于不允许嵌套的命令,可以调用函数string.gsub来完成这项工作(在下一节中,讲解如何处理嵌套的命):

s = [[the \quote(task} is to \em(change} that.]]
s = string.gsub(s, "\\(%a+){(.-)}", "<%1>%2n)
print(s)
--> the task is to change that.

另一个有用的示例是剔除字符串两端空格:

function trim (s)
  s = string.gsub(s,"^%s*(.-)%s*$", "%1")
  return s
end

请注意模式中修饰符的合理运用。两个定位标记(^和$)保证了我们可以获取到整个字符串。由于中间的’.-‘只会匹配尽可能少的内容,所以两个’%s*'便可匹配到首尾两端的空格。

替换

函数string.gsub的第3个参数不仅可以是字符串,还可以是一个函数或表。

  • 当第3个参数是一个函数时,函数string.gsub会在每次找到匹配时调用该函数,函数参数是捕获到的内容,函数的返回值则被作为替换字符串。
  • 当第3个参数是一个表时,函数string.gsub会把第一个捕获到的内容作为键,然后将表中对应该键的值作为替换字符串。

如果函数的返回值为nil或表中不包含这个键或表中键的对应值为nil,那么函数gsub不改变这个匹配。

_G是预先定义的包括所有全局变量的表,下述函数用于变量展开,它会把字符串中所有出现$varname替换为全局变量varname的值:

function expand(x)
  return string.gsub(s, "$(%w+)", _G)
end

name = "Lua"; status = "great"
print(expand("$name is $status, isn't it?"))
	--> Lua is great, isn't it?

对于每个与"$(%w+)"匹配的地方,函数gsub都会在全局表_G中査找捕获到的名字,并用找到的结果替换字符串中相匹配的部分;如果表中没有对应的键,则不进行替换:

print(expand("$othername is $status, isn't it?"))
	--> $othername is great, isn't it?

如果不确定是否指定变量具有字符串值,那么可以对它们的值调用函数tostring。在这种情况下,可以用一个函数来返回要替换的值:

function expand(s)
  return (string.gsub(s, "$(%w+)", function(n)
      			return tostring(_G[n]
        	end))
end

print(expand("print = $print; a = $a"))
  --> print = function: 0x1067b0800; a = nil

在函数expand中,对于所有匹配"$(%w+)"的地方,函数gsub都会调用给定的函数,传入捕获到的名字作为参数,并使用返回字符串替换匹配到的内容。

最后,让我们再回到上一节中提到的格式转换器。但这次允许嵌套的命令。以下的函数用递归的方式完成了这个需求:

function toxml(s)
  s = string.gsub(s, "\\(%a+)(%b{})", function (tag, body)
      body = string.sub(body, 2, -2)		--	移除括号
      body = toxml(body)								--处理嵌套的命令
      return string.format("<%s>%s", tag, body, tag)
      end)
  return s
end

print(toxml("\\title{The \\bold{big} example}"))
	--> The <bold>big</bold> example

数据文件和序列化

在处理数据文件时,写数据通常比读数据简单得多。当向一个文件中写时,我们拥有绝对的控制权;但是,当从一个文件中读时,我们并不知道会读到什么东西。一个健壮的程序除了能够处理一个合法文件中所包含的所有类型的数据外,还应该能够优雅地处理错误的文件。因此,编写一个健壮的处理输入的程序总是比较困难的。本节将会讲解如何使用Lua语言、通过简单地将数据以恰当的格式写入到文件中来从程序中剔除不必要的读取数据的代码。更确切地说,我们将学习如何像Lua程序在运行中写入数据那样,在运行时重建数据。

Lua语言自1993年发布以来,其主要用途之一就是描述数据。2001年开发了JSON, JSON基于JavaScript,类似于一种精简过的Lua语言数据文件。一方面,JSON的一大优势在于它是国际标准,包括Lua语言在内的多种语言都具有操作JSON文件的标准库。另一方面,Lua语言数据文件的读取更加容易和灵活。

使用一门全功能的编程语言来描述数据确实非常灵活,但也会带来两个问题。问题之一在于安全性,这是因为“数据”文件能够肆意地在我们的程序中运行。我们可以通过在沙盒中运行程序来解决这个问题。

另一个问题是性能问题。Lua语言不仅运行得快,编译也很快。Lua 5.3可以在4秒以内,占用240MB内存,完成1000万条赋值语句的读取、编译和运行。作为对比,Python 2.7和Python 3.4直接崩溃。

数据文件

对于文件格式来说,表构造器提供了一种有趣的替代方式。只需在写入数据时做一点额外的工作,就能使得读取数据变得容易。这种技巧就是将数据文件写成Lua代码,当这些代码运行时,程序也就把数据重建了。使用表构造器时,这些代码段看上去会非常像是一个普通的数据文件。

下面通过一个示例来进一步展示处理数据文件的方式。如果数据文件使用的是诸如CSV(comma-separated value,逗号分隔值)或XML等预先定义好的格式,那么我们能够选择的方法不多。不过,如果处理的是出于自身需求而创建的数据文件,那么就可以将Lua语言的构造器用于格式定义。此时,我们把每条数据记录表示为一个Lua构造器。这样,原来类似

Donald E. Knuth,Literate Programming,CSLI,1992
Jon Bentley,More Programming Pearls,Addison-Wesley,1990

的数据文件就可以改为:

Entry{"Donald E. Knuth",
      "Literate Programming",
      "CSLI",
      1992}

Entry{"Jon Bentley",
      "More Programming Pearls",
      "Addison-Wesley",
      1990}

上面这段数据也是一个Lua程序。当需要读取该文件时,我们只需要定义一个合法的Entry,然后运行这个程序即可。例如,以下的代码用于计算某个数据文件中数据条目的个数:

local count = 0
function Entry () count = count + 1 end
dofile("datan)
print("number of entries: " .. count)

下面的程序获取某个数据文件中所有作者的姓名,然后打印出这些姓名:

local authors = {} -- 保存作者姓名的集合
function Entry (b) authors[b[1]] = true end
dofile("data")
for name in pairs(authors) do print(name) end

请注意,上述的代码段中使用了事件驱动的方式:函数Entry作为一个回调函数会在函数dofile处理数据文件中的每个条目时被调用。

当文件的大小并不是太大时,可以使用键值对的表示方法:

Entry{
  author = "Donald E. Knuth",
  title = "Literate Programming",
  publisher = "CSL",
  year = 1992
}

Entry{
  author = "Jon Bentley",
  title = "More Programming Pearls",
  year = 1990,
  publisher = "Addison-Wesley",
}

这种格式是所谓的自描述数据格式,其中数据的每个字段都具有一个对应其含义的简略描述。它的优点是:

  • 自描述数据比CSV或其他压缩格式的可读性更好。
  • 当需要修改时,自描述数据也易于手工编辑。
  • 此外,自描述数据还允许我们在不改变数据文件的情况下对基本数据格式进行细微的修改。例如,当我们想要增加一个新字段时,只需对读取数据文件的程序稍加修改,使其在新字段不存在时使用默认值。

使用键值对格式时,获取作者姓名的程序将变为:

local authors = {}	-- 保存作者姓名的集合
function Entry (b) authors[b.author] = true end
dofile("data")
for name in pairs(authors) do print(name) end

此时,字段的次序就无关紧要了。即使有些记录没有作者字段,我们也只需要修改Entry函数:

function Entry (b)
	authors[b.author or "unknown"] = true
end

序列化

我们常常需要将某些数据序列化/串行化,即将数据转换为字节流或字符流,以便将其存储到文件中或者通过网络传输。我们也可以将序列化后的数据表示为Lua代码,当这些代码运行时,被序列化的数据就可以在读取程序中得到重建。

通常,如果想要恢复一个全局变量的值,那么可能会使用形如varname=exp这样的代码。其中,exp是用于创建这个值的Lua代码,而varname是一个简单的标识符。

接下来,我们将编写创建值的代码。例如,对于一个数值类型而言,可以简单地使用如下代码:

function serialize (o)
  if type(o) == "number" then
  	io.write(tostring(o))
  else other cases
	end
end

不过,用十进制格式保存浮点数可能损失精度。此时,可以利用十六进制格式来避免这个问题,使用格式"%a"可以保留被读取浮点型数的原始精度。此外,由于Lua 5.3开始就对浮点类型和整数类型进行了区分,因此通过使用正确的子类型就能够恢复它们的值:

local fmt = {integer = "%d", float = "%a"}

function serialize (o)
  if type(o) == "number" then
  	io.write(string.format(fmt[math.type(o)], o))
else other cases

对于字符串类型的值,最简单的序列化方式形如:

if type(o) == "string" then
	io.write("'", o, "'")

不过,如果字符串包含特殊字符(比如引号或换行符),那么结果就会是错误的。当然可以通过修改引号来解决这个问题:

if type(o) == "string" then
	io.write("[[", o,"]]")

这里,要当心代码注入!如果某个恶意用户设法使读者的程序保存了形如下列的内容:

"]]..os.execute('rm *')..[["

例如,恶意用户可以将其住址保存为该字符串,那么最终被保存下来的代码将变成:

varname = [[ ]]..os.execute('rm')..[[ ]]

一旦这样的“数据”被加载,就会导致意想不到的后果。

我们可以使用一种安全的方法来括住一个字符串,那就是使用函数string.format的"%q"选项,该选项被设计为以一种能够让Lua语言安全地反序列化字符串的方式来序列化字符串,它使用双引号括住字符串并正确地转义其中的双引号和换行符等其他字符。

a = 'a "problematic" \\string'
print(string.format("%q", a))	--> "a \"problematic\" \\string"

通过使用这个特性,函数serialize将变为:

function serialize (o)
  if type(o) == "number" then
 		io.write(string.format(fmt[math.type(o)], o))
  elseif type(o) == "string" then
  	io.write(string.format("%q", o))
  else other cases
  end
end

Lua 5.3.3对格式选项"%q"进行了扩展,使其也可以用于数值、nil和Boolean类型,进而使它们能够正确地被序列化和反序列化。(特别地,这个格式选项以十六进制格式处理浮点类型以保留完整的精度)。

因此,从Lua5.3.3开始,我们还能够再对函数serialize进行进一步的简化和扩展:

function serialize (o)
  local t = type(o)
  if t == "number" or t == "string" or 
     t == "boolean" or t == "nil" then
  	io.write(string.format("%q", o))
  else other cases
  end
end

另一种保存字符串的方式是使用主要用于长字符串的[=[…]=]。不过,这种方式主要是为不用改变字符串常量的手写代码提供的。在自动生成的代码中,像函数string.format那样使用"%q"选项来转义有问题的字符更加简单。

尽管如此,如果要在自动生成的代码中使用[=[…]=],那么还必须注意几个细节。首先,我们必须选择恰当数量的等号,这个恰当的数量应比原字符串中出现的最长等号序列的长度大1。由于在字符串中出现长等号序列很常见(例如代码中的注释),因此我们应该把注意力集中在以方括号开头的等号序列上。其次,Lua语言总是会忽略长字符串开头的换行符,要解决这个问题可以通过一种简单方式,即总是在字符串开头多增加一个换行符(这个换行符会被忽略)。

下例中的函数quote考虑了上述的注意事项。

function quote (s)
	-- 寻找最长等号序列的长度
  local n = -1
  for w in string.gmatch(s, "]=*") do
  	n = math.max(n, #w - 1) -- -1用于移除']'
  end
  
  -- 生成一个具有'n'+1个等号的字符串
  local eq = string.rep("=", n+1)
 
  --创建被引起来的字符串
  return string.format("[%s[\n%s]%s]", eq, s, eq)
end

该函数可以接收任意一个字符串,并返回按长字符串对其进行格式化后的结果。函数gmatch创建一个遍历字符串s中所有匹配模式’]=*'之处的迭代器(即右方括号后跟零个或多个等号)。在每个匹配的地方,循环会用当前所遇到的最大等号数量更新变量n。循环结束后,使用函数string.rep重复等号n+1次,也就是生成一个比原字符串中出现的最长等号序列的长度大1的等号序列。最后,使用函数string.format将s放入一对具有正确数量等号的方括号中,并在字符串s的开头插入一个换行符。

保存不带循环的表

接下来,更难一点的需求是保存表。保存表有几种方法,选用哪种方法取决于对具体表结构的假设,但没有一种算法适用于所有的情况。对于简单的表来说,不仅可以使用更简单的算法,而且输出也会更简洁和清晰。

下例是不使用循环序列化表的程序:

function serialize (o)
  local t = type(o)
  if t == "number" or t == "string" or 
   	 t == "boolean" or t == "nil" then
    io.write(string.format("%q", o))
  elseif t == "table" then
    io.write("{\n")
    for k,v in pairs(o) do
      io.write("  ", k, " = ")
      serialize(v)
      io.write(", \n")
    end
    io.write("}\n")
	else
  	error("cannot serialize a" .. type(o))
  end
end

尽管这个函数很简单,但它却可以合理地满足需求。只要表结构是一棵树(即没有共享的子表和环),那么该函数甚至能处理嵌套的表(即表中还有其他的表)。

上例中的函数假设了表中的所有键都是合法的标识符,如果一个表的键是数字或者不是合法的Lua标识符,那么就会有问题。解决该问题的一种简单方式是像下列代码一样处理每个键:

io.write(string.format(" [%s] = ", serialize(k)))

经过这样的修改后,我们提高了该函数的健壮性,但却牺牲了结果文件的美观性。考虑如下的调用:

serialize{a=12, b='Lua', key='another "one"'}

第1版的函数serialize会输出:

{
  key = "another \"one\"", 
  b = "Lua", 
  a = 12, 
}

与之对比,第2版的函数serialize则会输出:

{
 ["key"] = "another \"one\"", 
 ["a"] = 12, 
 ["b"] = "Lua", 
}

保存带有循环的表

由于表构造器不能创建带循环的或共享子表的表,所以如果要处理表示通用拓扑结构(例如带循环或共享子表)的表,就需要釆用不同的方法。我们需要引入名称来表示循环。因此,下面的函数把值外加其名称一起作为参数。另外,还必须使用一个额外的表来存储已保存表的名称,以便在发现循环时对其进行复用。这个额外的表使用此前已被保存的表作为键,以表的名称作为值。

下例是保存带有循环的表:

function basicSerialize (o)
    -- 假设'o'是一个数字或字符串
    return string.format("*q", o)
  end
  
function save (name, value, saved)
    saved = saved or {}
    io.write(name, "=")
    if type(value) == "number" or type(value) == "string" then
        io.write(basicSerialize(value), "\n")
    elseif type(value) == "table" then
        if saved[value] then							-- 值是否已被保存
            io.write(saved[value], "\n")	-- 使用之前的名称
        else
            saved[value] = name 					-- 保存名称供后续使用
            io.write("{}\n")							-- 创建新表
            for k,v in pairs(value) do		-- 保存表的字段
                k = basicSerialize(k)
                local fname = string.format("%s[%s]", name, k)
                save(fname, v, saved)
            end
        end
    else
        error("cannot save a " .. type(value))
    end
end

我们假设要序列化的表只使用字符串或数值作为键。函数basicSerialize用于对这些基本类型进行序列化并返回序列化后的结果,另一个函数save则完成具体的工作,其参数saved就是之前所说的用于存储已保存表的表。例如,假设要创建一个如下所示的表:

a = {x=1, y=2; {3,4,5}}
a[2] = a 		--循环
a.z = a[1]	--共享子表

调用save(“a”,a)会将其保存为:

a={}
a[1]={}
a[1][1]=3
a[1][2]=4
a[1][3]=5

a[2]=a
a["x"]=1
a["y"]=2
a["z"]=a[1]

取决于表的遍历情况,这些赋值语句的实际执行顺序可能会有所不同。不过尽管如此,上述算法能够保证任何新定义节点中所用到的节点都是已经被定义过的。

如果想保存具有共享部分的几个表,那么可以在调用函数save时使用相同的表saved函数。例如,假设有如下两个表:

a = {{"one", "two"}, 3}
b = {k = a[1]}

如果以独立的方式保存这些表,那么结果中不会有共同的部分。不过,使用同一个表saved,那么结果就会共享共同的部分:

local t = {}
save("a", a, t)
save("b", b, t)

结果如下:

a={}
a[1]={}
a[1][1]="one"
a[1][2]="two"
a[2]=3
b={}
b["k"]=a[1]

在Lua语言中,还有其他一些比较常见的方法。例如,我们可以在保存一个值时不指定全局名称而是通过一段代码来创建一个局部值并将其返回,也可以在可能的时候使用列表的语法,等等。Lua语言给我们提供了构建这些机制的工具。

你可能感兴趣的:(Lua程序设计)