🔥 本文专栏: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() 时:
- 首先通过 p1.showInfo 查找函数。
- 发现 p1 自身没有 showInfo,因此触发 __index。
- 根据 __index = PlayerMethods,从 PlayerMethods 中找到 showInfo 函数。
- 因为使用了冒号调用,Lua 自动将 p1 作为第一个实参传入函数。
- 函数的 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:
- 如果两个操作数原始比较就相等,例如本来就是同一张 Table,直接返回
true,不需要调用__eq。 - 如果两个操作数都是 Table(或者都是完整 userdata),但原始比较不相等,就尝试查找
__eq。 - 优先检查左操作数的元表;左边没有,再检查右操作数。
- 找到则尝试调用,传入左、右操作数,调用结果转换成 Boolean。
- 如果两边都没有
__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 游戏服务器中的业务对象、模块和共享方法时,就能够沿着同一套心智模型继续分析,而不再只是机械地记忆语法。
