lua-源码带读-06-元表与元方法
Lua 5.4 源码带读 第6篇:元表与元方法
本篇目标
理解 Lua 中元表(metatable)和元方法(metamethod)的工作原理,以及它们如何实现运算符重载、属性访问重载等特性。
前置知识
- 了解Lua的基本类型系统
- 了解表(table)的基本操作
- 阅读过第1-4篇
1. 元表基础
1.1 什么是元表
每个Lua对象(表、userdata、string)都可以有一个元表。元表定义了该对象的行为:如何响应运算符、如何访问不存在的字段、如何序列化等。
local t = {}
setmetatable(t, {__index = function(self, key) return "default" end})
print(t.anykey) -- 输出: "default"
1.2 元表API
// lua.h
int lua_setmetatable(lua_State *L, int objindex);
int lua_getmetatable(lua_State *L, int objindex);
2. 元方法列表
2.1 元方法名和触发条件
| 元方法 | 触发条件 | 函数签名 |
|---|---|---|
__index |
t[k],t没有k |
function(t, k) return v end |
__newindex |
t[k] = v,t没有k |
function(t, k, v) end |
__add |
a + b |
function(a, b) return c end |
__sub |
a - b |
function(a, b) return c end |
__mul |
a * b |
function(a, b) return c end |
__div |
a / b |
function(a, b) return c end |
__mod |
a % b |
function(a, b) return c end |
__pow |
a ^ b |
function(a, b) return c end |
__idiv |
a // b |
function(a, b) return c end |
__band |
a & b |
function(a, b) return c end |
__bor |
a | b |
function(a, b) return c end |
__bxor |
a ~ b |
function(a, b) return c end |
__shl |
a << b |
function(a, b) return c end |
__shr |
a >> b |
function(a, b) return c end |
__unm |
-a |
function(a) return b end |
__bnot |
~a |
function(a) return b end |
__len |
#a |
function(a) return n end |
__concat |
a .. b |
function(a, b) return c end |
__eq |
a == b |
function(a, b) return bool end |
__lt |
a < b |
function(a, b) return bool end |
__le |
a <= b |
function(a, b) return bool end |
__call |
t(...) |
function(t, ...) return ... end |
__gc |
对象回收前 | function(t) end |
__close |
变量关闭 | function(t, err) end |
2.2 __index 的两种实现方式
函数形式:
setmetatable(t, {
__index = function(self, key)
-- 动态计算
return rawget(self, key) or compute_value(key)
end
})
表形式:
local defaults = {name = "default", size = 10}
setmetatable(t, {__index = defaults})
-- 等效于 __index = function(self, key) return defaults[key] end
表形式更高效(C代码直接查找,无需调用Lua函数)。
3. 源码实现:ltm.c
3.1 元方法索引
// ltm.h
typedef enum {
TM_INDEX,
TM_NEWINDEX,
TM_ADD,
TM_SUB,
TM_MUL,
TM_MOD,
TM_POW,
TM_DIV,
TM_IDIV,
TM_BAND,
TM_BOR,
TM_BXOR,
TM_SHL,
TM_SHR,
TM_UNM,
TM_LEN,
TM_EQ,
TM_LT,
TM_LE,
TM_CONCAT,
TM_CLOSE,
TM_CALL,
TM_N /* 元方法总数 */
} TMS;
3.2 元表查找
// ltm.c
const TValue *luaT_gettm(Table *events, TMS event, TValue *k) {
const TValue *tm = luaH_getshortstr(events, luaS_eventname[event]);
if (ttisnil(tm)) {
// 标记该元方法不存在(flags字段)
setbit(events->flags, event);
return NULL;
}
return tm;
}
// 检查对象是否有元方法
int luaT_callTM(lua_State *L, const TValue *f, const TValue *p1, const TValue *p2, const TValue *p3) {
StkId func = L->top;
setobj(L, func++, f);
setobj(L, func++, p1);
setobj(L, func++, p2);
setobj(L, func++, p3);
L->top = func + 1;
return luaD_pcall(L, f_call, ...);
}
3.3 flags字段优化
// lobject.h
lu_byte flags; // 元方法缺失标记
// 每个位表示一个元方法是否不存在
// 1<<p 表示第p个元方法不存在
// 避免每次都查找元表
4. 运算符重载的实现
4.1 VM中的运算指令
// lvm.c
case OP_ADD: {
const TValue *rb = RB(i);
const TValue *rc = RC(i);
// 快速路径:两个整数
if (ttisinteger(rb) && ttisinteger(rc)) {
setivalue(ra, ivalue(rb) + ivalue(rc));
} else if (tonumber(rb, &nb) && tonumber(rc, &nc)) {
// 浮点运算
setfltvalue(ra, cast_num(nb) + cast_num(nc));
} else {
// 元方法调用
pc--;
if (luaT_callTM(L,TM_ADD,rb,rc,ra))
continue;
else
luaG_binoperror(L, rb, rc, "add");
}
vmbreak;
}
4.2 元方法调用流程
OP_ADD R[a] R[b] R[c]
→ 快速路径(整数/浮点)
→ 失败 → 调用 luaT_callTM(L, TM_ADD, rb, rc, ra)
→ 查找元表的 __add 元方法
→ 调用该函数
→ 结果存入 R[a]
4.3 OP_MMBIN 的连锁效应
-- a + b 的字节码
-- OP_ADD 0 1 2 -- 尝试快速路径
-- OP_MMBIN 0 0 0 -- 如果快速路径失败,调用元方法
-- OP_ADD 成功后跳过 OP_MMBIN(pc++)
5. __index 和 __newindex
5.1 __index 查找流程
// ltable.c
static int luaH_get(Table *t, const TValue *key) {
// 1. 如果是整数键,尝试数组部分
if (ttisinteger(key))
return luaH_getint(t, ivalue(key));
// 2. 如果是字符串键,先检查元表
const TValue *tm = luaT_gettmbytag(L, t->metatable, TM_INDEX);
if (tm != NULL) {
// 找到 __index 元方法,调用它
if (ttisfunction(tm))
luaT_callTM(L, tm, t, key, L->top);
else
return luaH_get(tm, key); // 递归查找(表形式__index)
}
// 3. 在哈希部分查找
Node *n = mainposition(t, key);
// ...
}
5.2 __newindex 查找流程
// lvm.c
case OP_SETTABLE: {
TValue *rb = RB(i);
TValue *rc = RC(i);
// 尝试直接设置
if (luaH_set(L, t, rb, rc) == 0) {
// 设置成功
} else {
// 设置失败,检查 __newindex 元方法
const TValue *tm = luaT_gettm(L, t->metatable, TM_NEWINDEX);
if (tm != NULL) {
luaT_callTM(L, tm, t, rb, rc);
} else {
luaG_callerror(L, t, "index");
}
}
}
6. 元方法与类型的关联
// ltm.c
static const char *const luaT_eventname[] = {
"__index", "__newindex", "__add", "__sub", "__mul",
"__mod", "__pow", "__div", "__idiv", "__band",
"__bor", "__bxor", "__shl", "__shr", "__unm",
"__len", "__eq", "__lt", "__le", "__concat",
"__close", "__call"
};
每个类型通过其元表(Table)查找元方法。字符串有特殊的元表处理:
// lstring.c
// 字符串的元表是共享的(全局的)
// table.getmetatable("hello") 返回同一个表
7. 本篇小结
| 概念 | 要点 |
|---|---|
| 元表 | 每个对象可关联的表,定义行为 |
| 元方法 | __index, __newindex, __add 等特殊键 |
| 三类元方法 | 算术、比较、对象生命周期 |
| 两种__index | 函数形式(灵活) vs 表形式(高效) |
| flags字段 | 缓存元方法缺失信息,避免重复查找 |
| 运算符重载 | VM指令快速路径 → 失败 → 查找元方法 |
思考题
- 为什么
__index有两种实现方式(函数和表)?各有什么优缺点? __len元方法如何影响#运算符?- 字符串的元表为什么是全局共享的?表的元表呢?
__gc元方法在什么时候被调用?它有什么限制?- 如果
__add元方法返回非数值类型,会发生什么?
思考题解答
1. 为什么 __index 有两种实现方式?
函数形式:
setmetatable(t, {__index = function(self, key) ... end})
- 优点:灵活,可以动态计算值
- 缺点:每次查找都调用Lua函数,有函数调用开销
表形式:
local defaults = {name = "default", size = 10}
setmetatable(t, {__index = defaults})
- 优点:高效,C代码直接调用
luaH_get()查找,无需Lua函数调用 - 缺点:值必须是静态的
源码中的区别:
// ltable.c
const TValue *tm = luaT_gettmbytag(L, t->metatable, TM_INDEX);
if (tm != NULL) {
if (ttisfunction(tm))
luaT_callTM(L, tm, t, key, L->top); // 函数形式:调用Lua函数
else
return luaH_get(tm, key); // 表形式:直接查找表
}
建议:如果值是固定的(如默认值),优先使用表形式。需要动态计算时用函数形式。
2. __len 元方法如何影响 # 运算符?
# 运算符的执行顺序:
- 检查对象是否有
__len元方法 - 如果有,调用它并返回结果
- 如果没有,使用默认的长度计算(数组部分的连续整数键数量)
// ltable.c
lua_Integer luaH_getn(Table *t) {
// 1. 先查找 __len 元方法
const TValue *tm = luaT_gettmbytag(L, t->metatable, TM_LEN);
if (tm != NULL) {
// 调用 __len
luaT_callTM(L, tm, t, &result);
return integerresult;
}
// 2. 使用默认计算:二分查找数组边界
unsigned int u = luaH_realasize(t);
// ...
}
注意:#t 对字符串的长度也受 __len 影响,但字符串默认没有 __len,所以返回字节数(不是字符数)。
3. 字符串的元表为什么是全局共享的?
原因:
- 字符串是不可变的(immutable),没有实例状态
- 所有同类型字符串需要相同的行为
- 避免为每个字符串分配单独的元表(内存效率)
- 短字符串驻留后,相同内容的字符串共享同一个对象,元表自然共享
表的元表是实例级的:
- 每个表可以有不同的元表
- 不同的表需要不同的行为(如不同的
__index) setmetatable(t1, mt1)和setmetatable(t2, mt2)可以设置不同的元表
字符串元表的设置:
// 全局字符串元表存储在 global_State 中
// string.setmetatable("str", mt) 设置的是全局字符串元表
// 所有字符串都会受到影响
4. __gc 元方法在什么时候被调用?
调用时机:在GC的finalize阶段,当对象被确认为不可达且即将被回收时。
// lgc.c
static void gctm(lua_State *L) {
// 从 finobj 链表中取出待终结对象
// 调用其 __gc 元方法
// 如果 __gc 中重新引用了对象,对象会"复活"
}
限制:
- 只能用于有GC头部的对象(table、userdata、thread等)
- 不能用于字符串(字符串有特殊的GC处理)
- 5.4中:
__gc只能用于userdata和table,不能用于函数 - 调用顺序不确定:多个对象的
__gc调用顺序取决于GC的发现顺序 - 不能阻止回收:即使在
__gc中重新引用对象,对象也可能在下一轮GC中被回收(除非使用collectgarbage("stop")) - 性能开销:
__gc元方法会延迟对象的内存释放
替代方案:5.4引入了 __close 变量(to-be-closed),更可预测的资源清理方式。
5. 如果 __add 元方法返回非数值类型,会发生什么?
local mt = {__add = function(a, b) return "not a number" end}
local t = setmetatable({}, mt)
local result = t + 1 -- result = "not a number"
行为:
- VM执行
OP_ADD快速路径,失败 - 调用
__add元方法,得到字符串"not a number" - 结果直接存入目标寄存器
- 不会报错(除非元方法本身报错)
// lvm.c
case OP_ADD: {
// 快速路径失败
pc--;
if (luaT_callTM(L, TM_ADD, rb, rc, ra))
continue; // 元方法调用成功,直接使用结果
else
luaG_binoperror(L, rb, rc, "add");
}
注意:后续对结果的使用可能报错(如赋值给需要数字的变量),但 + 运算符本身不检查结果类型。这与Python等语言的行为一致——运算符返回值的类型完全由元方法决定。

浙公网安备 33010602011771号