Lua 设计模式落地:用 table 与元表实现经典模式

把经典设计模式落到 Lua 里:单例、工厂、观察者、状态机、命令模式如何用 table、元表与闭包实现,每种模式在 Lua 里的简化形态,以及与面向对象风格的差异和取舍。

设计模式为什么在 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 只会增加间接层。模式服务可维护性,而不是相反。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 剖析与调试工具链:从 luaprofiler 到火焰图
  2. OpenResty WAF 与安全防护实战:用 Lua 构建 Web 防火墙
  3. Lua 与 Rust、Go 的互操作实践:mlua 与 gopher-lua