3.表拷贝与快照
3.1 知识点
为什么要聊拷贝
上一篇把表的引用与共享捋了一遍。
简单说,下面这段代码执行完以后,不会出现两张表:
local a = { id = 1 }
local b = a
这里只有一张表,只是 a 和 b 都能找到它。
共享引用本身不是问题。模块表、运行时对象、全局缓存,很多地方就是要共享。
问题出在有些数据不应该继续共享,比如战斗结算快照、回滚数据、UI 展示用临时数据,或者从配置初始化出来的运行时状态。
这个时候轮到拷贝派上用场。
但拷贝不是“越深越稳”,是按不同场景下分的。
深拷贝看起来最靠谱,实际会带来内存、性能和语义成本。项目里使用拷贝时要关注一下几个问题:
- 只是第一层字段要分开吗?
- 内层
table后面会不会被改? - 这份数据是不是要保留某一刻的状态?
- 我是真的需要拷贝,还是只是怕别人乱改?
通过以上捋清楚问题再决定用浅拷贝、深拷贝、快照,或者干脆不拷贝。
浅拷贝
浅拷贝只复制第一层字段。
外层表是新的,里面如果还有 table,只是把那张内层表的引用拿过来。
-- 浅拷贝:只复制第一层 key-value,不递归展开内层 table
local function ShallowCopy(source)
local copy = {}
-- pairs 遍历源表;value 若是 table,两边仍指向同一张内层表
for key, value in pairs(source) do
copy[key] = value
end
return copy
end
看一个很常见的例子:
local oldData = {
id = 1,
name = "Player",
attr = {
hp = 100,
mp = 50
}
}
local newData = ShallowCopy(oldData)
newData.name = "NewPlayer"
newData.attr.hp = 80
print(oldData.name) -- 输出:Player,第一层 name 已经分开
print(oldData.attr.hp) -- 输出:80,内层 attr 表仍然共享
name 是普通字段,浅拷贝以后新旧两边互不影响。
但 attr 是一张表,浅拷贝只是把 attr 的引用抄过去,所以 oldData.attr 和 newData.attr 还是同一张表。
浅拷贝适合这种情况:
- 只想让外层字段独立。
- 内层
table本来就可以共享。 - 数据结构很浅,内层数据基本只读。
如果后面要改内层表,又不想污染原数据,浅拷贝就不够用了。
深拷贝
深拷贝解决的是另一件事:内层 table 也要尽量独立。
比如角色运行时数据里有属性、技能、Buff:
local player = {
id = 1,
attr = {
hp = 100,
mp = 50
},
skills = {
1001,
1002
}
}
如果只做浅拷贝,player.attr 和 player.skills 仍然会被新旧两份数据一起引用。
这时候你改了快照里的 hp,原始角色数据也会跟着掉血。
战斗回滚、结算前快照、存档临时数据,最怕这种“我明明改的是副本,怎么本体也变了”。
所以深拷贝要处理的不是“把字段抄一遍”,而是下面这些细节:
- 遇到子
table,继续往下复制。 - 同一张旧表被多个地方引用时,拷贝后也应该指向同一张新表。
- 数据里有循环引用时,不能递归到栈爆。
- 非
table的值,Lua 层通常直接保留原值。
可以先写一版偏保守的深拷贝:
-- 深拷贝普通 Lua table。
-- 说明:
-- 1. 非 table 直接返回,number / string / boolean / nil / function / userdata / thread 都不会展开。
-- 2. copied 是“旧表 -> 新表”的登记簿,用来处理循环引用和重复引用。
-- 3. 这版不主动复制 metatable。配置表、快照表、普通运行时数据一般够用。
local function DeepCopy(object, copied)
if type(object) ~= "table" then
return object
end
copied = copied or {}
-- 这张旧表已经登记过,直接返回对应的新表。
-- 这样既能保住共享关系,也能处理 self / parent 这种回指。
if copied[object] then
return copied[object]
end
local copy = {}
-- 必须先登记,再递归填字段。
-- 不然 node.self = node 这种结构会一直往下绕。
copied[object] = copy
for key, value in pairs(object) do
-- key 也可能是 table,别只拷 value。
copy[DeepCopy(key, copied)] = DeepCopy(value, copied)
end
return copy
end
这段代码的关键是 copied 这张登记簿。这里存的是 原表 → 新表 的映射。
我更喜欢把它理解成一张“搬家登记表”:
旧背包A -> 新背包A
旧技能表B -> 新技能表B
旧节点C -> 新节点C
后面再遇到旧背包A,就不要重新造一份了,直接查 copied[旧背包A] 拿登记过的新背包A。
这样不会把本来共享的一份数据拆成两份,也不会在循环引用里死递归。
下面这个例子可以顺手验证几个关键行为:
local sharedSkill = {
id = 1001,
level = 1
}
local player = {
id = 1,
attr = {
hp = 100,
mp = 50
},
skills = {
sharedSkill,
sharedSkill
}
}
-- 模拟一个自引用结构
player.self = player
local copyPlayer = DeepCopy(player)
copyPlayer.attr.hp = 1
copyPlayer.skills[1].level = 10
print("原始血量 = " .. player.attr.hp)
-- 输出:原始血量 = 100
print("拷贝后血量 = " .. copyPlayer.attr.hp)
-- 输出:拷贝后血量 = 1
print("原始技能等级 = " .. player.skills[1].level)
-- 输出:原始技能等级 = 1
print("拷贝后技能等级 = " .. copyPlayer.skills[1].level)
-- 输出:拷贝后技能等级 = 10
print("拷贝后共享技能是否仍然只拷了一份 = " .. tostring(copyPlayer.skills[1] == copyPlayer.skills[2]))
-- 输出:拷贝后共享技能是否仍然只拷了一份 = true
print("自引用是否指向新表自己 = " .. tostring(copyPlayer.self == copyPlayer))
-- 输出:自引用是否指向新表自己 = true
print("新旧属性表是否已经断开 = " .. tostring(copyPlayer.attr ~= player.attr))
-- 输出:新旧属性表是否已经断开 = true
注意:深拷贝不是越“全能”越好。
上面这版只处理普通 Lua 数据。function、userdata、thread 会原样返回,metatable 也没有主动复制。
这在项目里反而更安全。大部分情况也够用。
比如 Unity 热更层里一个 userdata,背后可能是 C# 对象、资源句柄、网络对象。Lua 这边强行“深拷贝”它没有意义,也很容易制造假象。
对象系统里的元表也一样,很多时候代表的是类、方法表、只读保护、懒加载逻辑。它不一定应该跟着数据快照一起复制。
循环引用和重复引用
上面 copyPlayer 的几个 print 已经在验这两类情况:
player.self = player:没有copied登记,递归会绕回自己直到栈溢;登记后直接拿已有新表,自引用结构能保留下来。skills里两次sharedSkill:登记保证子表只拷一份,新表里两个槽位仍指向同一张新技能表。
面试里常把防死循环和保共享关系分开问;实现上都是 copied 在干。
key 也可能是 table
很多深拷贝代码只拷贝 value,不拷贝 key。
普通业务里 key 大多是字符串或数字,所以一开始看不出问题。
但 Lua 的 table key 本身也可以是 table:
local key = { id = 1001 }
local data = {
[key] = "SkillData"
}
这时 key 也是一张表。
正确的写法是:
copy[DeepCopy(key, copied)] = DeepCopy(value, copied)
而不是:
copy[key] = DeepCopy(value, copied)
如果 key 是 table,但只复制 value,新表里仍然拿旧 key 当索引。
这种情况不是经常遇到,但在实际项目中,缓存、对象映射、弱表结构里,用 table 做 key 也是很正常的。
元表要不要保留?
深拷贝时,元表要不要保留,是要开发者自己取舍的。
最简单的深拷贝不会处理元表:
return copy
这样拷贝出来的是一张普通表。原表如果靠 __index 找默认字段,新表就不会再有这套行为。
另一种常见写法是复用同一个元表:
setmetatable(copy, getmetatable(object))
return copy
注意这里是复用元表,不是把元表也深拷贝一份。
项目里一般重点注意这几个问题:
- 如果元表只是对象查找规则,比如 class 的
__index,拷贝实例时保留元表通常合理。 - 如果元表里也塞了运行时状态,就要小心,因为新表和旧表仍然会共享同一个元表。
- 如果表做了代理、只读保护、
__metatable保护,就别拿通用深拷贝硬拷,按项目规则单独处理。
越靠近对象系统、代理表、只读表,越不要把通用深拷贝当万能工具,而是开发者自己要有决定和判断。
function 和 userdata 怎么处理
深拷贝时,function 通常直接保留原引用。
local data = {
OnClick = function()
print("Click")
end
}
函数本身也是值,而且函数里可能还带着闭包和 upvalue。
纯 Lua 层想“复制一个函数本体,并把它捕获的状态也复制一份”,这已经不是普通深拷贝该干的事了。
其次是userdata,它往往是宿主层暴露出来的对象,比如 C/C++ 对象,或者 Unity 绑定出来的对象。
真正要复制宿主对象,要看绑定层怎么设计,这不是 Lua 层一个深拷贝函数能解决的问题。
所以通常按这个规则处理:
function直接返回原引用。userdata直接返回原引用。thread一般也不要尝试复制。
快照与适用场景
拷贝经常用来做快照。
快照可以理解成:保存某一刻的数据。
比如战斗结算前,保存一次角色状态:
local snapshot = DeepCopy(player)
后面角色继续掉血、加 Buff,都不影响这份快照。
适合做快照的东西一般是:
- 战斗结算时的一份角色属性。
- 回放系统里某一帧的状态。
- 调试时保存的一份现场数据。
- 某个流程里临时展示用的数据。
但快照不是万能解法。
它适合保存“数据当时长什么样”,不适合拿来掩盖边界设计问题。
不适合随便快照的东西可能有:
- 全局配置表。本来就是全项目只读共享,每次深拷贝又慢又占内存;真怕被改,该做只读保护或接口收敛,而不是靠拷贝挡一手。
- 模块单例状态。
require缓存和多处引用指向的是同一份模块表,快照只能冻住副本。 - 正在被多个系统共享的运行时对象。你快照了 A,别的系统还握着原对象,两边状态会悄悄分叉,比不拷贝更难查。
- 绑了 Unity 对象的数据。深拷贝只是在 Lua 里多一张表,
userdata还是指向同一个 C# 对象;那边Destroy了,快照里照样调不了。
总结
先想清楚要隔离哪一层,再选浅拷、深拷,还是干脆不拷。
浅拷贝只拆外层,内层 table 仍共享。
深拷贝用 copied 做旧表到新表的登记,用来处理循环引用和重复引用,注意 key 本身也可能是 table。
快照多是深拷贝的用法,适合结算、回放这类纯 Lua 现场。
全局配置、模块单例、绑 Unity 对象的数据,通常该只读或收敛接口,而不是靠整张深拷挡问题。function、userdata、thread 一般原样返回;元表按业务决定是否复用。
3.2 知识点代码
Lesson3_表拷贝与快照.lua
print("**********表拷贝与快照************")
print("**********知识点一 为什么要聊拷贝************")
local role = {
id = 1,
attr = {
hp = 100
}
}
-- viewData 不是拷贝,只是又拿到了 role 这张表的引用
local viewData = role
viewData.attr.hp = 80
print("role.attr.hp:", role.attr.hp) -- 输出:80,改内层字段两边一起变
print("viewData.attr.hp:", viewData.attr.hp) -- 输出:80,看到的还是同一张 attr 表
print("**********知识点二 浅拷贝************")
local function ShallowCopy(source)
local copy = {}
-- 只复制第一层;value 若是 table,引用原样抄过去
for key, value in pairs(source) do
copy[key] = value
end
return copy
end
local oldData = {
id = 1,
name = "Player",
attr = {
hp = 100,
mp = 50
}
}
local newData = ShallowCopy(oldData)
newData.name = "NewPlayer"
newData.attr.hp = 80
print("oldData.name:", oldData.name) -- 输出:Player,第一层 name 已经分开
print("newData.name:", newData.name) -- 输出:NewPlayer
print("oldData.attr.hp:", oldData.attr.hp) -- 输出:80,内层 attr 仍共享
print("newData.attr.hp:", newData.attr.hp) -- 输出:80
print("**********知识点三 深拷贝************")
local function DeepCopy(object, copied, keepMetatable)
if type(object) ~= "table" then
return object
end
copied = copied or {}
if copied[object] then
return copied[object]
end
local copy = {}
-- 先登记旧表 -> 新表,再递归填字段
copied[object] = copy
for key, value in pairs(object) do
copy[DeepCopy(key, copied, keepMetatable)] = DeepCopy(value, copied, keepMetatable)
end
if keepMetatable then
setmetatable(copy, getmetatable(object))
end
return copy
end
local sourceData = {
id = 1,
attr = {
hp = 100,
mp = 50
}
}
local copyData = DeepCopy(sourceData)
copyData.attr.hp = 60
print("sourceData.attr.hp:", sourceData.attr.hp) -- 输出:100,内层 attr 已经分开
print("copyData.attr.hp:", copyData.attr.hp) -- 输出:60
print("**********知识点四 循环引用和重复引用************")
local loopNode = {
name = "LoopNode"
}
loopNode.self = loopNode
local loopCopy = DeepCopy(loopNode)
print("loopNode == loopCopy:", loopNode == loopCopy) -- 输出:false
print("loopCopy.self == loopCopy:", loopCopy.self == loopCopy) -- 输出:true,自引用结构被保留
local sharedAttr = {
hp = 100
}
local refData = {
attrA = sharedAttr,
attrB = sharedAttr
}
local refCopy = DeepCopy(refData)
print("原表共享同一张 attr:", refData.attrA == refData.attrB) -- 输出:true
print("新表仍共享同一张 attr:", refCopy.attrA == refCopy.attrB) -- 输出:true
print("新旧内层 attr 已断开:", refCopy.attrA ~= sharedAttr) -- 输出:true
print("**********知识点五 key也可能是table************")
local tableKey = {
id = 1001
}
local keyData = {
[tableKey] = "SkillData"
}
local keyCopy = DeepCopy(keyData)
local copiedKey = nil
local copiedValue = nil
for key, value in pairs(keyCopy) do
copiedKey = key
copiedValue = value
end
print("copiedKey == tableKey:", copiedKey == tableKey) -- 输出:false,key 也被拷贝成新表
print("copiedKey.id:", copiedKey.id) -- 输出:1001
print("copiedValue:", copiedValue) -- 输出:SkillData
print("**********知识点六 元表要不要保留************")
local meta = {
__index = {
defaultHp = 100
}
}
local hero = {
name = "Hero"
}
setmetatable(hero, meta)
local heroNoMeta = DeepCopy(hero, nil, false)
local heroWithMeta = DeepCopy(hero, nil, true)
print("不保留元表 defaultHp:", heroNoMeta.defaultHp) -- 输出:nil
print("保留元表 defaultHp:", heroWithMeta.defaultHp) -- 输出:100
print("复用同一份元表:", getmetatable(heroWithMeta) == meta) -- 输出:true
print("**********知识点七 function和userdata怎么处理************")
local function OnClick()
print("执行按钮回调") -- 输出:执行按钮回调
end
local uiData = {
callback = OnClick
}
local uiCopy = DeepCopy(uiData)
print("回调是否同一函数:", uiData.callback == uiCopy.callback) -- 输出:true,function 只保留原引用
uiCopy.callback()
-- userdata / thread 和 function 一样:type 不是 table 就直接 return,Lua 层拷不出第二份
print("**********知识点八 快照与适用场景************")
local player = {
id = 1,
hp = 100,
buffs = {
"SpeedUp"
}
}
local snapshot = DeepCopy(player)
player.hp = 50
table.insert(player.buffs, "PowerUp")
print("当前 player.hp:", player.hp) -- 输出:50
print("快照 snapshot.hp:", snapshot.hp) -- 输出:100,快照冻住拷贝那一刻
print("当前 buff 数量:", #player.buffs) -- 输出:2
print("快照 buff 数量:", #snapshot.buffs) -- 输出:1
local SkillConfig = {
[1001] = {
id = 1001,
name = "FireBall",
damage = 100
}
}
local cfg = SkillConfig[1001]
-- 配置只读共享;运行时字段单独建表,别整张 DeepCopy 配置
local runtimeSkill = {
id = cfg.id,
name = cfg.name,
damage = cfg.damage,
level = 1
}
runtimeSkill.damage = runtimeSkill.damage + 20
print("运行时 damage:", runtimeSkill.damage) -- 输出:120
print("配置表 damage:", SkillConfig[1001].damage) -- 输出:100
print("**********知识点九 总结************")
print("先想清楚要隔离哪一层,再选浅拷、深拷或不拷") -- 输出:先想清楚要隔离哪一层,再选浅拷、深拷或不拷
print("copied 登记旧表到新表,兜住循环引用和重复引用") -- 输出:copied 登记旧表到新表,兜住循环引用和重复引用
print("配置只读共享,运行时状态单独维护") -- 输出:配置只读共享,运行时状态单独维护
print("function 和 userdata 一般原样返回") -- 输出:function 和 userdata 一般原样返回
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com