2.表引用与共享
2.1 知识点
引用语义回顾
基础篇里已经碰过这个点:table 直接赋值,不会把整张表复制一份。
local a = { id = 1 }
local b = a
这段代码执行完以后,一定不要想成开辟了两张表。
而是一张表,有 a 和 b 两个名字都能找到它。
所以改 b.id,a.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
a 和 b 指向同一张表,所以 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