第一次真正被Lua元表教训,是因为一行“顺手”写的 t[k]。当时要做一个带默认值的配置表:读取不存在的键时返回一个兜底值,而不是 nil。我很快想到了用元表,代码也很短,__index 里做了判断:
lua复制local t = setmetatable({}, {
__index = function(t, k)
if t[k] == nil then -- 想判断真实值是否存在
return "默认值"
end
return t[k]
end
})
print(t.name) -- 直接报错 stack overflow
问题就出在 t[k] 这一行。在 __index 元方法内部查询同一个表 t 时,因为键 k 不存在,会再次触发 __index,然后又进入函数体,又来一次 t[k],无限递归。最终程序卡死。盯着错误栈看了很久我才反应过来:元方法里的每一次表访问,都必须警惕是否绕过了元方法本身。
这段事故非常适合作为理解元表的第一课:元表不是一个复杂的语法糖,而是一组“行为钩子”,它在表被读取、写入、运算、拼接、比较时悄悄介入。如果你在钩子内部又去触发同一种操作,就相当于自己踩进自己挖的坑。
下面从最基础的概念讲起,再到常用元方法的细节、实际开发中的设计与避坑,最后给出一套调试工具链。这篇文章适合刚学完Lua基础语法、想真正把元表用起来的读者,也适合已经写了几个模块、但对某些“灵异现象”说不清道理的老手。
1. 从一次缓存模块事故看懂元表本质
1.1 表、元表、元方法的关系
最好用生活类比来理解。每张Lua表就像一间屋子,屋子里摆着各种家具,也就是键值对。元表则是贴在屋外的“物业管理手册”。它不直接装家具,但规定了“当有人对屋子做某种操作,且屋子里没有对应东西时,应该如何处理”。
比如你去找一把椅子,如果屋子里有,就直接拿给你;如果屋子里没有,系统就会翻看物业管理手册,看有没有“缺货处理办法”,这个办法就是元方法 __index。类似地,如果有人往屋里搬一个原本不存在的柜子(写入新键),系统也会去看手册,也许物业管理处会记录这笔账,这就是 __newindex 的职责。
Lua中所有的表都可以有自己的元表,且一张表只能有一个元表。这个“唯一”非常关键,它决定了继承的实现思路,也导致了很多“为什么我的对象不能同时有两种能力”的疑问。很多人把元表理解为“类的父类”,其实不太准确。元表更像是一份操作协议,它规定了表对某些外部行为的响应方式,而不是简单的“继承来源”。
1.2 两个基础API和三句口诀
最容易上手的两个API是 setmetatable 和 getmetatable。
lua复制local t = {}
local mt = {
__index = function()
return "missing"
end
}
setmetatable(t, mt)
print(getmetatable(t) == mt) -- true
print(t.hello) -- missing
setmetatable 返回被设置的表本身,所以可以链式写。getmetatable 返回元表。一旦设置了元表,后续对 t 的“非常规操作”就会按照元表中的元方法执行。
除了这两个API,还有一对经常一起出现的函数:rawget 和 rawset。它们的作用是跳过元方法,直接对表进行原始读取和写入。
lua复制local t = setmetatable({}, {
__index = function() return "hidden" end
})
print(t.a) -- hidden,触发了元方法
print(rawget(t, "a")) -- nil,绕过了元方法
关于元表,我自己总结了三个口诀:
- 读不到,找
__index。 - 写不进,找
__newindex。 - 要绕过,用
rawget和rawset。
这三句话能解决元表场景中九成以上的困惑。
1.3 元方法触发的时机
元方法不是所有操作都会触发,它有明确的时机。比如 __index 只在“读取表中不存在的键”时触发;如果键已经存在,哪怕值是 nil,也会直接返回,不会调用元方法。这一点很多人理解错,认为 __index 会在每次读取时都被调用,实际上不会。
我刚开始学习时就误以为 __index 等于“读方法”,其实它是“缺键补救方法”。这个区别看起来小,实际影响很大。比如你想统计某个表被读取的次数,用 __index 是统计不到已存在键的读取的,必须用代理模式或者其他的hook方案。
同理,__newindex 只在“写入表中不存在的键”时触发。如果键已经存在,普通赋值会直接覆盖,不会调用它。这意味着,如果你想用 __newindex 做一个“只读保护”,必须保证被保护的表里没有任何预置字段,否则这些字段依然可以被随意修改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读拦截与写拦截:__index 和 __newindex 必须成对理解
2.1 __index 的两种形态:函数和表
__index 可以被设置为函数,也可以被设置为表。
函数形态最常见,它接收两个参数:被访问的表本身和键名,然后返回你想给调用者的值。
lua复制local defaults = { name = "unknown" }
local obj = setmetatable({}, {
__index = function(t, key)
return defaults[key]
end
})
print(obj.name) -- unknown
print(obj.age) -- nil,因为没有对应默认值
表形态更简洁,它相当于把查找工作交给另一张表:
lua复制local Animal = {}
Animal.sound = "generic"
local cat = setmetatable({}, { __index = Animal })
print(cat.sound) -- generic
当 __index 是一个表时,如果被访问的表没有这个键,Lua会去 __index 指向的表中继续查找。如果那张表也没有,而它又有自己的元表和 __index,还会继续向上查找,形成一条完整的查找链。这正是Lua里手写继承的基础。
2.2 rawget:绕过读拦截的原生机制
上一节提到 rawget(t, key) 可以跳过 __index 直接查表。它的价值在实现“默认值”和“代理”时非常明显。
比如我需要在 __index 里判断“某个值真的不存在”,而非“该走默认值了”,就必须用 rawget:
lua复制local data = setmetatable({}, {
__index = function(t, key)
if rawget(t, key) == nil then
return "fallback"
end
return rawget(t, key)
end
})
data.name = "Lua"
print(data.name) -- Lua,rawget查到了已存在键
print(data.missing) -- fallback
如果不加 rawget,函数里写 t[key] 就会再次触发 __index,形成无限递归。很多初学者踩这个坑,就是因为没有意识到,在元方法内部访问表本身时,同样会走元方法的分发逻辑。
2.3 __newindex 的完整工作流程
__newindex 在给不存在的键赋值时触发。它可以是函数,也可以是表。
函数形态:
lua复制local t = setmetatable({}, {
__newindex = function(t, key, value)
rawset(t, key, value)
end
})
t.newKey = 123
print(t.newKey) -- 123
这里如果不使用 rawset,而是直接写 t[key] = value,会再次触发 __newindex,又进入函数体,再次执行 t[key] = value,无限循环。正确的做法是使用 rawset 执行“不会触发元方法”的原生写入。
__newindex 也可以是一个表,此时赋值操作会被转发到那张表上:
lua复制local backup = {}
local t = setmetatable({}, {
__newindex = backup
})
t.a = 1
print(t.a) -- nil,因为原表t里没有写入a
print(backup.a) -- 1,a被写进了backup
这个特性常被用来做“写代理”或“日志跟踪”。如果你希望在业务对象上记录每次新字段的赋值时间,可以在这里挂一个钩子。
2.4 经典场景:只读表、默认值表、日志代理
理解了读写拦截,就可以组合出很多实用结构。
只读表
lua复制function readonly(t)
local proxy = setmetatable({}, {
__index = t,
__newindex = function()
error("attempt to update a readonly table", 2)
end
})
return proxy
end
local conf = { port = 8080, debug = true }
local safeConf = readonly(conf)
print(safeConf.port) -- 8080
safeConf.port = 9090 -- 报错
注意这里我用的是代理表 proxy,而不是直接给 conf 设置 __newindex。因为直接给 conf 设置元表,只能拦截“新键”的写入,已经存在的键仍然可以直接赋值。用代理表后,所有读都通过 __index 转发到原表,所有写都会被拦截,保护得更彻底。
默认值表
lua复制function withDefault(t, default)
return setmetatable(t, {
__index = function()
return default
end
})
end
local cfg = withDefault({}, 0)
print(cfg.unknown) -- 0
这里有个隐含陷阱:如果 default 是表或对象,所有缺失键返回的是同一个引用。你给 cfg.missing1.value = 1,会同时影响 cfg.missing2.value。如果需要每次返回独立的新表,__index 里必须每次新建:
lua复制local cfg = setmetatable({}, {
__index = function()
return { value = 0 }
end
})
local a = cfg.missingA
local b = cfg.missingB
a.value = 1
print(b.value) -- 0,两个独立表
日志代理
用 __newindex 记录所有新字段的写入:
lua复制local log = {}
local obj = setmetatable({}, {
__newindex = function(t, key, value)
table.insert(log, string.format("%s = %s", key, tostring(value)))
rawset(t, key, value)
end
})
obj.name = "Lua"
obj.version = "5.4"
for _, line in ipairs(log) do
print(line)
end
-- name = Lua
-- version = 5.4
这个模式在做数据校验、埋点、缓存审计时很实用。但要注意,__newindex 只会捕获新字段,覆盖已有字段不会被记录,如果需要全量记录,需要额外的方案。
3. 运算符与调用:让表变成一等公民
3.1 数值运算符元方法:__add 到 __pow
Lua允许为表重载算术运算符。常用元方法包括:
| 运算符 | 元方法 | 说明 |
|---|---|---|
+ |
__add |
加法 |
- |
__sub |
减法 |
* |
__mul |
乘法 |
/ |
__div |
除法 |
% |
__mod |
取模 |
^ |
__pow |
幂 |
-(一元) |
__unm |
取负 |
来看一个简单例子:
lua复制local Counter = {}
Counter.__index = Counter
function Counter.new(v)
return setmetatable({ value = v }, Counter)
end
function Counter:__add(other)
return Counter.new(self.value + other.value)
end
local a = Counter.new(10)
local b = Counter.new(20)
local c = a + b
print(c.value) -- 30
元方法 __add 接收两个操作数。用冒号定义时,self 是左操作数,第二个参数是右操作数。你在表达式中写的 a + b,Lua会尝试调用 a 的元表中的 __add,如果 a 没有,再尝试 b 的元表中的 __add。
3.2 比较运算符:__eq / __lt / __le 的对称性坑
比较相关的元方法有三个:__eq 对应 ==,__lt 对应 <,__le 对应 <=。
lua复制local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
return setmetatable({ x = x, y = y }, Vector)
end
function Vector:__eq(other)
return self.x == other.x and self.y == other.y
end
local v1 = Vector.new(1, 2)
local v2 = Vector.new(1, 2)
print(v1 == v2) -- true
这里有个非常容易踩的坑:在没有 __eq 元方法时,两个表变量比较的是引用,即使字段完全相同,结果也是 false。有 __eq 之后,Lua会调用元方法进行比较。
但在Lua 5.1和5.2中,__eq 有个限制:只有当两个操作数的类型相同,并且共享同一个 __eq 函数时才会触发。如果你定义了两个不同的“类”都有各自的 __eq,拿A类对象和B类对象比较,结果可能直接是 false,而不会执行你的比较逻辑。Lua 5.3以后放宽了一些条件,但理解“同源才触发”依然很重要。
__lt 和 __le 也有类似细节。定义了 __lt 后,如果你写 a <= b,Lua可能会尝试 __le;没有 __le 时,某些版本会退化为 not (b < a)。为了行为可控,我建议成对实现,不要让Lua猜你的意图。
3.3 __concat 与 __len 的边界
.. 字符串拼接操作对应 __concat,# 取长度操作对应 __len。
lua复制local Name = {}
Name.__index = Name
function Name.new(first, last)
return setmetatable({ first = first, last = last }, Name)
end
function Name:__concat(other)
return self.first .. " " .. self.last .. other
end
local n = Name.new("Li", "Lei")
print(n .. "!") -- Li Lei!
__concat 接收另一个操作数,不一定是同类型对象,可以是字符串或数字。它常被用在日志对象、SQL子句拼接、路径拼接等场景里,让代码看起来更自然。
一个容易忽略的点是:Lua的 # 操作符对包含 nil 洞的数组行为未定义。如果你为一个表定义了 __len,又去 # 一个稀疏数组,结果可能不符合直觉。重载 __len 时,最好在文档里明确它的语义,避免团队其他人产生误解。
3.4 __call:构造器与闭包工厂
__call 让一张表可以被像函数一样调用,这在实现“类”的构造器时很常见。经典写法:
lua复制local Person = {}
Person.__index = Person
local mt = {
__call = function(_, name, age)
return setmetatable({ name = name, age = age }, Person)
end
}
setmetatable(Person, mt)
local p = Person("张三", 28)
print(p.name, p.age) -- 张三 28
这里 Person 是一张表,它的元表是 mt,mt 里有 __call。执行 Person("张三", 28) 时,Lua调用 mt.__call,第一个参数是 Person 本身,我在函数签名里用 _ 忽略它,然后返回一个元表为 Person 的实例。
__call 还有一个用途是闭包工厂:你可以拿到一个对象后,直接像函数一样调用它来做某事,不必暴露内部函数:
lua复制local calculator = setmetatable({}, {
__call = function(_, a, b)
return a * b + 1
end
})
print(calculator(2, 3)) -- 7
3.5 __tostring:调试体验的分水岭
实现 __tostring 后,直接用 print(obj) 或 tostring(obj),就能看到有意义的信息,而不是一串 table: 0x...。
lua复制function Person:__tostring()
return string.format("Person(%s, %d)", self.name, self.age)
end
print(p) -- Person(张三, 28)
我个人的习惯是,所有自定义的结构体都实现 __tostring。它在日志输出、断言失败提示、控制台调试里的价值极高,几乎不用额外思考成本,却能让整个调试过程顺畅很多。
3.6 其他元方法:__pairs、__gc、__metatable
这几个元方法在特定场景下才会用到。
__pairs 从Lua 5.2开始提供,用来控制 pairs(t) 的遍历结果。默认 pairs 只遍历表自身的键值对,不遍历 __index 链上的内容。如果你想自定义遍历规则,可以重写 __pairs。
__gc 在对象被垃圾回收时调用。Lua 5.2主要支持 userdata,Lua 5.4也支持表。如果对象持有外部资源句柄(文件、socket、C扩展创建的对象),可以通过 __gc 做释放。不过纯Lua业务代码中,大多数表不需要它。
__metatable 是保护元表的关键字段。设置了它之后,getmetatable(t) 返回的是这个字段的值,而setmetatable(t, newMt) 会报错:
lua复制local t = setmetatable({}, {
__metatable = "protected"
})
print(getmetatable(t)) -- protected
setmetatable(t, {}) -- 报错
这个机制常用于库的封装:外部只能看到你的“提示信息”,却无法修改内部对象的元表。
4. 用元表造一个小型Vector类型:从设计到实现
4.1 需求与接口设计
理论讲多了容易飘,下面用一个完整的例子把元方法串起来。目标:实现一个二维向量 Vector,支持加法、减法、比较、字符串输出和调用构造。
接口设计如下:
Vector.new(x, y)创建向量v1 + v2返回新向量v1 - v2返回新向量v1 == v2比较分量是否相等print(v)输出Vector(x, y)格式Vector(x, y)也能直接创建向量
先写元表和基础结构:
lua复制local Vector = {}
Vector.__index = Vector
local mt = {
__call = function(_, x, y)
return setmetatable({ x = x, y = y }, Vector)
end
}
setmetatable(Vector, mt)
这里把 Vector 本身设置为可调用表。注意我用了两个不同的元表:mt 是 Vector 表自己的元表,用于调用;Vector 则是实例的元表,用于查找实例方法。
4.2 元方法实现
lua复制function Vector.__add(a, b)
return Vector(a.x + b.x, a.y + b.y)
end
function Vector.__sub(a, b)
return Vector(a.x - b.x, a.y - b.y)
end
function Vector.__eq(a, b)
return a.x == b.x and a.y == b.y
end
function Vector.__tostring(v)
return string.format("Vector(%s, %s)", v.x, v.y)
end
全部使用点号定义,保证第一个参数是显式的操作数,避免冒号写法带来 self 混淆。
测试一下:
lua复制local v1 = Vector(1, 2)
local v2 = Vector(3, 4)
local v3 = v1 + v2
print(v3) -- Vector(4, 6)
print(v1 == Vector(1, 2)) -- true
加上点乘、模长等普通方法:
lua复制function Vector:dot(other)
return self.x * other.x + self.y * other.y
end
function Vector:length()
return math.sqrt(self.x * self.x + self.y * self.y)
end
实例通过 Vector.__index = Vector 找到这些方法,所以 v3:length() 可以直接使用。
4.3 性能与扩展
元表提供了极大灵活性的同时,也带来了间接寻址的成本。每次通过 v3:length() 调用方法,实际要经历:检查 v3 自身是否有 length 字段,没有则查 v3 元表 Vector 的 __index,然后在其指向的表里查找。这条链比C语言的函数指针调用多几步,但在Lua层面通常可以接受。
如果热点代码对性能要求极高,一个优化思路是:在构造实例时,把高频方法引用直接复制到实例上,减少查链次数:
lua复制local v = setmetatable({
x = 1, y = 2,
length = Vector.length
}, Vector)
这种方式牺牲了一点内存,换取了更快的调用路径。我通常只在日志和渲染循环这类热点里才这么做。
4.4 Lua 5.3位运算元方法:bit.band 的现代替代
在Lua 5.1和LuaJIT时代,位运算通常依赖 bit 库,比如 bit.band(a, b)。Lua 5.3引入了原生位运算符 &、|、~、<<、>>,并配套了元方法 __band、__bor、__bxor、__bnot、__shl、__shr。
这意味着你可以让自定义对象也支持位运算。比如实现一个“权限集合”类型,内部用一个整数存储多个开关位:
lua复制local Flags = {}
Flags.__index = Flags
function Flags.new(v)
return setmetatable({ value = v }, Flags)
end
function Flags.__bor(a, b)
return Flags.new(a.value | b.value)
end
local read = Flags.new(1)
local write = Flags.new(2)
local rw = read | write
print(rw.value) -- 3
对比以前用 bit.band(rw.value, read.value) 的写法,运算符风格可读性更好。如果你的项目还在Lua 5.1或LuaJIT,直接使用 bit 库即可,核心思路不变。
5. 元表实战中绕不开的坑
5.1 无限递归:__index 里访问同一张表
这是最常见的坑,我在文章开头就遇到过。凡是 __index 或 __newindex 的函数体里,直接对当前表做同类型操作,都要怀疑是否递归。
容易踩的地方有两类:
__index里写了local v = t[key]__newindex里写了t[key] = value
正确的做法是用 rawget 和 rawset:
lua复制local t = setmetatable({}, {
__index = function(t, key)
return rawget(t, key) -- 安全,不会递归
end
})
判断是否会发生递归的简单标准:在元方法内部,如果再次触发“同名元方法”的操作,就是裸写表的默认行为。不要把元表看作不可穿透的墙,要把它看作一层薄薄的膜,膜内对表的访问方法不同,行为就完全不同。
5.2 共享元表的副作用
多个对象共用同一个元表是正常设计,但如果你在运行期动态修改元表里的方法,所有共享该元表的对象都会受影响。这有时是优点,有时是灾难。
lua复制local mt = {
__index = { version = "1.0" }
}
local a = setmetatable({}, mt)
local b = setmetatable({}, mt)
mt.__index.version = "2.0"
print(a.version, b.version) -- 2.0 2.0
如果你希望某个对象拥有独立的默认行为,就不要共享同一个元表。可以在构造时给每个对象新建元表,代价是内存增加。大多数场景下,共享元表是合理的,但要注意不要随意修改共有字段。
一个更隐蔽的坑是:你把元表当作普通表转来转去,比如调用某个库函数,不小心改写了元表里的 __index,导致使用这个元表的所有实例行为突变。我给团队的建议是:把元表视为内部实现细节,不要暴露给外部随意访问,必要时用 __metatable 保护。
5.3 __eq 不触发或反向触发
很多人写了 __eq,却发现在某些比较场景下没有生效。原因前面提过:在Lua 5.1和5.2中,两个操作数必须共享同一个 __eq 函数才触发。
如果你把两个不同元表的对象拿来比较,即使两个元表都有 __eq,也可能返回 false。处理方式是尽量统一比较逻辑:要么所有同类对象都使用同一个元表,要么在 __eq 内部做类型判断,并且确保比较的对象确实属于同类。
还有一种情况是:整数和浮点数。比如 v == 1,左边是Vector,右边是数字。Lua不会为数字调用Vector的 __eq,结果会是 false。所以在重载比较运算符时,要明确用户预期,并在文档里给出边界。
5.4 只读保护的错觉
__newindex 只能拦截“写入不存在的键”,已存在的键赋值不会触发它。前面讲只读表时,我特意用了代理表。这里再强调一下:
lua复制local t = { port = 8080 }
setmetatable(t, {
__newindex = function()
error("readonly")
end
})
t.port = 9090 -- 不会报错!因为port已存在
如果你真的要保护一个表,要么在创建时就用代理层,要么遍历原表把所有键复制到新表,再对代理表设置保护。最保险的思路是:从外部只能拿到代理表,不能拿到原表引用。
5.5 集成开发环境与调试器:EmmyLua、VS Code、IntelliJ
调试元表问题,光靠 print 也能撑,但有一把趁手的调试器效率高很多。
IntelliJ IDEA 系有 EmmyLua 插件,支持断点、变量监视、类型推断和补全,对Lua项目非常友好。VS Code 上也有对应的 Lua 插件,社区最活跃的是 sumneko.lua,也就是 EmmyLua 项目的同名延续,安装后可以设置断点,在 launch.json 里指定 Lua 解释器路径。
我用 EmmyLua 的最大感受是:断点命中后,可以看到当前表的元表信息,能直接检查 getmetatable 的返回值,这对理解查找链帮助极大。调试栈里每次触发元方法,调用栈会显示从哪一次表访问开始的,能很快找到递归源头。
如果没有条件配IDE,print 配合 pcall / xpcall 也能定位问题:
lua复制local ok, err = pcall(function()
local v = someTable.missing
end)
if not ok then
print(debug.traceback(err))
end
再配合一个小工具函数:
lua复制function dumpMeta(t)
local mt = getmetatable(t)
if mt == nil then
print("no metatable")
else
for k, v in pairs(mt) do
print(k, type(v))
end
end
end
注意:如果对象设置了 __metatable,getmetatable 返回的是保护值,这个工具会把它当成普通键值对打印。别被误导。
6. 从游戏脚本到键盘宏:元表的现实应用
6.1 游戏框架里元表最常见的用法
很多游戏服务端框架会用Lua作为上层脚本语言,技能、任务、AI等玩法逻辑都用Lua编写。元表在这些框架里最常见的角色是“协议表”和“配置表”。
协议表通过 __index 实现字段默认值,避免解析数据时频繁判断某个字段是否存在。比如一个玩家数据结构,可能同时被多个模块读写,直接暴露原始表风险很高;框架通常会封装一层代理,用 __newindex 做写入前校验,用 __index 做字段不存在时的兜底。
技能系统的“基类”也大量使用元表继承。基类表负责通用逻辑,不同技能通过设置 __index 指向基类表,只需要覆盖差异方法。这种写法比在每个技能对象里复制方法要省很多内存。
开源项目里,有些经典的服务端项目会加载Lua脚本做扩展。你可以看到它们的Lua模块里到处都是 setmetatable,本质都是在标准化“对象的行为协议”。
6.2 键鼠设备脚本中元表的巧妙应用
键鼠外设的脚本也是Lua的重要应用场景。很多脚本要管理多个按键状态、配置文件和界面状态。如果每个按键状态都手动判空,代码会很啰嗦。
用元表给按键状态设置默认值,就能压缩大量条件判断:
lua复制local keyState = setmetatable({}, {
__index = function()
return { pressed = false, count = 0 }
end
})
-- 使用
keyState["ctrl"].count = keyState["ctrl"].count + 1
这里每个键第一次访问时都会获得一个独立的默认状态表,因为 __index 里每次返回新表,避免共享引用。
设备脚本里常常需要维护一个“上一次执行时间”之类的缓存,元表做缓存代理也很方便:不存在的键自动初始化为当前时间,第二次访问就是已有值。
6.3 从元表看Lua语言设计哲学
元表并不是为了炫技。它体现的是一种“可定制的最小机制”:语言本身提供表作为唯一数据结构,剩下的扩展能力通过钩子暴露给开发者。你不需要等待语言加入运算符重载、继承、默认值这些语法,而是用一套统一的元方法体系自己搭。
这种设计有代价:新手不容易理解行为钩子,调试时也多了间接层。但一旦掌握,你会发现自己可以用很小的代码量搭出很灵活的系统。这也是Lua能长期存活在游戏、嵌入式、脚本领域的重要原因之一。
最后分享一个我自己养成的习惯:每新建一个结构体,我第一件事就是给它的元表加上 __tostring。哪怕只是最简单的字段拼接,也能让后续所有 print 调试变得舒服很多。我还会写一个通用的 dumpMeta 工具函数放在自己的 .lua 公共库里,遇到行为异常的表,先打印它的元方法列表,再判断问题出在哪一层。元表本身并不难,难的是每次访问表时都清楚“这一下,到底会不会触发元方法”。想清楚这个问题,你就真的入门了。
