3.2 遍历、长度之谜与应用场景 本节摘要:ipairs 从 1 走到边界就停,pairs 全表都走但顺序不保证;长度操作符在有洞的表上结果实现相关——三条特性同出一个根源:边界(border)即数组段的连续长度。本节讲透遍历协议与 table 库的常用操作,然后进入四个应用场景:配置合并、缓存与去重、对象注册表、与宿主交换数据的桥。 长度之谜现场 先看一段"灵异代码"。三张看起来差不多的表, 报出三个难以预期的长度: a 把中间挖了洞,长度还是 5;b 生来有洞,报 4;c 隔一个空位放 5,可能报 3 也可能报 5,依实现与运行历史而定。这不是 bug,是规范明文允许的行为。要理解它,得先认识"边界"。
本节摘要:ipairs 从 1 走到边界就停,pairs 全表都走但顺序不保证;长度操作符在有洞的表上结果实现相关——三条特性同出一个根源:边界(border)即数组段的连续长度。本节讲透遍历协议与 table 库的常用操作,然后进入四个应用场景:配置合并、缓存与去重、对象注册表、与宿主交换数据的桥。
先看一段"灵异代码"。三张看起来差不多的表,# 报出三个难以预期的长度:
> local a = { 1, 2, 3, 4, 5 } > a[3] = nil > print(#a) 5 > local b = { 1, 2, nil, 4 } > print(#b) 4 > local c = { 1, 2, 3 } > c[5] = 5 > print(#c) 3 (也可能是 5)
a 把中间挖了洞,长度还是 5;b 生来有洞,报 4;c 隔一个空位放 5,可能报 3 也可能报 5,依实现与运行历史而定。这不是 bug,是规范明文允许的行为。要理解它,得先认识"边界"。
边界(border)的正式定义:满足"键 1 到 k 全存在、键 k+1 不存在"的非负整数 k。一张表可以有多个边界:上面的 c 里 3 是边界(4 不存在),5 也是边界(1 到 5 中只有 4 不存在?不对——边界要求 1 到 k 全部存在,c 里 4 不存在,所以 5 不是边界,3 才是。而 a 里 1 到 5 都存在、6 不存在,边界恰好是 5,尽管语义上我们挖掉了值——nil 赋值删除了键 3,所以 a 的边界其实也只有 2……这正说明:洞一出现,边界就可能不唯一或不确定,# 只承诺返回"某一个边界")。
这段绕口令式的推演就是长度之谜的全部内容。工程结论只有一条:要确定的行为,就别造洞。数组段保持 1 到 n 连续,#、ipairs、table.concat、unpack 的行为就完全可预测;需要"带空位的稀疏数组"时,换结构——用 {idx=true} 集合、用长度字段自管理、或干脆用哈希段存映射并放弃 #。

两条遍历的实操经验补在图后。其一,需要确定顺序的哈希遍历用"先收键再排序":
local cfg = { alpha = 1, beta = 2, gamma = 3, delta = 4 } local keys = {} for k in pairs(cfg) do keys[#keys + 1] = k end table.sort(keys) for _, k in ipairs(keys) do print(k, cfg[k]) end -- 输出:alpha 1 / beta 2 / delta 4 / gamma 3(字典序,稳定)
其二,手写无状态迭代器。泛型 for 的完整协议是"反复调用迭代函数、传回控制值",迭代函数不一定要靠闭包记状态——把状态外置成两个参数就是无状态版:
local function iter(t, i) i = i + 1 if t[i] ~= nil then return i, t[i] end end local function mypairs(t) return iter, t, 0 end for i, v in mypairs({ "a", "b", "c" }) do io.write(i, v, " ") end -- 输出:1a 2b 3c
泛型 for 保存 iter, t, 0 三件套,每轮以 iter(t, i) 推进——这正是 ipairs 的真实实现思路。第 2 章的闭包版与这里的无状态版对照着读,迭代协议就完全透明了。
table 库的函数几乎全部面向数组段(边界内),常用五个:
> local t = { 10, 20, 30 } > table.insert(t, 40) -- 尾插 > table.insert(t, 1, 5) -- 位插,后移 > print(table.concat(t, ",")) -- 拼接(只认边界内) 5,10,20,30,40 > print(table.remove(t, 1)) -- 位删,前移;返回被删值 5 > print(#t, t[1]) 4 10 > table.sort(t) -- 默认升序 > print(table.concat(t, " ")) 10 20 30 40 > local u = table.move(t, 1, 3, 2) -- 把 1 到 3 段移到从 2 起的位置 > print(table.concat(u, " ")) 10 10 20 30
几个工程要点:table.remove 不带位置参数时删末尾(当栈用);table.concat 是拼接字符串的正确姿势——循环里 s = s .. x 每次都造新串,量级一大性能雪崩,concat 内部用缓冲区一次成型(4.1 节从字符串不可变的角度再论证一遍);table.sort 的比较函数必须构成严格弱序(相等返回 false),写错会报 "invalid order function";table.move 常用于切片、滚动窗口与手写队列。
配套还有 2.1 节讲过的 table.pack 与 table.unpack,以及 table.unpack(t, i, j) 的区间形式——只展开 i 到 j:
> local args = { "a", "b", "c", "d" } > print(table.unpack(args, 2, 3)) b c
场景一:配置合并。宿主配置常有"默认值加用户覆盖"结构,表的递归合并是最常见的开局代码。先写一个最朴素的浅合并,看清它的不足再升级:
local function shallow_merge(base, override) for k, v in pairs(override) do base[k] = v end return base end
浅合并的问题一眼可见:用户只想改 upstream.port,就得把整个 upstream 表都传一遍,否则 host、retry 会被覆盖丢掉。深度合并解决它——两边都是"映射表"就递归,否则整体替换:
local function is_map(t) return type(t) == "table" and t[1] == nil -- 惯用启发:没有键1就当映射表 end local function merge(base, override) for k, v in pairs(override) do if is_map(base[k]) and is_map(v) then merge(base[k], v) else base[k] = v end end return base end local user = { upstream = { port = 7000 }, log = { level = "debug" } } local cfg = merge(defaults, user) print(cfg.upstream.host, cfg.upstream.port, cfg.upstream.retry) -- 输出:10.0.0.8 7000 2
深度覆盖 port 与 level,未提到的 host、retry 保持默认。这套十行代码是无数配置系统的内核;判断"是不是映射表"(而不是数组)的 is_map 检查是容易漏的细节——不查的话,覆盖方传数组会把默认数组整个换掉,行为也对,但若想"数组合并"就得另写策略。合并策略要按业务定死并写进注释,这是配置系统的第一坑。
场景二:缓存与去重。哈希段的天然用途:
local cache = {} local function memoize(fn) return function(x) if cache[x] == nil then cache[x] = fn(x) end return cache[x] end end local slow_square = memoize(function(n) -- 假装很慢 local r = 0 for i = 1, n do r = r + n end return r end) print(slow_square(200), slow_square(200)) -- 第二次命中缓存
用表做去重集合时注意值语义:cache[x] = nil 与"没缓存过"无法区分(都读出 nil),要区分"缓存了 nil 结果"就改存 cache[x] = false 或包一层 { v = x }。这条与 1.3 节"nil 表示缺席"的设计两相呼应。
场景三:注册表与分发。把函数收进表,按键分发——路由器、协议分发、事件总线都是这个形状:
local handlers = {} local function on(event, fn) handlers[event] = fn end on("GET", function(req) return 200, "list" end) on("POST", function(req) return 201, "created" end) local function dispatch(method, req) local h = handlers[method] or function() return 405, "method not allowed" end return h(req) end print(dispatch("GET", {})) --> 200 list print(dispatch("PUT", {})) --> 405 method not allowed
查不到时用 or 给兜底函数(1.4 节惯用法的升级版)。OpenResty 路由库的核心、游戏引擎的技能表、Neovim 的命令表,扒开看都是 handlers[method] 这一行。
场景四:与宿主交换数据的桥。表过 C 边界的样子(细节第 9 章讲,这里看脚本侧的形状)。Redis EVAL 里脚本收到宿主备好的两张表:
-- redis.call 与 KEYS、ARGV 由宿主注入 -- 命令:EVAL "script" 2 user:1000 user:2000 30 local from, to = KEYS[1], KEYS[2] local amount = tonumber(ARGV[1]) local bal = tonumber(redis.call("GET", from) or "0") if bal < amount then return { err = "insufficient", need = amount, have = bal } end redis.call("DECRBY", from, amount) redis.call("INCRBY", to, amount) return { ok = true, from = bal - amount }
KEYS 与 ARGV 是宿主从命令行参数搬进脚本环境的两张表;返回的表会被宿主转回 RESP 响应。宿主注入表、脚本读表、脚本回表、宿主转译——"表是会客厅"在 Redis 现场就是这样逐字兑现的。OpenResty 的 ngx.req.get_headers() 返回头表、游戏引擎的 entity:get_components() 返回组件表,同一模式的四种方言。
长度之谜的实践出口是"别造洞",但业务里确有"下标有意义、取值稀疏"的需求(按用户 ID 索引、按日期挂计数)。四种替代结构按语义选:
-- 方案一:纯哈希,放弃 # 与 ipairs(最常用) local visits_by_day = { [1] = 10, [15] = 3, [230] = 7 } local sum = 0 for _, v in pairs(visits_by_day) do sum = sum + v end -- 手动求和,不依赖 # -- 方案二:自管长度字段 local sparse = { n = 0 } local function put(t, k, v) t[k] = v; t.n = t.n + 1 end put(sparse, 101, "a"); put(sparse, 999, "b") print(sparse.n) --> 2(自己的账自己记) -- 方案三:存在性集合 + 值表分离 local present = {} -- [k] = true 的集合表 local values = {} -- 真正的值 present[101] = true; values[101] = "a" -- 方案四:位置数组存 {k, v} 对(要排序与区间遍历时) local pairs_list = { {101, "a"}, {999, "b"} }
方案一最诚实(承认这是字典不是数组);方案二适合"要个数"的场景;方案三把"有没有"与"是什么"分开,与 1.3 节 nil 语义的讨论呼应;方案四保序保排序,适合做区间查询。选型的统一问句:你需要的是数组的顺序性,还是只是非零起点的索引? 后者从来不需要数组。
table.sort 的比较函数一次只能定义一个次序,多级排序(先按分数降序、同分按名字升序)在函数里串联:
local board = { { name = "ann", score = 90 }, { name = "bob", score = 85 }, { name = "cyd", score = 90 }, { name = "dot", score = 85 }, } table.sort(board, function(a, b) if a.score ~= b.score then return a.score > b.score end return a.name < b.name end) for _, r in ipairs(board) do io.write(r.name, ":", r.score, " ") end -- 输出:ann:90 cyd:90 bob:85 dot:85
不同字段不同方向(分数降、名字升)在同一条比较链里混搭——这是排序函数"返回 a 是否应排在 b 前"语义的自然推论。注意 a.score ~= b.score 的分支判断:先比主键,不同即定序;相同再比次键。排序结果要稳定输出(日志、榜单、分页)时务必给出全序,否则同分元素的相对位置依实现而定。
把"注册分发"场景升级成网关风格——处理器支持中间件链(鉴权、限流、日志依次包裹业务):
local Dispatcher = {} Dispatcher.__index = Dispatcher function Dispatcher.new() return setmetatable({ routes = {}, middleware = {} }, Dispatcher) end function Dispatcher:use(fn) -- 注册中间件 self.middleware[#self.middleware + 1] = fn end function Dispatcher:on(method, fn) -- 注册路由处理器 self.routes[method] = fn end function Dispatcher:dispatch(method, ctx) local idx, n = 1, #self.middleware local function next_mw() -- 手写的"链条推进器" if idx <= n then local mw = self.middleware[idx] idx = idx + 1 return mw(ctx, next_mw) -- 中间件可选择是否续链 end return self.routes[method] and self.routes[method](ctx) or { status = 405, body = "no handler" } end return next_mw() end local d = Dispatcher.new() d:use(function(ctx, nxt) ctx.trace = (ctx.trace or "") .. "auth>" ; return nxt() end) d:use(function(ctx, nxt) if ctx.token ~= "9f2a" then return { status = 403, body = "forbidden" } end return nxt() end) d:on("GET", function(ctx) return { status = 200, body = ctx.trace .. "list" } end) print(d:dispatch("GET", { token = "9f2a" }).body) --> auth>list print(d:dispatch("GET", { token = "bad" }).status) --> 403
二十行实现了 Express 风格的洋葱模型。技术要点三条:函数进表(中间件与路由都是表里的函数值,第 2 章一等公民);闭包造推进器(next_mw 捕获 idx 与 self,链条状态无需外部簿记);中间件可短路(返回 403 时不调 nxt,后续链与业务处理器全部跳过)。把 dispatch 的 ctx 换成 OpenResty 的 ngx 上下文、把返回表换成 ngx.say 输出——这就是网关插件系统的可运行内核。第 8.2 节的三板斧,本质是把这套结构搬进宿主的阶段模型里。
# 只承诺返回某个边界,洞一出现行为就不确定——要确定行为就别造洞;iter, t, 0 三件套就是泛型 for 的全部协议;表的静态能力到此讲完。下一章给"文本即接口"的字符串与模式匹配——脚本与世界交换的另一半语言。