3.表拷贝与快照

3.表拷贝与快照


3.1 知识点

为什么要聊拷贝

上一篇说了表引用:local b = a 只是多一个入口,不会拷贝。

拷贝只在要隔离数据时才有价值,比如战斗快照、回滚、UI 临时展示。深拷贝看着最干净,但成本也是最高的。拷贝前先想清楚:

  • 只要外层独立,还是内层表也要独立?
  • 后面会不会被改?是不是存某一时刻的状态?
  • 是真需要拷贝,还是怕别人乱改?

浅拷贝

浅拷贝只复制第一层字段。
外层表是新的,内层 table 还是原来的引用。

local function ShallowCopy(source)
    local copy = {}

    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
print(oldData.attr.hp) -- 输出:80

name 在第一层,所以分开了。
attr 是内层表,仍然共享。

浅拷贝适合数据结构很浅、内层数据本来就允许共享的情况。
如果后面要改内层表,浅拷贝是挡不住污染的。

深拷贝

深拷贝要处理的不只是“递归复制字段”:

  • 子表也要复制。子表的子表也要。
  • 同一张旧表在被拷贝表中引用多次,新表里也应该保持共享关系。
  • 循环引用不能递归到栈溢出。
  • table 的值一般直接保留。
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 = {}

    -- 先登记,再递归填字段;self 引用和重复引用都靠这张表兜住。
    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

copied 缓存已经拷贝的对象,避免循环引用栈溢出,重复引用也会指向同一个复制后的对象。

以上代码只处理了普通 Lua 数据。
functionuserdatathread 会原样返回。
元表除非调用方明确传 keepMetatable = true,否则默认不带。

循环引用和重复引用

循环引用是表里又指回了自己,如果没有 copied 记录已经拷贝过的表,递归会一直往下走,最后栈溢出。

local node = {
    name = "Node"
}

node.self = node

local copyNode = DeepCopy(node)

print(copyNode ~= node)          -- 输出:true,新旧 node 不是同一张表
print(copyNode.self == copyNode) -- 输出:true,self 指向拷贝后的 node 自己

关键点是:拷贝 node 时要先创建 copyNode,然后立刻记录:

copied[node] = copyNode

后面再拷到 node.self,发现 node 已经拷过了,就直接返回 copyNode,就不会继续递归下去。

重复引用是多个字段引用同一张子表。

local sharedAttr = {
    hp = 100
}

local data = {
    attrA = sharedAttr,
    attrB = sharedAttr
}

local copyData = DeepCopy(data)

print(data.attrA == data.attrB)         -- 输出:true,原数据里 attrA 和 attrB 共用同一张 attr
print(copyData.attrA == copyData.attrB) -- 输出:true,拷贝后仍然保持共用关系
print(copyData.attrA ~= sharedAttr)     -- 输出:true,拷贝后的 attr 已经和旧 attr 分开

如果没有 copiedattrA 会拷一份,attrB 又拷一份。
虽然看起来也是深拷贝,但破坏了原来的共享关系。

所以 copied 不是只用来防循环引用。
它真正做的是维护一张映射表:旧对象 -> 新对象

-- 旧表 -> 新表
copied[oldTable] = newTable

同一张旧表,不管后面遇到多少次,都只会对应同一张新表。

key 也要拷贝

Lua 的 table 不只是能当 value,也可以当 key。

local key = { id = 1001 }

local data = {
    [key] = "SkillData"
}

大部分表都是字符串 key、数字 key,比如 idname1001 这种。

但在一些映射关系里,table 当 key 也是有可能的。
比如用对象表做索引、记录某个对象对应的数据、做缓存映射,或者一些弱表结构。

key 如果也是 table,也要走同一套拷贝逻辑:

copy[DeepCopy(key, copied)] = DeepCopy(value, copied)

如果 value 拷了,但 table key 还指向旧表,那这份数据没有完全和旧数据斩断,还是藕断丝连。

元表要不要保留

一般来说元表不会去深拷贝。
很多元表代表的是类方法、代理逻辑、只读保护、懒加载规则。

按业务决定复用元表,一般不把元表再拷一份。

-- 不带元表:拷贝出来就是普通表
local copy = DeepCopy(source)

-- 复用元表:保留 __index 等行为
local copyWithMeta = DeepCopy(source, nil, true)

一般这样判断是否保留元表:

  • 纯数据快照:通常不带元表。
  • 对象实例拷贝:如果还要保留方法查找,可能要复用元表。
  • 代理表、只读表、带 __metatable 保护的表:按项目规则单独写。

function 和 userdata 怎么处理

function 一般原样保留。
函数可能带闭包和 upvalue,普通深拷贝不负责复制这些状态。

userdata 也一般原样保留。
Unity / xLua / toLua 场景里,userdata 背后可能是 C# 对象、资源句柄、Native 对象。Lua 层复制不出这样的真实宿主对象。

thread 同理,通常不尝试复制。

只有 table 往下展开。
其他类型直接返回。

if type(object) ~= "table" then
    return object
end

快照

快照的意思是把某一刻的数据存下来。可以用深拷贝。

local snapshot = DeepCopy(player)

适合做快照的东西:

  • 战斗结算前的角色属性。
  • 回放系统某一帧的状态。
  • 调试时保存现场数据。
  • UI 展示用临时数据。

不适合随手快照的东西:

  • 全局配置表。配置应该只读共享,怕误改就做保护。
  • 模块单例状态。多处引用同一份模块表,快照只会制造另一份副本。
  • 正在被多个系统共享的运行时对象。拷贝后状态分叉,查问题更麻烦。
  • 绑了宿主对象的数据。Lua 表复制了,userdata 还是同一个。

总结

浅拷贝只拆第一层;
深拷贝靠 copied 缓存避免循环引用和重复引用;
table 当 key 也要拷贝;
元表按业务决定是否复用;
function / userdata / thread 不拷贝;
快照适合纯 Lua 数据现场。


3.2 知识点代码

Lesson3_表拷贝与快照.lua

print("**********表拷贝与快照************")

print("**********知识点一 为什么要聊拷贝************")

local role = {
    id = 1,
    attr = {
        hp = 100
    }
}

local viewData = role

viewData.attr.hp = 80

print(role.attr.hp)     -- 输出:80,viewData 不是副本
print(viewData.attr.hp) -- 输出:80,两边看到的是同一张 attr 表


print("**********知识点二 浅拷贝************")

local function ShallowCopy(source)
    local copy = {}

    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,第一层字段已经分开
print(newData.name)    -- 输出:NewPlayer
print(oldData.attr.hp) -- 输出:80,内层 attr 仍然共享
print(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) -- 输出:100,内层 attr 已经分开
print(copyData.attr.hp)   -- 输出:60


print("**********知识点四 循环引用和重复引用************")

local node = {
    name = "Node"
}

node.self = node

local copyNode = DeepCopy(node)

print(copyNode ~= node)          -- 输出:true,新旧表不是同一张
print(copyNode.self == copyNode) -- 输出:true,自引用指向新表自己

local sharedAttr = {
    hp = 100
}

local refData = {
    attrA = sharedAttr,
    attrB = sharedAttr
}

local refCopy = DeepCopy(refData)

print(refData.attrA == refData.attrB)     -- 输出:true,原表里共享同一张 attr
print(refCopy.attrA == refCopy.attrB)     -- 输出:true,新表里仍然保持共享关系
print(refCopy.attrA ~= sharedAttr)        -- 输出:true,新旧内层 attr 已断开


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) -- 输出:false,key 也被拷成新表
print(copiedKey.id)          -- 输出:1001
print(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(heroNoMeta.defaultHp)           -- 输出:nil,不保留元表就没有默认字段
print(heroWithMeta.defaultHp)         -- 输出:100,复用元表后还能走 __index
print(getmetatable(heroWithMeta) == meta) -- 输出:true,复用的是同一张元表


print("**********知识点七 function 和 userdata 怎么处理************")

local function OnClick()
    return "Click"
end

local uiData = {
    callback = OnClick
}

local uiCopy = DeepCopy(uiData)

print(uiData.callback == uiCopy.callback) -- 输出:true,function 原样保留
print(uiCopy.callback())                  -- 输出:Click
print("userdata/thread 一般按项目规则处理")


print("**********知识点八 快照边界************")

local player = {
    id = 1,
    hp = 100,
    buffs = {
        "SpeedUp"
    }
}

local snapshot = DeepCopy(player)

player.hp = 50
table.insert(player.buffs, "PowerUp")

print(player.hp)        -- 输出:50,当前数据继续变化
print(snapshot.hp)      -- 输出:100,快照保留拷贝那一刻
print(#player.buffs)    -- 输出:2
print(#snapshot.buffs)  -- 输出:1

local SkillConfig = {
    [1001] = {
        id = 1001,
        name = "FireBall",
        damage = 100
    }
}

local cfg = SkillConfig[1001]
local runtimeSkill = {
    id = cfg.id,
    name = cfg.name,
    damage = cfg.damage,
    level = 1
}

runtimeSkill.damage = runtimeSkill.damage + 20

print(runtimeSkill.damage)        -- 输出:120,运行时数据自己变化
print(SkillConfig[1001].damage)   -- 输出:100,配置仍保持原值


print("**********知识点九 总结************")

print("浅拷贝只拆第一层")
print("深拷贝用 copied 处理循环引用和重复引用")
print("function、userdata、thread 通常原样返回")
print("快照适合纯 Lua 数据现场")


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏