对于文件格式来说,表构造器提供了一种有趣的替代方式。只需在写人数据时做一点额外的工作,就能使得读取数据变得容易。这种技巧就是将数据文件写成 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
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 case
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 case
end
end
对于字符串类型的值,最简单的序列化方式形如:
if type(o) == "string" then
io.write("'", o, "'")
不过,如果字符串包含特殊字符(比如引号或换行符),那么结果就会是错误的。也许有人会告诉读者通过修改引号来解决这个问题:
if type(o) == "string" then
io.write("[[", o, "]]")
这里,要当心代码注入(code injection)!如果某个恶意用户设法使读者的程序保存了形如 "[[..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 case
end
end
Lua 5.3.3 对格式选项 "%q" 进行了扩展,使其也可以用于数值、nil 和 Boolean 类型,进而使它们能够正确地被序列化和反序列化。因此,从 Lua 5.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 case
end
end
另一种保存字符串的方式是使用主要用于长字符串的 [=[...]=] 。不过,这种方式主要是为不用改变字符串常量的手写代码提供的。在自动生成的代码中,像函数 string.format 那样使用 "%q" 项来转义有问题的字符更加简单。
尽管如此,如果要在自动生成的代码中使用 [=[...]=] ,那么还必须注意几个细节。首先,我们必须选择恰当数量的等号,这个恰当的数量应比原字符串中出现的最长等号序列的长度大 1 。由于在字符串中出现长等号序列很常见(例如代码中的注释),因此我们应该把注意力集中在以方括号开头的等号序列上。其次,Lua 语言总是会忽略长字符串开头的换行符,要解决这个问题可以通过一种简单方式即总是在字符串开头多增加一个换行符(这个换行符会被忽略)。
示例 15.1 中的函数 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 的开头插入一个换行符。
接下来,更难一点的需求是保存表。保存表有几种方法,选用哪种方法取决于对具体表结构的假设,但没有一种算法适用于所有的情况。对于简单的表来说,不仅可以使用更简单的算法,而且输出也会更简洁和清晰。
第一种尝试参见示例 15.2,不使用循环序列化表:
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\"",
}
通过测试每个键是否需要方括号,可以在健壮性和美观性之间得到平衡。
由于表构造器不能创建带循环的或共享子表的表,所以如果要处理表示通用拓扑结构(例如带循环或共享子表)的表,就需要采用不同的方法。我们需要引入名称来表示循环。因此,下面的函数把值外加其名称一起作为参数。另外,还必须使用一个额外的表来存储己保存表的名称,以便在发现循环时对其进行复用。这个额外的表使用此前已被保存的表作为键,以表的名称作为值。
示例 15.3 为保存带有循环的表。
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 语言给我们提供了构建这些机制的工具。