Lua数据文件和序列化


    在处理数据文件时,写数据通常比读数据简单很多。当向一个文件中写时,我们拥有绝对的控制权;但是,当从一个文件中读时,我们并不知道会读什么东西。一个健壮的程序除了能够处理一个合法文件中所包含的所有类型的数据外,还应该能够优雅地处理错误的文件。因此,编写一个健壮的处理输入的程序总是比较困难的。
    Lua语言自1993年发布以来,其主要用途之一就是描述数据。在那个年代,主要的文本数据描述语言之一是SGML。对于很多人来说,SGML既臃肿又复杂。在1998年,有些人将其简化成XML,但以我们的眼光看仍然臃肿又复杂。有些人跟我们的观点一直,进而在2001年开发了JSON。JSON基于JavaScript,类似于一种精简过的Lua语言数据文件。一方面,JSON的一大优势在于它是国际标准,包括Lua语言在内的多种语言都具有操作JSON文件的标准库。另一方面,Lua语言数据文件的读取更加容易和灵活。
    使用一门全功能的编程语言来描述数据确实非常灵活,但也会带来两个问题。问题之一在于安全性,这是因为“数据”文件能够肆意地在我们的程序中运行。我们可以通过沙盒中运行程序来解决这个问题。
    另一个问题是性能问题。Lua语言不仅运行得快,编译也很快。例如,在笔者的新机器上,Lua5.3可以在4秒以内,占用240MB内存,完成1000万条赋值语句的读取、编译和运行。作为对比,Perl5.18需要21秒、占用6GB内存,Python2.7和Python3.4直接崩溃,Node.js0.10.25在运行8秒后抛出“内存溢出”异常,Rhino1.7在运行6分钟后也抛出了“内存溢出”异常。

数据文件

    对于文件格式来说,表构造器提供了一种有趣的替代方法。只需在写入数据时做一点额外的工作,就能使得读数据变得容易。这种技巧就是将数据文件写成Lua代码,当这些代码运行时,程序也就把数据重建了。使用表构造器时,这些代码段看上去会非常像是一个普通的数据文件。
    下面通过一个示例来进一步展示处理数据文件的方式。如果数据文件使用的是诸如CSV或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}

请注意,Entry{code}与Entry({code})是相同的,后者以表作为唯一的参数来调用函数Entry。因此,上面这段数据也是一个Lua程序。当需要读取该文件时,我们只需要定义一个合法的Entry,然后运行这个程序即可。例如,以下代码用于计算某个数据文件中数据条目的个数:

local count = 0
function Entry() count = count + 1 end
dofile("data")
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 = "CSLI",
	year = 1992
}
Entry{
	author = "Jon Bentley",
	title = "More Programming Pearls",
	year = 1990,
	pyblisher = "Addison-Wesley"
}

这种格式是所谓的自描述数据格式,其中数据的每个字段都具有一个对应其含义的简略描述。自描述数据比CSV或其他压缩格式的可读性更好;同时,当需要修改时,自描述数据也已于手工编辑;此外,自描述数据还允许我们在不改变数据文件的情况下对基本数据格式进行细微的修改。例如,当我们想要增加一个新字段时,只需要对读取数据文件的程序稍加修改,使其在新字段不存在时使用默认值。
    此时,字段的次序就无关紧要了。即使有些记录没有作者字段,我们也只需要修改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"可以保留被读取浮点型树洞额原始精度。此外,由于从Lua5.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)
	it 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

    Lua5.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语言总是会忽略长字符串开头的换行符,要解决这个问题可以通过一种简单方式,即总是在字符串开头多增加一个换行符。

示例: 引用任意字符串常量

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的等号序列。最后,使用函数strig.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会输出:

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

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

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

通过测试每个键是否需要方括号,可以在健壮性和美观性之间得到平衡。

保存带有循环的表

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

示例:保存带有循环的表

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["y"] = 2
a["x"] = 1
a["z"] = a[1]

取决于表的遍历情况,这些赋值语句的实际执行顺序可能会有所不同。不过尽管如此,上述算法能够保证任何新定义节点中所用到节点都是已经被定义过的。
    如果想保存具有共享部分的几个表,那么可以在调用函数save时使用相同的表saved函数,例如,假设有如下两个表:

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

如果以独立的方式保存这些表,那么结果中不会有共同的部分。不过,如果调用save函数时使用同一个表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预压给我们提供了构建这些机制的工具。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章