2.1 函数的定义、调用与多返回值 本节摘要:Lua 函数的形与调用规则:function 语句与函数表达式两种定义形态、点调用与冒号调用、参数与返回值的"多退少补"调整规则、可变参数 及 select、table.pack、table.unpack 三件工具。这些规则合起来构成宿主与脚本之间的"签名协议"——宿主注册的每个回调、脚本实现的每个接口,都靠它们对接。 从一次回调注册说起 先看一段"宿主现场"的代码,它比任何定义都更能说明 Lua 函数的定位: 这段代码把一个当场写出来的匿名函数交给宿主,宿主每帧调用它。注意三件事:函数没有名字也能存在;函数可以作为参数传递;宿主保存它、在合适的时机调用它。
本节摘要:Lua 函数的形与调用规则:function 语句与函数表达式两种定义形态、点调用与冒号调用、参数与返回值的"多退少补"调整规则、可变参数
...及 select、table.pack、table.unpack 三件工具。这些规则合起来构成宿主与脚本之间的"签名协议"——宿主注册的每个回调、脚本实现的每个接口,都靠它们对接。
先看一段"宿主现场"的代码,它比任何定义都更能说明 Lua 函数的定位:
-- 宿主注入的 api 表提供了事件注册能力 api.on_tick(function(dt) -- 每帧回调,dt 是距上帧秒数 accumulator = (accumulator or 0) + dt if accumulator >= 1.0 then api.log("info", "one second passed") accumulator = 0 end end)
这段代码把一个当场写出来的匿名函数交给宿主,宿主每帧调用它。注意三件事:函数没有名字也能存在;函数可以作为参数传递;宿主保存它、在合适的时机调用它。这三件事合起来就是"函数是一等公民"的全部含义,也是嵌入模式的日常——Redis 没有这个形态(脚本是整体执行的),但游戏引擎、OpenResty 的定时器与钩子、Neovim 的事件系统全都长这个样子。本节要把支撑这个形态的每条语法规则讲透。
function 语句是惯用形态:
local function fib(n) if n < 2 then return n end return fib(n - 1) + fib(n - 2) end print(fib(10)) --> 55
函数表达式把函数当值写在表达式里:
local fib = function(n) if n < 2 then return n end return fib(n - 1) + fib(n - 2) -- 注意:这里引用的是谁的 fib? end
两者的差别在递归:local function f 先把名字 f 登记进当前块,再编译函数体,函数体里的 f 指向自己;local f = function 先求值右边再完成赋值,函数体里引用 f 时赋值还没发生,读到的是外层或全局的同名——上例若外层没有 fib,递归处就是 nil 调用错误。所以递归函数固定用 local function 写法,这条规矩值得写进团队风格检查的第一页。
匿名函数没有官方的 lambda 缩写(没有 => 之类),Lua 的立场是:function (args) body end 已经够短,不值得再发明箭头。写多了你会发现 Lua 匿名函数的可读性反而稳定——结构永远完整,没有语法糖的分叉。
命名细节:函数名可以是 a.b.c 形式(往表里塞函数,模块的标配),也可以是 a:b 形式(往表里塞"方法",第 5 章展开)。function api.on_tick(f) ... end 与 api.on_tick = function(f) ... end 完全等价,前者只是后者加了一层定位语法。
调用函数有两种句式。点调用 obj.fn(x) 与冒号调用 obj:fn(x)——后者自动把 obj 自己作为第一个隐式参数传入,等价于 obj.fn(obj, x):
local counter = { n = 0 } function counter:bump(step) self.n = self.n + (step or 1) -- self 就是 counter return self.n end print(counter:bump(), counter:bump(10)) --> 1 11
定义与调用要配套:用冒号定义就用冒号调用。混用是新手重灾区——点调用方法会报"缺 self 参数"或把第一个实参错当 self。记住语义本质:冒号只是"把自己塞进第一个参数"的语法糖,Lua 没有"方法"这个独立概念,一切是函数加表。这层薄薄的糖到第 5 章会撑起整套面向对象。
字符串有个贴心设计:字符串值上可以直接用冒号调 string 库的方法,("abc"):upper() 等价于 string.upper("abc")——因为字符串的元表里挂了 string 库(元表第 5 章讲,这里先当语法记住)。全册大量使用这个写法,先适应起来。
Lua 函数对参数个数不做任何检查,规则只有一条——多退少补:实参多于形参,多出的丢弃;少于形参,缺的补 nil:
local function f(a, b, c) print(a, b, c) end f(1) --> 1 nil nil f(1, 2, 3, 4) --> 1 2 3(4 被丢弃)
返回值同规则,但更灵活——函数可以返回多个值,逗号隔开即可:
local function minmax(t) local lo, hi = math.huge, -math.huge for _, v in ipairs(t) do if v < lo then lo = v end if v > hi then hi = v end end return lo, hi end print(minmax({3, 1, 4, 1, 5, 9, 2, 6})) --> 1 9
多返回值什么时候"展开"成多个值、什么时候被折叠成一个?三条场景规则:
print(f())、local a, b = f()、t[f()] 中的 f() 处在参数表/值表末位时,全部返回值展开;local a, b = (f()) 括号强制单值,a 得第一个返回值,b 得 nil;t[f()] = x 里 f() 只取第一个值;{f()} 收全部返回值,{f(), 0} 只收第一个。> local function three() return 1, 2, 3 end > print(three()) 1 2 3 > print((three())) -- 括号:只要第一个 1 > local t = {three(), three()} > print(#t) 4
最后一个例子值得算一遍:第一个 three() 不在末位,只贡献 1;第二个在末位,贡献 1、2、3,共四个元素。**"末尾展开、其余截断"**八个字背下来。
多返回值与多赋值配合,省掉一切临时变量:
-- 交换 x, y = y, x -- 接住一个函数的完整输出 local ok, err = pcall(some_func) -- 第 6 章的主角 -- 忽略前几个值 local _, v = next(t) -- 惯例:下划线占位不要的值
pcall 的"成功与否加结果"返回签名、next 的"键加值"返回签名、string.find 的"起点加终点"签名——Lua 标准库的 API 设计大量建立在多返回值上,接值侧的惯用法要练到条件反射。

这张图同时回答了"为什么末尾才展开":调用约定里返回值连续排在栈顶,接收方(赋值、参数表、表构造器)只能从栈顶往下取确定个数;处在末尾的表达式正好对齐栈顶,自然全收,非末位的必须先定长截断给后面的表达式让位。语法规则背后是调用约定,理解到这一层,规则就不用背了。
参数写成 ... 就能接收任意个实参,函数体内 ... 是一个"可展开的表达式":
local function sum(...) local s = 0 for _, v in ipairs({...}) do s = s + v end return s end print(sum(1, 2, 3, 4)) --> 10
{...} 把可变参数打包成表——但有个坑:参数里混入 nil 时打包会断尾(表构造器在 nil 处停下的边界问题,第 3 章细说)。更稳的三件工具:
local function audit(...) local n = select("#", ...) -- 参数个数,含 nil 也数 local first = select(1, ...) -- 取第 i 个起的值 local packed = table.pack(...) -- 打包成 { ... } 并附 n 字段 for i = 1, packed.n do print(i, packed[i]) end print("count", n) end audit("GET", nil, "/", nil)
输出:
1 GET 2 nil 3 / 4 nil count 4
select("#", ...) 数出的 4 包含两个 nil——这正是它存在的意义;table.pack 记录真实长度在 .n 字段,绕开 # 的边界歧义。反向操作是 table.unpack(t)(5.1 时代叫 unpack),把表的数组段展开成多个值:
local args = { "zone", "east", 3 } print(string.format("%s-%s-%d", table.unpack(args))) --> zone-east-3
展开与打包是脚本世界里的"参数变形术":把表的字段变成调用参数、把收到的参数转手存进结构。C 边界同样用它——宿主注册的回调在 C 侧看到的可变参数,过栈后就是这个形态。
Lua 没有命名参数语法,也没有参数默认值语法,但一条惯用法同时解决了两个需求——最后一个参数用表:
local function connect(opts) opts = opts or {} local host = opts.host or "127.0.0.1" local port = opts.port or 6379 local timeout = opts.timeout or 3.0 return { host = host, port = port, timeout = timeout } end local c1 = connect() -- 全默认 local c2 = connect{ port = 7000, timeout = 0.5 } -- 调用时括号可省 print(c2.host, c2.port, c2.timeout) --> 127.0.0.1 7000 0.5
注意 connect{...} 的写法:当唯一参数是表字面量时,调用括号可以省略。这条糖让表参数看起来像真正的命名参数。标准库与自己团队库里随处可见这个形态(require 的配置、错误处理的上下文表)。缺省值一律用 or 链实现,与 1.4 节的短路惯用法闭环。
多退少补的宽容意味着"调用错了也可能悄悄跑对"。给对外暴露的函数(模块公共接口、宿主回调入口)加参数校验,是脚本工程质量的第一分水岭。三个层次按严格程度递进:
-- 层次一:宽容归一 —— 能救则救 local function normalize(opts) opts = type(opts) == "table" and opts or {} opts.retry = tonumber(opts.retry) or 1 opts.host = tostring(opts.host or "127.0.0.1") return opts end -- 层次二:断言契约 —— 错了就喊 local function transfer(from, to, amount) assert(type(from) == "string", "from must be a key string") assert(type(to) == "string", "to must be a key string") assert(type(amount) == "number" and amount > 0, "amount must be positive") -- ... end -- 层次三:类型表驱动 —— 声明式校验 local SPEC = { from = "string", to = "string", amount = "number" } local function check(spec, args) for k, ty in pairs(spec) do local v = args[k] assert(v ~= nil, ("missing field %s"):format(k)) assert(type(v) == ty, ("field %s expect %s got %s"):format(k, ty, type(v))) end return args end
层次一适合内部胶水(容错优先),层次二适合模块边界(早爆早好),层次三适合配置与协议入口(第 8.1 节技能表校验的推广)。校验写在哪一层,本质是回答"错误应该在离案发现场多近的地方爆"。
函数调用与值访问在语法上有一个微妙的分界:obj.fn 不带括号只是取函数值,不会调用;带了括号才是调用。两者出现在表达式里时新手容易看混:
> local h = io.write -- 取值,不调用 > h("no newline; ") -- 手动调用 no newline; > > type(io.write) -- 依然是 function function
把函数值传来传去是本章的常态(回调、高阶函数、登记表),"取值"与"调用"的分界线要始终清楚:括号才是扳机。另一处相关的细节:调用 nil 会报 "attempt to call a nil value",常见于字段名拼错(dispatch 写成 dispath)——报错信息里的字段名(5.4 起)就是最好的线索,逐字核对即可。
把本节规则放到寄宿现场验收。Redis 的 EVAL 给脚本的是 KEYS 与 ARGV 两张定长签名的表——脚本内第一步几乎总是"解包与校验"(8.3 节的三个实现里都有 tonumber 防御);OpenResty 给请求处理函数的是一张 ngx 上下文加参数表——表参数惯用法的官方版;游戏引擎的技能回调通常是 on_cast(caster, target, ctx) 三参签名加一张上下文表——多返回值用于把结果回传引擎(命中与否、伤害列表)。
三种宿主、一套规则:固定参数定骨架、表参数装可变内容、多返回值交结果。学会用本节的语法规则读任何宿主的 API 文档,签名部分一眼即过。
把全节知识拼成一个真实任务的完整解法:宿主给了一个"原始风"的调用接口(参数位置化、返回多值、错误码在前),要包装成"团队风"(表参数、错误即 nil 加原因)。这类适配层是嵌入项目的日常第一步:
-- 宿主原生接口(假设注入为 raw_kv): -- raw_kv(op, key, value) 返回 code, value -- code: 0 成功 · 1 不存在 · 2 类型错误 local raw_kv = raw_kv or function(op, key, value) -- 演示桩:模拟宿主行为 if op == "get" then return 1, nil end return 0, value end local KV = {} local E = { -- 错误码到语义的映射(第 3 章的注册表模式) [1] = "missing", [2] = "wrong_type", } function KV.get(key) local code, value = raw_kv("get", key) if code ~= 0 then return nil, E[code] or ("unknown_%d"):format(code), code end return value end function KV.set(opts) -- 团队风:表参数加缺省(本节惯用法) opts = opts or {} local key = assert(opts.key, "key required") local ttl = opts.ttl or 0 local code, value = raw_kv("set", key, opts.value, ttl) if code ~= 0 then return nil, E[code] or ("unknown_%d"):format(code) end return true end return KV
逐条对照本节清单:多返回值用于把错误信息分层交回(nil 加语义加原始码,调用方按需取深度);表参数加 or 缺省让接口对人与对配置文件都友好;assert 卫语句把"缺 key"这类程序缺陷与"键不存在"这类状态错误分开(第 6.2 节错误分类的预演);错误码映射表让适配层成为宿主错误语义的翻译点——团队代码从此不出现魔数。四十行适配层,五大规则全部落地。这类适配层的写法还有一个组织学建议:每个宿主接口一个适配文件(host_redis、host_ngx、host_engine 各自收容),业务代码只依赖适配层暴露的统一签名——宿主更换或接口升级时,改动面被压进一个文件,业务的几百处调用点纹丝不动。适配层是嵌入项目的"防火分区",越早建立越省心。
local function f 支持递归(先登记名字再编译体),local f = function 不支持,递归函数固定用前者;obj:fn(x) 即 obj.fn(obj, x),定义与调用配套使用,字符串上可直接 ("s"):upper();select("#", ...) 数个数(含 nil)、table.pack 打包带 .n、table.unpack 展开成参数;下一节把"函数是值"推到底:函数带着它出生的环境走(闭包),函数调函数不叠栈(尾调用)。