AIGC标识 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指令快速路径 → 失败 → 查找元方法

思考题

  1. 为什么 __index 有两种实现方式(函数和表)?各有什么优缺点?
  2. __len 元方法如何影响 # 运算符?
  3. 字符串的元表为什么是全局共享的?表的元表呢?
  4. __gc 元方法在什么时候被调用?它有什么限制?
  5. 如果 __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 元方法如何影响 # 运算符?

# 运算符的执行顺序:

  1. 检查对象是否有 __len 元方法
  2. 如果有,调用它并返回结果
  3. 如果没有,使用默认的长度计算(数组部分的连续整数键数量)
// 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. 字符串的元表为什么是全局共享的?

原因

  1. 字符串是不可变的(immutable),没有实例状态
  2. 所有同类型字符串需要相同的行为
  3. 避免为每个字符串分配单独的元表(内存效率)
  4. 短字符串驻留后,相同内容的字符串共享同一个对象,元表自然共享

表的元表是实例级的

  • 每个表可以有不同的元表
  • 不同的表需要不同的行为(如不同的 __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 中重新引用了对象,对象会"复活"
}

限制

  1. 只能用于有GC头部的对象(table、userdata、thread等)
  2. 不能用于字符串(字符串有特殊的GC处理)
  3. 5.4中__gc 只能用于userdata和table,不能用于函数
  4. 调用顺序不确定:多个对象的 __gc 调用顺序取决于GC的发现顺序
  5. 不能阻止回收:即使在 __gc 中重新引用对象,对象也可能在下一轮GC中被回收(除非使用 collectgarbage("stop")
  6. 性能开销__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"

行为

  1. VM执行 OP_ADD 快速路径,失败
  2. 调用 __add 元方法,得到字符串 "not a number"
  3. 结果直接存入目标寄存器
  4. 不会报错(除非元方法本身报错)
// lvm.c
case OP_ADD: {
    // 快速路径失败
    pc--;
    if (luaT_callTM(L, TM_ADD, rb, rc, ra))
        continue;  // 元方法调用成功,直接使用结果
    else
        luaG_binoperror(L, rb, rc, "add");
}

注意:后续对结果的使用可能报错(如赋值给需要数字的变量),但 + 运算符本身不检查结果类型。这与Python等语言的行为一致——运算符返回值的类型完全由元方法决定。

posted @ 2026-09-04 17:26  IcarusLee  阅读(2)  评论(0)    收藏  举报