Lua--数据文件和持久性
你有没有过这样的困扰?想给 Lua 程序存点结构化数据(比如配置项、用户列表、书籍信息),又不想写复杂的解析逻辑 —— 比如手动处理 CSV 的逗号分隔,或者 XML 的标签嵌套?其实 Lua 早就帮我们想好了解决方案,《Lua 程序设计第二版》第 12 章 “数据文件与持久性”,就教我们用 Lua 自身的特性,轻松实现 “数据存得爽、读得快”,全程不用额外学新格式!
一、先解决第一个问题:怎么存数据才方便?——“数据文件” 的思路
平时我们存数据,可能会写个 txt,一行一条用逗号隔开,比如:
Donald E.Knuth,Literate Programming,CSLI,1992
Jon Bentley,More Programming Pearls,Addison-Wesley,1990
但读的时候得 split 逗号、记字段顺序(哪个是作者、哪个是书名),改数据也容易出错。而 Lua 第 12 章给了个更聪明的办法:用 Lua 的 table 构造式当数据格式。
1. 数据文件长啥样?—— 就是合法的 Lua 代码!
我们不用写复杂的自定义格式,直接把每条数据写成Entry(...)的调用,括号里放一个 table(键值对,字段名明明白白)。比如存书籍数据的data.lua:
-- data.lua:我的书籍数据文件
Entry({author = "Donald E.Knuth", -- 作者title = "Literate Programming", -- 书名publisher = "CSLI", -- 出版社year = 1992 -- 出版年份
})Entry({author = "Jon Bentley",title = "More Programming Pearls",publisher = "Addison-Wesley",year = 1990
})
你没看错,这就是一个合法的 Lua 程序!Entry是我们后面要定义的 “处理函数”,每个Entry(...)就代表一条数据。
2. 怎么读这个数据文件?—— 回调函数 +dofile
读数据的核心逻辑特别简单:先定义Entry函数(告诉 Lua “每条数据该怎么处理”),再用dofile("data.lua")运行数据文件 —— 此时文件里的每个Entry调用都会触发我们定义的函数,自动处理数据。
举个实际需求:我想统计有多少本书,再把所有作者名打印出来。代码可以这么写:
-- 1. 准备一个容器,存作者名(用table的key去重,值随便设为true)
local authors = {}
-- 2. 定义Entry函数:每条数据触发时,提取author字段存起来
function Entry(book_data)-- book_data就是data.lua里每个Entry括号里的tableauthors[book_data.author] = true -- 存作者名,自动去重
end-- 3. 运行数据文件,触发所有Entry调用
dofile("data.lua")-- 4. 打印结果:遍历authors,输出所有作者
print("所有作者:")
for author_name in pairs(authors) doprint("- " .. author_name)
end
运行后会输出:
所有作者:
- Donald E.Knuth
- Jon Bentley
3. 这种方式的好处:谁用谁知道!
- 不用记字段顺序:CSV 要记 “第 1 列是作者、第 2 列是书名”,这里直接用
book_data.author,看名字就知道是啥,改数据也不怕顺序乱。 - 支持复杂数据:如果某本书有 “译者”“评分” 等额外字段,直接在 table 里加
translator = "XXX",Entry 函数里按需处理就行,不用改整个格式。 - Lua 原生支持:不用引入任何库,
dofile直接运行,效率还高 —— 处理几 MB 的数据文件也就一秒钟。
二、再解决第二个问题:怎么存 Lua 变量?——“串行化” 的思路
有时候我们不是存静态数据,而是要存 Lua 程序里动态创建的变量,比如一个复杂的 table:
-- 程序里的一个复杂table:存用户信息和他的收藏
local user = {name = "Lua爱好者",age = 25,favorites = {books = {"Programming in Lua", "Lua设计与实现"},games = {"塞尔达传说", "星露谷物语"}}
}
怎么把这个user变量存到文件里,下次运行程序还能恢复?这就需要 “串行化”—— 把 Lua 变量转成字符串(或者 Lua 代码),存到文件;下次读的时候,再把字符串转成原来的变量。
1. 串行化的核心:把变量转成 Lua 代码
Lua 里的串行化很讨巧:因为 Lua 的 table 构造式本身就是合法代码,所以我们可以写一个函数,把变量 “翻译” 成 Lua 代码字符串,存到文件里。下次用dofile运行这个文件,就能恢复变量。
比如一个简化的串行化函数(处理数字、字符串、table):
-- 串行化函数:把变量o转成Lua代码字符串
function serialize(o)if type(o) == "number" then-- 数字直接输出(比如123 → "123")io.write(o)elseif type(o) == "string" then-- 字符串用string.format("%q")转义,避免特殊字符(比如引号、换行)io.write(string.format("%q", o))elseif type(o) == "table" then-- table转成构造式(比如{name="xxx"})io.write("{\n")for k, v in pairs(o) doio.write(" [")serialize(k) -- 递归处理keyio.write("] = ")serialize(v) -- 递归处理valueio.write(",\n")endio.write("}")elseerror("不支持的类型:" .. type(o))end
end-- 把user变量串行化到文件user_data.lua
local f = io.open("user_data.lua", "w")
io.output(f) -- 把输出定向到文件
io.write("user = ") -- 写变量名,方便恢复
serialize(user)
io.write("\n")
io.close(f)
运行后,user_data.lua里会生成这样的代码:
user = {[age] = 25,[name] = "Lua爱好者",[favorites] = {[books] = {[1] = "Programming in Lua",[2] = "Lua设计与实现",},[games] = {[1] = "塞尔达传说",[2] = "星露谷物语",},},
}
下次恢复变量时,只需要dofile("user_data.lua"),程序里就有user变量了,和之前的一模一样!
2. 关键注意点:安全第一
如果变量里有特殊字符串(比如包含引号、换行),直接写文件会出错。比如字符串He said "Lua is great!",用string.format("%q")转义后会变成"He said \"Lua is great!\"",这样存到文件里才是合法的 Lua 代码,不会出错。
另外,章节里还提到处理 “有环的 table”(比如 a 的字段指向 b,b 的字段又指向 a),需要记录已经处理过的 table,避免递归死循环 —— 不过日常中小规模数据,上面的简化函数基本够用了。
三、保存无环的 table:树状结构的直接串行化
1. 核心场景与问题
无环的 table 指没有循环引用、没有共享子表的 table,结构呈 “树状”(如嵌套的数组、普通记录式 table)。这类 table 的串行化不需要处理 “重复引用” 问题,只需通过递归遍历,将每个键值对转换为 Lua 构造式即可。
2. 实现思路:递归遍历 + 安全转义
核心是编写serialize函数,通过递归遍历 table 的所有键值对,将数据转换为 Lua 代码形式(table 构造式),关键细节包括:
- 基础类型处理:数字直接输出(如
12→12);字符串必须用string.format("%q")安全转义(避免双引号、换行等特殊字符导致代码非法,如He said "Lua"→"He said \"Lua\"")。 - table 递归处理:遇到 table 时,先输出
{,再遍历所有键值对(pairs遍历),对键和值分别调用serialize(递归),最后输出}。需注意:若键不是合法的 Lua 标识符(如数字、含特殊字符的字符串),需用方括号[]包裹(如键为2→[2],键为"my key"→["my key"])。
3. 关键代码示例(文档核心实现)
lua
-- 无环table的串行化函数
function serialize(o)if type(o) == "number" thenio.write(o) -- 数字直接输出elseif type(o) == "string" then-- 字符串安全转义,避免特殊字符问题io.write(string.format("%q", o))elseif type(o) == "table" thenio.write("{\n") -- 开始table构造式for k, v in pairs(o) do-- 处理键:递归序列化键,用方括号包裹(兼容非标识符键)io.write(" [")serialize(k)io.write("] = ")-- 处理值:递归序列化值serialize(v)io.write(",\n")endio.write("}") -- 结束table构造式elseerror("无法串行化类型:" .. type(o)) -- 不支持其他类型end
end
4. 特点与局限
- 优点:逻辑简单,无需额外追踪,生成的 Lua 代码直观,可直接
dofile重建数据。 - 局限:仅适用于无环、无共享子表的 table;若 table 有循环(如
a[2]=a)或共享子表(如a.z=a[1]、b.z=a[1]),会导致递归无限循环或重复保存共享部分。
四、保存有环的 table:用 “已保存表追踪” 解决循环与共享
1. 核心场景与问题
有环的 table 指存在循环引用(如a[2]=a,table 引用自身)或共享子表(如a.z=a[1]、b.z=a[1],多个 table 引用同一个子表)的 table。此时简单递归会无限循环(循环引用),或重复保存共享子表(浪费空间且无法还原共享关系),需额外机制追踪已保存的 table。
2. 实现思路:“已保存表追踪表”+ 命名引用
核心是在串行化函数中加入 “已保存表追踪表”(通常命名为saved),解决循环和共享问题,具体逻辑:
- 追踪表
saved:这是一个辅助 table,以 “已保存的 table” 为键,对应的 “table 名称” 为值(如saved[a] = "a",saved[a[1]] = "a[1]")。作用:遍历 table 时,先检查当前 table 是否在saved中 —— 若存在,直接引用其名称(避免重复保存);若不存在,记录到saved中,再递归处理其键值对。 - table 命名规则:为每个 table 的键生成唯一名称(如根 table 名为
a,其键1对应的子表名为a[1],键"x"对应的字段名为a["x"]),确保引用时能准确定位。
3. 关键代码示例(文档核心实现)
(1)基础类型串行化辅助函数
lua
-- 辅助函数:串行化数字和字符串(复用逻辑)
function basicSerialize(o)if type(o) == "number" thenreturn tostring(o)else -- 假设是字符串return string.format("%q", o) -- 安全转义end
end
(2)支持有环 table 的串行化函数
-- name:当前table的名称(如"a"、"a[1]")
-- value:当前要串行化的table/值
-- 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" thenif saved[value] then-- 情况1:当前table已保存过→直接引用之前的名称(避免循环/重复)io.write(saved[value], "\n")else-- 情况2:当前table未保存→记录到追踪表,再处理键值对saved[value] = name -- 记录“table→名称”映射io.write("{}\n") -- 创建新table(Lua构造式)-- 遍历table的所有键值对for k, v in pairs(value) do-- 生成当前键的唯一名称(如"a[1]"、"a[\"x\"]")local fieldName = string.format("%s[%s]", name, basicSerialize(k))save(fieldName, v, saved) -- 递归串行化子键值对(传递saved表)endendelseerror("无法串行化类型:" .. type(value))end
end
4. 典型示例(文档中的循环 table 案例)
假设要串行化的有环 table:
a = {x=1, y=2, {3,4,5}} -- a[1]是子表{3,4,5}
a[2] = a -- 循环引用:a的索引2指向自身
a.z = a[1] -- 共享子表:a.z引用a[1]
调用save("a", a)后,生成的 Lua 代码(顺序可能因遍历不同变化,但确保依赖已定义):
lua
a = {}
a[1] = {} -- 处理子表a[1](未保存过,记录到saved)
a[1][1] = 3
a[1][2] = 4
a[1][3] = 5
a[2] = a -- 循环引用:a[2]已保存过(saved[a]="a"),直接引用"a"
a["y"] = 2 -- 处理字段y
a["x"] = 1 -- 处理字段x
a["z"] = a[1] -- 共享子表:a[1]已保存过,直接引用"a[1]"
5. 特点与价值
- 解决核心问题:通过
saved表避免循环递归和共享子表重复保存,确保串行化后的代码能正确还原循环和共享关系。 - 灵活性:支持任意拓扑结构的 table(含环、共享子表);若多个 table 共享子表,只需传递同一个
saved表即可实现共享引用(如save("a", a, t)、save("b", b, t),b 的共享子表会引用 a 的子表名称)。
三、两者核心差异对比
| 维度 | 12.2.1 保存无环 table | 12.2.2 保存有环 table |
|---|---|---|
| 适用场景 | 无循环、无共享子表的树状 table | 有循环引用、有共享子表的任意拓扑 table |
| 关键依赖 | 递归遍历 table | 递归 +saved表(追踪已保存 table) |
| 核心风险 | 无(无循环,不会无限递归) | 若无saved表,会无限循环或重复保存 |
| 生成代码特点 | 纯 table 构造式,结构紧凑 | 含 table 命名引用(如a[2] = a) |
| 典型案例 | {name="Lua", version=5.1, features={"轻量","灵活"}} | a={x=1,y=2;{3,4,5}}; a[2]=a; a.z=a[1] |
