【Lua入门系列】从 Table 到元表与元方法:一文理解 Lua 的行为扩展机制

🔥 本文专栏:Lua

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:有些路,只有走过之后才能知道值不值得;有些答案,只有经历之后才有资格回答。


思维导图:先建立整体的逻辑链条

text 复制代码
Lua 中的 Table
    ↓
普通字段读取、赋值、运算、函数调用都有默认行为
    ↓
业务场景希望扩展这些默认行为
    ↓
元表(Metatable):本质仍是一张 Table
    ↓
setmetatable() 建立对象与元表的关联
    ↓
Table 内部的 metatable 指针记录关联关系
    ↓
元表通过特殊字段名配置元方法
    ↓
Lua 虚拟机:识别操作 → 判断触发条件 → 查找对应字段 → 执行处理协议
    ├── __index      索引读取未命中后如何处理
    ├── __newindex   给不存在的键赋值时如何处理
    ├── __add        普通加法无法处理时如何计算
    ├── __call       非函数对象如何支持调用语法
    ├── __tostring   如何生成自定义的字符串表示
    └── __eq         两个不同 Table 如何自定义相等判断
    ↓
在游戏业务中实现:默认配置、共享方法、数据校验、代理对象、向量运算等

一、先从 Table 的默认行为出发:为什么需要元表?

此前我们已经认识了 Lua 中非常重要的一种数据类型:table。

一张 Table 可以保存不同类型的键值对,因此我们完全可以用它来表示一个玩家对象:

lua 复制代码
local player = {
    name = "战士",
    level = 10,
    hp = 100
}

print(player.name)    -- 战士
print(player.level)   -- 10
print(player.attack)  -- nil

这里有一个我们已经熟悉的规则:读取 Table 中不存在的字段,默认返回 nil。

但是在游戏服务器的业务开发中,可能会遇到一个需求:玩家自身没有某项属性时,希望从默认配置中获取;多个玩家没有分别定义同一个方法时,希望能够调用共享的方法。按照普通 Table 的索引规则,这两种情况都只能得到 nil。

1.1 一个实际场景:玩家的默认配置

lua 复制代码
local defaults = {
    moveSpeed = 5,
    maxBagSize = 100
}

local player1 = { name = "战士", level = 10 }
local player2 = { name = "法师", level = 20 }

我们希望执行 player1.moveSpeed 时,如果玩家自身没有定义该字段,就继续获取 defaults.moveSpeed。

注意,这里不是说所有玩家的数据都应该共享。例如 hp、name、level 等玩家各自的运行时状态,通常应当单独保存;多个对象具有相同的字段结构,并不代表这些字段的值应当共享。

更适合共享的是默认配置、通用方法、静态规则等。至于已经共享的默认值,也不是在玩家创建时自动复制的一份快照。

1.2 为什么会想到 C++ 继承?

这里很容易联想到 C++ 中的基类与派生类:把共同的内容提取到基类,派生类进行复用。

但是我们需要区分其具体机制。

cpp 复制代码
class Player {
public:
    int hp = 100;
    int attack = 20;
    void showInfo() const {}
};

class Warrior : public Player {};

Warrior a;
Warrior b;

a 和 b 分别拥有自己的 Player 基类子对象,因此非静态成员变量 hp、attack 也是各自独立的;成员函数代码通常由对象共享,并不是每个对象保存一份机器代码。

而在 Lua 中,如果两个玩家通过元方法查找同一张公共 Table,那么它们访问的实际上是同一张 Table 对象中的值,不是自动复制到两个玩家里的独立副本。

所以,C++ 继承和 Lua 元表可以实现相似的复用效果,但不能直接把元表等同于基类:

  • C++ 继承涉及类型关系以及对象中基类子对象的布局。
  • Lua 元表首先是一种动态行为扩展机制,共享属性和方法只是它的一类应用。

这就引出了本文最核心的认识:不要把元表仅仅理解为保存公共属性的表。它更重要的作用,是告诉 Lua 在某种操作发生时,后续应该怎样处理。


二、元表到底是什么?怎样建立关联?

2.1 元表本质上也是普通的 Table

lua 复制代码
local player = {
    name = "战士",
    level = 10
}

local mt = {
    attack = 100,
    defense = 50
}

这里的 mt 并不是一种新类型,它依然是一张普通的 Table。mt 只是我们给变量起的名字,并没有任何强制命名要求。

但 Lua 允许我们给某一张 Table 关联另一张 Table,作为它的元表(Metatable)。这时,元表里具有特定名称的字段,可以参与原始对象的一些操作。

2.2 setmetatable():建立元表关联

lua 复制代码
setmetatable(player, mt)

setmetatable(table, metatable) 的两个参数分别是:需要设置元表的 Table,以及想要关联的元表。多个对象可以关联同一张元表。

需要强调:关联成功,不代表原始 Table 可以直接读取元表中的普通字段。

lua 复制代码
local player = { name = "战士" }
local mt = { attack = 100 }

setmetatable(player, mt)

print(player.attack)  -- nil

为什么?因为 Lua 没有规定"原始 Table 找不到字段,就直接到元表找同名字段"。它规定的是:索引读取未命中时,检查关联元表中有没有 __index 这个特殊字段。

2.3 从 TValue 和 Table 结构理解底层的关联关系

我们此前已经建立了一个心智模型:Lua 运行时用带类型标记的 TValue 来表示值。当某个值的类型是 Table 时,它的值部分保存对实际 Table 对象的引用。

实际的 Table 对象有自己的内部结构,其中不仅包含存放数据的数组部分和哈希部分,还包含一个 metatable 指针。

c 复制代码
/* Lua 5.4 Table 核心结构的概念化节选,省略其他字段 */
typedef struct Table {
    /* ... */
    TValue *array;             /* 数组部分 */
    Node *node;                /* 哈希部分 */
    struct Table *metatable;   /* 关联的元表 */
    /* ... */
} Table;

这里数组部分主要保存适合数组布局的正整数键;哈希部分可以保存其他类型的键,也可能保存部分整数键。本文的重点是元表关联,因此不再展开 Table 的存储细节。

所以,之前我猜测的"虚拟机维护一张全局映射表,通过原始 Table 查找对应元表"并不是 Lua 5.4 对普通 Table 采用的方式。

它直接在每个 Table 对象自己的结构中保存关联元表的指针。

现在我们就能理解:虚拟机当然知道当前对象有没有关联元表,问题不是"是否知道有元表",而是"元表里有没有配置当前操作需要的特殊字段"。


三、元方法是什么?Lua 虚拟机怎么使用它?

3.1 元表是配置容器,元方法是特定字段对应的值

我们先看一张普通 Table:

lua 复制代码
local mt = {
    attack = 100,
    defense = 50
}

attack 和 defense 都是普通字段。而下面的写法就不同了:

lua 复制代码
mt.__index = mt

这里 __index 是 Lua 预先规定的特殊字段名。虚拟机在索引读取未命中时,会查找这个名字对应的值。

严格说,官方手册把特殊字段中的值称为 metavalue ,其中函数值通常称为 metamethod ;学习和日常交流中,经常把 __index 这样的机制整体称为"__index 元方法"。

我们只需要建立三个层次:

text 复制代码
元表 mt:一张普通的 Table
    └── 特殊字段名 __index:Lua 预先规定,名称必须匹配
            └── 字段值:由我们配置,决定后续处理方式

特别注意,字段名必须是 __index(前面两个下划线),不能随意改成 _index、myindex 等。其他名字仍然可以作为普通字段使用,但虚拟机不会自动把它识别为索引读取的元方法。

3.2 元方法很像回调,但不同元方法的规则并不相同

我们以前已经认识了 Lua 的执行过程:脚本编译为字节码,由虚拟机执行相应操作。虚拟机本身已经实现了加法、索引读取、索引赋值以及函数调用等处理逻辑。

元方法不是我们为虚拟机额外发明了一套指令,而是 Lua 已经提供了可配置的扩展入口。

text 复制代码
发生某个操作
    ↓
Lua 先尝试默认行为,或判断是否满足元方法触发条件
    ↓
根据预先规定的名字定位元表中的特殊字段
    ↓
读取用户提供的元方法值
    ↓
按照该元方法自己的处理规则执行

所以:

Lua 虚拟机预先规定元方法的名字、触发条件、查找规则和调用协议;应用层配置具体的元方法值,必要时再实现函数内部的行为。

注意,不是所有元方法都执行相同的"函数 / Table 两路分派"。__index 和 __newindex 有自己的读写转交规则;__add、__call、__tostring 等则是尝试调用对应的值。

3.3 元方法的值能保存任意类型吗?

从 Table 字段的存储 角度看,__index 也就是一个字段,因此可以存储数字、字符串、布尔值、Table 或函数。

但是从元方法的执行角度看,某个值能否正常工作,取决于 Lua 对这种元方法的处理规则。

例如:

lua 复制代码
mt.__index = false

赋值本身合法,但当原始 Table 的索引读取未命中时,Lua 会尝试对 false 继续索引。布尔值默认不支持索引,因此会报错。相反,字符串等类型可能因为自己的类型元表而具备某种索引行为。

因此,"能够保存"与"触发后能够正确执行"不是同一回事。

3.4 函数调用协议:固定的是实参含义,不是形参声明

我们此前知道,Lua 的函数本身也是一种值。元表中的特殊字段可以保存函数值,当元方法触发时,真正发起调用的是 Lua 虚拟机。

但是虚拟机怎么知道应该传入什么参数?这里的答案就是调用协议。

例如 __index 是函数时,Lua 规定传入两个实参:被索引对象、访问的键。

lua 复制代码
mt.__index = function(t, key)
    print(t, key)
    return 100
end

假设执行 player.attack,函数被调用的效果相当于:

lua 复制代码
mt.__index(player, "attack")

这里固定的是第一个实参是被访问对象、第二个实参是键,而不是要求我们只能声明两个形参。Lua 仍然遵循一般函数调用规则:

lua 复制代码
local function test(a, b, c)
    print(a, b, c)
end

test(10)              -- 10 nil nil
test(10, 20, 30, 40)  -- 10 20 30

实参不足时,缺少的形参为 nil;如果没有 ...,多余实参被忽略。如果声明了可变参数 ...,就可以接收剩余实参。

例如:

lua 复制代码
local function test(a, ...)
    print(a)                  -- 10
    print(select("#", ...))   -- 3
    print(select(2, ...))     -- 30 40
end

test(10, 20, 30, 40)

select("#", ...) 获取可变参数数量,select(2, ...) 返回第 2 个可变参数到最后一个的多个结果。多返回值还会遵循 Lua 的表达式列表展开与接收规则。

这些函数基础知识在元方法中一样适用,只是实参由虚拟机按协议提供。


四、__index:读取不存在的字段时,接下来怎么办?

现在正式回到最初引出元表的场景。

lua 复制代码
local player = { name = "战士" }
local defaults = { attack = 100 }

普通情况下:

lua 复制代码
print(player.attack)  -- nil

我们希望改成:当 player 自身没有这个字段时,从 defaults 获取。

4.1 为什么不直接去元表找?

先看我们最初觉得"多绕了一步"的写法:

lua 复制代码
local player = { name = "战士" }
local mt = { attack = 100 }

mt.__index = mt
setmetatable(player, mt)

print(player.attack)  -- 100

即使 attack 恰好保存在元表本身,虚拟机也不是直接查找 mt.attack 。它先检查 mt.__index,发现这个字段指向 mt,于是才继续索引 mt.attack。

为什么这样设计?因为 __index 可以指向完全不同的第三张 Table:

lua 复制代码
local defaults = {
    attack = 100,
    defense = 50
}

local mt = {
    __index = defaults
}

local player = { name = "战士" }
setmetatable(player, mt)

print(player.attack)  -- 100

现在 player、mt、defaults 分别负责玩家数据、行为配置和共享属性。元表不是必须亲自保存目标属性;它的主要任务是告诉虚拟机后续去哪查、如何处理。

4.2 __index 的触发条件和分派规则

针对普通 Table,虚拟机处理索引读取的简化流程如下:

text 复制代码
读取 t[key]
    ↓
当前 Table 自身有非 nil 的值?
    ├── 有:直接返回
    └── 没有:定位关联元表,原始查找 __index
                 ├── 不存在:返回 nil
                 ├── 是函数:调用 __index(t, key),取一个结果
                 └── 其他非 nil 值:对该值继续执行 [key] 索引

这里还有一个容易忽略的细节:虚拟机寻找元表中的 __index 时,采用原始查找,不会因为元表自己还有元表,就在寻找特殊字段本身的过程中再次触发索引元方法。

4.3 __index 是 Table:可形成连续查找链

lua 复制代码
local baseConfig = { defense = 50 }
local defaults = { attack = 100 }
local player = { name = "战士" }

setmetatable(defaults, { __index = baseConfig })
setmetatable(player, { __index = defaults })

print(player.name)     -- 战士
print(player.attack)   -- 100
print(player.defense)  -- 50
print(player.gold)     -- nil

以 player.defense 为例:

text 复制代码
player.defense 没有
    ↓
player 元表的 __index → defaults
    ↓
defaults.defense 没有
    ↓
defaults 元表的 __index → baseConfig
    ↓
baseConfig.defense = 50
    ↓
返回 50

为什么可以继续查找? 因为 __index 指向 Table 时,执行的是另一轮普通索引读取 ;如果那张 Table 也没有目标字段,仍可能触发它自己的 __index。

如果配置形成死循环,Lua 5.4 对这类连续查找设有限制,超过限制会报错,而不是无限循环。

4.4 __index 是函数:虚拟机按协议调用

lua 复制代码
local defaults = { attack = 100, defense = 50 }
local player = { name = "战士" }

local mt = {}

mt.__index = function(t, key)
    print("访问对象:", t.name)
    print("未命中字段:", key)
    return defaults[key]
end

setmetatable(player, mt)

print(player.attack)  -- 访问日志,然后输出 100

其效果可以理解为虚拟机调用:

lua 复制代码
mt.__index(player, "attack")
  • 第一个实参是本次被索引的对象 player。
  • 第二个实参是本次访问的键 "attack";若访问 player[123],它就会是数字 123。
  • 函数的第一个返回值成为整个索引表达式的结果;没有返回值则为 nil。

注意,函数返回了 100,不代表 Lua 自动把 attack = 100 写入 player。

4.5 业务意义:默认属性与共享方法

__index 不只是"找默认值",还可以用于共享对象方法。

lua 复制代码
local PlayerMethods = {}

function PlayerMethods:showInfo()
    print(self.name, self.level)
end

function PlayerMethods:levelUp()
    self.level = self.level + 1
end

local mt = { __index = PlayerMethods }

local p1 = setmetatable({ name = "战士", level = 10 }, mt)
local p2 = setmetatable({ name = "法师", level = 20 }, mt)

p1:showInfo()  -- 战士 10
p2:showInfo()  -- 法师 20
p1:levelUp()

执行 p1:showInfo() 时:

  1. 首先通过 p1.showInfo 查找函数。
  2. 发现 p1 自身没有 showInfo,因此触发 __index。
  3. 根据 __index = PlayerMethods,从 PlayerMethods 中找到 showInfo 函数。
  4. 因为使用了冒号调用,Lua 自动将 p1 作为第一个实参传入函数。
  5. 函数的 self 接收到 p1,因此 self.name 和 self.level 都访问 p1 自身的数据

这也是 Lua 利用 Table 与 __index 组织对象行为的一种基础方式。不过这里只讨论共享方法,不必把元表直接等同于 C++ 的类继承。

4.6 共享默认值不是"给每个对象复制一份"

再回到最初与 C++ 继承进行比较时的疑问:既然两个玩家都通过 __index 访问 defaults,那么它们获得的是两份独立的数据快照吗?

lua 复制代码
local defaults = { attack = 20 }
local mt = { __index = defaults }

local p1 = setmetatable({ name = "战士" }, mt)
local p2 = setmetatable({ name = "法师" }, mt)

print(p1.attack, p2.attack)  -- 20 20

defaults.attack = 30
print(p1.attack, p2.attack)  -- 30 30

p1.attack = 50
print(p1.attack, p2.attack)  -- 50 30
print(rawget(p1, "attack")) -- 50

这里的逻辑并不复杂:

首先,p1 和 p2 自身都没有 attack,所以通过 __index 查找同一张 defaults。修改 defaults.attack 后,两个玩家后续读取到的共享默认值都会变化。

接着,我们执行 p1.attack = 50。由于目前没有给 p1 配置 __newindex,这次赋值直接在 p1 中新增自己的 attack 字段。此后再访问 p1.attack,就能够在 p1 自身找到 50,自然无需继续使用默认值,而 p2 仍然通过元表得到 30。


五、__newindex:给不存在的字段赋值时,为什么需要拦截?

前面讨论的是读 ,现在过渡到写。

5.1 先认识 Table 默认的赋值行为

lua 复制代码
local player = { level = 10 }

player.level = 20   -- level 已存在,直接修改
player.hp = 100     -- hp 不存在,正常情况下新增键值对

这里可以联想到 C++ 的 std::unordered_map 的 operator[],但两者不完全一样:Lua 读取不存在的键默认不会插入 ;而 C++ 的 unordered_map::operator[] 在访问缺失键时会插入默认值。

如果我们希望新增字段前先校验数据,例如新增 hp 必须是 Number 且不能为负数,那么普通赋值无法自动实现这个检查。

__newindex 的核心价值就是让我们有机会介入"缺失字段的赋值过程",而不是禁止原始 Table 新增字段。

5.2 触发条件与调用协议

lua 复制代码
local player = { name = "战士" }

local mt = {
    __newindex = function(t, key, value)
        print("赋值对象:", t.name)
        print("键:", key)
        print("准备写入的值:", value)
    end
}

setmetatable(player, mt)
player.hp = 100

print(rawget(player, "hp"))  -- nil

这里虽然触发了 __newindex,但函数只是打印,并没有实际写入。因此 player 仍然不存在 hp。

虚拟机调用的效果相当于:

lua 复制代码
mt.__newindex(player, "hp", 100)

三个实参顺序固定为:被赋值对象、键、准备写入的值。第一个参数不是元表,而是本次执行赋值操作的原始 Table。

还需要记住:player.hp = 100 是赋值语句 ,不是产生值的表达式。即使 __newindex 函数写了 return 999,虚拟机也不会把 999 当成赋值结果,更不会自动写入原始对象。

5.3 __newindex 是 Table:赋值操作可以被转交

lua 复制代码
local storage = {}
local proxy = {}

setmetatable(proxy, {
    __newindex = storage
})

proxy.hp = 100

print(rawget(proxy, "hp"))  -- nil
print(storage.hp)           -- 100

这里不是在 proxy 新增字段,而是把赋值操作转交给 storage。如果 storage 仍然没有这个键,并且没有另一个 __newindex,就按照默认行为在 storage 中新增。

与 __index 一样,目标 Table 自己还可能关联元表,从而再次触发 __newindex,形成连续的赋值转交链。

5.4 为什么 t[key] = value 会递归触发?

来看一个典型的错误写法:

lua 复制代码
local player = {}
local mt = {}

mt.__newindex = function(t, key, value)
    t[key] = value  -- 注意:普通赋值!
end

setmetatable(player, mt)
player.hp = 100

这里容易产生一个误解:"既然我们已经进入函数了,完成赋值以后直接返回不就行了吗?"

问题就在于,函数内部的 t 实际上就是 player,而 player 依然没有 hp。执行 t[key] = value,等于又执行一次 player.hp = 100,因此再次触发 __newindex。

text 复制代码
player.hp = 100
    ↓
字段不存在 → 触发 __newindex(player, "hp", 100)
    ↓
函数内部又执行 player.hp = 100
    ↓
字段仍不存在 → 再次触发同一个函数
    ↓
不断递归,无法正常结束

如果要在校验后真正写入原始 Table,就需要绕开元方法。

5.5 rawset():完成原始赋值

lua 复制代码
local player = { name = "战士" }
local mt = {}

mt.__newindex = function(t, key, value)
    if key == "hp" then
        if type(value) ~= "number" or value < 0 then
            print("生命值不合法")
            return
        end
    end

    rawset(t, key, value)
end

setmetatable(player, mt)

player.hp = -100       -- 被拦截,不写入
player.hp = 100        -- 检查通过,写入
print(player.hp)       -- 100

rawset(t, key, value) 直接写入指定 Table,不触发它的 __newindex;它既能新增,也能修改已有字段,赋值 nil 还能删除键。

这里就形成了一套完整流程:

尝试新增字段 → 触发 __newindex → 完成合法性检查 → 使用 rawset() 真正写入 → 元方法返回。

5.6 一个重要局限:已有字段不会被 __newindex 再次拦截

刚才通过 rawset() 成功创建了 hp 后:

lua 复制代码
player.hp = -200
print(player.hp)  -- -200

为什么又能写入非法值?因为当前 player 自身已经有非 nil 的 hp 字段,普通赋值会直接修改它,不再触发 __newindex。

所以,上面的校验只保证新增字段时 被检查,不能自动保证所有后续修改都被检查。


六、__add:从 C++ 运算符重载过渡到 Lua 自定义加法

此前我们学习 C++ 时已经认识了运算符重载:自定义类型参与 + 等运算时,可以用重载函数规定其行为。

Lua 中的 __add 有相似的设计目的,不过它是运行时元方法机制,而 C++ 通过重载候选和重载决议决定调用哪个实现,不能简单理解为"C++ 先检查左对象,没有才检查右对象"。

6.1 为什么两个 Table 不能直接相加?

lua 复制代码
local pos1 = { x = 10, y = 20 }
local pos2 = { x = 30, y = 40 }

-- local pos3 = pos1 + pos2  -- 默认无法进行普通数值加法

我们期望"向量相加":x 相加、y 相加。这不是 Lua 天然知道的规则,需要自行定义。

lua 复制代码
local mt = {}

mt.__add = function(a, b)
    return {
        x = a.x + b.x,
        y = a.y + b.y
    }
end

setmetatable(pos1, mt)
setmetatable(pos2, mt)

local pos3 = pos1 + pos2
print(pos3.x, pos3.y)  -- 40 60

6.2 触发条件不是"必须两个都是 Table"

更准确的触发条件是:普通数值加法无法直接完成时,Lua 尝试寻找 __add。 两个操作数并不要求都是 Table,也不要求一定有一个 Table;其他具有元表行为的值也可能参与。

6.3 __add 先查左操作数,再查右操作数

lua 复制代码
local a = { x = 10 }
local b = { x = 20 }

setmetatable(a, {
    __add = function(left, right)
        print("使用左边的 __add")
        return left.x + right.x
    end
})

setmetatable(b, {
    __add = function(left, right)
        print("使用右边的 __add")
        return left.x + right.x
    end
})

print(a + b)
-- 使用左边的 __add
-- 30

Lua 5.4 会优先查左操作数的 __add;如果没有,再查右操作数。不要求两边关联同一张元表。

而且,无论选中了哪一侧的元方法,调用时仍然传入原来顺序的两个操作数:

lua 复制代码
__add(left, right)

返回的第一个值作为 a + b 的结果。两边都没有可用的元方法而普通加法又无法完成时,就会报错。

6.4 和 __index 的关键区别:不进行索引转交

lua 复制代码
local handler = {}
local mt = { __add = handler }

这并不代表 Lua 会继续去 handler 的元表中寻找 __add。__add 找到元方法值后,是尝试调用这个值,而不是再次执行索引访问或加法查找。

那么 handler 一定要是 Function 吗?也不是。如果 handler 是有 __call 的可调用 Table,它仍然能被调用。这个问题正好可以引出下一个元方法。


七、__call:为什么 Table 也能够像函数那样调用?

7.1 从 C++ 仿函数建立直觉

在 C++ 中,如果类实现了 operator(),就可以把类对象像函数一样调用,这就是我们以前认识的仿函数。

cpp 复制代码
struct Player {
    int hp = 100;
    int operator()(int amount) {
        hp += amount;
        return hp;
    }
};

Lua 中也有类似的效果:一张 Table 可以正常保存字段,同时通过 __call 支持调用语法。

lua 复制代码
local player = {
    name = "战士",
    hp = 100
}

setmetatable(player, {
    __call = function(t, amount)
        t.hp = t.hp + amount
        return t.hp
    end
})

print(player(20))  -- 120
print(player.hp)   -- 120

player 仍然是 Table,不会因为能调用就变成 Function。

7.2 触发条件与调用协议

当 Lua 尝试调用一个非函数值时,就会检查它的 __call。对于上述示例:

lua 复制代码
player(20)

调用效果相当于:

lua 复制代码
getmetatable(player).__call(player, 20)

这里第一个实参是原本被调用的对象 ,后续实参是原始调用传入的参数。第一个实参的作用可以类比 C++ 非静态成员函数中的 this,但 Lua 是按协议显式传参

另一个值得注意的特点是:__call 保留调用返回的全部结果,不像大多数其他元方法只取一个结果。

7.3 为什么 __call 的值还能是一张 Table?

如果配置:

lua 复制代码
local handler = {}
setmetatable(player, { __call = handler })

此时普通 handler 不可调用,会报错。但如果 handler 本身配置了 __call,就可以继续处理。

lua 复制代码
local handler = {}

setmetatable(handler, {
    __call = function(h, p, amount)
        print(h == handler)  -- true
        p.hp = p.hp + amount
        return p.hp
    end
})

local player = { hp = 100 }
setmetatable(player, { __call = handler })

print(player(20))  -- 120

整个调用过程可以理解为:

text 复制代码
player(20)
    ↓
player 的 __call = handler
    ↓
尝试 handler(player, 20)
    ↓
handler 不是函数,但自身也配置了 __call
    ↓
最终调用 handler 的 __call(handler, player, 20)

注意:每次非函数值通过 __call 被调用时,当前被调用的那个非函数值 都会被放在参数列表最前面。因此如果连续触发多个 __call,参数列表也会相应变化。

这也解释了之前 __add 的问题:__add 虽然通常是函数,但如果其值是一个支持 __call 的 Table,也可以作为可调用的处理器。


八、__tostring:为什么 Table 默认打印出类似地址的内容?

我们之前已经认识了 Table 的引用语义,所以现在执行:

lua 复制代码
local player1 = { name = "战士" }
local player2 = player1
local player3 = { name = "战士" }

print(tostring(player1))
print(tostring(player2))
print(tostring(player3))

没有自定义 __tostring 时,输出通常类似:

text 复制代码
table: 0x55ab1000
table: 0x55ab1000
table: 0x55ab2000

前两个变量引用同一张 Table,因此具有相同的默认对象标识;第三个变量引用另一张 Table,即使字段内容相同,默认标识也不同。

0x... 是指针形式的对象标识信息,适合用于理解引用身份与调试;不需要把它理解为 Lua 层面可以直接操作的内存地址。Lua 默认也不会自动递归展开所有字段,因为 Table 可能有大量嵌套、共享引用甚至循环引用。

8.1 __tostring 的触发与调用

lua 复制代码
local player = {
    name = "战士",
    level = 10,
    hp = 100
}

setmetatable(player, {
    __tostring = function(t)
        return string.format(
            "玩家:%s,等级:%d,生命值:%d",
            t.name, t.level, t.hp
        )
    end
})

print(tostring(player))
print(player)

两个输出都可以得到自定义的玩家描述。

这里的调用协议比较简单:传入正在转换的值 一个实参,函数必须返回用于转换的字符串。如果没有定义 __tostring,则使用默认字符串表示。

与 __add、__call 类似,它会尝试调用元方法的值,不执行 __index 那种"函数 / 非函数继续索引"的分派。


九、__eq:两个 Table 比较相等,究竟比较什么?

9.1 默认比较的是对象身份,不是字段内容

这一点可以直接联系之前建立的 TValue 与 Table 引用模型。

lua 复制代码
local p1 = { id = 1001, name = "战士" }
local p2 = { id = 1001, name = "战士" }
local p3 = p1

print(p1 == p2)  -- false
print(p1 == p3)  -- true

p1 和 p2 是两张不同的 Table,默认比较为 false。p1 和 p3 引用的是同一个对象,默认比较为 true。

这里很像 C++ 的指针地址比较:我们有时不仅想知道两个引用是否指向同一个对象,还想按照对象内部的数据判断其"业务意义上是否相等"。

9.2 通过 __eq 定义业务上的相等

lua 复制代码
local mt = {
    __eq = function(a, b)
        return a.id == b.id
    end
}

local p1 = setmetatable({ id = 1001, name = "战士" }, mt)
local p2 = setmetatable({ id = 1001, name = "法师" }, mt)

print(p1 == p2)  -- true

这里虽然两个对象不同、名字也不同,但业务上约定 ID 相等就视为同一位玩家,因此自定义比较结果为 true。

注意:元方法改变的是比较结果,不会让这两张 Table 真的变成同一个对象。

9.3 __eq 的触发条件和查找顺序

对于 Lua 5.4:

  1. 如果两个操作数原始比较就相等,例如本来就是同一张 Table,直接返回 true,不需要调用 __eq。
  2. 如果两个操作数都是 Table(或者都是完整 userdata),但原始比较不相等,就尝试查找 __eq。
  3. 优先检查左操作数的元表;左边没有,再检查右操作数。
  4. 找到则尝试调用,传入左、右操作数,调用结果转换成 Boolean。
  5. 如果两边都没有 __eq,返回 false,不会报错。
lua 复制代码
local a = { id = 1001 }
local b = { id = 1001 }

print(a == b)  -- false,不报错

setmetatable(a, {
    __eq = function(left, right)
        return left.id == right.id
    end
})

print(a == b)  -- true;b 没有配置元表也可以

调用时始终按操作数原来的顺序传参:__eq(left, right)。Lua 5.4 不要求两张 Table 使用同一个元表。

这里特别注意:__eq 与 __add 有一处非常关键的不同。

  • __add:默认加法无法完成,而且找不到可用元方法时,会报错。
  • __eq:对于两张不同的 Table,即使没有元方法,也有默认答案 false,不会报错。

十、把几个元方法统一放到一张表里

到这里我们已经认识了六个元方法,它们共同遵循"虚拟机规定名称、触发条件与处理协议;应用层提供具体值"的设计,但并不意味着它们的处理方式完全一样。

元方法 典型触发条件 找到元方法之后 调用协议(函数场景) 结果处理
__index Table 索引读取未命中 函数则调用;否则对元方法值继续索引 (对象, 键) 第一个结果作为读取值
__newindex 给 Table 不存在的键赋值 函数则调用;否则对元方法值继续赋值 (对象, 键, 值) 忽略返回值
__add 普通加法无法完成 尝试调用元方法值 (左操作数, 右操作数) 第一个结果作为运算值
__call 尝试调用非函数值 尝试调用元方法值 (被调用对象, 原实参...) 保留全部返回值
__tostring 字符串转换时存在该元方法 尝试调用元方法值 (被转换对象) 必须得到字符串
__eq 两个同类 Table / 完整 userdata 原始比较不等 尝试调用元方法值 (左操作数, 右操作数) 转换为 Boolean

10.1 还需要记住的几个边界

其一,不要把元表等同于公共属性表。 公共属性或方法可以保存在另一张 Table,元表通过 __index 指向它即可。元表的核心作用是配置行为。

其二,不是所有元方法都能形成"继续查找下一张 Table"的链。 __index 和 __newindex 明确具有索引 / 赋值转交语义;__add、__call 等通常是尝试调用一个可调用值。

其三,Lua 并没有要求元方法函数的声明必须恰好有规定数量的形参。 固定的是元方法调用时传入的实参及其含义。Lua 动态类型和可变参数规则依然适用。

其四,__index 与 __newindex 的第一个实参都不是元表。 它是本轮实际执行索引读取或赋值的对象;如果操作被转交到另一张 Table,后续触发的函数收到的对象也会相应改变。


十一、综合实践:把元表用于一个简化的游戏玩家对象

现在我们不再单独练习一个元方法,而是把前面的知识点放在一起。

我们希望实现以下功能:

  • 玩家自身保存独立数据。
  • 公共方法由多个玩家共享。
  • 玩家可以通过 player:showInfo() 调用公共方法。
  • tostring(player) 可以输出自定义玩家信息。
  • == 可以按照玩家 ID 判断业务相等。
lua 复制代码
local PlayerMethods = {}

function PlayerMethods:showInfo()
    print(self.name, self.level, self.hp)
end

function PlayerMethods:levelUp()
    self.level = self.level + 1
end

local PlayerMT = {
    __index = PlayerMethods,

    __tostring = function(t)
        return string.format(
            "玩家[%d] %s,等级=%d,生命值=%d",
            t.id, t.name, t.level, t.hp
        )
    end,

    __eq = function(a, b)
        return a.id == b.id
    end
}

local function newPlayer(id, name)
    local player = {
        id = id,
        name = name,
        level = 1,
        hp = 100
    }
    return setmetatable(player, PlayerMT)
end

local p1 = newPlayer(1001, "战士")
local p2 = newPlayer(1002, "法师")
local p3 = newPlayer(1001, "战士副本")

p1:showInfo()         -- 战士  1  100
p1:levelUp()

print(tostring(p1))   -- 玩家[1001] 战士,等级=2,生命值=100
print(p1 == p2)       -- false
print(p1 == p3)       -- true(业务 ID 相同)
print(rawequal(p1, p3)) -- false(仍然是不同 Table)

这里需要注意:p1:showInfo() 调用的方法来自公共 PlayerMethods,但方法访问的是 p1 自己的数据。

这个示例只使用了三个与对象组织最相关的元方法;__newindex、__add、__call 适合根据具体业务需要再引入,不应为了"用到元方法"而强行堆在一起。


十二、结语:元表的真正意义是什么?

从最开始我们认识 Table,到现在学习完整套元表机制,其实整个过程可以概括为一条清晰的逻辑链:

text 复制代码
Table 有自己的默认操作行为
    ↓
业务希望在某些情况下改变这些行为
    ↓
Lua 允许 Table 关联一张元表
    ↓
Table 内部的 metatable 指针保存关联关系
    ↓
元表中的特殊字段配置元方法值
    ↓
Lua 虚拟机预先实现触发条件、查找规则和调用协议
    ↓
运行时按规则调用函数、继续索引、转交赋值或产生比较结果
    ↓
实现共享方法、默认配置、校验、代理和自定义运算等能力

最开始,我们容易把元表理解为"提取公共属性的一张表",甚至直接将其类比成 C++ 的基类。但是现在回头看就会发现,这只是一种特定的应用场景。

元表的本质不是公共数据容器,而是 Lua 提供的一套可配置的操作行为扩展机制。

进一步说,元方法是元表中由 Lua 规定特殊名称的字段所承载的配置;虚拟机负责决定什么时候使用它、以什么规则使用它,而具体值和业务处理逻辑由我们提供。

理解了这一点,再学习其他元方法,或者后续阅读 Lua 游戏服务器中的业务对象、模块和共享方法时,就能够沿着同一套心智模型继续分析,而不再只是机械地记忆语法。


相关推荐
qq_406981032 小时前
什么是顶层 const什么是底层const
开发语言·c++
Eider19982 小时前
python基础语法之并发基础练习
开发语言·python
#麻辣小龙虾#2 小时前
网页布局css中flex+margin的组合布局技巧
开发语言·前端·javascript
云和数据.ChenGuang2 小时前
python和java的try
java·开发语言·python
我是唐青枫2 小时前
Kotlin Inheritance(继承)详解:语法、原理、实战 Demo
开发语言·kotlin
ly76892 小时前
从 OpenAPI 到多语言 SDK 生成:Java 服务端契约漂移与生成器版本治理
java·开发语言·openapi·java 21·版本兼容·契约漂移·sdk 生成
lqbzlll3 小时前
CVE-2026-91403 排查指南:Java JSSE 信任链漏洞的根因、影响范围和修复步骤
java·开发语言·信任链
CarIise3 小时前
AOP 概念的引入
java·开发语言
凉茶钱3 小时前
多态:从虚函数语法到虚表寻址,一次讲透动态绑定
开发语言·c++·多态