Lua 语言系列:1.2 metatable 深挖——__index、__newindex 与运算符重载
你写过这样的需求吗:查不到的配置项要有默认值;这张表不许别人改;两个"向量"要能用 + 相加、用 == 比较。
在 C++ 里,这些分别对应——给类写 getter 兜底、const 或私有成员、operator+ 与 operator== 重载。在 Python 里是 __getattr__、__setattr__、__add__、__eq__。
而在 Lua 里,它们全部是同一件事:给一张表挂上一张元表(metatable),在元表里写下双下划线开头的元方法。
Lua 没有 class 关键字,也没有运算符重载语法——所有"让一张表拥有行为"的能力,都收敛到了这一张元表里。本篇把它拆到底:查、写、运算、调用、打印、销毁,每一条路径上元表站在哪里,以及那些足以让程序静默出错的坑。
一、__index:只在"自己没有"时才来找我
__index 是元表里最常用的字段,它只在从表里读一个不存在的键时被调用。它有两种形态——一张表,或一个函数。
1 | -- 形态一:__index 指向一张表 → 查不到就去那张表里找 |
输出:
1 | animal |
rawget(dog, "kind") 返回 "dog"、rawget(dog, "speak") 返回 nil,说明:继承来的字段从来不在实例身上,__index 只是一条"查不到时去别处看看"的兜底规则。理解这一点,后面所有"继承"的怪现象都能解释。
再看函数形态——它更灵活,也因此藏着本系列第一个重量级坑:
1 | local bad = setmetatable({}, { |
输出(... 是省略的文件名与行号,随运行环境变化):
1 | false ...: stack overflow |
为什么第一段会栈溢出?因为 __index 函数里的 t[k] 是一次普通索引,而 t 里没有 k,于是又去调用 __index,无限递归。在元方法内部读写表,一律用 rawget / rawset 绕开元方法——这是 Lua 元表编程的第一条纪律。
二、__newindex:赋值拦截,以及"键已存在就不触发"的坑
__newindex 管的是"写入一个键"。但它触发的条件极其反直觉:
1 | local log = {} |
输出:
1 | 1 a |
__newindex 只在"这个键当前不存在"时触发。 一旦键有了值,后续赋值就是普通写入,元方法完全不知情。想记录每一次写入,得让表永远"空着"(用代理表转发到另一张真实表,见本章末尾)。
它最适合的两件事:只读表,和代理转发。
1 | -- 只读表:读走 __index,写直接报错 |
输出:
1 | localhost |
还有个容易踩的点:pairs 只遍历表自身,看不见 __index 指向的内容:
1 | local defaults = {a = 1, b = 2} |
输出:
1 | pairs count: 1 |
pairs 只数出 1 个键(c),但 t.a 却读得到值。序列化、日志打印、深拷贝这类操作如果只依赖 pairs,会静默丢掉所有"继承"来的字段。
三、运算符元方法:一张表学会 +、==、#
算术与比较运算各有对应的元方法。先看一个完整例子:
1 | local Vec = {} |
输出:
1 | Vec(11, 22) |
注意 Vec.__index = Vec 这行:它让实例能找到 new 等方法;而 __add、__eq 这些元方法本身就在元表 Vec 上,不需要 __index 也能被运算触发——元方法的查找,和 t[k] 的查找,是两条不同的路。
坑 1:== 会失去对称性
二元运算的元方法查找顺序是:先看第一个操作数,没有再看第二个。这会让 == 变得不对称:
1 | local x = setmetatable({}, {__eq = function() return true end}) |
输出:
1 | x==y: true |
同样的两个对象,换个顺序结果就变了。所以同一个"类"的所有实例必须共享同一张元表(也就是同一个 __eq 函数),否则相等性就是一团乱麻。
另外,__eq 还有个前提:两边都是表(或都是 userdata),且不是同一个对象时才调用。类型不同直接返回 false,连元方法都不看:
1 | local z = setmetatable({}, {__eq = function() return true end}) |
输出:
1 | z==0: false |
坑 2:<= 别指望自动推导(版本差异,实测两版)
只定义 __lt,不定义 __le,然后用 <=——这在 Lua 5.2/5.3 上能跑(解释器用 not (b < a) 推导),在更新的版本上会直接报错:
1 | local mt = {} |
实测输出(Lua 5.3):
1 | p<q: true |
同一段代码在 Lua 5.5 下,第二行变成:
1 | p<=q: false ...: attempt to compare two table values |
结论:需要 <= 就显式定义 __le。 依赖"自动推导"的代码会在版本升级后静默从"能跑"变成"报错",这是最难排查的一类坑——因为改的不是你的代码,是解释器。
坑 3:# 被 __len 劫持,真实长度要用 rawlen
1 | local arr = setmetatable({1, 2, 3}, {__len = function() return 99 end}) |
输出:
1 | #arr: 99 |
# 对带 __len 的表返回的是元方法的结果(这里是 99),真实元素个数要用 rawlen。顺带一提:__len 对表生效是 Lua 5.2 才有的,5.1 里它只对 userdata 有效。
四、__call 与 __tostring:让表伪装成函数和字符串
__call 让一张表可以被"调用",用来做带状态的函数对象再合适不过:
1 | local counter = setmetatable({n = 0}, { |
输出:
1 | 1 2 12 |
__concat 管 .. 连接,参数顺序与表达式一致:
1 | local W = {} |
输出:
1 | hello world |
__tostring 决定 print / tostring 看到什么;而 __metatable 是个特殊字段——它不拦截任何操作,只用来把元表藏起来:
1 | local account = setmetatable({balance = 100}, { |
输出:
1 | Account(balance=100) |
getmetatable 不再返回真元表,而是返回 __metatable 的值;谁再想 setmetatable 就报错。这是 Lua 里少数能做出"封装"的手段——但记住,getmetatable 只能防君子,debug.getmetatable 照样能拿到一切。
五、__gc 与元表链:能力边界在这里
__gc 是对象被回收时触发的终结器。表的 __gc 从 Lua 5.2 起支持,但它有三个硬约束,每一条都能让人调试半天:
1 | -- ① 正常:元表在 setmetatable 时就带 __gc |
输出:
1 | gc: res |
三条结论:
__gc必须在setmetatable那一刻就存在于元表里,之后补进去的不会被登记(② 里late gc fired从未打印)。- 触发时机由 GC 决定,不是变量离开作用域的瞬间——所以
__gc不适合做文件句柄、锁这类需要确定性释放的资源管理(那要用 Lua 5.4 的<close>变量)。 - 对象仍被引用时不会触发(③ 里
keep还活着,gc: alive到释放后才打印)。
元表链:多级查找与"继承"的极限
__index 可以指向另一张也有元表的表,于是形成查找链——这就是 Lua 的"继承":
1 | local Animal = {} |
输出:
1 | woof |
而"子类覆盖"的本质,只是把值写进自己身上,把上游的挡住:
1 | local Base = {kind = "base"} |
输出:
1 | before: base nil |
这就是元表"继承"的全部真相:没有类、没有副本、没有私有,只有"读不到就顺着链往上问"。它带来三个必须知道的极限:
- 没有封装:任何代码都能
rawset改写、都能用debug.getmetatable看穿,__metatable只是君子协定。 - 没有类型检查:
Vec.__add拿到什么就加什么,传个字符串进来只会在a.x上报错,错误还在"错误的层次"。 - 链有代价:每次字段缺失都要沿链走一遍;深链 + 高频访问是隐藏的性能陷阱(热路径上把用到的字段缓存到实例自身即可)。
六、对比表 + 🐾 小结
| 能力 | Lua(元表) | Python | C++ |
|---|---|---|---|
| 读缺失字段 | __index(表 / 函数) |
__getattr__ |
编译期成员,不存在即报错 |
| 拦截写入 | __newindex(仅键不存在时) |
__setattr__(每次都触发) |
无(靠私有 + setter 约定) |
| 运算符重载 | __add/__eq/__lt/__len/__concat |
__add__/__eq__/__lt__/__len__ |
operator+ 等(编译期绑定) |
| 让对象可调用 | __call |
__call__ |
operator() |
| 打印友好 | __tostring(5.4 另有 __name) |
__str__ / __repr__ |
operator<< |
| 析构 | __gc(时机不确定) |
__del__(同样不确定) |
析构函数(确定性,RAII) |
| 继承 | __index 链(原型式) |
class 继承(MRO) | class 继承(编译期) |
| 常用元方法 | 触发时机 | 备注 |
|---|---|---|
__index |
读到不存在的键 | 表或函数;内部用 rawget 防递归 |
__newindex |
写入不存在的键 | 内部用 rawset;已存在的键不触发 |
__add 等算术 |
二元 / 一元运算 | 先查第一个操作数,再查第二个 |
__eq / __lt / __le |
比较运算 | __eq 要求两边同为表/userdata;__le 可由 __lt 推导 |
__len / __concat |
# / .. |
表的 __len 需 Lua 5.2+;真实长度用 rawlen |
__call / __tostring |
t(...) / print(t) |
让表伪装成函数 / 字符串 |
__gc |
对象被回收 | 表需 Lua 5.2+;必须 setmetatable 时就存在 |
__metatable |
getmetatable / setmetatable |
元表"锁",防君子不防 debug |
| 高频坑 | 症状 | 解法 |
|---|---|---|
__index 里写 t[k] |
栈溢出 | 改用 rawget(t, k) |
以为 __newindex 每次都触发 |
第二次赋值没反应 | 想全程拦截就用代理表 + rawset 转发 |
两个实例的 __eq 不同 |
a==b 与 b==a 结果相反 |
同类共享同一张元表 |
pairs 看不到继承字段 |
序列化丢数据 | 序列化前显式展开 __index 链 |
事后给元表补 __gc |
终结器从不执行 | setmetatable 时就把 __gc 写进元表 |
🐾 小结:元表的全部秘密,是**"在表做不到的事上接管一次"——读不到 key 时接管(__index)、写不存在的 key 时接管(__newindex)、做不了运算时接管(__add/__eq/__len)、被调用时接管(__call)、被回收时接管(__gc)。它不是类系统,而是一组可插拔的钩子**:正因如此,Lua 用一个关键字都没加,就长出了对象、继承、运算符重载和资源终结。代价是这些钩子全靠约定维系——用 rawget/rawset 防递归、同类共享一张元表、pairs 只看自身、__gc 必须当场登记,记住这四条,元表就从"玄学"变成" predictable 的工具"。

