5.3 用元表造对象:类与继承


文档摘要

5.3 用元表造对象:类与继承 本节摘要:Lua 没有 class 关键字,面向对象是"表加元表"的组装件:类是共享一张元表的方法表,实例是挂了这张元表的普通表;构造器是工厂函数,继承是 链,多继承是函数形态的逐父查找,私有成员靠闭包。本节从零搭一个完整的多代类层次,给出多继承与封装两个进阶件,最后讨论工程取舍——什么时候该用面向对象,什么时候表加函数更清爽。 语法糖与真身 第 2 章讲过冒号语法: 等价于 。先揭穿两颗糖,面向对象的"真身"就露出来了: 方法定义糖: 等价于 ——往类表 C 里塞一个接收 self 的普通函数; 方法调用糖: 等价于 ——把实例自己塞进第一个参数。 剥掉糖之后,"对象"只剩三个部件:实例表存数据、类表存方法、元表链负责让实例找到方法。

5.3 用元表造对象:类与继承

本节摘要:Lua 没有 class 关键字,面向对象是"表加元表"的组装件:类是共享一张元表的方法表,实例是挂了这张元表的普通表;构造器是工厂函数,继承是 __index 链,多继承是函数形态的逐父查找,私有成员靠闭包。本节从零搭一个完整的多代类层次,给出多继承与封装两个进阶件,最后讨论工程取舍——什么时候该用面向对象,什么时候表加函数更清爽。

语法糖与真身

第 2 章讲过冒号语法:obj:method(a) 等价于 obj.method(obj, a)。先揭穿两颗糖,面向对象的"真身"就露出来了:

  • 方法定义糖function C:method(a) ... end 等价于 function C.method(self, a) ... end——往类表 C 里塞一个接收 self 的普通函数;
  • 方法调用糖c:method(1) 等价于 c.method(c, 1)——把实例自己塞进第一个参数。

剥掉糖之后,"对象"只剩三个部件:实例表存数据、类表存方法、元表链负责让实例找到方法。看最小可用的类:

local Account = {} Account.__index = Account -- 类的元表:方法查找指回自己 function Account.new(balance) -- 构造器:普通工厂函数 return setmetatable({ balance = balance or 0 }, Account) end function Account:deposit(v) self.balance = self.balance + v return self.balance end function Account:withdraw(v) if v > self.balance then error("insufficient funds", 2) end self.balance = self.balance - v return self.balance end function Account.__tostring(a) return ("Account(%g)"):format(a.balance) end local acc = Account.new(100) print(acc:deposit(50), acc:withdraw(30)) -- 150 120 print(acc) -- Account(120)

逐行核对机制:Account.new 造一张普通表、把 Account 当元表挂上;acc:deposit(50) 走查找链——acc 本体没有 deposit,扑空,问元表 Account(它的 __index 指向自己),命中方法函数,self 收到 acc。所谓方法调用,就是一次带自动首参的函数调用;所谓继承,就是查找链长一截

__index 直接指回类表自身(而不是再造一张 { __index = Account }),是 Lua 社区的标准缩写——类表自己兼任自己的元表,少一张表、少一层间接。

继承:链式上溯

子类要让"自己的方法自己管、没有的问爸爸":

local CheckedAccount = setmetatable({}, { __index = Account }) CheckedAccount.__index = CheckedAccount function CheckedAccount.new(owner, balance) local obj = Account.new(balance) obj.owner = owner return setmetatable(obj, CheckedAccount) end function CheckedAccount:withdraw(v) if v > 500 then error("single withdrawal limit 500", 2) end return Account.withdraw(self, v) -- 显式调用父类实现 end local ca = CheckedAccount.new("ops", 1000) print(ca:deposit(100), ca:withdraw(200)) -- 1100 900 print(ca:withdraw(600)) -- 报错:single withdrawal limit 500

链路走一遍:ca 的元表是 CheckedAccount,ca:deposit 在 CheckedAccount 没有,沿 __index 上溯到 Account 命中;ca:withdraw 在子类命中,不再上溯。重写即遮蔽、复用即显式点名父类Account.withdraw(self, v)——冒号糖剥掉后的原形),这两条就是 Lua 继承的全部语法。没有 super 关键字,没有自动父类调用——显式、啰嗦、零意外。

继承委托链全景

继承委托链全景

链上每一跳都有代价。游戏里几千个实体每帧几十次方法调用,继承链三层以上就该警觉:热点方法上移、或把方法缓存成实例字段(首次查到后 rawset 回填,把链式查找摊销成一次)。这张权衡在所有动态语言里都一样,Lua 只是把它显式化了。

多继承:函数形态逐父查找

__index 换成函数,查找策略就完全归你管。多继承的标准写法——按父类列表顺序找:

local function createClass(...) local parents = { ... } local cls = {} cls.__index = function(t, k) for i = 1, #parents do local v = parents[i][k] if v ~= nil then return v end end end return cls end local Serializable = { serialize = function(self) local parts = {} for k, v in pairs(self) do parts[#parts + 1] = k .. "=" .. tostring(v) end return table.concat(parts, " ") end } local Loggable = { logname = "obj", describe = function(self) return "<" .. self.logname .. ">" end } local Mixed = createClass(Serializable, Loggable) local m = setmetatable({ id = 7 }, Mixed) print(m:serialize()) -- id=7 print(m:describe()) -- <obj>

两个父类的成员都可达,顺序决定优先级。菱形继承的共享祖先问题、MRO 线性化这类深水区,Lua 的立场是不提供——函数形态给了你手工实现任何策略的自由,也把复杂性留在了明处。绝大多数项目用到双继承就到头了,更深的层次化需求通常说明该用组合而不是继承。

私有成员:闭包式封装

字段全暴露是 Lua 默认状态,想要真正的私有,用闭包把状态锁进工厂函数:

local function SecureCounter(start) local count = start or 0 -- 闭包私有:外界无法触达 local function get() return count end local function inc(v) count = count + (v or 1) if count > 1000 then error("counter overflow", 2) end return count end return { get = get, inc = inc } -- 只出口这两个函数 end local c1, c2 = SecureCounter(), SecureCounter(995) print(c1.inc(), c2.inc(), c2.inc()) -- 1 996 997 -- c2.inc(10) 会触发 overflow 报错;count 从外面读不到也改不了

闭包私有是编译期保证,比命名约定(下划线前缀)与文档约定硬得多。代价:每个实例一套函数(不能跨实例共享方法表,内存略贵)、不能参与继承链(没有 __index 委托)。实践中的折中:敏感字段(余额、密钥、连接句柄)用闭包锁,普通数据字段用表加约定。银行账户这么写,配置对象就别这么折腾了。

混入:组合优于继承的 Lua 写法

继承链表达"是什么",混入表达"会什么"。把可复用能力做成一张函数表,往类里一倒:

local Serializable = {} function Serializable:serialize() local parts = {} for k, v in pairs(self) do if type(v) ~= "function" then parts[#parts + 1] = ("%s=%s"):format(k, tostring(v)) end end table.sort(parts) return table.concat(parts, " ") end local Loggable = {} function Loggable:log(msg) print(("[%s] %s"):format(self._tag or "?", msg)) end local function mixin(cls, ...) for _, src in ipairs({ ... }) do for k, v in pairs(src) do if cls[k] == nil then cls[k] = v end -- 不覆盖已有定义 end end return cls end local Session = { _tag = "session" } Session.__index = Session function Session.new(id) return setmetatable({ id = id }, Session) end mixin(Session, Serializable, Loggable) local s = Session.new(7) s:log("created") print(s:serialize()) -- id=7 _tag=session

mixin 是"把几张表的字段拷进类表"——静态拷贝、运行时零开销(不像多继承的函数 __index 每次查找都要逐父扫)。代价是失去动态性(拷完后改源表不影响已混入的类)。绝大多数"多继承需求"真实想要的其实是混入:能力堆叠而不是身份分叉。游戏里的"会燃烧、会中毒、会掉落"的组件属性、网关里"可序列化、可计量、可缓存"的横切能力,用混入各得其所。

类与模块同构:一图收束

到此可以说破:类与模块在 Lua 里是同一个东西的两种用法——都是"一张挂了函数的表"。模块这张表被 require 单次加载、函数当命名空间用(metric.to_cm);类这张表被实例共享挂元表、函数当方法用(acc:deposit)。区别只在调用风格(点与冒号)与生命周期(单例与多实例)。很多成熟的 Lua 库干脆两者兼备:模块表本身就是类,local Http = require("resty.http") 拿到的表既能 Http.new() 造实例、又能挂几个模块级工具函数。理解了这层同构,第 6 章的模块与本章的类就完全打通了。

要不要用面向对象

Lua 社区的真实生态分两派。轻派主张只用表加函数:模块即命名空间表,状态显式传参,几百行的问题域不引入类层次——标准库里几乎全是这个风格(string、table、math 都是命名空间表,没有类)。重派搭出完整 class 框架:继承、混入、接口约束、属性方法,游戏逻辑与大型框架常见——OpenResty 生态的 lua-resty-* 库大量用类(连接池、上游对象),Neovim 的很多插件也走类路线。

判断标准给三条。状态生命周期:实例有明确生老病死(连接、会话、实体),类能把这些状态圈在一起;纯转换函数(编解码、校验)用命名空间表就够。多态需求:一批类型要同一套接口(不同协议的解析器),继承或混入让分发自然;只有一两处分支,函数表就解决(3.2 节的 handlers)。团队契约:类是显式的结构契约,新人来了能按图索骥;表加函数的自由度高但依赖纪律。两派都对,选了就贯彻——一个项目里混两套风格才是最差的选择。

⚠️ 常见坑三连。其一,Account.new 忘了 setmetatable,实例查不到方法报 "attempt to call a nil value"——检查构造器。其二,子类忘写 Child.__index = Child,实例的元表链断在子类,全跑去父类甚至直接扑空——两个模板行抄全。其三,冒号点号混用:obj.method(a) 少传了 self,报错位置在方法体内的 self 使用处,离案发现场很远——定义与调用保持同一风格。

一个极简 class 工具

把 5.3 节的全部零件收进一个四十行的 class 工具——多数项目的面向对象需求到这个规模就封顶:

local Class = {} function Class.def(name, super) local cls = { __name = name, __super = super } cls.__index = cls if super then setmetatable(cls, { __index = super }) -- 类层面继承(找方法) end function cls.new(...) local obj = setmetatable({}, cls) if cls.init then obj:init(...) end -- 构造钩子 return obj end -- 显式父类调用(没有 super 关键字的替代品) function cls:super(method, ...) local s = self.__super while s do local fn = rawget(s, method) if fn then return fn(self, ...) end s = s.__super end end function cls:is_a(ancestor) -- 类型判定 local c = cls while c do if c == ancestor then return true end c = c.__super end return false end return cls end return Class

使用侧的样子:

local Class = require("class") local Animal = Class.def("Animal") function Animal:init(name) self.name = name end function Animal:speak() return self.name .. " makes a sound" end local Dog = Class.def("Dog", Animal) function Dog:init(name, breed) self:super("init", name) -- 显式调用父类构造 self.breed = breed end function Dog:speak() return self.name .. " barks" end local d = Dog.new("rex", "labrador") print(d:speak()) --> rex barks print(d:is_a(Animal)) --> true print(getmetatable(d).__name) --> Dog(配合 5.2 节的 __name 报错可读性)

这个工具暴露了 Lua 面向对象的全部实现细节:继承是元表链、构造是约定钩子、super 是沿链 rawget 的显式查找、类型判定是链上比对。社区成熟库(middleclass、30log 一类)在此基础上加混入、类型检查、元方法继承,但骨架与此无异。建议经历一次"自己写 class 工具"再决定是否引入库——写过后你对库的每个行为都有解释权,没写过则永远在猜。

本节要点回顾

  • 三部件真身:实例表存数据、类表存方法(兼任自己的元表)、查找链负责接线;冒号语法两颗糖剥掉后只剩普通函数与普通调用;
  • 继承即链式上溯:子类元表的 __index 指向父类,重写即遮蔽、复用即显式 Parent.method(self, ...),没有 super;
  • 链有价:三层以上警觉,热点方法上移或实例级缓存回填;
  • 多继承用函数形态 __index 按序逐父查找,策略自定义、复杂性在明处;
  • 闭包式私有是编译期保证,敏感字段值得,普通字段用表加约定即可;
  • 两派都合法:按状态生命周期、多态需求、团队契约选型,选了就贯彻,别混搭。

对象之道讲完。下一章进入脚本的边界——模块怎么拼装、错误怎么兜住、标准库的家当与沙箱的边界。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U