【Lua 入门系列】Lua 函数深入理解:函数对象、多返回值、可变参数、闭包、递归与尾调用优化

🔥 本文专栏:Lua

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:没有谁能提前知道故事的结局,所以请不要在故事最精彩之前替自己写下遗憾。


思维导图:整篇文章的逻辑链

text 复制代码
Lua 脚本经过编译 → 字节码 → Lua 虚拟机执行
                   ↓
             函数体也会被编译
                   ↓
       执行函数定义,创建运行时函数对象
                   ↓
       函数是值,可以赋值 / 传参 / 返回
                   ↓
           如何向函数传递数据?
              ├─ 普通形参:值传递
              ├─ Table:复制引用,共享对象
              └─ 可变参数:...
                   ↓
           如何从函数获得结果?
              ├─ 多返回值
              ├─ 展开 / 截断 / nil 补齐
              └─ select() 处理可变参数
                   ↓
          函数与 Table 结合组织对象行为
              ├─ Table 保存 Function 值
              ├─ 点号 .:获取函数字段
              └─ 冒号 ::传入 self
                   ↓
           函数名与词法作用域
              ├─ 普通具名函数
              ├─ local function
              └─ 闭包与 Upvalue
                   ↓
           函数之间如何互相调用?
              ├─ 递归与局部递归
              ├─ 普通调用需要保存调用方状态
              ├─ 尾调用:调用方不再需要恢复
              └─ 虚拟机复用调用记录 → 尾递归
                   ↓
          调用可能出错:error() / pcall()

一、先回到函数的本质:Lua 函数究竟是什么?

1. 从字节码与虚拟机的执行模型说起

在之前学习 Lua 时,我们已经建立过一个基本认知:Lua 脚本并不是简单地让 CPU 直接执行源代码,而是先将源码编译成字节码,再由解释器内部的虚拟机负责执行。

这里需要注意,所谓编译阶段和执行阶段,是同一个 Lua 运行系统中的两个工作阶段。并不是说一定存在两个独立运行的程序,一个只负责编译、另一个只负责解释。

那么函数又是什么?来看一个例子:

lua 复制代码
function add(a, b)
    return a + b
end

对于这个函数,可以分成两个阶段来理解。

编译阶段: Lua 会将 add 的函数体编译为相应的字节码,并形成描述这段函数代码的内部信息。

运行阶段: 当 Lua 执行到这条函数定义语句时,会创建一个运行时函数对象,并让它关联此前编译好的函数代码,然后将这个函数值赋给 add。

这里出现了一个非常重要的区别:函数代码 和运行时函数对象不是同一个概念。

函数代码决定"调用以后执行什么",而运行时函数对象则是 Lua 在运行期间能够引用、传递和调用的函数值。对于多个闭包来说,它们可以关联同一份函数代码,但各自携带不同的捕获状态;后面学习闭包时还会用到这一点。

2. 具名函数其实是一种语法糖

前面的写法:

lua 复制代码
function add(a, b)
    return a + b
end

可以理解为:

lua 复制代码
add = function(a, b)
    return a + b
end

这是什么意思?其实就是先构造一个函数值,再将这个值赋给变量 add。

这里 function(a, b) ... end 是匿名函数表达式,而 add 是保存函数值的变量。

假设当前代码位置不存在可见的同名局部变量 add,那么上面的普通具名函数写法会将函数值赋给全局变量 add。但需要注意:并不是只要没有写 local,就必然创建全局变量;若已有可见的同名局部变量,赋值也会命中这个局部变量。

3. 为什么函数可以赋值、传参和返回?

因为 Lua 的 function 本身就是一种数据类型,和 number、string、table 一样,函数也是一个值。

lua 复制代码
local function add(a, b)
    return a + b
end

local f = add
print(f(10, 20))    -- 30

这里执行 local f = add 并没有重新编译一份函数代码,也没有重新创建一个独立的函数对象。我们可以理解为:f 和 add 保存的是同一个函数值,引用同一个函数对象。

既然函数是一种值,自然也可以作为参数:

lua 复制代码
local function calculate(operation, a, b)
    return operation(a, b)
end

local function add(a, b)
    return a + b
end

print(calculate(add, 10, 20))  -- 30

同样,也可以作为返回值:

lua 复制代码
local function createAdder(x)
    return function(y)
        return x + y
    end
end

local add10 = createAdder(10)
print(add10(5))  -- 15

这里暂时只需要抓住一点:函数名不是函数对象本身,函数名只是我们访问这个函数值的一种方式。 后面的局部函数、递归、Table 方法和闭包,都建立在这个认识之上。


二、函数参数传递:为什么修改 Table 字段会影响外部?

1. 形参本身是函数的局部变量

例如:

lua 复制代码
local function add(a, b)
    return a + b
end

print(add(10, 20))  -- 30

我们已经知道,调用时的 10、20 是实参,函数定义中的 a、b 是形参。Lua 形参本质上是函数内部的局部变量,调用时以实参的值来初始化它们。

Lua 采用值传递。这里并不是说所有类型的值都需要复制一个完整的对象,而是说形参得到的是实参所保存的那个值。

2. 普通数值:修改形参不会修改实参变量

lua 复制代码
local a = 100

local function change(x)
    x = 200
end

change(a)
print(a)  -- 100

形参 x 获得数值 100,随后执行 x = 200 只是修改了函数自己的局部变量。外部变量 a 仍然是 100。

3. Table:拷贝的是引用,而不是复制整张 Table

这里联系之前建立的 TValue 心智模型:我们可以把一个 Lua 值理解为"类型信息 + 对应的值或引用"。对于 Table,变量持有的是指向运行时 Table 对象的引用。

lua 复制代码
local player = {
    hp = 100
}

local function takeDamage(t, damage)
    t.hp = t.hp - damage
end

takeDamage(player, 20)
print(player.hp)  -- 80

这里的过程是:

text 复制代码
player 变量 ──┐
             ├──> 同一个 Table 对象 { hp = 100 }
t 形参      ──┘

执行 t.hp = t.hp - 20
             ↓
同一个 Table 对象变为 { hp = 80 }

因此,外部可以观察到 Table 字段被修改了。

但是如果函数内部写成:

lua 复制代码
local function change(t)
    t = {hp = 200}
end

local player = {hp = 100}
change(player)
print(player.hp)  -- 100

这里仅仅是让局部变量 t 引用另一张 Table,并没有修改原来的 Table。

所以需要准确区分:

  • t = {...}:修改形参所保存的引用,不会改变外部变量保存的引用。
  • t.hp = 200:通过形参引用修改同一个 Table 对象,调用方能够观察到对象内容变化。

最终结论:Lua 统一采用值传递。对于 Table,复制的值是引用,因此可以通过共享引用修改同一个 Table 对象。 Function 等引用类型也有类似的引用语义。

4. 形参与实参数量不必严格相等

lua 复制代码
local function test(a, b)
    print(a, b)
end

test(10, 20)      -- 10  20
test(10)          -- 10  nil
test(10, 20, 30)  -- 10  20

普通固定形参函数中,传入的实参不足时,用 nil 补齐;实参多于形参数量时,多余的值不会绑定到这些固定形参。后面通过 ... 才能访问可变参数部分。


三、多返回值:先理解返回,再理解表达式列表的展开

1. 为什么 Lua 允许返回多个值?

对于 C++ 的普通函数,我们常见的接口形式通常只有一个返回对象;如果需要携带多个字段,可以将它们封装为结构体、对象或者 std::tuple 等。

而 Lua 函数可以直接写出:

lua 复制代码
local function getPlayerInfo()
    return "战士", 20, 100
end

local name, level, hp = getPlayerInfo()
print(name, level, hp)   -- 战士  20  100

这里 return 直接产生多个返回值。Lua 并不是自动把它们包装成一张 Table,再让调用方去解包。

2. 接收变量的数量不匹配怎么办?

仍然定义:

lua 复制代码
local function values()
    return 10, 20, 30
end

现在分别尝试:

lua 复制代码
local a, b, c = values()        -- 10, 20, 30
local x, y = values()           -- 10, 20
local p, q, r, s = values()     -- 10, 20, 30, nil
local only = values()           -- 10

可以看到,Lua 的多重赋值有一条非常自然的规则:按位置依次赋值;值多了就截断,值少了就补 nil。

不过,这里还存在一个特别容易忽略的前置条件:函数调用究竟提供几个结果,还受到它在表达式列表中所处位置的影响。

3. 为什么只有表达式列表末尾的函数调用才能展开多个结果?

我们来看:

lua 复制代码
local function values()
    return 10, 20, 30
end

local a, b, c = values(), 100
print(a, b, c)   -- 10  100  nil

这里 values() 虽然能够产生 10、20、30,但是它并不位于表达式列表的最后一个位置 。因此在这个位置,Lua 会将该调用的结果调整为一个值,只保留第一个 10。

再看:

lua 复制代码
local a, b, c = 100, values()
print(a, b, c)   -- 100  10  20

这次 values() 位于表达式列表的最后,所以它可以展开全部结果,然后整组结果再根据左侧三个变量的数量调整,最后的 30 被截断。

因此,这里其实是两个连续的步骤:

第一步,根据函数调用在表达式列表中的位置,决定是否允许产生多个结果。

第二步,在多重赋值等需要固定数量结果的场景中,再根据接收变量的数量截断或补齐。

所以,"位于最右侧可以展开"并不代表所有返回值都一定会被保留;如果左侧变量不足,展开后依然可能被截断。

4. 括号为什么也会影响返回值?

lua 复制代码
local a, b, c = (values())
print(a, b, c)   -- 10  nil  nil

虽然 values() 看上去位于最右侧,但是表达式外面加了括号 (values())。Lua 会将带括号的表达式调整为一个结果。

这条规则不仅涉及多重赋值,还会影响后面学习的尾调用。

5. 多结果展开还适用于哪些位置?

它不只适用于赋值语句,还适用于 return 表达式列表、函数实参列表、Table 构造器的列表字段等。

lua 复制代码
local function values()
    return 10, 20, 30
end

print(values(), 100)      -- 10  100
print(100, values())      -- 100  10  20  30
print((values()))         -- 10

local t1 = {values(), 100}  -- {10, 100}
local t2 = {100, values()}  -- {100, 10, 20, 30}

local function forward()
    return values()         -- 返回全部三个结果
end

这里我们不需要死记每个例子的结论,只要记住:函数调用与 ... 都可能产生多个结果,但是否展开由具体表达式上下文决定。


四、可变参数 ...:不固定数量的输入

1. 为什么需要可变参数?

前面我们研究了函数可以返回多个值,那么反过来思考:函数能不能接收数量不固定的实参?

例如,我们希望一个日志函数既能接收两个值,也能接收五个值,而不需要为每一种数量分别定义函数。

Lua 提供了可变参数语法:

lua 复制代码
local function show(...)
    print(...)
end

show(10, "hello", true, 3.14)

这里有两处 ...,含义相互联系,但位置不同:

形参列表中的 ...: 表示这个函数可以接收数量不固定的实参。

函数体中的 ...: 表示可变参数表达式,可以按照多结果规则提供这些实参的值。

我们可以从概念上将它类比为 C++ 可变参数模板中的"参数包",但是要注意:C++ 模板参数包通常在编译期展开;Lua 的可变参数由运行时管理。 这只是帮助理解用途的类比,并不是说两者的底层实现相同。

2. 和 C 语言可变参数有什么区别?

C 语言也支持可变参数,例如 printf()。不过 C 语言并不要求所有可变参数的类型相同;真正需要注意的是,接收方不能像 Lua 一样自动获得完整的类型和数量信息,通常需要通过格式字符串、显式数量或其他约定来访问参数,并使用 stdarg.h 提供的 va_list 等机制。

Lua 的值在运行时携带类型信息,解释器又负责管理调用参数,因此对于上层 Lua 代码来说,... 的使用更加直接。

这里不需要自行处理参数到底经由寄存器还是原生栈传递,也不需要通过指针偏移读取可变参数。

尤其需要注意:... 并不是一张自动创建的 Table。 我们只需要将其理解为一个能在合适上下文中产生多个值的特殊表达式。

3. 固定形参与可变参数可以组合

lua 复制代码
local function log(level, ...)
    print("日志级别:", level)
    print(...)
end

log("INFO", "玩家登录", 1001, true)

这里 level 先接收第一个实参,后面的参数由 ... 提供。可变参数标记必须位于形参列表末尾。

4. ... 也遵循多值展开规则

lua 复制代码
local function test(...)
    local a, b, c = ...
    print(a, b, c)
end

test(10, 20, 30)  -- 10  20  30

再看:

lua 复制代码
local function test(...)
    local a, b, c = ..., 100
    print(a, b, c)
end

test(10, 20, 30)  -- 10  100  nil

为什么?因为 ... 在第二个例子的表达式列表中不是最后一项,所以被调整为一个值。

这个地方,直接复用上一节的多返回值规则即可。


五、select():如何知道可变参数有多少个,并逐个访问?

1. 为什么要引入 select?

现在我们已经能够通过 ... 接收和展开一组可变参数了。但是新的问题出现了:如果参数数量不固定,我们如何知道实际传进来几个?

Lua 的基础库提供了内置函数 select():

lua 复制代码
select("#", ...)
select(index, ...)

我们可以将它的第一个参数理解为一个操作选择符。

  • 第一个参数为字符串 "#":获取后续实参的数量。
  • 第一个参数为数字 index:从指定位置开始,返回后面的全部实参(包含该位置)。

注意,这里的 "#" 是一个字符串,不是我们此前学习的长度运算符 #。

2. 获取参数数量

lua 复制代码
local function test(...)
    local count = select("#", ...)
    print(count)
end

test(10, 20)            -- 2
test(10, nil, 30)       -- 3
test(nil, nil, nil)     -- 3

即使实参中存在 nil,select("#", ...) 仍然能够统计参数的数量,因为它计算的是实际收到多少个值 ,不是有多少个非 nil 值。

3. 从指定位置开始取参数

lua 复制代码
local function test(...)
    print(select(2, ...))
end

test(10, 20, 30, 40)  -- 20  30  40

注意:select(2, ...) 不是只返回第二个参数,而是返回从第二个参数开始的全部参数。

如果只想获取第二个值,就可以直接利用多返回值的截断规则:

lua 复制代码
local function test(...)
    local value = select(2, ...)
    print(value)
end

test(10, 20, 30, 40)  -- 20

因为左侧只有一个接收变量,后面的 30、40 就被舍弃了。

此外,select() 还支持从末尾计数的负索引,例如 select(-1, ...) 可从最后一个实参开始返回。

4. 结合 for 循环遍历所有可变参数

lua 复制代码
local function test(...)
    local count = select("#", ...)

    for i = 1, count do
        local value = select(i, ...)
        print(i, value)
    end
end

test(10, "hello", nil, true)

输出:

text 复制代码
1   10
2   hello
3   nil
4   true

整个逻辑很简单:先拿到数量,确定循环范围;每次通过 select(i, ...) 取得从第 i 个位置开始的值,再利用单变量赋值只接收第一个结果。

至此,可变参数、select()、多返回值、数值 for 循环这几部分就串起来了。


六、函数与 Table 结合:为什么会有面向对象的感觉?

1. 从 Table 的本质出发

此前学习 Table 时,我们已经知道:Table 可以用来表示键值对的集合,而 Value 可以是 Lua 的各种类型。

例如:

lua 复制代码
local player = {
    name = "战士",
    level = 10,
    hp = 100,
    alive = true
}

这里的 Table 已经可以保存一个玩家对象的属性。既然 function 也是一种值,那么自然也可以将函数放进 Table:

lua 复制代码
local player = {
    name = "战士",
    hp = 100,

    show = function()
        print("玩家信息")
    end
}

player.show()

这里 show 与 name、hp 一样,只是一个普通字段;区别只是它对应的 Value 为函数值。

实际上:

lua 复制代码
player.show()

可以拆成两步理解:先通过 player.show 取出函数值,然后用 () 调用它。

所以,Table 能够保存属性,也能够保存函数形式的行为,这就为用 Table 表示对象提供了基础。 当然,严格来说,对象不一定必须同时包含方法;只保存数据的 Table 也可以充当对象。

2. 不必在创建 Table 的同时定义函数

lua 复制代码
local player = {
    name = "战士",
    hp = 100
}

function player.show()
    print("玩家信息")
end

这里:

lua 复制代码
function player.show()
    print("玩家信息")
end

其实只是下面写法的语法糖:

lua 复制代码
player.show = function()
    print("玩家信息")
end

Lua 依然是在创建函数值,然后将它赋给 player 这张 Table 的 show 字段。

3. 当多个对象共享同一个函数时,如何知道操作哪个对象?

这里可以联系 C++ 的非静态成员函数:不同对象可以执行同一份成员函数代码,但函数必须知道当前要访问的是哪个实例,所以通常存在隐式的 this 指针传递机制。

Lua 会遇到同样的需求,但解决方式更直接:我们可以将要操作的 Table 作为普通形参传进去。

lua 复制代码
local function takeDamage(self, damage)
    self.hp = self.hp - damage
end

local player1 = {name = "战士", hp = 100, takeDamage = takeDamage}
local player2 = {name = "法师", hp = 80,  takeDamage = takeDamage}

player1.takeDamage(player1, 20)
player2.takeDamage(player2, 30)

print(player1.hp)  -- 80
print(player2.hp)  -- 50

这里两张 Table 的 takeDamage 字段引用同一个函数对象,并没有因为存在两个玩家,就必须创建两份不同的函数代码。

而函数体内的 self 只是普通形参。它接收的是哪张 Table 的引用,后续就操作哪张 Table。

4. : 为什么能省掉重复传参?

我们刚才写的是:

lua 复制代码
player1.takeDamage(player1, 20)

这里每次都要把 player1 再写一遍。为简化这种场景,Lua 提供了冒号语法:

lua 复制代码
player1:takeDamage(20)

其调用语义可以理解为:

lua 复制代码
player1.takeDamage(player1, 20)

需要注意的是,冒号会确保作为接收者的表达式只求值一次;上面的展开写法主要用于理解语义,对于带副作用的复杂表达式不应机械替换。

定义阶段也可以使用冒号:

lua 复制代码
function player1:takeDamage(damage)
    self.hp = self.hp - damage
end

其含义相当于:

lua 复制代码
player1.takeDamage = function(self, damage)
    self.hp = self.hp - damage
end

因此:

写法 实际作用
function t.f(self, x) 显式声明 self 形参
function t:f(x) 自动添加第一个形参 self
t.f(t, 10) 显式传入接收者
t:f(10) 自动把 t 作为第一个实参

这里最关键的结论就是:self 不是 Lua 的关键字,也不存在一种单独的"方法函数类型"。冒号只是简化对象自身的传递。

对于需要操作对象自身属性的函数,通常使用冒号写法更清晰;普通工具函数或不需要 self 的函数,用点号即可。

本文先把 Table 与函数的关系讲清楚。Table 之间如何共享方法、元表和 __index,以后单独学习,不在这里引入。


七、局部函数:local 改变的究竟是什么?

1. 从普通具名函数过渡到局部函数

lua 复制代码
function add(a, b)
    return a + b
end

前面已经说过,这可以理解为 add = function(a, b) ... end。在没有可见同名局部变量时,add 是全局变量。

现在我们在前面添加 local:

lua 复制代码
local function add(a, b)
    return a + b
end

这里的函数对象并没有变成某种特殊类型,变化的是引用函数值的名字具有局部作用域。

这段代码可以按照下面的形式理解:

lua 复制代码
local add
add = function(a, b)
    return a + b
end

所以,局部函数和局部变量其实遵循同一套词法作用域规则。

2. 局部的函数名,不代表函数对象只能在局部作用域执行

lua 复制代码
local f

do
    local function hello()
        print("hello")
    end

    f = hello
end

f()   -- hello

这里 hello 这个局部名字在 do ... end 结束后已经不可见了。但是 f 仍然保存着该函数值,所以函数对象依然能够被调用。

因此,局部的是名字的可见范围,不是给函数对象设置了一个"禁止在外部执行"的限制。

八、闭包与 Upvalue:函数为什么能够记住外部局部变量?

1. 从有状态迭代器的问题说起

我们之前学习迭代器时已经思考过:一个迭代器每次执行,必须记住上次访问到了哪个位置。

如果把位置放在所有函数都能访问的全局变量中,那么多个迭代器容易相互干扰,也容易被其他代码修改。

自然可以想到:把状态变量放到创建迭代器的函数内部,再返回一个真正执行迭代的内部函数。

lua 复制代码
local function createIterator()
    local index = 0

    return function()
        index = index + 1
        return index
    end
end

local iter = createIterator()
print(iter())  -- 1
print(iter())  -- 2
print(iter())  -- 3

这里我们需要回答两个问题。

第一个问题: createIterator() 已经返回了,为什么 index 没有随着这次函数调用结束而失效?

第二个问题: 为什么后续多次调用 iter(),操作的仍然是同一个 index,而不是每次重新创建一个 0?

2. Upvalue 捕获的是变量,而不是一次性的数值快照

内部匿名函数引用了定义时所在外部函数的局部变量 index。对内部函数而言,这个被捕获的局部变量就是 Upvalue(上值)。

因此,返回的函数对象不仅关联其函数代码,还通过 Upvalue 关联到需要继续访问的 index 变量。

这里要注意:捕获的不是 index 在创建时的数值快照,而是对这个变量本身的访问。 所以第一次调用把 index 改为 1,下一次调用就能在 1 的基础上继续修改。

当原本定义 index 的函数调用结束后,Lua 会保证仍然被可达闭包使用的变量可以继续存在。并不需要把整个外层函数的调用栈记录永久留下;Lua 可以通过 Upvalue 机制保留所需的变量状态。

3. 多次创建闭包,为什么状态互不干扰?

lua 复制代码
local iter1 = createIterator()
local iter2 = createIterator()

print(iter1())  -- 1
print(iter1())  -- 2
print(iter2())  -- 1
print(iter1())  -- 3

每次调用 createIterator() 都会执行一次 local index = 0,因此创建的是不同的局部变量。每次执行内部匿名函数定义,也会得到新的运行时闭包对象。

两者关联的函数代码可以相同,但捕获的 index 不同。

反过来,如果这样写:

lua 复制代码
local iter1 = createIterator()
local iter2 = iter1

那么两个变量引用的是同一个闭包对象 ,自然也访问同一个 index。

此外,同一次外部函数调用中创建的多个内部闭包,如果捕获了同一个外部局部变量,它们也可以共享那个 Upvalue 所关联的变量。

4. 闭包函数一定是匿名函数吗?

不是。例如:

lua 复制代码
local function createCounter()
    local count = 0

    local function increase()
        count = count + 1
        return count
    end

    return increase
end

这里的 increase 是局部具名函数,但它同样引用了外部局部变量 count,因此同样涉及 Upvalue。

还要再补充一个实现层面的区别:Lua 运行时的 Lua 函数实例本身就是闭包对象,即使没有捕获外部局部变量,也仍可以使用这种运行时表示。 我们平时强调"闭包函数",往往是在强调其访问外部局部变量的能力,而不是说 Lua 另外提供了一个与 Function 并列的新数据类型。

所以现在可以建立一个清晰的模型:

闭包对象 = 关联的函数代码 + 对其所需 Upvalue 的关联。

通过这套机制,函数可以在定义它的局部作用域结束之后,继续访问那些仍然需要保留的外部局部变量。


九、递归函数:局部函数为什么也能调用自己?

1. 递归本身没有新的函数机制

我们知道,递归就是函数调用自身。例如:

lua 复制代码
local function countdown(n)
    if n <= 0 then
        return
    end

    print(n)
    countdown(n - 1)
end

countdown(3)

这里每一次调用都会有独立的形参和执行状态,但是调用的仍然是同一个函数值所关联的函数代码。

递归只需注意两件事:一是存在终止条件;二是每次调用都能让执行过程向终止条件推进。

2. 局部递归和全局递归没有本质区别

lua 复制代码
local function factorial(n)
    if n <= 1 then
        return 1
    end

    return n * factorial(n - 1)
end

print(factorial(5))  -- 120

这完全可以正常递归。factorial 虽然是局部变量,但其作用域包含函数体,因此函数内部能够通过它再次访问同一个函数值。

真正值得注意的,是局部变量的初始化表达式与自身作用域之间的关系。

3. 为什么不推荐用 local f = function() ... f() ... end 实现局部递归?

来看:

lua 复制代码
local f = function(n)
    if n <= 0 then
        return
    end

    f(n - 1)
end

初看似乎也没问题:先定义匿名函数,再赋给局部变量 f,最后在函数内部引用 f 即可。

但 Lua 的词法作用域规则规定:新声明的局部变量,从声明语句之后才开始进入可见范围,并不包含自身初始化表达式。

因此,在右侧这个匿名函数的函数体里,f 并不会引用当前语句新声明的局部变量;它会寻找外层可见的同名变量。如果没有,默认环境下就会访问全局的 f。如果全局 f 为 nil,执行递归分支时就会出错。

正确写法是先声明、再赋值:

lua 复制代码
local f

f = function(n)
    if n <= 0 then
        return
    end

    print(n)
    f(n - 1)
end

f(3)

现在匿名函数定义时,前面的 local f 已经可见了,因此函数体内的 f 会引用这个局部变量。对于匿名函数来说,它还是一个被捕获的 Upvalue。

于是我们也就理解了:

lua 复制代码
local function f(n)
    -- 可以在函数体内引用局部名字 f
end

为什么与下面的形式相对应:

lua 复制代码
local f
f = function(n)
    -- 可以引用局部名字 f
end

最终结论:递归机制没有变化,真正的区别在于局部变量声明和初始化时的词法作用域规则。


十、尾调用:为什么 Lua 要优化这种特殊的函数调用?

1. 尾调用中的"尾",不是整条函数调用链的末尾

先考虑一条调用链:

text 复制代码
A → B → C → D

不能因为 D 是最后一个函数,就认为只有 C → D 才可能是尾调用。

尾调用是以某一次调用关系中的"当前调用方"为判断单位的。 我们分别看:A 调用 B 后是否还需要继续执行,B 调用 C 后是否还需要继续执行,C 调用 D 后是否还需要继续执行。

例如:

lua 复制代码
local function D()
    return 100
end

local function C()
    return D()
end

local function B()
    return C()
end

local function A()
    return B()
end

这里 A→B、B→C、C→D 都是尾调用。

所以,尾调用中的"尾"指的是当前函数这条执行路径中的尾位置,而不是整条调用链上的最后一个节点。

2. Lua 中到底什么写法才是尾调用?

先认识一个典型例子:

lua 复制代码
local function B()
    return 10, 20
end

local function A()
    return B()
end

对于 A 来说,调用 B() 之后不再需要对结果做任何处理,而是把 B() 的返回结果原样交给 A 的调用者。

这就是 Lua 中典型的尾调用形式:

lua 复制代码
return B()

现在拿几个写法对比:

写法 是否是 Lua 尾调用 原因
return B() 是 直接返回该函数调用的全部结果
return B() + 1 否 还需要执行加法
return n * B() 否 还需要执行乘法
local x = B(); return x 否 不是直接返回函数调用,且只接收第一个结果
return (B()) 否 外层括号将调用结果调整为一个值
B(); return 否 丢弃了 B 的返回结果
return x, B() 否 return 不是单独一个函数调用

注意,return B(10, 20) 仍然是尾调用 ;我们说的"不能额外加括号",指的是不要写成 return (B()),而不是说函数正常传参时不能使用括号。

此外,Lua 5.4 有一个暂时不深入的限制:在待关闭局部变量(to-be-closed variable)的作用域中,return f() 不会成为尾调用。本文先掌握通常情况下的识别规则即可。

3. 先从普通函数调用理解:为什么需要保存调用方状态?

lua 复制代码
local function B()
    return 100
end

local function A()
    local result = B()
    return result + 10
end

执行 A() 时,会建立 A 的调用状态。随后 A 调用 B,又需要建立 B 的调用状态。

这里为什么不能直接丢弃 A 的调用状态?

因为 B 返回之后,A 还需要恢复执行 result + 10。因此,虚拟机必须保存后续继续执行所需要的信息,包括相应的局部值、执行位置等。

如果函数调用链很深,而且每一个调用方都需要等待下一个函数返回后再执行,那么未完成的调用记录就会越来越多,持续占用运行时栈空间。

普通递归也是这个道理:有大量尚未完成的函数调用,才会使递归深度带来调用栈压力。

4. 尾调用为什么可以省掉这些记录?

再看:

lua 复制代码
local function A()
    return B()
end

这里 A 调用 B 后,不需要再计算任何东西,也不需要在 B 返回后重新恢复自己的执行逻辑。

于是问题就来了:既然 A 已经没有后续操作,为什么还要保留一个等待恢复的 A 的调用记录?

答案是没有必要。

Lua 因此可以让 B 接替 A 的调用位置,复用 A 不再需要的调用栈记录。这样,沿着尾调用链继续调用,也不需要为了已经结束自身任务的调用方不断叠加记录。

这就是 Lua 尾调用优化的重要意义:主要是避免不必要的调用栈增长,尤其适用于很深的尾调用链或尾递归。 它也可能减少部分调用管理开销,但不能简单认为写成尾调用以后,所有代码都一定大幅加速。

5. 进一步联系 Lua 虚拟机:谁在保存调用状态?

前面我们曾建立过 Lua 的执行模型:Lua 解释器本身是由 C 实现的运行时程序,独立运行时表现为一个操作系统进程。该进程具有自己的虚拟地址空间,其中包括代码、堆、原生线程栈等区域。

但这还不够。解释器在执行 Lua 脚本时,还需要维护Lua 层面的运行时数据:变量值、函数对象、参数、局部值、函数执行位置等。

注意,这不是出现了第二个独立的进程地址空间,而是 Lua 解释器在自己的进程内存中,进一步组织和管理 Lua 虚拟机的运行时数据结构。

在 Lua 5.4 的实现中,我们可以简单认识两类相关结构:

  • Lua 虚拟栈:保存函数调用所需的参数、局部变量值以及临时结果等。
  • CallInfo:记录一次函数调用相关的执行状态。

因此,不能把每一层 Lua 函数调用都直接等同于一次原生 C 函数的递归调用。Lua 虚拟机有自己的执行循环和调用状态管理方式。

对于普通调用,虚拟机保留调用方的记录,以便被调用函数返回后恢复。

对于尾调用,Lua 5.4 有专门的尾调用处理路径,可以调整调用状态、安排被调用函数的参数,并复用原调用方的调用位置。这里所谓"原地覆盖"是概念上的复用,不是机械地把旧函数占用的每一个字节全部覆盖。

与此同时,如果此前某个局部变量已经被闭包捕获,Lua 还必须正确处理相关 Upvalue 的生命周期,不能因为复用栈记录就破坏外部函数的闭包状态。

6. 尾调用优化的编码意义

现在我们已经认识了识别方法和底层动机,自然可以总结出实际编码规范:

如果一个函数的唯一任务,就是把另一个函数的返回结果原样交给调用方,那么直接写 return f(...)。

这样做有两个直接好处:

一是能够完整保留被调用函数的多返回值;二是在满足 Lua 5.4 尾调用条件时,能够利用尾调用优化,避免不必要的调用记录增长。

相反,刻意写成 local result = f(); return result 不仅无法得到这种尾调用优化,还会把多返回值截断为一个。

不过也不必为了追求尾调用,把确实需要进行结果校验、计算或日志处理的逻辑强行删掉。优先保证业务语义正确,再在可以直接返回的场景中自然使用尾调用。

7. 尾递归不就是递归应用尾调用吗?

没错。尾递归(Tail Recursion)就是尾调用的一种特殊情况:当前函数在尾位置调用自身。

lua 复制代码
local function countdown(n)
    if n <= 0 then
        return
    end

    print(n)
    return countdown(n - 1)
end

这里 return countdown(n - 1) 是尾递归,可以利用尾调用优化。

反过来:

lua 复制代码
local function factorial(n)
    if n <= 1 then
        return 1
    end

    return n * factorial(n - 1)
end

虽然也是递归,但递归调用返回后还需要执行乘法,因此不是尾递归。

如果想把阶乘写为尾递归,需要将"递归返回后继续做的乘法"改为提前累计:

lua 复制代码
local function factorial(n, acc)
    if n <= 1 then
        return acc
    end

    return factorial(n - 1, acc * n)
end

print(factorial(5, 1))  -- 120

此时递归调用本身直接处在 return 的尾位置。需要注意,尾递归优化避免的是递归调用记录持续累积,并不意味着所有运算都没有成本,也不改变数值类型本身的范围限制。


十一、函数执行失败怎么办?从 error() 过渡到 pcall()

1. 函数不一定总能正常返回

前面讨论了大量"函数正常执行并返回结果"的情况。但在实际业务中,函数也可能遇到错误。

lua 复制代码
local function B()
    error("B 函数执行失败")
end

local function A()
    print("开始执行 A")
    B()
    print("A 执行结束")
end

A()

error() 是 Lua 基础库提供的函数,用于主动产生错误。若这个错误没有被适当处理,它会沿调用链传播,后续的普通执行语句也无法照常执行。

这里要区分:函数正常返回一个错误码 和函数发生 Lua 错误并不是同一回事。

2. pcall():受保护地调用函数

Lua 提供了 pcall(),即 Protected Call(受保护调用):

lua 复制代码
pcall(f, arg1, arg2, ...)

它接收一个函数值,并把后续参数传递给该函数。若函数发生 Lua 错误,pcall() 能够捕获错误,使控制权返回到调用方,而不必让错误继续向外传播。

lua 复制代码
local function B()
    error("B 函数执行失败")
end

local ok, result = pcall(B)
print(ok)       -- false
print(result)   -- 错误信息(通常还包含代码位置)

print("后续代码仍可执行")

注意:pcall() 并不是让出错的 B 函数从错误位置接着执行,而是结束这次失败的受保护调用,再把执行状态交给调用方。

3. pcall() 也遵循多返回值机制

lua 复制代码
local function calculate(a, b)
    return a + b, a * b
end

local ok, sum, product = pcall(calculate, 10, 20)

print(ok)       -- true
print(sum)      -- 30
print(product)  -- 200

这里 pcall() 成功时会返回:

text 复制代码
true, 被调用函数的所有返回值...

失败时返回:

text 复制代码
false, 错误对象

所以我们能够直接联系上一节学习的多返回值:pcall() 第一个结果表示调用成功与否,后续结果根据成功或失败情况分别表示函数结果或错误对象。

至于 xpcall() 与完整错误堆栈,可以在专门学习 Lua 错误处理时继续展开,本文先建立基础概念。


十二、最终总结:把 Lua 函数的知识点串成一套认知

学习到这里,我们会发现 Lua 函数并不是很多毫无关系的独立语法,而是可以串成一条非常清楚的逻辑链。

第一条链:函数本身也是值

text 复制代码
函数源码编译为字节码
        ↓
运行时执行函数定义,创建函数对象
        ↓
变量或 Table 字段引用函数值
        ↓
函数能够赋值、传参、返回、被调用

所以,具名函数、匿名函数、局部函数、Table 中的函数字段,其实都建立在"函数是值"这一共同基础上。

第二条链:函数如何交换数据

text 复制代码
实参按值传给形参
        ↓
Table / Function 等值可以携带对象引用
        ↓
return 可以产生多个结果
        ↓
表达式位置决定是否展开
        ↓
多重赋值进行截断 / nil 补齐
        ↓
... 提供可变参数
        ↓
select() 获取数量或指定位置开始的值

所以,我们之前学习过的 TValue、Table 引用、函数传参以及多重赋值,并不是互不相关的知识点。

第三条链:作用域为什么影响函数

text 复制代码
函数名是引用函数值的变量
        ↓
local 决定名字的词法作用域
        ↓
内部函数可以捕获外部局部变量
        ↓
Upvalue 让这些变量在需要时继续存在
        ↓
闭包可以保存状态
        ↓
局部递归要注意声明和初始化的作用域时机

所以,闭包并不是神秘的新函数类型,而是运行时函数对象与词法作用域、外部变量访问机制共同产生的效果。

第四条链:尾调用为什么能优化

text 复制代码
普通函数调用必须保存调用方状态
        ↓
因为被调用函数返回以后,调用方还需要恢复
        ↓
如果 return f() 直接返回全部结果
        ↓
调用方没有后续计算需要执行
        ↓
不再需要保留等待恢复的调用记录
        ↓
Lua 虚拟机可以复用调用记录
        ↓
避免很深的尾调用链持续占用更多栈空间

这里我们实际上又从 Lua 的上层语法,回到了最开始学习的字节码与虚拟机执行模型。

整个学习过程最重要的,不是记住多少条 Lua 函数语法,而是搞清楚每一种机制为什么存在,以及它与此前学习过的变量、Table、作用域和虚拟机之间有什么关系。

这样一来,后面阅读真实 Lua 业务代码时,碰到函数赋值、多值返回、...、self、闭包状态、局部递归或者 return f(),就能够从语言机制的角度判断代码为什么这样写,而不是只认识语法表面。


相关推荐
飞浪纪元[FWC–FE]2 小时前
从零到一键绿:Qt 5.14.2 + VS2019 开发环境搭建、Kit 套件配置与构建错误修复完全手册
开发语言·qt
Wang's Blog2 小时前
Java 项目实战:交友项目 - 联系人列表
java·开发语言·交友
我命由我123452 小时前
访客系统形式(有用户概念、无用户概念)
java·开发语言·前端·javascript·python·产品运营·产品经理
架构师伯子明2 小时前
FFmpeg C++学习(一):编译和demo
开发语言·c++·学习
海域云SeaArea_2 小时前
Windows Server 2008 R2 从零安装 IIS7.5(二):php.ini 配置
开发语言·windows·php
叼烟扛炮2 小时前
[LangChain] 学习笔记(02):LCEL 链式调用与动态模型配置实战
服务器·开发语言·网络·笔记·学习·langchain
xixingzhe22 小时前
图表中同比与环比的区别:看趋势还是看变化?
开发语言·python
andyweike2 小时前
Swift
开发语言·ios·swift
纪念 2292 小时前
C++算法(六)
开发语言·c++·算法