5.2 常用元方法:从索引到运算符 本节摘要:元方法按用途分六组:读写对(index 与 newindex)、调用(call)、呈现(tostring 与 name)、比较(eq 与 lt/le)、算术与连接(add 等十余个)、生命周期(gc、close、mode)。本节每组配可运行的最小例子,做出默认值表、只读表、可调用对象、向量运算、自描述打印五个经典外挂,并给出选型建议:哪些元方法值得常驻你的工具箱。 读写对:index 与 newindex 管"读扑空"(5.1 节已讲透),它的对偶 管"写落空"——当要写的键在本体上不存在时咨询它: 输出 。执行顺序值得走一遍:写 zone 时本体没有此键,触发 ,函数记录日志后用 rawset 落真值;下次读时本体命中,不再走 。
本节摘要:元方法按用途分六组:读写对(index 与 newindex)、调用(call)、呈现(tostring 与 name)、比较(eq 与 lt/le)、算术与连接(add 等十余个)、生命周期(gc、close、mode)。本节每组配可运行的最小例子,做出默认值表、只读表、可调用对象、向量运算、自描述打印五个经典外挂,并给出选型建议:哪些元方法值得常驻你的工具箱。
__index 管"读扑空"(5.1 节已讲透),它的对偶 __newindex 管"写落空"——当要写的键在本体上不存在时咨询它:
local log = {} local watched = setmetatable({}, { __index = function(t, k) return ("value-of-%s"):format(k) end, __newindex = function(t, k, v) log[#log + 1] = { key = k, value = v, at = os.time() } rawset(t, k, v) -- 真正落值必须 rawset,否则无限递归 end, }) watched.zone = "east" print(watched.zone, #log) -- value-of-zone?不——写已落值,读到 east
输出 east 1。执行顺序值得走一遍:写 zone 时本体没有此键,触发 __newindex,函数记录日志后用 rawset 落真值;下次读时本体命中,不再走 __index。钩子里改本体必须用 raw 系,直接 t[k] = v 会再次触发钩子自己调自己——这是 __newindex 实现的第一铁律。
__newindex 为表时语义稍不同:新键的写入被重定向到钩子表,本体永远不被写入(旧键的修改不受影响)。想做"只读表",这是最省事的底子:
local function readonly(t) return setmetatable({}, { __index = t, -- 读全部转发给真身 __newindex = function() error("attempt to modify read-only table", 2) end, __len = function() return #t end, }) end local cfg = readonly({ host = "10.0.0.8", port = 6379 }) print(cfg.host) --> 10.0.0.8 cfg.port = 7000 -- 抛错:attempt to modify read-only table
返回一张代理空表:读转发、写报错。配置下发、常量暴露、跨模块共享的契约表,用这个十行函数就能锁死。错误处理细节(error 的第二个参数、error 对象)下一章展开。
__call 让一个值可以像函数一样被调用,虚拟机把调用转发给钩子:
local memo = setmetatable({ cache = {} }, { __call = function(self, x) if self.cache[x] == nil then self.cache[x] = x * x -- 假装这是昂贵计算 end return self.cache[x] end}) print(memo(9), memo(12), next(memo.cache) ~= nil) -- 81 144 true
memo 是一张表,却能 memo(9)——调用被转给钩子,self 收到 memo 本身。带状态的可调用对象:既当函数用,又随时能查它的字段(缓存表、统计计数)。函数式接口与状态管理的中间态,游戏 AI 的行为树节点、网关的限流器常做成这个形状。
print 与 tostring 遇到表时默认只给地址,挂上 __tostring 就能自我介绍:
local point = setmetatable({ x = 3, y = 4 }, { __tostring = function(self) return ("Point(%g, %g)"):format(self.x, self.y) end, __name = "point", }) print(point) --> Point(3, 4) print(tostring(point)) --> Point(3, 4)
__name(5.3 起)只改默认呈现里的类型名,错误消息跟着变——不带 __tostring 时 print 出 point: 0x... 而不是 table: 0x...,排查日志时非常有用。错误对象、配置对象、领域实体都值得挂 tostring:日志是给半年后的自己看的,地址没有信息量。
== 默认按引用比较表。想让"内容相等"就挂 __eq:
local mt = { __eq = function(a, b) return a.x == b.x and a.y == b.y end, __tostring = function(p) return ("(%g,%g)"):format(p.x, p.y) end, } local function P(x, y) return setmetatable({ x = x, y = y }, mt) end print(P(1, 2) == P(1, 2), P(1, 2) == P(1, 3)) -- true false
注意三点:__eq 只在**两边都是表(或都是 userdata)**且引用不等时才被咨询;< 与 <= 对应 __lt、__le,挂了它们表就能 sort;~= 由 == 取反自动获得,不需要单独的钩子。同型值配同元表再配比较钩子,是让自定义类型融入语言习惯用法的钥匙。
算术家族:__add __sub __mul __div __mod __pow __idiv(整除)__unm(一元负)__band __bor __bxor __bnot __shl __shr(位运算),外加 __concat(连接)与 __len(长度)。向量类是全家福的标准演示:
local Vec = {} Vec.__index = Vec function Vec.new(x, y) return setmetatable({ x = x, y = y }, Vec) end function Vec.__add(a, b) return Vec.new(a.x + b.x, a.y + b.y) end function Vec.__sub(a, b) return Vec.new(a.x - b.x, a.y - b.y) end function Vec.__mul(a, b) if type(a) == "number" then return Vec.new(a * b.x, a * b.y) end if type(b) == "number" then return Vec.new(a.x * b, a.y * b) end return a.x * b.x + a.y * b.y -- 点积 end function Vec.__unm(a) return Vec.new(-a.x, -a.y) end function Vec.__len(a) return math.sqrt(a.x ^ 2 + a.y ^ 2) end function Vec.__tostring(a) return ("Vec(%g, %g)"):format(a.x, a.y) end local p, q = Vec.new(3, 4), Vec.new(1, 2) print(p + q) --> Vec(4, 6) print(p * 2) --> Vec(6, 8) print(p * q) --> 11(点积) print(-q) --> Vec(-1, -2) print(#p) --> 5(长度)
几个要点值得圈出。类型分发:__mul 里判断哪个操作数是数字,向量乘标量与点积两个语义共存于一个运算符——宿主侧的 userdata 更依赖这招(实体加标量、矩阵乘矩阵)。返回新值不修改本体:__add 返回新向量,与字符串不可变同理,语义可预期。__len 让 #p 出模长:方便但容易出乎同事意料,公开文档写清楚,或改用显式方法 p:length()。字段名 Vec.__index 直接指向 Vec 自身——这已经是完整的类定义了,5.3 节正式拆解。
顺带一提:字符串之所以支持 ..,是因为它的默认元表里挂着 string 库与相关行为——"语法糖"在 Lua 里经常就是"语言核心自用的元表"。
三个低频但关键的钩子。
__gc:对象被垃圾回收时调用(通常是函数)。宿主用它释放 userdata 背后的 C 资源——socket 关闭、内存释放。纯脚本层少用(表自己会没),且 5.4 对 __gc 的设置时机有讲究(要在 setmetatable 时就带上下,或用 5.4 的表版形式),跨版本行为差异大,用它管理关键资源并不保险。
__close(5.4):配合 <close> 属性的局部变量,离开作用域时自动调用——确定性清理:
local function guard(name) return setmetatable({ name = name }, { __close = function(self, err) print("leaving " .. self.name .. (err and " with error" or " normally")) end}) end do local g <close> = guard("critical section") -- 声明处标注 close 属性 end -- 离开块时打印:leaving critical section normally
local g <close> = ... 的标注必须写在声明处,离块即触发钩子,异常路径也保证执行——数据库连接、文件句柄、分布式锁的归还,从此有了语言级保底。
__mode:弱表开关。值为 "k" 键弱、"v" 值弱、"kv" 双弱:
local cache = setmetatable({}, { __mode = "v" }) do local obj = { big = string.rep("x", 1e6) } cache["entry"] = obj -- 值弱:cache 不阻止 obj 被回收 end collectgarbage() -- 模拟一轮回收 print(cache["entry"]) -- nil(大对象已被回收)
弱表的语义:弱引用不算数——GC 发现一个对象只被弱引用指着时照收不误。缓存(对象没了缓存项自动消失)、对象元数据表(对象注销附注随之蒸发)、去重集合,都是弱表的辖区。游戏里"实体附属数据表"挂弱键,实体销毁数据自动清理,不漏内存。
| 元方法 | 常驻价值 | 典型用途 |
|---|---|---|
__index |
极高 | 继承、默认值、命名空间回退 |
__newindex |
高 | 只读表、写审计、代理 |
__tostring / __name |
高 | 日志可读、错误自述 |
__call |
中 | 带状态的可调用对象、兼容旧接口 |
__eq |
中 | 值语义比较 |
| 算术家族 | 按域 | 数学类型、协议封装、DSL |
__close |
高(5.4) | 确定性资源清理 |
__mode |
中 | 缓存与附属数据 |
__gc |
谨慎 | C 资源兜底,勿当主清理路径 |
💡 关键直觉:元方法是"与语言习惯用法接轨"的接口。挂上
__eq你的类型就能进==的世界,挂上__lt就能 sort,挂上__tostring日志就有人话。每挂一个,用户要背的专有 API 就少一页。
把读写对与呈现组合起来,能得到一张"代理表"——本体数据藏在闭包里,代理表对外提供受控行为。日志审计版:
local function audited(real, label) local access = {} return setmetatable({}, { __index = function(_, k) access[#access + 1] = { op = "read", key = k } return real[k] end, __newindex = function(_, k, v) access[#access + 1] = { op = "write", key = k, val = v } rawset(real, k, v) end, __len = function() return #real end, __tostring = function() return ("proxy(%s) ops=%d"):format(label, #access) end, }), access end local config, audit_log = audited({ retries = 3 }, "config") print(config.retries) -- 读一次,进审计 config.timeout = 5 -- 写一次,进审计 print(config.timeout, tostring(config)) --> 5 proxy(config) ops=2
代理表的价值:在不动本体的前提下给数据加行为——审计、校验、缓存、远程转发(读写都打到网络层)都是这个骨架的变体。OpenResty 生态里的一些配置对象、游戏引擎的属性同步层(脚本读属性走代理、代理批量转发到网络)同款。注意 rawset 落真身、审计表用闭包私藏——5.3 节的封装手法在元方法层的应用。
清单的另一面同样重要。三种场景元方法是负资产:
热点直通路径。每次经过 __index 都多一跳函数调用或表查找——实体每帧几十次的属性访问挂上函数型 __index,帧率立刻反映在 profiler 上。热路径要直给:数据表不加元表,或把热点字段提升为普通函数调用。
团队读不懂的魔术。db.query 返回的表挂了 __index 自动懒加载关联对象,写的人痛快,读的人以为访问了 nil 字段——隐式行为要配文档与命名(叫 lazy_rows 而不是 rows)。元方法改写语言行为,行为的可见性就要加倍补偿。
一层就够的非要用三层。默认值链三级够用的场景硬要造四级委托,查找成本与调试成本同时上升。结构上"为了将来灵活"加的层,将来多半用不上(YAGNI 的老话在元表上格外灵验)。
把向量例补完,让它真正"像一个本地类型"——可比较、可排序、可打印、可拼接进错误消息:
function Vec.__eq(a, b) return a.x == b.x and a.y == b.y end function Vec.__lt(a, b) local la, lb = #a, #b -- 复用 __len:模长比较 if la ~= lb then return la < lb end return a.x < b.x -- 模长相同时按字典序,保证全序 end function Vec.__le(a, b) return a == b or a < b end function Vec.__concat(a, b) return tostring(a) .. " -> " .. tostring(b) end local path = { Vec.new(0, 0), Vec.new(3, 4), Vec.new(1, 1) } table.sort(path) -- 按模长排序:0,0 < 1,1 < 3,4 print(path[1] .. path[3]) --> Vec(0, 0) -> Vec(3, 4) print(Vec.new(1, 1) < Vec.new(3, 4)) --> true
四个钩子把向量接进了四个语言习惯用法:== 与 <、<= 进比较世界(sort 直接可用)、.. 进字符串世界(日志与错误消息直接拼接)。注意 __lt 的全序保证——模长相同时再比 x,否则相等元素在 sort 里的相对位置不确定(3.2 节排序纪律的元方法版)。这就是"元方法是类型与语言的接轨件"的完整含义:每挂一个钩子,用户少背一个专用 API。
运算符钩子的终极形态是造领域微语言——让某类业务代码读起来像数学。给区间(开闭区间)造一套:
local Range = {} Range.__index = Range function Range.new(lo, hi) return setmetatable({ lo = lo, hi = hi }, Range) end function Range.__tostring(r) return ("[%g, %g]"):format(r.lo, r.hi) end function Range.__add(a, b) -- 并的粗略近似:包络区间 return Range.new(math.min(a.lo, b.lo), math.max(a.hi, b.hi)) end function Range.__sub(a, b) -- 差:从 a 中挖掉 b 的重叠段 if b.lo > a.hi or b.hi < a.lo then return a end if b.lo <= a.lo and b.hi >= a.hi then return Range.new(0, 0) end if b.lo <= a.lo then return Range.new(b.hi, a.hi) end return Range.new(a.lo, b.lo) end function Range.__len(r) return r.hi - r.lo end local sla = Range.new(9, 18) local lunch = Range.new(12, 13) print(sla - lunch) --> [9, 12](近似示意) print(#(sla - lunch)) --> 3 print((sla - lunch) + Range.new(18, 21)) --> [9, 21]
sla - lunch(工时减午休)、#range(时长)、区间相加(包络)——排班、限流窗口、缓存 TTL 领域的代码从此可以写成算式。这个用法要配两条护栏:运算符语义必须在该领域内无歧义(减号就是集合差,不是别的);以及文档化——# 在这里是时长不是元素数,读代码的人需要一次声明。微语言是元方法能力的上限,也是滥用的高发区——先问"这个领域的从业者是否天然这样读算式",答案肯定再造符号。
__index 读扑空、__newindex 写落空;钩子内改本体必须 rawset,直接赋值会自递归;__newindex 表形态重定向写入、函数形态全权接管;只读表 = 空代理加 index 转发加 newindex 报错;__call 造带状态的可调用对象;__tostring 加 __name 让日志说人话;__eq 只在两表引用不等时咨询,~= 自动派生;__lt、__le 让自定义类型可排序;__close 加 <close> 是 5.4 的确定性清理,异常路径也执行;__mode 弱表让缓存不阻止回收;__gc 谨慎用于 C 兜底;零件齐了。下一节组装整机:用表加元表造出类、继承与封装。