2.2 闭包、一等公民与尾调用


文档摘要

2.2 闭包、一等公民与尾调用 本节摘要:函数在 Lua 里是彻底的一等公民——能进变量、进表、当参数、当返回值。当一个函数携带了它出生时的局部环境一起离开出生地,它就成了闭包,被捕获的局部变量叫 upvalue。本节讲透 upvalue 的共享语义、闭包的三个经典应用(计数器、迭代器、按上下文隔离的回调),再讲正确的尾调用如何让递归与状态机不爆栈——这是热更新章状态机写法的地基。 函数是值:先把这件事看惯 第 2.1 节末尾已经见过匿名函数当参数。再补三个"函数作为值"的场景,把手感练熟。 存进表里当方法: 从表里取出来当参数——排序器是最常见的例子: 的第二个参数是"小于"函数,传不同的函数就得到不同的排序逻辑——策略模式的全部实现成本就是一个函数值。

2.2 闭包、一等公民与尾调用

本节摘要:函数在 Lua 里是彻底的一等公民——能进变量、进表、当参数、当返回值。当一个函数携带了它出生时的局部环境一起离开出生地,它就成了闭包,被捕获的局部变量叫 upvalue。本节讲透 upvalue 的共享语义、闭包的三个经典应用(计数器、迭代器、按上下文隔离的回调),再讲正确的尾调用如何让递归与状态机不爆栈——这是热更新章状态机写法的地基。

函数是值:先把这件事看惯

第 2.1 节末尾已经见过匿名函数当参数。再补三个"函数作为值"的场景,把手感练熟。

存进表里当方法

local router = {} router.dispatch = function(path) return "handle " .. path end print(router.dispatch("/a")) --> handle /a

从表里取出来当参数——排序器是最常见的例子:

local players = { {name="a", score=90}, {name="b", score=70}, {name="c", score=85} } table.sort(players, function(x, y) return x.score > y.score end) for _, p in ipairs(players) do io.write(p.name, ":", p.score, " ") end -- 输出:a:90 c:85 b:70

table.sort 的第二个参数是"小于"函数,传不同的函数就得到不同的排序逻辑——策略模式的全部实现成本就是一个函数值。

当返回值返回——工厂函数:

local function make_threshold(n) return function(v) return v > n end end local over50 = make_threshold(50) print(over50(60), over50(49)) --> true false

第三个例子已经踩进闭包了:返回的匿名函数用到外层的 n,而 make_threshold 早就返回了,n 为什么还在?答案就是闭包。

闭包:函数加上它的环境

先给一个可操作的描述:闭包 = 函数体 + 它引用的外层局部变量(upvalue)。只要闭包还活着,它引用的 upvalue 就不能回收——即使定义它们的外层函数早已返回。

闭包:函数加上它的环境

实现层面的真相:普通局部变量住在栈帧里,函数返回就没了;一旦被内层函数引用,虚拟机把它"搬"到堆上的 upvalue 盒子里,闭包持有盒子引用。搬盒子的时机与共享规则决定了一切行为:

  • 同一次调用里派生的多个闭包共享同一个 upvalue 盒子;
  • 不同次调用产生的同名局部变量是不同的盒子,闭包之间互不可见。

用计数器把两条规则同时验证:

local function make_counter() local count = 0 local function inc() count = count + 1; return count end local function get() return count end return inc, get -- 两个闭包,共享同一个 count end local inc1, get1 = make_counter() local inc2, get2 = make_counter() -- 第二次调用,全新的 count inc1(); inc1(); inc1() print(get1(), get2()) --> 3 0

inc1get1 操作同一个 count(共享),与 inc2 的 count 互不相干(隔离)。共享给协作,隔离给安全——这两个词就是闭包的全部工程价值。

循环变量的捕获:版本差异要记牢

闭包捕获循环变量时的行为,5.4 与旧版本不同,是真实项目里出过无数事故的点:

local fns = {} for i = 1, 3 do fns[i] = function() return i end end print(fns[1](), fns[2](), fns[3]()) -- Lua 5.4 输出:1 2 3 -- Lua 5.1 输出:4 4 4(循环结束后 i 的最终值)

5.4 里每次迭代都新建一个循环变量,三个闭包各捕获各的;5.1 里循环变量是同一个,闭包们共享它,循环跑完都读到终值。跨版本写兼容代码的惯用法是手动制造新变量:

for i = 1, 3 do local j = i -- 显式拷贝一份 fns[i] = function() return j end end

这个写法在所有版本下行为一致,也成了很多团队的风格强制项。

闭包的三个经典应用

迭代器。泛型 for 每轮调用迭代函数,闭包让迭代函数记住走到哪了:

local function range(n) local i = 0 return function() i = i + 1 if i <= n then return i end end end for v in range(4) do io.write(v, " ") end -- 输出:1 2 3 4

range(4) 返回的闭包带着自己的 i,泛型 for 反复调用它直到返回 nil。第 3 章会手写 ipairs 与无限迭代器,第 7 章协程会给出"无闭包版迭代器",到时候对照着看,能彻底理解迭代协议。

私有状态与按需授权。计数器已见。再如只读视图:闭包只暴露 get 不暴露 set,封装的最小实现:

local function readonly_view(t) return function(k) return t[k] end -- 只给读键的能力 end local cfg_view = readonly_view({ secret = "9f2a" }) print(cfg_view("secret")) --> 9f2a -- 没有任何途径改写 t,除非闭包放出来 setter

按上下文隔离的回调。图里那段伪代码是 OpenResty 每请求处理的缩影:每个请求生成自己的闭包链,闭包捕获的上下文(开始时间、连接句柄、用户身份)天然互不串扰。Lua 协程模型(第 7 章)出现之前,"闭包包住上下文再交给宿主"就是脚本世界做异步的常规手段——它把"状态"从全局变量赶进了函数参数与 upvalue,可测试性与并发安全同时受益。

⚠️ 常见坑:闭包捕获的是变量本身(按引用),不是声明那一刻的值。先定义闭包、后修改变量,闭包看到的跟着变;想"定格"就用局部拷贝(循环里的 local j 就是定格)。另一个方向的大坑:闭包长期持有大对象(整张配置表、整个场景)会阻止 GC 回收,长生命周期回调要精确捕获,只抓需要的字段。

用 debug 库给闭包做体检

闭包的行为偶尔"诡异"时(值不对、共享了不该共享的状态),debug 库能把 upvalue 摆到眼前:

local function make_pair() local n = 0 local function next_id() n = n + 1; return n end local function peek() return n end return next_id, peek end local nxt, peek = make_pair() nxt(); nxt() local i = 1 while true do local name, value = debug.getupvalue(nxt, i) if not name then break end print(name, value) --> n 2 i = i + 1 end

debug.getupvalue(函数, 序号) 逐个列出闭包捕获的名字与当前值——排查"计数器为什么从 2 开始"这类问题的显微镜。它也是 9.4 节热更新做 upvalue 手术的入口,此处先混个脸熟。

闭包的内存账单

每次调用工厂函数都产生新的闭包对象加 upvalue 盒子,这笔账在热路径上要心里有数:

-- 每次请求都造一个新闭包:分配加捕获 for i = 1, 10000 do local handler = function(x) return x + i end -- 一万个闭包 handler(1) end -- 改写:无捕获的函数只造一次,i 走参数 local function add(x, base) return x + base end for i = 1, 10000 do add(1, i) end

两段行为等价,第一段多造一万个闭包。GC 会收拾,但帧预算敏感的游戏循环与高吞吐网关里,"每请求新闭包"是 profiler 常客。原则:闭包用在需要状态隔离的地方(计数器、迭代器、按请求上下文),纯转发逻辑用普通函数加参数——不要为了写法时髦付内存税。

尾调用:不叠栈的跳转

函数体最后一步是 return f(args) 形式时,这个调用是尾调用。正确实现的尾调用(proper tail call)不复用新栈帧——直接复用当前栈帧跳过去:

local function loop(n) if n == 0 then return "done" end return loop(n - 1) -- 尾调用:栈深不增长 end print(loop(50000000)) --> done,一千万层也不爆栈

五十万次递归在 C 里早段错误了,Lua 毫发无伤。判定"是不是尾调用"只认一种形态:return 直接跟函数调用。下面这些都不算——栈照样涨:

return f(x) + 1 -- 调用后还要加一,不是尾调用 return (f(x)) -- 括号包裹要求截断返回值,不是尾调用 local r = f(x) return r -- 先存局部再返回,不是尾调用

尾调用的工程意义集中在两处。其一,无界递归的算法:遍历链表、语法树、状态流转,只要每步都是尾形态,深度就不是问题。其二,状态机——热更新与游戏 AI 的经典写法:

local function patrol(self) if self:see_enemy() then return chase, self end self:walk_route() return patrol, self -- 下一步还是巡逻 end local function chase(self) if not self:see_enemy() then return patrol, self end if self:in_range() then return attack, self end self:move_to(self.target) return chase, self end local function attack(self) self:swing() return patrol, self -- 打完回巡逻 end -- 驱动循环:state 一直是函数值,尾调用流转 local state, actor = patrol, { see_enemy = function() return false end, walk_route = function() end } for step = 1, 10 do state, actor = state(actor) -- 每次都是尾调用形态的接力 end

状态即函数、迁移即返回值、驱动即循环。这套写法在 9.4 节热更新里会再放一次大招:换状态函数就是换行为,新逻辑加载后下一拍自动生效,旧栈帧早已退场,不残留任何调用历史。顺便一提,尾调用会"抹掉"调用栈——调试时看不到来路(栈回溯里上一级函数消失了),这是省栈的代价,排查深度递归问题时要有心理准备。

记忆化:三个知识点的合奏

用"记忆化"把本章两个知识点(闭包、多返回值)与第 3 章(表)合成一个经典工具——缓存函数的计算结果:

local function memoize(f) local cache = {} -- 闭包私有的缓存(每函数一份) return function(x) local hit = cache[x] if hit ~= nil then -- 用 ~= nil 区分"缓存了 false 结果" return hit, true -- 第二返回值:命中标记 end local v = f(x) cache[x] = v return v, false end end local slow_fib = memoize(function(n) -- 演示:带记忆的递归要配合"自引用缓存"才高效 if n < 2 then return n end return slow_fib(n - 1) + slow_fib(n - 2) end) print(slow_fib(30)) --> 832040(毫秒级返回)

这段代码浓缩了三个细节。其一,命中判断用 ~= nil 而不是真值判断——被缓存的值可能是 false 或 nil 之外的任何东西,1.3 节 nil 语义的实践。其二,多返回值带出命中标记——调用方想区分"算出来的"与"查出来的"(采样命中率做监控)时不必另开接口。其三,递归版的 memoize 之所以快,是因为 slow_fib 这个局部名字指向的已经是包装后的函数——递归走缓存,复杂度从指数降为线性。注意缓存本身没有失效机制:参数域有限(枚举、小整数)才适合无 TTL 记忆化,无限域要配弱表(第 5 章 __mode)或显式上限——工具的边界与工具一样重要。

本节要点回顾

  • 一等公民的三个日常:进表当方法、当参数传给 sort 这类高阶函数、当返回值从工厂函数出厂;
  • 闭包 = 函数 + upvalue:被引用的局部变量搬进堆上的盒子,闭包活着盒子不回收;同次调用派生的闭包共享盒子,跨调用隔离;
  • 5.4 循环变量每次迭代新建,旧版本共享终值,跨版本代码用 local j = i 定格;
  • 闭包三大应用:迭代器(记住走到哪)、私有状态(只暴露授权操作)、按上下文隔离的回调(异步安全的基础);
  • 捕获按引用不按值,想定格先拷贝;闭包抓大对象会拖住 GC,精确捕获;
  • debug.getupvalue 是闭包的显微镜,热更手术(9.4 节)的入口;
  • 内存账单:热路径别为写法时髦造闭包,纯转发逻辑用普通函数加参数;
  • 正确的尾调用只认 return f(...) 形态,栈帧复用、深度无界;状态机写法(状态即函数、迁移即返回)是它在工程上的最大回报,也是热更新的地基。

三个高频问答

闭包和"对象"有什么区别? 两者都是"数据加行为"的封装。闭包把行为放外面(返回的函数们共享隐藏状态),对象把行为放里面(表挂方法)。闭包封装是编译期保证的(外面绝对碰不到),对象封装靠纪律与约定。小而敏感的状态(计数器、密钥)用闭包,大而开放的状态(配置、实体属性)用表——这与 5.3 节的选型建议殊途同归。

尾调用优化为什么不能自动判断? return f(x) + 1return f(x) 只差一个加法,但前者必须保留栈帧(加法要在 f 返回后做)。语言无法预知你是否"不在乎那个加法",于是把决定权交给写代码的人——语义显式,优化才有依据。这也是 Lua 一贯的口味:机器能做的机器做(栈帧复用),语义歧义处人来表态(写不写 return)。

一等公民对宿主意味着什么? 意味着"行为可以像数据一样过边界"。宿主把 C 函数注册进表(9.2 节),脚本把 Lua 函数交给宿主当回调——两个方向传的都是函数值。没有一等公民地位,事件系统、插件钩子、策略注入这些嵌入世界的核心模式全都无从谈起。

下一章进入表的世界——Lua 唯一的复合结构、宿主与脚本之间唯一的复合数据公约。


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