CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

【语法算法】Lua+select计数:可变参数的取长本质

2026-09-29 19:33 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

【语法算法】

写变参函数的人几乎都背过 select("#", ...),但"它数的是什么"十个人里有八个答错——答"参数个数"的,一遇到末尾 nil 就翻车。今天把 select 的两个面拆干净:select(n, ...) 的截取语义、select("#", ...) 计数与 # 取长的本质区别,以及为什么"变参打包到 nil 处截断"是变参函数第一定律。

一、select 的两面:截取与计数

select(n, ...) 返回第 n 个参数起的所有参数(n 也可为负,-1 是最后一个,超出范围报错);select("#", ...) 返回参数总数,含 nil。关键在第一定律:多值传到寄存器里不截断,但塞进表就截断。{...} 打包时 nil 之后的参数直接丢失,表的 # 长度也停在"边界含糊"的地带;而 select("#", ...) 走的是栈层面的参数计数,nil 照数不误。table.pack(...) 则是官方给的正解——它把参数打包成表,同时用 n 字段记录真实个数,nil 位置的洞保留在表里:

lua
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 处,转发就在这里悄悄断掉。

lua
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
mermaid
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("#") 量的是区间的长度,把这两个量纲分开,变参函数的所有诡异行为就都变成确定行为;而理解了"截断只发生在单值化位置"这条机制,你就能在任何转发、日志、粘合层代码里一眼看出哪个 ... 会被吃掉。

四、实战串讲:带计数的日志转发层

把四条选型收敛进一个完整组件:给老函数包一层"计时 + 参数计数 + 原样转发"的日志皮。这个场景同时踩中变参的全部坑,是检验上面四条的试金石:

lua
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 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

连枝血纹:打这一只,那一只陪着疼

【游戏功能】 var ld2=Math.max(1,Math.round(d 0.25)) ——先拆这一行。连枝转移的账就在…

2026-10-01 21:55 996 技术组
进阶实战游戏功能

让先礼纹:让它一招,下一刀翻倍

【游戏功能】 前阵子的投诉出得冤:玩家开着让先纹被怪的头一招打掉了半管血,找到客服问"让先让了个寂寞"。翻流水发现机制没算错…

2026-10-01 21:55 996 技术组
进阶实战游戏功能

秋后账纹:笔笔记着,收摊一笔清算

【游戏功能】 上一版留了条只在连打两场时冒头的隐蔽报错:秋后纹第二次开的清算比第一次翻倍——头一场十二笔账清完没清零,第二场…

2026-10-01 21:55 996 技术组
进阶实战游戏功能

悔刀仙纹:砍出去的刀能收回,重砍更重

【游戏功能】 先抛一个坑:砍出去的刀,能收回来吗?刀光落了地、数字蹦出来、血条掉了——按理说木已成舟。可玩家心里都有过那一拍…

2026-10-01 21:55 996 技术组
进阶实战游戏功能

头刀卯纹:新怪头一刀,先点先得加倍

【游戏功能】 var fresh=(tg===M)?fr.a:fr.b ——先拆这一行。两只怪,两本"点没点过卯"的小账,出…

2026-10-01 21:55 996 技术组
进阶实战游戏功能

憋劲蛮纹:落空三刀不白落,憋成一刀重的

【游戏功能】 上个月一场差评事故:一个闪避堆得高的怪成了玩家的噩梦——十刀落空七刀,打得着的两三刀又不痛不痒,玩家在频道里骂…

2026-10-01 21:55 996 技术组