设计模式为什么在 Lua 里长得不一样
设计模式是「针对反复出现的问题总结出的解决方案骨架」。GoF 那套模式最初面向强类型、基于类的语言,到了 Lua 里,很多模式会被语言特性大幅简化甚至完全消解。Lua 有三个原生特性在悄悄改变模式的形态:
- 函数是一等公民:需要「策略对象」「命令对象」的地方,直接传一个函数就够了。
- table 万能:类、对象、命名空间、注册表、配置,全都是 table。
- 闭包即状态:
upvalue天然实现了私有状态,很多「状态模式」「策略模式」用闭包比用对象更简洁。
本文用五个经典模式演示「Lua 式落地」。它们与 Lua 面向对象编程 的区别在最后一节专门说明:那篇文章解决「怎么造类」,这里解决「类造好之后,怎么组织对象之间的关系」。
-- Lua 里最常见的「模式载体」:一个返回 table 的工厂函数
local function make_counter()
local n = 0 -- upvalue 私有状态
return {
inc = function() n = n + 1; return n end,
get = function() return n end,
}
end
一个观察:Lua 里很多模式会「退化」成两三行惯用法。写不出模式,不代表没有模式;很多时候是语言替你实现了。
单例模式
单例的意图是「一个类只存在一个实例」。在 Lua 里,模块加载机制本身就有单例语义:require 对每个模块只执行一次,之后返回同一个缓存结果。
-- config.lua:require 缓存即单例
local Config = {
temperature = 0.75,
max_retries = 3,
}
function Config.set(name, value)
Config[name] = value
end
return Config
-- 任意多处 require,拿到的都是同一个 table
local a = require("config")
local b = require("config")
print(a == b) -- true
如果确实需要一个「类 + 唯一实例」的写法(比如延迟初始化),可以这样:
local Database = {}
Database.__index = Database
local instance
function Database.get()
if not instance then
instance = setmetatable({}, Database)
instance.connected = false
end
return instance
end
function Database:connect(url)
self.url = url
self.connected = true
end
-- 使用:全局唯一连接
local db = Database.get()
db:connect("postgres://127.0.0.1:5432/app")
单例在 Lua 里真正的风险不在「怎么实现」,而在「要不要用」。全局唯一状态意味着全局耦合:测试难以隔离、并发下要加锁、改动波及面大。能注入就注入,单例留给真正全局的资源(数据库连接、日志器、配置)。
工厂模式
工厂的意图是「把对象的创建逻辑集中起来,调用方不关心具体类型」。Lua 里最轻量的工厂就是一个返回 table 的函数;需要可扩展时,加一张「类型名 → 构造器」的注册表。
-- 注册表工厂
local Registry = {}
function Registry.register(kind, factory)
Registry[kind] = factory
end
function Registry.create(kind, ...)
local factory = Registry[kind]
if not factory then
error("未知类型: " .. tostring(kind))
end
return factory(...)
end
-- 注册几种实体
Registry.register("player", function(id)
return { kind = "player", id = id, hp = 100 }
end)
Registry.register("npc", function(id, talk)
return { kind = "npc", id = id, talk = talk or "你好" }
end)
-- 使用:调用方只认 kind,不关心实现细节
local e1 = Registry.create("player", 1001)
local e2 = Registry.create("npc", 2001)
注册表工厂最大的价值是解耦:新增一种实体时,只需要在注册处加一行,使用方代码零改动。这也是很多框架(路由表、事件表、插件系统)的底层结构。若构造逻辑较复杂,工厂函数可以返回带元表的对象,与面向对象风格无缝衔接。
观察者模式
观察者的意图是「对象状态变化时,自动通知一组订阅者」。Lua 里实现事件总线只需要一张「事件 → 处理器列表」的表。
local EventBus = {}
EventBus.__index = EventBus
function EventBus.new()
return setmetatable({ handlers = {} }, EventBus)
end
function EventBus:on(event, handler)
local list = self.handlers[event]
if not list then
list = {}
self.handlers[event] = list
end
table.insert(list, handler)
end
function EventBus:off(event, handler)
local list = self.handlers[event]
if not list then return end
for i = #list, 1, -1 do
if list[i] == handler then
table.remove(list, i)
end
end
end
function EventBus:emit(event, ...)
local list = self.handlers[event]
if not list then return end
-- 拷贝一份再遍历,允许处理器在回调里退订
local snapshot = {}
for i = 1, #list do snapshot[i] = list[i] end
for _, handler in ipairs(snapshot) do
handler(...)
end
end
-- 使用
local bus = EventBus.new()
local function on_player_dead(uid, killer)
print(string.format("%d 被 %d 击杀", uid, killer))
end
bus:on("player_dead", on_player_dead)
bus:emit("player_dead", 1001, 2002)
两点工程注意:一是退订必须在 emit 快照上处理,否则回调里退订会打乱遍历;二是回调里抛错会中断整个 emit,生产环境建议用 pcall 包裹每个处理器(错误处理细节见 Lua 错误处理)。
状态机模式
状态机的意图是「对象的行为随状态切换而变化」。游戏里角色 AI、网络连接、界面流程都适合用有限状态机表达。Lua 用「状态名 → 行为表」驱动,结构非常清晰。
local FSM = {}
FSM.__index = FSM
function FSM.new(init_state)
return setmetatable({ state = init_state, handlers = {} }, FSM)
end
function FSM:on(state, handlers)
-- handlers: { enter = fn, update = fn, leave = fn }
self.handlers[state] = handlers
end
function FSM:transition(next_state, ...)
local cur = self.handlers[self.state]
local nxt = self.handlers[next_state]
if not nxt then
error("未知状态: " .. tostring(next_state))
end
if cur and cur.leave then cur.leave(self) end
self.state = next_state
if nxt and nxt.enter then nxt.enter(self, ...) end
end
function FSM:update(dt)
local h = self.handlers[self.state]
if h and h.update then
h.update(self, dt)
end
end
-- 连接状态机:connecting -> connected -> closed
local conn = FSM.new("connecting")
conn:on("connecting", {
enter = function(self)
print("开始连接...")
self:transition("connected") -- 模拟连接成功
end,
})
conn:on("connected", {
enter = function(self) print("已连接") end,
update = function(self, dt) print("心跳 dt=" .. dt) end,
})
conn:on("closed", {
enter = function(self) print("已关闭") end,
})
conn:transition("closed")
conn:update(0.1)
状态机的另一种形态是闭包式:每个状态是一个闭包,返回下一状态。相比对象式,闭包式更轻、更局部,但状态间共享数据需要显式传入。
命令模式
命令的意图是「把一次操作封装成对象,以便排队、撤销、日志」。Lua 里「命令」就是带 run 和 undo 字段的 table,闭包捕获操作所需上下文。
local CommandStack = {}
CommandStack.__index = CommandStack
function CommandStack.new()
return setmetatable({ undo_stack = {}, redo_stack = {} }, CommandStack)
end
function CommandStack:execute(cmd)
cmd:run()
table.insert(self.undo_stack, cmd)
self.redo_stack = {}
end
function CommandStack:undo()
local cmd = self.undo_stack[#self.undo_stack]
if not cmd then return end
table.remove(self.undo_stack)
cmd:undo()
table.insert(self.redo_stack, cmd)
end
function CommandStack:redo()
local cmd = self.redo_stack[#self.redo_stack]
if not cmd then return end
table.remove(self.redo_stack)
cmd:run()
table.insert(self.undo_stack, cmd)
end
-- 具体命令:用闭包捕获旧值,实现可撤销
local function make_move_cmd(unit, from, to)
return {
run = function()
unit.x, unit.y = to.x, to.y
end,
undo = function()
unit.x, unit.y = from.x, from.y
end,
}
end
local stack = CommandStack.new()
local unit = { x = 0, y = 0 }
stack:execute(make_move_cmd(unit, {x=0,y=0}, {x=3,y=4}))
stack:undo() -- 回到 (0,0)
stack:redo() -- 回到 (3,4)
命令模式把「操作」数据化了:可以放进队列延迟执行,可以批量回放,可以持久化到日志。编辑器、RTS 游戏的指令队列、事务型操作都靠它。Lua 里写起来几乎零样板,闭包天然替你把「命令要带哪些参数」这件事解决了。
与 OOP 风格的区别
Lua 面向对象编程 讲的是用 __index 元表构建类、继承、多态的那套体系;本文的模式则不一定需要「类」:
| 维度 | OOP 风格 | 模式落地风格 |
|---|---|---|
| 单元 | 类、实例、继承 | table、闭包、函数 |
| 状态 | 实例字段 | 字段或 upvalue |
| 多态 | 方法重写 | 注册表分发或直接传函数 |
| 抽象 | 接口、基类 | 约定「字段名/方法名」即可 |
| 样板 | 构造、继承链 | 一个工厂函数即可 |
判断用哪种风格有一个实用标准:对象要不要「有身份、会长期存续、被多处引用」。要——用 OOP(玩家、实体、连接);只是「临时组织一次行为」——用函数与闭包(命令、策略、回调)。设计模式在 Lua 里最大的价值不是照搬类结构,而是理解意图,然后用最小的 Lua 惯用法实现它。
注意事项
在 Lua 中落地设计模式时,需注意以下要点:
- 优先利用语言特性:
require缓存就是单例,函数即策略,闭包即私有状态。 - 单例能不用就不用,全局唯一状态会引入隐式耦合,测试也难隔离。
- 事件总线的处理器要支持退订,emit 时先拍快照再遍历,处理器内用
pcall兜错。 - 状态机的转移要集中在一处(
transition),避免状态在业务代码里到处乱改。 - 命令的
undo必须在run时记录足够信息(旧值、旧索引),否则无法撤销。 - 用注册表做工厂时,注册表本身要防写:上线后不应再动态增删类型。
- 模式是「可选的复杂度」,小脚本用不上的模式不要硬套,Lua 的简洁是最大优势。
常见问题(FAQ)
为什么单例在 Lua 里常常不需要显式实现?
因为 require 的缓存机制已经是单例:同一模块路径只执行一次,之后的 require 都返回同一张表。只有当你要「延迟初始化」或「一个模块产出多个潜在单例」时,才需要写 get() 模式。绝大多数配置、日志、连接管理直接导出一张 table 即可。
观察者模式的事件总线会内存泄漏吗?
会,如果订阅了却不退订。EventBus:on 把 handler 存进 handlers 表,只要表还活着,handler(及其闭包捕获的 upvalue)就不会被回收。解决办法:对象销毁时主动 off,或用弱表存放 handler 列表(弱表机制见 Lua 元表与元方法),让 GC 帮忙兜底。
状态机用表驱动还是闭包?
量级小的用闭包更轻:每个状态一个闭包,返回下一状态,无需额外结构。状态多、共享数据复杂、要可视化调试时,用表驱动(状态名 → 行为表)更清晰,转移路径集中可审计。两者可以混用:表驱动做外层骨架,个别状态的细节用闭包实现。
设计模式会让 Lua 变慢吗?
模式本身几乎不拖累性能,真正影响性能的是对象和闭包的数量。大量创建小对象(命令、事件处理器)会推高 GC 压力;热循环里频繁创建闭包更是要避免。性能不是模式的敌人,滥用抽象(为了模式而拆对象)才是,具体指标见 Lua 性能优化实战指南。
什么时候不应该用设计模式?
当模式的引入只带来结构、不解决问题时。判断方法:去掉这个「模式」,代码是更简单还是更混乱?Lua 脚本往往只有几百行,一个函数 + 局部表就能表达清楚的逻辑,硬套 Observer 或 Command 只会增加间接层。模式服务可维护性,而不是相反。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。