【Lua 进阶系列】从 C++ 对象到 Lua 变量:一文理解 userdata、Lua 栈与元表绑定机制

🔥 本文专栏:Lua

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

💪 今日博客励志语录:努力的意义,不是确保命运给予回报,而是让自己拥有更多选择命运的权利。


思维导图

在开始讨论 userdata 之前,我们先把整篇文章的逻辑链条确定下来。本文不打算一上来就罗列 Lua C API,而是从此前已经认识的游戏服务端分层架构出发,逐步推导出 userdata 的作用与实现机制。

text 复制代码
游戏服务端:C/C++ 宿主程序 + Lua 业务层
    │
    ├── 上层需要调用下层函数 → 注册 C 函数
    │
    └── 上层还需要使用下层自定义数据 / 对象
            │
            ├── Lua 原生类型与 C/C++ 自定义类型存在差异
            ├── userdata:让 Lua 持有原生数据或指针
            │
            ├── TValue 如何表示 userdata?
            │     ├── Full userdata → 引用 Udata 对象
            │     └── Light userdata → 直接保存 C 指针
            │
            ├── Full userdata
            │     ├── Udata 的元信息 / 原始数据内存 / User Value
            │     ├── Lua C API 创建对象并分配内存
            │     ├── C++ 在原始内存中构造数据
            │     └── Lua 栈完成值的传递
            │
            ├── Lua C API 虚拟栈
            │     ├── 每个 C 函数有独立的栈视图
            │     ├── 参数:索引 1、2、3......
            │     └── 返回值:压栈 + return N
            │
            ├── Lua → C++:取得 userdata 并访问原始数据
            ├── 元表 + __index:把字段访问映射为 C 函数调用
            └── Light userdata:不创建 Udata,直接传递 C 指针

引入:为什么 Lua 已经有 Table,还需要 userdata?

在此前的学习中,我们已经认识了 Lua 的基本数据类型,例如 number、boolean、string、table、function 等,也了解了 Table 的数组部分、哈希部分以及基于元表的面向对象机制。

那么现在出现一个问题:

既然 Lua 已经有了 Table,可以保存各种数据,甚至可以使用元表实现类似面向对象的调用,为什么还需要单独设计 userdata 这种数据类型?

要回答这个问题,我们先不看 userdata 的具体 API,而是回到一个比较熟悉的应用场景:使用 C/C++ 作为宿主程序、Lua 负责上层业务逻辑的游戏服务端。

一、从游戏服务端的分层架构理解 userdata 的存在意义

1. Lua 运行时与 C/C++ 宿主程序的关系

我们已经知道,Lua 是一门可嵌入的脚本语言。所谓嵌入式,并不是说 Lua 一定独立运行在另一个进程中,而是宿主程序可以把 Lua 运行时作为自身的一部分,用来加载并执行 Lua 业务脚本。

例如,一个游戏服务端可以采用这样的分层结构:

  • 下层 C/C++ 宿主程序:负责网络连接、字节流收发、部分协议处理、底层资源与服务等。
  • 上层 Lua 业务逻辑:负责登录、行军、战斗规则、状态更新等具体业务。

客户端发来请求后,下层服务器完成必要的网络与协议处理,再将请求交给 Lua 业务层;Lua 处理完成后,也可能需要调用下层提供的接口,将响应发送给客户端。

这里需要明确:嵌入在宿主程序内部的 Lua 运行时,与宿主程序通常共享同一个进程地址空间。 因此,它们之间并不存在 RPC 那样的跨进程地址空间隔离。不过,共享地址空间,并不意味着 Lua 虚拟机天然认识 C++ 的所有类型。

2. Lua 使用下层能力,其实涉及两个不同的问题

第一个问题是:Lua 如何调用 C/C++ 的函数?

例如,下层提供了发送消息的能力,我们可以通过 Lua C API 注册一个符合 lua_CFunction 调用约定的函数,让 Lua 业务能够执行:

lua 复制代码
sendMessage(1001, "hello")

这里 Lua 调用的是已经注册好的 C 函数,由它在底层完成真正的操作。这个过程本身 不要求使用 userdata。

第二个问题是:Lua 如何持有并操作 C/C++ 宿主程序中的自定义数据或对象?

例如,C++ 宿主程序中定义了一个玩家对象:

cpp 复制代码
struct Player {
    int id;
    int level;
    int hp;
};

现在希望上层 Lua 业务能够像下面这样操作这个对象:

lua 复制代码
local player = getPlayer()
print(player:getLevel())

这里就不是单纯调用一个无状态接口的问题了,而是 Lua 需要持有一个能够代表底层 Player 对象的值,并在后续的函数调用中持续使用它。

userdata 就是为这类需求提供支持的重要机制。

当然,Lua 也可以通过 Table 保存对象 ID,再把 ID 交给 C++ 查询。因此,userdata 并不是 Lua 与 C++ 交换数据的唯一方式。

3. 可以类比 RPC 的封装思想,但两者解决的问题不同

我们之前学习 RPC 时知道,RPC 框架通过封装请求构造、序列化、网络通信等细节,让远程服务调用看起来接近本地函数调用。

userdata 配合绑定函数与元表,也体现了一种类似的封装思想:上层可以使用自然的对象操作形式,而不必关心底层 C/C++ 数据布局和访问细节。

但不要把两者混为一谈:

  • RPC 主要解决跨进程、跨网络的函数调用。
  • userdata 与绑定代码主要解决Lua 如何持有、识别和操作原生数据。

由此,我们已经理解了 userdata 的应用场景。下面进一步从 Lua 的值表示机制出发,看看它本身究竟是什么。

二、从 Lua 的 TValue 出发认识 userdata

1. Lua 认识的是自身的数据类型,而不是任意 C++ 类型

我们之前已经学习过,在标准 Lua 5.4 的典型实现中,运行时使用 TValue 表示一个 Lua 值。其结构可以简化为:

c 复制代码
/* Lua 5.4 源码的简化形式 */
typedef struct TValue {
    Value value_;  /* 实际值 */
    lu_byte tt_;   /* 类型标记 */
} TValue;

其中:

  • tt_ 用于记录这个值的类型标记。
  • value_ 保存整数、浮点数或者指向某个对象的引用等。

例如,Lua 5.3/5.4 的 number 内部区分整数与浮点数。对于 Table,其 TValue 的实际值部分保存的是一个指向 Table 对象的引用,真正的 Table 对象并不完整存放在这个 TValue 里面。

那么 userdata 呢?

对于 Lua 来说,userdata 同样是一种基本数据类型。 Lua 知道如何识别这种值、保存这种值、传递这种值,以及如何按照规定的元方法和垃圾回收规则处理它。

但是,Lua 不知道 Player 这种 C++ 自定义结构体由哪些成员组成,也不知道 level 的内存偏移量,更不会因为得到了一个 userdata,就自动学会读取 C++ 对象。

这里要区分两个层次:

text 复制代码
Lua 层次:这是一个 userdata 值
C++ 层次:这个 userdata 所关联的数据被约定为 Player 类型

Lua 虚拟机负责认识 userdata;C/C++ 绑定代码负责解释具体原生数据。

2. userdata 为什么还分成 Full 和 Light 两类?

类似于我们之前认识的 number 在较新版本中可以区分整数和浮点数,userdata 在内部也有两种不同的表示方式:

类别 TValue 的实际值 是否额外存在 Udata 对象
Full userdata(完整用户数据) 引用 Lua 管理的 Udata 对象 是
Light userdata(轻量用户数据) 直接保存一个 C 指针值 否

在 Lua 脚本中,对两者调用 type() 都会得到 "userdata";但 Lua 内部知道它们是不同的变体,C API 也可以进一步区分。

我们先学习 Full userdata,因为它能够把 TValue、堆对象、原始数据内存以及元表机制联系起来。

三、Full userdata:TValue 引用的 Udata 对象是什么?

1. TValue 不直接保存整个 Udata 对象

假设 Lua 中有:

lua 复制代码
local player = getPlayer()

在我们的例子中,getPlayer() 返回 Full userdata。于是可以从概念上理解为:

text 复制代码
player 对应的 Lua 值(TValue)
    ├── tt_:Full userdata 类型标记
    └── value_.gc:指向 Udata 对象
                     │
                     ▼
              Lua 堆上的 Udata 对象

因此这里需要明确一点:栈槽或变量保存的是 userdata 类型的 Lua 值,真正的 Udata 对象已经存在于 Lua 运行时管理的动态内存中。

2. Udata 内部包含哪些内容?

Lua 5.4 源码中的 Udata 头部大体包含下面这些内容(为方便理解略去具体宏展开与内存对齐细节):

c 复制代码
/* 源码结构示意,不是直接复制粘贴即可编译的定义 */
typedef struct Udata {
    /* CommonHeader:GC 管理相关字段 */
    unsigned short nuvalue;  /* User Value 数量 */
    size_t len;              /* 原始数据内存的字节数 */
    struct Table *metatable; /* 元表指针 */
    GCObject *gclist;        /* GC 遍历相关信息(特定布局) */
    UValue uv[1];            /* User Value 区域的表示入口 */
    /* 后面关联原始数据内存区域 */
} Udata;

这里 uv[1] 并不意味着只能关联一个 User Value。Lua 5.4 根据创建时指定的数量安排实际内存布局;对于 0 个 User Value 的情况,源码还存在不同的紧凑布局。不能把上面的示意结构体机械理解成所有 userdata 都严格拥有同一段固定大小的字段。

我们当前只需要认识四类关键信息:

  1. GC 管理信息:使 Lua GC 能够追踪并回收 Full userdata 的内存。
  2. 原始数据长度:记录该 userdata 所拥有的原始内存块大小。
  3. 元表指针 :关联一个 Lua Table,用来配置 __index 等元方法。
  4. User Value 与原始数据内存:分别用于关联普通 Lua 值、存放 C/C++ 自定义数据。

3. User Value:为什么原生数据之外还要关联普通 Lua 值?

这里很容易把两个概念混在一起:

  • 原始数据内存 :可以由 C/C++ 按照 Player 等自定义类型解释。
  • User Value:是额外关联的普通 Lua 值,本身也按照 Lua 的值表示机制保存。

例如,C++ 原始数据保存:

cpp 复制代码
Player{1001, 10, 100}

但是我们还希望给它关联一张 Lua Table:

lua 复制代码
{
    nickname = "张飞",
    title = "五虎上将"
}

那么这张 Table 就可以作为 userdata 的 User Value。这样,我们不需要修改 Player 结构体的内存布局,就能关联一些 Lua 层面的附加状态。

而且 User Value 还有一个重要作用:当它引用某个 Lua 对象时,只要 userdata 可达,对应的引用就会被 Lua GC 正确追踪。

到这里,Udata 的内部结构已经比较清楚了。下面回到我们最关心的原始数据内存。

四、Full userdata 的原始数据内存如何创建并存放 C++ 对象?

1. C++ 向 Lua 运行时申请原始数据内存

假设宿主程序定义一个结构体:

cpp 复制代码
struct Player {
    int id;
    int level;
    int hp;
};

现在希望 Full userdata 中直接存放一个 Player 对象。以 Lua 5.4 为例,可以调用:

cpp 复制代码
void* memory = lua_newuserdatauv(L, sizeof(Player), 0);

这里的参数分别是:

  • L:当前操作的 Lua 线程状态。
  • sizeof(Player):原始数据内存的大小。
  • 0:User Value 槽位数量,这里不需要额外关联 Lua 值。

调用完成后,Lua 运行时会创建 Full userdata、分配原始数据内存,并把对应的 userdata 值压入当前 Lua C API 栈中。

与此同时,这个 API 向 C++ 返回的是原始数据内存的起始地址 ,不是整个 Udata 对象头部的地址。

注意:Lua 只知道分配多少字节,并不知道这块内存未来一定用来放 Player。

2. C++ 为什么知道如何写入这片内存?

因为宿主程序自己定义了 Player 结构体,C++ 编译器知道各成员的类型、对齐要求以及内存布局。

cpp 复制代码
#include <new>

void* memory = lua_newuserdatauv(L, sizeof(Player), 0);
Player* p = new (memory) Player{1001, 10, 100};

这里的 new (memory) ... 是定位 new,表示在一块已经分配好的内存中构造对象,而不是再次分配内存。

此时,Full userdata 对应的原始内存中就存在一个有效的 Player 对象。之后 C++ 可以直接使用:

cpp 复制代码
p->level = 20;

编译器会生成符合 Player 类型布局的成员访问操作,通常不需要程序员手动计算 level 的字节偏移量。

这里有一个 C++ 细节不能省略:获得一片原始内存并转换为 Player*,不等于任意 C++ 对象已经构造完成。 对于有构造函数、析构函数的类型,还需要遵循 C++ 对象生命周期规则。

3. Full userdata 不一定保存对象本体

我们现在介绍的是"在 userdata 原始内存中直接构造 Player"的方案。但实际上,也可以只在 Full userdata 里面保存一个 Player* 指针,然后让它指向宿主程序早已存在的 Player 对象。

这就意味着,Full userdata 与 Light userdata 的区别,并不是简单的「一个存对象,一个存指针」。这一点在后面会进一步比较。

五、补齐前置知识:Lua 栈为什么能让 C/C++ 与 Lua 交换数据?

理解了 Full userdata 的分配机制之后,我们还需要解决一个问题:

C++ 已经创建了 userdata,为什么 Lua 业务执行 local player = getPlayer() 就能得到它?

要回答这个问题,必须先认识 Lua C API 使用的虚拟栈。

1. Lua 运行时不是把所有数据都放在 Lua 栈中

Lua 运行时和宿主程序共享一个进程地址空间,但 Lua 会维护自己的执行状态及数据管理机制。

例如:

lua 复制代码
local a = 100
local player = { level = 10 }

从 Lua 5.4 的典型实现理解,局部变量及执行过程中的部分临时值可以保存在 Lua 栈的值槽位中;而 Table、Full userdata 等对象通常由 Lua 内存分配器单独分配,再由 TValue 引用。

text 复制代码
Lua 栈中的槽位                 Lua 管理的对象内存

TValue:a = 100
TValue:player ────────────→ Table 对象 { level = 10 }

所以,Lua 栈是执行与值传递机制的一部分,并不是所有 Lua 对象统一存放的地方。

这里所说的 Lua 栈,也不要直接等同于操作系统给 C++ 线程提供的原生调用栈。Lua 运行时通常会自行分配并维护它需要的执行栈存储。

2. 每个 C 函数对应自己的 C API 栈视图

我们已经知道,Lua 函数调用时会涉及调用栈帧。对于注册的 C 函数,Lua C API 也规定了一套参数和结果的栈操作协议。

例如 Lua 执行:

lua 复制代码
getLevel(player, 100, "hello")

进入 C 函数后,当前 C API 栈视图可以理解为:

text 复制代码
栈顶
┌──────────────────────┐
│ 索引 3:"hello"      │
├──────────────────────┤
│ 索引 2:100          │
├──────────────────────┤
│ 索引 1:player       │
└──────────────────────┘
栈底

因此,1 表示当前 C 函数的第一个实参,而不是整个 Lua 线程执行栈的第一个槽位。-1 表示当前 C API 栈视图的栈顶,-2 表示栈顶下方一个位置。

准确地说,每个被调用的 C 函数获得的是与其他函数调用相隔离的 API 栈视图;其底层依然属于相应 Lua 线程的执行状态,并非每调用一个 C 函数就创建一条全新、独立的 Lua 线程栈。

3. C++ 如何向 Lua 栈压入一个整数?

先不考虑 userdata,我们看最简单的例子:

cpp 复制代码
static int getNumber(lua_State* L) {
    lua_pushinteger(L, 100);
    return 1;
}

其中:

cpp 复制代码
lua_pushinteger(L, 100);

表示将 100 作为一个 Lua 整数值 压入当前 C API 栈帧。对于 Lua 5.3/5.4,它属于 number 的整数子类型;这不意味着 Lua 脚本直接接收了一个 C++ int 对象。

但真正需要理解的是下面这行代码:

cpp 复制代码
return 1;

return 1 返回的是 Lua 结果的数量,而不是把数值 1 作为 Lua 脚本的返回值。

实际要返回的 Lua 数据放在栈顶,C 函数自身的 int 返回值用于说明结果数量。

4. 如果栈顶又压入一个 200,会发生什么?

我们之前讨论过这个特别容易忽略的例子:

cpp 复制代码
static int getNumber(lua_State* L) {
    lua_pushinteger(L, 100);
    lua_pushinteger(L, 200);
    return 1;
}

此时当前 C API 栈视图:

text 复制代码
栈顶
┌─────────────┐
│ 索引 2:200 │  ← return 1 选择这个值
├─────────────┤
│ 索引 1:100 │
└─────────────┘

由于 return 1 表示返回栈顶的 1 个 Lua 值,因此 Lua 执行:

lua 复制代码
local n = getNumber()
print(n)  -- 200

最后拿到的是 200,不是 100。

如果把 C 函数改成 return 2,那么 Lua 就会依照原有顺序得到两个结果:

lua 复制代码
local a, b = getNumber()
-- a = 100, b = 200

这里需要补充一个精确的描述:Lua 运行时并不是简单地机械执行 N 次 pop(),而是把当前 C 函数栈顶的 N 个值安排到调用方的结果位置,并清理本次 C 函数的调用栈帧。其他未被选为结果的栈值不会继续作为该函数的返回值存在。

现在有了这层基础,我们就能理解 userdata 如何通过栈返回给 Lua 了。

六、C++ → Lua:Full userdata 如何交给 Lua 业务代码?

1. 为什么 lua_newuserdatauv() 不需要再次手动压栈?

假设我们注册如下 C 函数:

cpp 复制代码
#include <new>

static int getPlayer(lua_State* L) {
    void* memory = lua_newuserdatauv(L, sizeof(Player), 0);
    new (memory) Player{1001, 10, 100};
    return 1;
}

这里代码非常简单,但值得注意的有两件事情。

第一,lua_newuserdatauv() 不仅返回原始数据内存地址,还会把刚创建的 userdata 值压入 Lua 栈。 所以我们不需要再额外调用一个 push userdata 接口。

第二,C++ 在这片内存中构造完 Player 后,执行 return 1,表示把当前栈顶的一个 userdata 值返回给 Lua。

整个过程可以理解为:

2. C 函数返回时,到底复制了什么?

Lua 业务执行:

lua 复制代码
local player = getPlayer()

这里并不是把 Udata 对象从 Lua 栈搬运到堆中。

真正的 Udata 对象从创建时起,就已经位于 Lua 管理的动态内存中。Lua 栈中只是保存一个 TValue,其实际值部分引用这个对象。

C 函数返回时,从语义上可以理解为:

text 复制代码
C 函数栈帧中的 TValue
    ├── 类型:Full userdata
    └── 引用:Udata 地址
                │
                │ 返回值传递
                ▼
Lua 局部变量 player 对应的 TValue
    ├── 类型:Full userdata
    └── 引用:同一个 Udata 地址

所以,传递的是 TValue 所表示的值及其引用,不是复制整块 Udata 原始内存。

这也说明了:C 函数返回后,虽然其调用栈帧会被清理,但只要 Lua 的 player 变量仍然引用这个 userdata,它就不会因为栈帧清理而立即被释放。

七、Lua → C++:Lua 如何通过 userdata 读取原始数据?

现在我们已经让 Lua 获得了一个 Full userdata:

lua 复制代码
local player = getPlayer()

接下来我们希望读取 Player 的等级:

lua 复制代码
local level = getLevel(player)

注意:此时的 getLevel 仍然是一个单独注册的 C 函数,我们先不使用元表语法。

1. getLevel() 如何取出参数?

假设我们已经为 Player userdata 配置并注册了名为 "PlayerMT" 的元表,那么 C 绑定函数可以写成:

cpp 复制代码
static int getLevel(lua_State* L) {
    Player* p = static_cast<Player*>(
        luaL_checkudata(L, 1, "PlayerMT")
    );

    lua_pushinteger(L, p->level);
    return 1;
}

我们按执行顺序分析。

首先:

cpp 复制代码
luaL_checkudata(L, 1, "PlayerMT")

其中:

  • L:当前 Lua 线程状态。
  • 1:当前 C 函数栈视图中的第一个实参位置 ,也就是传入的 player。
  • "PlayerMT":事先注册的元表名称,用于检查 userdata 是否符合我们约定的类型。

这里返回的不是存放 TValue 的栈槽地址,也不是整个 Udata 对象头部地址 ,而是 Full userdata 原始数据内存的起始地址。

由于这片内存中已经正确构造了一个 Player,所以 C++ 可以将返回的 void* 解释为 Player*。

接着:

cpp 复制代码
lua_pushinteger(L, p->level);

从 Player 对象中读取 level,再将结果作为 Lua 整数值压入当前 C 函数的栈帧。

最后:

cpp 复制代码
return 1;

表示返回栈顶的一个 Lua 值。

如果 level 为 10,那么 Lua 中的:

lua 复制代码
local level = getLevel(player)
print(level)  -- 10

就可以得到正确结果。

2. 这里真正理解 Player 内存布局的是谁?

我们再回到最初的问题。

Lua 虚拟机知道 player 是 userdata,也知道应该怎样把它作为参数传递给 C 函数;但 Lua 仍然不知道它对应的结构体有哪些成员。

真正完成这些工作的,是绑定函数:

text 复制代码
Lua:getLevel(player)
        ↓
C 函数栈帧:索引 1 保存 player userdata
        ↓
luaL_checkudata():检查类型约定,取得原始数据内存地址
        ↓
C++:按照 Player 类型解释地址
        ↓
p->level:取得结构体成员
        ↓
lua_pushinteger():转换成 Lua 值并压栈
        ↓
return 1:将结果交给 Lua

因此,userdata 本身并不理解 C++ 对象的内存布局。userdata 负责承载或表示原生数据;C/C++ 绑定函数负责解释和操作这些数据。

八、元表与 __index:让 userdata 像 Lua 对象一样使用

到目前为止,Lua 访问 C++ 数据的方式是:

lua 复制代码
local level = getLevel(player)

但我们之前学习过 Lua 元表、__index 以及冒号语法。既然这些机制能够用于 Table 面向对象,那么是不是也能用在 userdata 上?

答案是:可以。

1. Full userdata 为什么能够关联元表?

我们之前介绍 Udata 结构时已经看到,它内部有一个指向元表 Table 的指针。

因此 C/C++ 可以为某个 Full userdata 关联一个元表,并在这个元表中放入需要的 C 绑定函数。

例如,我们希望元表具有下面的逻辑结构:

lua 复制代码
-- 这里只是用于展示元表的逻辑结构
local PlayerMT = {}
PlayerMT.__index = PlayerMT
PlayerMT.getLevel = getLevel  -- 代表已绑定的 C 函数

2. player:getLevel() 到底如何找到 C 函数?

现在 Lua 执行:

lua 复制代码
player:getLevel()

我们之前已经认识了冒号语法,它在调用时会把对象自身作为第一个实参。可以按下面的形式理解:

lua 复制代码
player.getLevel(player)

接下来,Lua 需要先取得 player.getLevel 对应的函数值。

对于 Table,Lua 会先查找自身字段,找不到时才考虑 __index;但是对于普通 Full userdata,它没有 Table 那样的普通字段存储结构,因此对它进行这种索引操作时,需要借助元表提供的索引规则。

于是执行过程为:

  1. player 对应的 TValue 引用一个 Udata 对象。
  2. Lua 取得这个 userdata 关联的元表 PlayerMT。
  3. 发现 PlayerMT.__index 指向 PlayerMT 自身。
  4. Lua 根据 __index 的 Table 查找规则,继续查找 PlayerMT.getLevel。
  5. 得到其中注册的 C 函数,并把 player 作为第一个实参传入。
  6. C 绑定函数从当前栈帧的索引 1 中取得 userdata,读取 Player 的 level 并返回。

因此这里其实是两个阶段:

第一阶段:元表 + __index 负责找到函数。

第二阶段:C 绑定函数负责取得原始数据并完成操作。

不要把这两件事情混为一谈。

九、Light userdata:为什么不直接把 C++ 指针交给 Lua?

前面我们认识的 Full userdata,都会创建一个由 Lua GC 管理的 Udata 对象。

但是现在考虑另一种情况:

宿主程序已经创建并维护着一个 Player 对象,我们希望 Lua 能够暂时持有这个对象的地址,并在需要时交给 C 函数使用。

这时,并不一定需要再创建一个独立的 Udata 对象。

1. Light userdata 的本质就是 C 指针值

对于 Full userdata,我们之前建立的结构是:

text 复制代码
TValue → Udata 对象 → 原始数据内存 → Player 本体或 Player* 指针

而对于 Light userdata,则可以直接理解为:

text 复制代码
TValue
  ├── 类型:Light userdata
  └── 实际值:void* 指针 ───→ C++ 对象

Light userdata 不会创建一个独立的 Udata 对象,也不会因此为指针所指的 C++ 数据分配新的原始内存。

2. C++ 如何将指针传递给 Lua?

假设 C++ 已经有:

cpp 复制代码
struct Player {
    int id;
    int level;
};

static Player player{1001, 10};

我们可以注册:

cpp 复制代码
static int getPlayerPointer(lua_State* L) {
    lua_pushlightuserdata(L, &player);
    return 1;
}

其中:

cpp 复制代码
lua_pushlightuserdata(L, &player);

会把 &player 这个指针作为 Light userdata 类型的 Lua 值压入当前 C API 栈帧。

再执行 return 1,Lua 就能得到这个值:

lua 复制代码
local player = getPlayerPointer()
print(type(player)) -- userdata

注意:这里没有另外创建 Udata,只是把已有的 C 指针作为 Lua 值保存和传递。

3. Lua 如何通过 Light userdata 访问对象?

它和 Full userdata 的思想仍然类似:通过注册的 C 函数取得参数,再由 C++ 操作对象。

cpp 复制代码
static int getLightLevel(lua_State* L) {
    if (!lua_islightuserdata(L, 1)) {
        return luaL_error(L, "expected light userdata");
    }

    Player* p = static_cast<Player*>(lua_touserdata(L, 1));
    if (p == nullptr) {
        return luaL_error(L, "null player pointer");
    }

    lua_pushinteger(L, p->level);
    return 1;
}

假设这个函数已经注册到 Lua 的全局环境,那么 Lua 可以执行:

lua 复制代码
local player = getPlayerPointer()
local level = getLightLevel(player)
print(level) -- 10

这里需要特别注意:lua_islightuserdata() 只能确认 Lua 值的种类,并不能证明这个指针确实指向一个仍然有效的 Player 对象。上面代码仅用于解释机制;真正的工程代码必须自行保证类型正确和对象生命周期有效。

4. Light userdata 能否使用元表?

我们之前已经知道,Full userdata 的 Udata 对象中有独立的元表指针,因此不同的 Full userdata 可以关联不同元表。

而 Light userdata 只是一个指针值,本身没有独立的 Udata 对象,自然也没有"每个实例一个元表指针"的结构。

因此,很多场景下会选择直接把 Light userdata 作为参数传给注册的 C 函数,而不是为每个外部对象单独绑定一套方法。

十、Full userdata 与 Light userdata 究竟应该如何选择?

我们把两种方式放在一起比较:

对比维度 Full userdata Light userdata
TValue 中保存什么 指向 Udata 的引用 直接保存 void* 指针值
是否创建 Udata 对象 是 否
是否拥有 Lua 分配的专用原始数据内存 是 否
能否在原始内存中存放 C++ 数据 可以 不适用,本身只是指针
能否保存已有 C++ 对象的地址 可以(存放 Player*) 可以(指针值本身)
元表机制 每个对象可关联独立元表 不支持逐实例独立元表
User Value 支持 不支持
垃圾回收 Lua GC 管理 Udata 内存 指针值本身不创建可回收对象
外部 C++ 对象的生命周期 仍需明确归属 由宿主程序等外部机制管理

这里还有一个很重要的细节,我们在前面反复强调过:Full userdata 也可以只保存指针。

例如:

cpp 复制代码
// 假设已有一个生命周期受控的外部 Player 对象
static Player externalPlayer{1001, 10, 100};

void* memory = lua_newuserdatauv(L, sizeof(Player*), 0);
Player** slot = new (memory) Player*(&externalPlayer);

这样存储的是一个 Player*,而不是整个 Player 对象。

如果之后通过 luaL_checkudata() 取得这个 Full userdata 的原始内存地址,读出的首先是保存指针的那个槽位 ,所以需要按 Player** 来解释,再取出里面的 Player*。这里不能直接把这个槽位当成 Player 本体。

因此,二者的真正差异不是"是否存指针",而是:

Full userdata 有独立的 Lua 管理对象、元表及原始内存;Light userdata 只是一个直接承载 C 指针的 Lua 值。

如果希望为原生对象建立稳定的 Lua 侧身份、配置独立元表、附加 User Value、配合 GC 处理资源,Full userdata 通常更方便;如果只是传递受外部管理、生命周期明确的指针,Light userdata 更直接。

十二、容易混淆的几个问题

1. Lua 变量并不是"自己拥有一种固定类型"

更准确地说:Lua 是动态类型语言,类型属于值,而不属于变量名本身。

我们使用"变量对应的 TValue"来说明运行时的具体值表示,而不是说一个 Lua 变量从创建开始就永久固定为 userdata 类型。

2. userdata 不能自己解释 C++ 内存

TValue 识别 userdata,Udata 保存管理信息与原始数据内存,元表让 Lua 找到绑定函数;真正解释 Player 成员布局的依然是 C++ 代码。

因此不要把:

text 复制代码
Lua 能够保存 userdata

误认为:

text 复制代码
Lua 自动理解 userdata 里面的 C++ 结构体

3. return N 的返回值传递机制

我们知道,Lua 栈中的槽位保存的是 TValue。当注册的 C 函数执行 return N 时,意味着这个函数将当前栈顶的 N 个 Lua 值作为返回结果。

从概念上来说,我们可以将其理解为取出栈顶的 N 个 TValue,并将它们所表示的值复制到调用方对应的位置,随后清理当前 C 函数的栈帧。

这里需要注意,复制的是 TValue 表示的 Lua 值。对于 Full userdata,复制的是类型标记和 Udata 对象引用,而不是重新复制堆上的整个 Udata 对象。

4. 注册 C 函数和创建 userdata 是两回事

注册 C 函数只是让 Lua 能调用某个接口。userdata 则是在需要时由相关 C API 创建或压入的 Lua 值。

Lua 调用 C 函数本身,并不必然涉及 userdata;Lua 使用宿主程序数据,也不一定非得通过 userdata。


总结:把 userdata 放回整个 Lua/C++ 交互体系中

经过以上分析,我们可以把 Full userdata 的核心链路整理为:

text 复制代码
C++ 宿主程序定义 Player 类型
        │
        ▼
注册 getPlayer()、getLevel() 等 C 函数
        │
        ▼
Lua 调用 getPlayer()
        │
        ▼
C 函数调用 lua_newuserdatauv()
        │
        ├── Lua 创建 Udata 对象及原始数据内存
        ├── Lua 栈压入引用 Udata 的 TValue
        └── C++ 得到原始数据内存地址
                    │
                    ▼
            C++ 构造 Player 对象
                    │
                    ▼
                 return 1
                    │
                    ▼
Lua 局部变量获得 userdata 值
                    │
                    ▼
Lua 执行 player:getLevel()
                    │
                    ▼
Udata 元表 __index 找到 C 绑定函数
                    │
                    ▼
绑定函数从参数 1 取得 userdata
                    │
                    ▼
luaL_checkudata() 获取原始内存地址
                    │
                    ▼
C++ 解释 Player 内存布局并读取 level
                    │
                    ▼
将结果压入 Lua 栈 + return 1
                    │
                    ▼
Lua 业务取得 number 值

而 Light userdata 则省去了 Lua 创建 Udata 对象的过程,直接将 C 指针作为一种 Lua 值进行传递。它的优势是简单、直接,但具体对象的类型及生命周期需要外部代码自行保证。

因此,我们最终可以把 userdata 的机制归纳为三个层次:

  1. 值表示与对象承载 :TValue 让 Lua 识别 userdata;Full userdata 的 Udata 管理原始内存,Light userdata 直接保存指针。
  2. Lua/C++ 数据传递 :Lua C API 虚拟栈用于传递参数和结果,C 函数通过 return N 指明返回值数量。
  3. 操作方式与数据解释 :元表与 __index 将 Lua 方法查找关联到 C 函数,绑定函数按照 C/C++ 类型解释并操作底层数据。

userdata 并不是让 Lua 虚拟机学会理解任意 C++ 类型,而是让 Lua 能够持有代表这些原生数据的值,并通过约定好的 C/C++ 绑定接口操作它们。

到这里,我们就从应用场景出发,把 userdata 的存在意义、底层表示、创建、传递、访问以及两种实现方式完整联系起来了。


相关推荐
慧都产品智能化35 分钟前
Qt 表格升级攻略:复用已有编辑器,减少重复开发
c++·qt·编辑器·qt6.3
ShineWinsu1 小时前
对于Qt:QTableWidget常见属性以及QTableWidgetItem类的解析
开发语言·c++·qt·gui·客户端·表格·qtablewidget
ShineWinsu2 小时前
对于Qt:QListWidget常见属性以及QListWidgetItem类的解析
开发语言·c++·qt·客户端·列表·qlistwidget·qlistwidgetitem
坤坤子吖2 小时前
Linux网络编程:自定义应用层协议与 JSON 序列化
linux·服务器·网络·c++·笔记·json
阿卡好可爱3 小时前
Lua 热更新不生效:改完文件线上仍是旧逻辑,package.loaded 与 upvalue 重绑定详解
数据结构·分布式·后端·架构·lua
沫璃染墨4 小时前
从零入门计算机网络系列(十八):TCP连接管理详解——从三次握手与四次挥手到TIME_WAIT机制
运维·服务器·网络·c++·网络协议·tcp/ip·tcp
Joy T4 小时前
Redis Lua 原子限流:Lua 脚本、竞态条件与并发控制
数据库·redis·lua·限流·竞态条件·redis scripting
自信150413057595 小时前
重生之从0开始学习c++之——vector-中篇
c++·学习·算法
ShineWinsu8 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial