15 数据文件和序列化

15.1 数据文件

对于文件格式来说,表构造器提供了一种有趣的替代方式。只需要写入数据时做一点额外的工作,就能使得读取数据变得容易。这种技巧就是将数据文件写成 Lua 代码,当这些代码运行时,程序也就把数据重建了。使用表构造器时,这些代码段看上去会非常像是一个普通的数据文件。
如果哦处理的是处于自身需求而创建的数据文件,那么就可以将 Lua 语言的构造器用于格式定义。此时,我们把每条数据记录表示为一个 Lua 构造器。这样,原来类似

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

的数据文件就可以改为:

-- data.lua

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 authors = {}

---@param b table
function Entry(b)
    authors[b[1]] = true
end

dofile("data.lua")

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",
    publisher = "Addison-Wesley",
    yaer = 1990
})

这种格式是所谓的自描述数据格式,其中数据的每个字段都具有一个对应其含义的简略描述。自描述数据比 CSV 或其他压缩格式的可读性更好;同时,当需要修改时,自描述数据也易于手工编辑;此外,自描述数据还允许我们在不改变数据文件的情况下对基本数据格式进行细微的修改。例如,我们想要增加一个字段时,只需要对读取数据文件的程序稍加修改,使其在新字段不存在时使用默认值。
使用键值对格式时,获取作者姓名的程序将变为:

local authors = {}

---@param b table
function Entry(b)
    authors[b.author] = true
end

dofile("data.lua")

for name in pairs(authors) do
    print(name)
end

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

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

15.2 序列化

我们常常需要将数据转换为字节流或字符流,以便将其存储到文件中或者通过网络传输。我们也可以将序列化后的数据表示为 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(0) == "number") then
        io.write(string.format(fmt[math.type(o)], o))
    else
        -- other case
    end
end

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

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

不过,如果字符串包含特殊字符,那么结果就会是错误的。
也许有人会告诉读者通过修改引号来解决这个问题:

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

这里,要小心代码注入!如果某个恶意用户设法使读者的程序保存了形如]] .. os.execute('rm *') .. [[这样的内容,那么最终被保存下来的代码将变成:

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

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

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

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

---@param o any
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 类型,进而使它们能够正确地被序列化和反序列化。(特别地,这个格式选项以十六进制格式处理浮点类型以保留完整的精度。)因此,从 Lua 5.3.3 开始,我们还能够再对函数 serialize 进行进一步的简化和扩展:

---@param o any
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 语言总是会忽略长字符串开头的换行符,要解决这个问题可以通过一种简单的方式,即总是在字符串开头多增加一个换行符(这个换行符会被忽略)。
示例 15.1 中的函数 quote 考虑了上述注意事项:

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

function quote(s)
    local n = 1
    for w in string.gmatch(s, "]=*") do
        n = math.max(n, # w - 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.1 保存不带循环的表

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

不使用循环序列化表

---无循环表结构序列化
---@return
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"'}

第一版的函数 serialize 会输出:

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

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

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

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

15.2.2 保存带有循环的表

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

示例15.3 保存带有循环的表

---假设 'o' 是一个数字或字符串
---@return string
---@param o number|string
function basicSerialize(o)
    return string.format("%q", o)
end

---@return string
---@param name string
---@param value any
---@param saved table
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,那么结果就会共享共同的部分:

require("save")

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 语言给我们提供了构建这些机制的工具。


15.3 练习

  • 练习 15.1:修改示例 15.2 中的代码,使其带缩进地输出嵌套表(提示:给函数 serialize增加一个额外的参数来处理缩进字符串)

在修改之前,我们首先应该知道原本的输出是什么样

---无循环表结构序列化
---@return void
---@param o table
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

serialize({ 1, 2, { 3, 4 } })

--------------------------
{
 1 = "1",
 2 = "2",
 3 = {
 1 = "3",
 2 = "4",
}
,
}

可以看到,嵌套在内部的表的项跟外部的项排在一起显得不那么美观,因此,我们应该在内部项和反括号处增加缩进。

---无循环表结构序列化
---@return void
---@param o table
---@param num number
function serialize(o, num)
    local t = type(o)
    num = num or 0
    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")
        local tabs = string.rep(" ", num * 4)
        for k, v in pairs(o) do
            io.write(tabs .. " ", k, " = ")
            serialize(v, num + 1)
            io.write(",\n")
        end
        io.write(tabs .. "}")
    else
        error("cannot serialize a " .. type(o))
    end
end
serialize({ 1, 2, { 3, 4, {5, 6} } })

----------------------------------------
{
 1 = "1",
 2 = "2",
 3 = {
     1 = "3",
     2 = "4",
     3 = {
         1 = "5",
         2 = "6",
        },
    },
}
  • 练习 15.2:修改前面的代码,使其像 15.2.1 节中推荐的那样使用形如 ["key"] = value 的语法
---无循环表结构序列化
---@return void
---@param o table
---@param num number
function serialize(o, num)
    local t = type(o)
    num = num or 0
    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")
        local tabs = string.rep(" ", num * 4)
        for k, v in pairs(o) do
            io.write(tabs .. string.format(" [%s] = ", k))
            serialize(v, num + 1)
            io.write(",\n")
        end
        io.write(tabs .. "}")
    else
        error("cannot serialize a " .. type(o))
    end
end
  • 练习 15.3:修改前面练习中的代码,使其只在必要时(即当键为字符串而不是合法标识符时)才使用形如 ["key"] = value 的语法。
---无循环表结构序列化
---@return void
---@param o table
---@param num number
function serialize(o, num)
    local t = type(o)
    num = num or 0
    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")
        local tabs = string.rep(" ", num * 4)
        for k, v in pairs(o) do
            if (type(k) == "string") then
                io.write(tabs .. string.format(" [%s] = ", k))
            else
                io.write(tabs .. " ", k, " = ")
            end
            serialize(v, num + 1)
            io.write(",\n")
        end
        io.write(tabs .. "}")
    else
        error("cannot serialize a " .. type(o))
    end
end
  • 练习 15.4:修改前面练习中的代码,使其在可能时使用列表的构造器语法。例如,应将表 {14, 15, 19}序列化为 {14, 15, 19} 而不是 {[1] = 14, [2] = 15, [3] = 19} (提示:只要键不是 nil 就从1, 2, ... 开始保存对应键的值。请注意,在遍历其余表的时候不要再次保存它们)。

没搞懂这一题的意思,姑且认为是数字索引不输出,删除else中的输出内容即可

---无循环表结构序列化
---@return void
---@param o table
---@param num number
function serialize(o, num)
    local t = type(o)
    num = num or 0
    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")
        local tabs = string.rep(" ", num * 4)
        for k, v in pairs(o) do
            if (type(k) == "string") then
                io.write(tabs .. string.format(" [%s] = ", k))
            else
                io.write(tabs)
            end
            serialize(v, num + 1)
            io.write(",\n")
        end
        io.write(tabs .. "}")
    else
        error("cannot serialize a " .. type(o))
    end
end
  • 练习 15.5:在保存具有循环的表时,避免使用构造器的方法过于激进了。对于简单的情况,是能够使用表构造器以一种更加优雅的方式来保存表的,并且也能够在后续使用赋值语句来修复共享表和循环。请使用这种方式重新实现 save(示例 15.3),其中要运用前面练习中的所有优点(缩进、记录式语法及列表式语法)。
---假设 'o' 是一个数字或字符串
---@return string
---@param o number|string
function basicSerialize(o)
    return string.format("%q", o)
end

---@return string
---@param name string
---@param value any
---@param saved table
---@param num number
function save(name, value, saved, num)
    saved = saved or {}
    num = num or 0
    if (type(value) == "number" or type(value) == "string") then
        io.write(basicSerialize(value))
    elseif type(value) == "table" then
        if saved[value] then
            io.write(saved[value])
        else
            saved[value] = name
            io.write("{\n")
            local tabs = string.rep(" ", num * 4)
            for k, v in pairs(value) do
                if (type(k) == "string") then
                    io.write(tabs .. string.format(" [%s] = ", k))
                else
                    io.write(tabs .. " ", k, " = ")
                end
                save(k, v, saved, num + 1)
                io.write(",\n")
            end
            io.write(tabs .. "}")
        end
    else
        error("cannot save a " .. type(value))
    end
end

local a = { x = 1, y = 2, { 3, 4, 5, { 6, 7 } } }
a[2] = a
a.z = a[1]

save("a", a)

--------------------------------
{
 1 = {
     1 = "3",
     2 = "4",
     3 = "5",
     4 = {
         1 = "6",
         2 = "7",
        },
    },
 2 = a,
 [y] = "2",
 [x] = "1",
 [z] = 1,    -- 注意这里的 1 并没有带引号 这说明这里的 1 是表里面的键 1
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,701评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,649评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,037评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,994评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,018评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,796评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,481评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,370评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,868评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,014评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,153评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,832评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,494评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,039评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,156评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,437评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,131评论 2 356