2.表引用与共享

2.表引用与共享


2.1 知识点

引用语义回顾

基础篇里已经碰过这个点:table 直接赋值,不会把整张表复制一份。

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

这段代码执行完以后,一定不要想成开辟了两张表。
而是一张表,有 ab 两个名字都能找到它。

所以改 b.ida.id 也会跟着变:

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

b.id = 2

print(a.id) -- 输出:2
print(b.id) -- 输出:2

通过这个例子可以引出一个关于Lua引用的关键问题:

拿到的是一份独立数据,还是某张表的另一个引用?

配置、对象、模块、热重载、回调残留,很多坑都和它有关。
如果没了解清楚这个问题,实际项目中,代码写着写着就会变成“只改了这里,为什么那里也变了?”。

修改字段和替换引用

Lua中,改字段通常是有两种含义,但是这两个“改”的语义完全不同:

  • 改字段:动的是表里的内容。
  • 换引用:动的是当前变量指向哪张表。

修改字段

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

b.id = 2

print(a.id) -- 输出:2

ab 指向同一张表,所以 b.id = 2 改的是原表里的字段。
只要还拿着这张表的引用,就都能看到这个变化。

替换引用

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

b = { id = 2 }

print(a.id) -- 输出:1
print(b.id) -- 输出:2

b 改去指向另一张新表。
a 还握着旧表,所以 a.id 还是 1。

函数参数里的 table

函数参数也是同一套规则。
严格说,Lua 的参数是按值传递;只是传进去的这个值如果是 table,它指向的还是原来那张表。

所以函数里改字段,外面能看到:

local player = { hp = 100 }

local function Hurt(target)
    target.hp = target.hp - 10
end

Hurt(player)

print(player.hp) -- 输出:90

target 是函数里的局部变量,但它找到的是作为参数传进来的 player 表。
target.hp,就是动原表里的字段。

换成给参数重新赋值就不一样了:

local player = { hp = 100 }

local function Reset(target)
    target = { hp = 999 }
end

Reset(player)

print(player.hp) -- 输出:100

外面的 player 不会变。
target = { hp = 999 } 只是让函数内部的 target 指向新表,旧表没有被动过。

模块表、对象表、配置表的共享

Lua 项目写多了会发现,很多东西最后都落到表上。

模块通常是一张表:

local M = {}

M.count = 0

return M

对象运行时数据经常是一张表:

local player = {
    id = 1,
    hp = 100
}

配置也经常是一张表:

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

这些表很可能会被多个地方拿到,要当成共享引用来处理,不能默认它是自己的私有数据,否则随意乱改会造成意想不到的后果。

真实项目里有这些case:

  • 模块表被多个脚本 require,通常拿到的是同一份返回值。
  • 角色对象传给战斗、AI、表现层,几边改的可能是同一份运行时状态。
  • 配置表直接暴露出去,业务代码一顺手就把原始配置改了。

项目中共享数据是很正常的,但是一定要清楚这个表应不应该改,和能不能改。

比如下面这种配置误改,就是个典型例子:

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

local cfg = SkillConfig[1001]

-- cfg 指向的是原始配置表
-- 这里不是在改“临时伤害”,而是在改配置本身
cfg.damage = cfg.damage + 50

print(SkillConfig[1001].damage) -- 输出:150,原始配置已经被改了

如果只是想得到一份战斗中的技能数据,就单独建运行时对象然后初始化拿配置:

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 + 50

print(runtimeSkill.damage)      -- 输出:150,只改运行时技能数据
print(SkillConfig[1001].damage) -- 输出:100,原始配置没有被改

配置只提供初始值,战斗过程中的变化放到运行时数据里。
这条线划清楚,后面很多莫名其妙的状态污染会少一大截。

类表字段被共享

还有一种常见坑:类表上的默认 table 字段被多个实例共享。

比如类表里放了一个默认 buffs

local PlayerClass = {
    buffs = {}
}

如果实例自己没有 buffs 字段,就会通过 __index 读到 PlayerClass.buffs
多个实例读到的就是同一张表。

local PlayerClass = {
    buffs = {}
}

local playerA = {}
local playerB = {}

setmetatable(playerA, { __index = PlayerClass })
setmetatable(playerB, { __index = PlayerClass })

table.insert(playerA.buffs, "SpeedUp")

print(#playerA.buffs) -- 输出:1
print(#playerB.buffs) -- 输出:1

这里不是 playerB 真的加了 buff。
只是两个对象都通过 __index 读到了类表上的那张 buffs

如果 buffs 是每个角色自己的运行时状态,就应该在实例创建时单独初始化:

local playerA = {
    buffs = {}
}

local playerB = {
    buffs = {}
}

table.insert(playerA.buffs, "SpeedUp")

print(#playerA.buffs) -- 输出:1
print(#playerB.buffs) -- 输出:0

有点像“静态成员”和“实例成员”的区别。
类表里的 buffs 属于类;实例里的 buffs 才是每个角色自己的。

引用问题

旧引用可能藏得很深,常见的是回调、缓存、模块、协程。

比如 UI 打开时注册了按钮回调,回调里闭包捕获了一个旧对象。
界面刷新、脚本热更以后,如果旧回调没有清理,它照样能继续调用旧逻辑。

比如对象替换了新表,可以看以下case:

local function CreateHpPrinter(target)
    -- 回调把 target 这张表捕获住了
    return function()
        print("回调里 target.hp:", target.hp) -- 输出:100,回调里还拿着旧表
    end
end

local oldRole = {
    hp = 100
}

local printHp = CreateHpPrinter(oldRole)

-- 外部变量已经换成新表,但旧表仍然被回调持有
oldRole = {
    hp = 999
}

print("新 oldRole.hp:", oldRole.hp) -- 输出:999,外部变量已经指向新表
printHp() -- 输出:回调里 target.hp:100,闭包里还拿着创建回调时的旧表

表面看起来像“热重载没生效”。实际可能只是某个地方还握着旧表、旧函数、旧闭包。

总结

表引用与共享的核心是:分清这张表是共享数据,还是当前逻辑自己的数据。

共享表不要随便改,尤其是配置、模块、缓存、回调里拿到的表。
要改运行时状态,就明确改字段;要独立数据,就新建表或拷贝。

很多状态污染、热重载旧逻辑残留,最后查下来都是因为有地方拿着旧表、旧函数、旧闭包。


2.2 知识点代码

Lesson2_表引用与共享.lua

print("**********表引用与共享************")

print("**********知识点一 引用语义回顾************")

local playerA = {
    id = 1,
    name = "PlayerA"
}

-- playerB 不是新表,只是和 playerA 指向同一张表
local playerB = playerA

playerB.name = "PlayerB"

print("playerA.name:", playerA.name) -- 输出:PlayerB,原表字段被改了
print("playerB.name:", playerB.name) -- 输出:PlayerB,两个变量看到的是同一张表


print("**********知识点二 修改字段和替换引用************")

local dataA = {
    value = 100
}

local dataB = dataA

-- 修改字段:改的是 dataA 和 dataB 共同指向的表
dataB.value = 200

print("改字段后 dataA.value:", dataA.value) -- 输出:200,原表字段被改了

-- 替换引用:只是让 dataB 指向新表
dataB = {
    value = 300
}

print("换引用后 dataA.value:", dataA.value) -- 输出:200,dataA 仍然指向旧表
print("换引用后 dataB.value:", dataB.value) -- 输出:300,dataB 已经指向新表


print("**********知识点三 函数参数里的table************")

local role = {
    hp = 100
}

local function Hurt(target, damage)
    -- target 拿到的是 role 那张表
    target.hp = target.hp - damage
end

local function ResetTarget(target)
    -- 这里只是替换函数内部的 target,不会影响外部 role
    target = {
        hp = 999
    }

    print("函数内部 target.hp:", target.hp) -- 输出:999,函数内部已经指向新表
end

Hurt(role, 20)

print("受伤后 role.hp:", role.hp) -- 输出:80,函数内部改字段会影响原表

ResetTarget(role)

print("Reset后 role.hp:", role.hp) -- 输出:80,外部 role 没有被替换


print("**********知识点四 模块表、对象表、配置表的共享************")

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

local skillCfg = SkillConfig[1001]

-- skillCfg 指向原始配置表,这里会污染配置
skillCfg.damage = skillCfg.damage + 50

print("配置表 damage:", SkillConfig[1001].damage) -- 输出:150,原始配置已经被改了

-- 运行时数据单独建表,配置只提供初始值
local runtimeSkill = {
    id = skillCfg.id,
    name = skillCfg.name,
    damage = skillCfg.damage,
    level = 1
}

runtimeSkill.damage = runtimeSkill.damage + 20

print("运行时技能 damage:", runtimeSkill.damage)   -- 输出:170,只改运行时数据
print("配置表 damage:", SkillConfig[1001].damage) -- 输出:150,配置表没有继续被 runtimeSkill 改动


print("**********知识点五 类表字段被共享************")

local PlayerClass = {
    buffs = {}
}

local player1 = {}
local player2 = {}

setmetatable(player1, { __index = PlayerClass })
setmetatable(player2, { __index = PlayerClass })

-- player1 自己没有 buffs,会读到类表上的 PlayerClass.buffs
table.insert(player1.buffs, "SpeedUp")

print("player1 buff数量:", #player1.buffs) -- 输出:1
print("player2 buff数量:", #player2.buffs) -- 输出:1,两个实例读到的是同一张 buffs

local fixedPlayer1 = {
    buffs = {}
}

local fixedPlayer2 = {
    buffs = {}
}

table.insert(fixedPlayer1.buffs, "SpeedUp")

print("fixedPlayer1 buff数量:", #fixedPlayer1.buffs) -- 输出:1,只改了 fixedPlayer1
print("fixedPlayer2 buff数量:", #fixedPlayer2.buffs) -- 输出:0,两个实例的 buffs 已经分开


print("**********知识点六 引用问题************")

local function CreateHpPrinter(target)
    -- 回调把 target 这张表捕获住了
    return function()
        print("回调里 target.hp:", target.hp) -- 输出:100,回调里还拿着旧表
    end
end

local oldRole = {
    hp = 100
}

local printHp = CreateHpPrinter(oldRole)

-- 外部变量已经换成新表,但旧表仍然被回调持有
oldRole = {
    hp = 999
}

print("新 oldRole.hp:", oldRole.hp) -- 输出:999,外部变量已经指向新表
printHp() -- 输出:回调里 target.hp:100,闭包里还拿着创建回调时的旧表


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

print("配置表默认只读,运行时状态单独放")       -- 输出:配置表默认只读,运行时状态单独放
print("要改原对象就改字段,要独立数据就新建表") -- 输出:要改原对象就改字段,要独立数据就新建表
print("旧逻辑残留,常见原因是旧引用没清掉")     -- 输出:旧逻辑残留,常见原因是旧引用没清掉


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

×

喜欢就点赞,疼爱就打赏