【语法算法】
写变参函数的人几乎都背过 select("#", ...),但"它数的是什么"十个人里有八个答错——答"参数个数"的,一遇到末尾 nil 就翻车。今天把 select 的两个面拆干净:select(n, ...) 的截取语义、select("#", ...) 计数与 # 取长的本质区别,以及为什么"变参打包到 nil 处截断"是变参函数第一定律。
一、select 的两面:截取与计数
select(n, ...) 返回第 n 个参数起的所有参数(n 也可为负,-1 是最后一个,超出范围报错);select("#", ...) 返回参数总数,含 nil。关键在第一定律:多值传到寄存器里不截断,但塞进表就截断。{...} 打包时 nil 之后的参数直接丢失,表的 # 长度也停在"边界含糊"的地带;而 select("#", ...) 走的是栈层面的参数计数,nil 照数不误。table.pack(...) 则是官方给的正解——它把参数打包成表,同时用 n 字段记录真实个数,nil 位置的洞保留在表里:
local function probe(...)
print(select("#", ...)) -- 4(nil 也算)
local t = table.pack(...)
print(t.n) -- 4:真实个数, 含 nil
print(t[3], t[4]) -- nil d(洞与值同在一张表)
print((select(3, ...))) -- 第 3 个起(单值括号只取第一个)
end
probe("a", "b", nil, "d")
上面例子给出工程结论:变参函数内部永远用 select("#") 或 t.n 计数,永远不要用 #{...};需要打包时用 table.pack(5.1 没有就自己 {..., n = select("#", ...)} 补上),遍历用 for i = 1, t.n 而不是 ipairs——ipairs 在第一个 nil 处就会停。
二、为什么 nil 会"看起来消失"
根子在 Lua 的多值传递机制:return f(g()) 这类尾置位置会把 g 的全部返回值转发出去;但多值表达式一旦被放进截断为单值的位置——表构造器非末位、一对括号、非末位实参、赋值语句——多出来的值全部丢弃。{...} 打包后,t[3] 与 t[4] 都是 nil,你无法区分"第 3 个是 nil"和"只有 2 个参数"——这就是 select("#") 存在的意义:它绕过打包,直接问栈要个数。写日志、写转发器(wrapper 把参数原样转给真实现)时,保持透传完整的唯一正解是把 ... 放在尾置位置:return real(fname, ...);若必须在中间做处理,就 table.pack 收好、处理完 table.unpack(t, 1, t.n) 展开——unpack 必须显式给到 t.n,缺省会停在第一个 nil 处,转发就在这里悄悄断掉。
local function log_call(fname, ...)
local t = table.pack(...)
print(fname, "argc=", t.n)
return real(fname, table.unpack(t, 1, t.n)) -- 显式 n, 透传不截断
end
flowchart TD
A["probe(a, b, nil, d)"] --> B["select('#', ...) → 4(nil 计入)"]
A --> C["{...} 打包 → nil 截断, #t 边界含糊"]
A --> D["table.pack(...) → t.n = 4"]
C --> E["t[3], t[4] 无法与缺失区分"]
B --> F["for i = 1, n 逐个安全访问"]
D --> F
F --> G["table.unpack(t, 1, n) 透传完整"]
三、性能与选型总结
性能上 select(n, ...) 每次调用都会把第 n 起的参数搬到新的返回槽,循环里反复 select(i, ...) 是 O(n²) 的隐性坑——正确姿势是开头 local t = table.pack(...) 打包一次,之后全走表访问;select("#", ...) 本身是 O(1) 计数,随便用。选型收束成四条:计数用 select("#") 或 t.n,绝不 #t;打包用 table.pack,保留 nil 位置的真相;遍历用数值 for 配 t.n,不用 ipairs;转发用尾置 ... 或显式 unpack(t, 1, t.n),保证透传不截断。变参的本质是"栈上的一段无类型区间",nil 只是区间里一个普通的值——# 量的是表的形状,select("#") 量的是区间的长度,把这两个量纲分开,变参函数的所有诡异行为就都变成确定行为;而理解了"截断只发生在单值化位置"这条机制,你就能在任何转发、日志、粘合层代码里一眼看出哪个 ... 会被吃掉。
四、实战串讲:带计数的日志转发层
把四条选型收敛进一个完整组件:给老函数包一层"计时 + 参数计数 + 原样转发"的日志皮。这个场景同时踩中变参的全部坑,是检验上面四条的试金石:
local function timed(orig, name)
return function(...)
local t0 = os.clock()
local t = table.pack(...)
print(("[timed] %s argc=%d"):format(name, t.n))
for i = 1, t.n do
print((" arg%d=%s"):format(i, tostring(t[i])))
end
local res = table.pack(orig(table.unpack(t, 1, t.n)))
print(("[timed] %s rets=%d dt=%.3fms")
:format(name, res.n, (os.clock() - t0) * 1000))
return table.unpack(res, 1, res.n)
end
end
逐点对照:计数用 t.n 而非 #t,所以参数里带 nil 也不会把日志写短;打印参数用 tostring 而非直接拼接,nil 与布尔都能安全落盘;调用真实现用 table.unpack(t, 1, t.n) 显式给 n,透传不截断;返回值也用 table.pack 接——多数返回值同样会遇 nil,res.n 是唯一可信的返回个数,最后再显式展开返回,整条链路的 nil 语义从头保到尾。漏掉任何一处,bug 的形态都很隐蔽:日志里参数个数对不上、调用方拿到的返回值比真实现少、偶发"参数丢了但复现不了"——全都指向同一个根源:某一处用了 # 或 ipairs 去量一段可能含 nil 的区间。写粘合层代码时把这张清单贴在手边:打包 pack、计数 n、遍历数值 for、展开 unpack 带范围、返回值同样 pack——五步做完,变参就从"玄学"变成"管道"。
最后补一个容易被忽视的角落:select("#") 与 ... 在协程与 pcall 里的表现。pcall(f, ...) 的变参同样走寄存器传递,select("#") 在 pcall 的回调里照常精确计数;但 pcall 的返回值形态是 ok, ret1, ret2, ...——要在 pcall 外面统计真实现的返回个数,得先 select("#") 减一再 pack:local ok; ok, rets... 的惯用展开是 local t = table.pack(pcall(f)),然后 t.n - 1 才是 f 的真实返回个数(第 1 个是 ok 布尔)。同理 coroutine.resume 的返回也是 ok, ... 形态,协程产出的多值同样要 pack 减一。这两处"减一"是变参语义在标准库边界上的两次平移,忘写减一的症状很典型:日志里返回个数永远比实际多 1,或者把 ok 布尔当成了第一个业务返回值用。到此为止,从打包、计数、遍历、展开到标准库边界的平移,变参的完整地图已经画完——地图上没有一个点需要"背",每一个都从"截断只发生在单值化位置"这一条机制自然推出来;记住机制,忘掉细节,下次遇到变参 bug 时先画这段数据的流转路径,答案往往自己浮出来。变参没有坑,只有没画完的地图——select 与 table.pack 就是把这段地图补完的最后两块拼图。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
【游戏功能】 var ld2=Math.max(1,Math.round(d 0.25)) ——先拆这一行。连枝转移的账就在…
【游戏功能】 前阵子的投诉出得冤:玩家开着让先纹被怪的头一招打掉了半管血,找到客服问"让先让了个寂寞"。翻流水发现机制没算错…
【游戏功能】 上一版留了条只在连打两场时冒头的隐蔽报错:秋后纹第二次开的清算比第一次翻倍——头一场十二笔账清完没清零,第二场…
【游戏功能】 先抛一个坑:砍出去的刀,能收回来吗?刀光落了地、数字蹦出来、血条掉了——按理说木已成舟。可玩家心里都有过那一拍…
【游戏功能】 var fresh=(tg===M)?fr.a:fr.b ——先拆这一行。两只怪,两本"点没点过卯"的小账,出…
【游戏功能】 上个月一场差评事故:一个闪避堆得高的怪成了玩家的噩梦——十刀落空七刀,打得着的两三刀又不痛不痒,玩家在频道里骂…