3.表拷贝与快照

3.表拷贝与快照


3.1 知识点

为什么要聊拷贝

上一篇把表的引用与共享捋了一遍。
简单说,下面这段代码执行完以后,不会出现两张表:

local a = { id = 1 }
local b = a

这里只有一张表,只是 ab 都能找到它。

共享引用本身不是问题。模块表、运行时对象、全局缓存,很多地方就是要共享。
问题出在有些数据不应该继续共享,比如战斗结算快照、回滚数据、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.attrnewData.attr 还是同一张表。

浅拷贝适合这种情况:

  • 只想让外层字段独立。
  • 内层 table 本来就可以共享。
  • 数据结构很浅,内层数据基本只读。

如果后面要改内层表,又不想污染原数据,浅拷贝就不够用了。

深拷贝

深拷贝解决的是另一件事:内层 table 也要尽量独立。

比如角色运行时数据里有属性、技能、Buff:

local player = {
    id = 1,
    attr = {
        hp = 100,
        mp = 50
    },
    skills = {
        1001,
        1002
    }
}

如果只做浅拷贝,player.attrplayer.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 数据。
functionuserdatathread 会原样返回,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 对象的数据,通常该只读或收敛接口,而不是靠整张深拷挡问题。
functionuserdatathread 一般原样返回;元表按业务决定是否复用。


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

×

喜欢就点赞,疼爱就打赏