闭包不是"函数套函数"的语法糖。在 Lua 的 C API 中,它是函数实例 + 被捕获上值的运行时组合体------理解了这一点,才能看懂 mpv 那种"三层闭包自动管理资源"的精妙设计。
本文用最小可运行的 C 代码,把闭包和上值在 C API 层的本质讲清楚,为后续 mpv 源码拆解打下基础。
文章目录
-
- [1. Lua 层面的闭包](#1. Lua 层面的闭包)
- [2. 上值在 C API 中的本质](#2. 上值在 C API 中的本质)
- [3. 最小示例:C 实现计数器闭包](#3. 最小示例:C 实现计数器闭包)
- [4. 关键认知:上值 = 被"打包"进闭包的栈顶值](#4. 关键认知:上值 = 被"打包"进闭包的栈顶值)
- [5. 两种上值存储方式](#5. 两种上值存储方式)
- [6. 与 C++ lambda 对比:同为"捕获",不同语义](#6. 与 C++ lambda 对比:同为"捕获",不同语义)
-
- [6.1 捕获方式](#6.1 捕获方式)
- [6.2 生命周期管理](#6.2 生命周期管理)
- [6.3 底层实现](#6.3 底层实现)
- [6.4 对照速查表](#6.4 对照速查表)
- [7. 从原理到实战](#7. 从原理到实战)
1. Lua 层面的闭包
从工程角度看,闭包是函数实例 + 其词法环境的绑定。函数不仅能执行代码,还能携带它定义时所依赖的状态。
lua
function make_counter()
local count = 0 -- 这个局部变量...
return function() -- 被这个匿名函数"捕获"
count = count + 1
return count
end
end
local c1 = make_counter()
local c2 = make_counter()
print(c1()) -- 1
print(c1()) -- 2
print(c2()) -- 1 ← c2 有自己的 count
这里 count 既不是全局变量,也不是参数------它是被闭包捕获到的外部变量 ,Lua 称之为上值(Upvalue)。
闭包的两个核心能力:
- 延长变量生命周期:函数离开定义作用域后,上值仍然存活
- 保留状态:同一个闭包实例可以跨多次调用维护私有数据
2. 上值在 C API 中的本质
Lua 的 C API 中,创建闭包的核心函数是 lua_pushcclosure:
c
void lua_pushcclosure(lua_State *L, lua_CFunction fn, int n);
它做三件事:
- 把
fn封装成 Lua 可调用的函数对象 - 把栈顶的
n个值"抽走",作为这个闭包的上值 - 把这个闭包压入栈顶
在 C 函数内部,通过伪索引 lua_upvalueindex(i) 访问第 i 个上值。
3. 最小示例:C 实现计数器闭包
下面用 C API 实现和上面 Lua 版本等价的 make_counter:
c
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdio.h>
// 闭包内的 C 函数------每次调用 count++ 并返回
// 上值1: 一个指向 int 的 userdata(存储计数器值)
static int counter_fn(lua_State *L) {
// 通过上值索引(-1 相对伪索引)拿到计数器指针
int *count = (int *)lua_touserdata(L, lua_upvalueindex(1));
(*count)++;
lua_pushinteger(L, *count);
return 1; // 返回递增后的值
}
// make_counter: 创建闭包并返回
static int make_counter(lua_State *L) {
// 在 Lua 内存分配器中创建计数器存储
int *count = (int *)lua_newuserdata(L, sizeof(int));
*count = 0;
// ★ 核心:创建闭包,把栈顶 1 个值(count 指针)"打包"为上值
lua_pushcclosure(L, counter_fn, 1);
// ↑ fn ↑ 上值个数 = 1
return 1; // 返回这个闭包给 Lua
}
int main() {
lua_State *L = luaL_newstate();
luaL_openlibs(L);
// 注册 make_counter 为全局函数
lua_register(L, "make_counter", make_counter);
// 执行 Lua 代码:创建两个独立计数器
luaL_dostring(L,
"c1 = make_counter()\n"
"c2 = make_counter()\n"
"print(c1(), c2(), c1()) -- 输出: 1 1 2\n"
);
lua_close(L);
return 0;
}
运行结果:
1 1 2
c1 和 c2 各自持有一块独立的 int* 上值,互不干扰------这和 Lua 版本的 make_counter 行为完全一致。
4. 关键认知:上值 = 被"打包"进闭包的栈顶值
lua_pushcclosure(L, fn, 2) 的语义:
调用前的栈:
[ -3 ] some_value
[ -2 ] upvalue_2 ← 将成为上值 2
[ -1 ] upvalue_1 ← 将成为上值 1(离栈顶最近的是上值 1)
↑ 栈顶
调用后:
[ -1 ] closure(fn, [upvalue_1, upvalue_2])
栈顶的 2 个值被"消耗"了,封闭在闭包内部。在 fn 中可以通过 lua_upvalueindex(1) 和 lua_upvalueindex(2) 访问它们。
重要 :lua_upvalueindex(i) 返回的是伪索引,只对当前 C 函数有效。它是"访问当前闭包的第 i 个上值"的句柄,不是栈上的真实位置。
5. 两种上值存储方式
| 方式 | 代码 | 适用场景 |
|---|---|---|
| userdata | lua_newuserdata(L, size) |
C 侧需要读写的数据(计数器、缓冲区、结构体) |
| 轻量指针 | lua_pushlightuserdata(L, ptr) |
传递已有的 C 指针(函数地址、上下文指针),不归 Lua GC 管 |
| Lua 值 | lua_pushnumber/string/table 等 |
纯 Lua 数据 |
mpv 的 af_pushcclosure 就同时用了后两种:一个轻量指针存储目标函数地址,一个 lua_pushcclosure 创建内层闭包作为上值。
6. 与 C++ lambda 对比:同为"捕获",不同语义
C++ 开发者对闭包的概念来自 lambda 表达式。C++ 的捕获和 Lua 的上值看起来相似,但有三个本质差异值得关注。
6.1 捕获方式
C++:显式声明捕获方式,值捕获 vs 引用捕获语义截然不同。
cpp
int count = 0;
auto by_val = [count]() mutable { return ++count; }; // 值捕获:独立副本
auto by_ref = [&count]() { return ++count; }; // 引用捕获:共享原变量
by_val(); // count 仍然是 0(只改了副本)
by_ref(); // count 变成 1(改了原变量)
Lua :上值永远是"对原变量的引用"。不存在"值捕获"的概念------闭包内修改上值,外部也可见(如果外部还能访问到的话)。
lua
local count = 0
local fn = function() count = count + 1; return count end
fn() -- count 变成 1,原变量被修改
关键差异 :C++ 需要显式选择
=还是&,选错可能导致悬垂引用或意外共享。Lua 没有这个心智负担------上值就是原变量本身。
6.2 生命周期管理
C++:捕获引用的 lambda 不会延长被引用变量的生命周期。这是 C++ lambda 最常见的坑:
cpp
std::function<int()> create_bug() {
int count = 0;
return [&count]() { return ++count; }; // ❌ 悬垂引用!count 已销毁
}
Lua:上值会被闭包"攥住",GC 不会回收------生命周期天然延长。
lua
function create_counter()
local count = 0 -- count 本应在函数返回后销毁...
return function()
count = count + 1 -- ...但被闭包引用,GC 保留它
return count
end
end
6.3 底层实现
C++ lambda 本质是匿名函数对象(functor),捕获变量编译为对象的成员字段:
lambda [count](int x) { return x + count; }
↓ 编译器
struct __lambda_123 {
int count; // ← 捕获的变量成为成员
auto operator()(int x) const { return x + count; }
};
Lua 闭包 用独立的 upvalue 表存储捕获的变量,不嵌入函数对象本身:
make_counter() 返回的闭包:
┌─────────────────────┐
│ CClosure / LClosure │
│ proto: counter_fn │
│ upvals: [0] ─────────→ UpVal { v = &count }
└─────────────────────┘
6.4 对照速查表
| 维度 | C++ lambda | Lua 闭包 |
|---|---|---|
| 捕获语义 | = 值捕获 / & 引用捕获,显式选择 |
永远是引用,自动 |
| 生命周期 | 引用的变量不会延长生命周期 ⚠️ | GC 保证上值存活 ✅ |
| 可变性 | 值捕获需 mutable 才能修改 |
上值始终可读写 |
| 底层实现 | functor 对象,捕获变成员字段 | 函数原型 + upvalue 链表 |
| C API 对应 | 无直接对应 | lua_pushcclosure(L, fn, upval_count) |
| 典型陷阱 | 悬垂引用([&] 捕获局部变量) |
意外共享(多个闭包共享同一个 upvalue) |
7. 从原理到实战
到这里,你已经掌握了理解 mpv 闭包机制所需的所有原理:
- 闭包 = 函数 + 上值
lua_pushcclosure(L, fn, n)把栈顶 n 个值封装为上值lua_upvalueindex(i)在 C 函数内访问第 i 个上值
下一步,把上值换成 mpv 的真实对象------函数指针、talloc 上下文、内层闭包------就是 af_pushcclosure 的核心机制。详见下一篇:《mpv 的 af_pushcclosure --- lua闭包与自动资源管理》。
可运行代码:上面的完整示例可直接编译运行:
bash# macOS (Homebrew LuaJIT) cc -o counter counter.c $(pkg-config --cflags --libs luajit) && ./counter # 预期输出:1 1 2