[mpv插件系统] (一) Lua 闭包与上值 — 从概念到 C API

闭包不是"函数套函数"的语法糖。在 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)

闭包的两个核心能力:

  1. 延长变量生命周期:函数离开定义作用域后,上值仍然存活
  2. 保留状态:同一个闭包实例可以跨多次调用维护私有数据

2. 上值在 C API 中的本质

Lua 的 C API 中,创建闭包的核心函数是 lua_pushcclosure

c 复制代码
void lua_pushcclosure(lua_State *L, lua_CFunction fn, int n);

它做三件事:

  1. fn 封装成 Lua 可调用的函数对象
  2. 把栈顶的 n 个值"抽走",作为这个闭包的上值
  3. 把这个闭包压入栈顶

在 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

c1c2 各自持有一块独立的 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
相关推荐
秋越1 小时前
从工程角度理解嵌入式C语言关键字
c语言·开发语言·嵌入式·嵌入式软件开发·嵌入式c语言·c语言关键字
代码地平线1 小时前
C++ 入门篇类和对象·上篇:从本质深剖类与对象与C++基本用法
c语言·开发语言·数据结构·c++·笔记·算法
syker2 小时前
AIFerric 多硬件后端完整支持方案
c语言
社交怪人2 小时前
【三个数】信息学奥赛一本通C语言解法(题号2053)
c语言
Dovis(誓平步青云)5 小时前
《QT学习第四篇:常见事件与UDP、TCP、文件系统、(锁、信号量、条件变量》
c语言·开发语言·汇编·qt
zz0723205 小时前
Lua 脚本
lua·脚本语言·redis+lua
.千余18 小时前
【C++】C++类与对象2:C++构造函数、运算符重载与流输入输出全面解析
c语言·开发语言·前端·c++·经验分享
QiLinkOS20 小时前
【用呼吸重构创造价值关系——QiLink生态】
c语言·数据结构·c++·人工智能·单片机·嵌入式硬件·算法
水无痕simon20 小时前
8 判断,分支,循环语句
c语言