Lua 中使用 C 语言的用户自定义类型——userdata

零、前言

经过之前的文章分享,我们已经知道如何通过扩展函数来扩展 Lua 。但这里涉及到一个问题,Lua 脚本中如何使用 C/C++ 中的类型,如何像操作对象一样操作 C/C++ 类型的实例。解决这一问题就需要用到 userdata 类型(用户数据类型)。

userdata 类型,分为两种:

  1. 完全用户数据(full userdata)
  2. 轻量级用户数据(light userdata)

一、full userdata(完全用户数据)

full userdata 为 Lua 语言提供了可以用来存储任何数据的原始内存区域,没有预定义的操作。 使用完全用户数据的 C-API 为:lua_newuserdata

使用 full userdata 创建的内存不需要进行释放,Lua 会进行管理和释放。 而如果 full userdata 关联的对象需要释放,可以通过给 full userdata 设置元表,元表中设置 __gc 进行监听释放,调用关联的对象的释放方法进行释放,下面的第五小节会进行展示这一过程。

1、lua_newuserdata

cpp 复制代码
#define lua_newuserdata(L,s)	lua_newuserdatauv(L,s,1)

描述:

分配一块指定大小的内存,然后将该内存以 userdata 的类型进行压栈,并返回该内存的指针。

参数:

  • 参数 L: Lua 状态的指针。
  • 参数 size: 要分配的 userdata 对象的字节大小。

返回值:

返回指向该内存的指针,可以通过这个指针进行对象的操作。

2、举个例子

假设我们需要向 Lua 暴露一个 C++ 的类型:User ,在 Lua 脚本中可以创建该类型,并且调用该类型的方法。

第一步,定义一个 User 类。

cpp 复制代码
class User {
private:
    std::string name;
    long long age;
public:
    std::string introduce() {
        std::stringstream info;
        info << "大家好。我叫" << name << "。今年" << age << "岁。请关注我。";
        return info.str();
    }

    void setName(std::string name) {
        this->name = std::move(name);
    }

    void setAge(long long age) {
        this->age = age;
    }

    std::string getName() {
        return this->name;
    }

    long long getAge() {
        return this->age;
    }
};

第二步,定义对 User 操作相关的方法,并将这些方法用作库方法,向 Lua 暴露一个 user 库,可以通过该库创建 User 类型的 full userdata ,并操作背后真正 C++ 实例。

cpp 复制代码
static int newUser(lua_State *L) {
    std::string name = luaL_checkstring(L, 1);
    long long age = luaL_checkinteger(L, 2);

    // 生成一个 User Data 并压入栈中
    auto *user = (User *) lua_newuserdata(L, sizeof(User));
    user->setName(std::string(name));
    user->setAge(age);

    return 1;
}

static int introduce(lua_State *L) {
    User *user = (User *) lua_touserdata(L, 1);
    lua_pushstring(L, user->introduce().c_str());
    return 1;
}

static int setName(lua_State *L) {
    User *user = (User *) lua_touserdata(L, 1);
    std::string name = luaL_checkstring(L, 2);
    user->setName(std::string(name));
    return 0;
}

static int setAge(lua_State *L) {
    User *user = (User *) lua_touserdata(L, 1);
    long long age = luaL_checkinteger(L, 2);
    user->setAge(age);
    return 0;
}

static int getName(lua_State *L) {
    User *user = (User *) lua_touserdata(L, 1);
    lua_pushstring(L, user->getName().c_str());
    return 1;
}

static int getAge(lua_State *L) {
    User *user = (User *) lua_touserdata(L, 1);
    lua_pushinteger(L, user->getAge());
    return 1;
}

static const struct luaL_Reg userlib[] = {
        {"new",       newUser},
        {"introduce", introduce},
        {"setName",   setName},
        {"setAge",    setAge},
        {"getName",   getName},
        {"getAge",    getAge},
        {nullptr,     nullptr}
};

int luaopen_user(lua_State *L) {
    luaL_newlib(L, userlib);
    return 1;
}

这里准备了六个方法,下一步会将这六个方法作为 user 模块的方法。

  • newUser 方法:该方法会接收 Lua 传递过来的两个参数:姓名和年龄,然后使用 lua_newuserdata 进行创建一个 full userdata ,将指针转为 User 类型的指针后进行赋值操作,最后返回给 Lua , Lua 便会获取到一个 userdata 类型的数据。
  • introduce 方法:会通过 lua_touserdata 获取并检查 Lua 传递的第一个参数是否为 userdata ,如果是则将其转为 User 类型指针,并调用 User 的 introduce 方法,并将 introduce 的返回值入栈返回给 Lua 调用点。
  • setName 方法和 setAge 方法:和 introduce 一样,会检测并获取到 User 指针,然后分别调用 setName 和 setAge 方法进行设置值。
  • getName 方法和 getAge 方法:和 introduce 一样,会检测并获取到 User 指针,然后分别调用 getName 和 getAge 方法进行获取值,并将其返回到 Lua 调用点。

可以通过 newUser 的方法,得知 lua_newuserdata 方法只是为我们在 Lua 中开辟了我们所需要尺寸的内存块,至于这块内存具体是什么类型的,则由后续的 C 代码进行操作了。

第三步,将库加载到 Lua 中,并调用 Lua 脚本。

cpp 复制代码
lua_State *L = luaL_newstate();
luaL_openlibs(L);

luaopen_user(L);
lua_setglobal(L, "user");

std::string fileName = PROJECT_PATH + "/10、userdata/user/1普通版本/user.lua";
if (luaL_loadfile(L, fileName.c_str()) || lua_pcall(L, 0, 0, 0)) {
    printf("can't run config. file: %s\n", lua_tostring(L, -1));
}

lua_close(L);

第四步,编写 Lua 脚本,脚本中通过 user 库,就可以调用到第二步的方法,通过 newUser 进行创建一个 User 类型的 full userdata,然后通过暴露的方法对其进行操作。

lua 复制代码
local myUser = user.new("江澎涌", 29)
print("type(user) =>>", type(user))
print("type(myUser) =>>", type(myUser))

print(user.introduce(myUser));

user.setName(myUser, "jiang peng yong");
user.setAge(myUser, 28);

print(user.introduce(myUser));

print("姓名 -->> ", user.getName(myUser));
print("年龄 -->> ", user.getAge(myUser));

运行后输出的结果

lua 复制代码
type(user) =>>	table
type(myUser) =>>	userdata
大家好。我叫江澎涌。今年29岁。请关注我。
大家好。我叫jiang peng yong。今年28岁。请关注我。
姓名 -->> 	jiang peng yong
年龄 -->> 	28

3、lua_touserdata

cpp 复制代码
void *(lua_touserdata) (lua_State *L, int idx);

描述:

用于从 Lua 栈上获取一个指向用户数据(userdata)的指针。

参数:

  • 参数 L: 指向 Lua state 的指针。
  • 参数 idx: 要获取的 userdata 在栈上的索引值。

返回值:

函数返回一个 void* 类型的指针,指向用户数据的实际内存地址。如果给定索引的 value 不是 userdata 类型,或者索引无效,则函数返回 NULL

4、如何在 C/C++ 保证 full userdata 参数的正确性

4-1、代码的漏洞

从第二小点你会感受到,这段代码有两处漏洞:

  1. lua_touserdata 没有做 NULL 判断,直接将其转为 User* ,如果使用者传递的参数有问题时,会导致程序直接奔溃,没有收到任何的错误提示,花费大量时间排查。
  2. full userdata 是一个 void* 类型,对于 Lua 来说并不知道指针真正指向的类型数据,所以他可以传递任何 full userdata 给 C/C++ 函数库,但函数库中的函数并不知道此时的 full userdata 具体类型是有问题的,此时 full userdata 也不是 NULL 。进行转换为 User* 进行使用,这时便会出现问题,可能此时指向内存块正好可以获取到我们想要的数据类型但数据是错误的,可能是直接奔溃。调用者此时又需要大量的时间排查,为什么出现这种偶发的奔溃和夹杂着数据错误的问题。

这些问题的主要问题点在于如何区别不同类型的 full userdata (NULL 也就不算 full userdata 了)。

常用的解决方法是为每种类型创建唯一的元表。 每次创建 full userdata 时,用相应的元表进行标记,然后每当获取 full userdata 时,检查其是否具有正确的元表。

由于 Lua 脚本代码中不能修改 full userdata 的元表,因此这不能绕过这检查。

4-2、如何存储元表

在每次创建新的 full userdata 的时候,都需要带上同一个元表,这样后续才能进行检查,所以这个元表的存储需要借助 "注册表" 或 "上值" 。

在 Lua 语言中,惯例是将所有新的 C 语言类型注册到注册表中,用类型名作为索引,以元表作为值。 这里需要谨慎的选择类型名,以避免冲突。

4-3、如何在 C 语言中使用元表

主要使用三个 C-API:

  1. luaL_newmetatable
  2. luaL_getmetatable
  3. lua_setmetatable
  4. luaL_checkudata

(1)luaL_newmetatable

cpp 复制代码
int   (luaL_newmetatable) (lua_State *L, const char *tname);

描述:

会创建一张新表(后续用作元表),然后将其压入栈顶,并将该表与 tname 以 key-value 的形式存入注册表中。

参数:

  • 参数 L: 指向 Lua 状态机(Lua state)的指针。
  • 参数 tname: 用于标识元表的字符串,这一字符串作为 key 和创建的元表关联存入注册表。

返回值:

该函数返回一个整数值,表示是否成功创建元表。

  • 如果元表已经存在,函数返回 0 。
  • 如果成功创建了一个新的元表,函数返回非 0 值。

(2)luaL_getmetatable

cpp 复制代码
#define luaL_getmetatable(L, tname)	(lua_getfield(L, LUA_REGISTRYINDEX, (tname)))

描述:

从注册表中获取与 tname 关联的元表,并将元表压入栈顶。

参数:

  • 参数 L: Lua 状态机(Lua state)指针。
  • 参数 tname: 要获取的元表名称。

返回值:

没有返回值。但运行了该函数后会将 tname 对应的元表压入栈顶,如果 tname 没有对应的元表,则会导致压入 nil 。

(3)lua_setmetatable

cpp 复制代码
int   (lua_setmetatable) (lua_State *L, int objindex);

描述:

用于为栈中索引 objindex 的 Lua 值设置元表。

参数:

  • 参数 L: Lua 状态机(Lua state)指针。
  • 参数 index: 需要设置元表的 Lua 变量,在栈中的索引值。

(4)luaL_checkudata

cpp 复制代码
void *(luaL_checkudata) (lua_State *L, int ud, const char *tname);

描述:

检查栈中指定位置 ud 上的对象是否与指定名称 tname 的元表相匹配。

参数:

  • 参数 L: Lua 状态机(Lua state)指针。
  • 参数 ud: 需要检查的 full userdata 的索引位置。
  • 参数 tname: 需要与 full userdata 比较的元表在注册表中存储的 key 名称。

返回值:

如果 ud 索引位置的元素不是用户数据,或该用户数据没有正确的元表,则 luaL_checkudata 会抛出异常至 Lua 脚本中。否则,luaL_checkudata 会返回这个 full userdata 的地址。

4-4、举个例子

将上面 User 的例子改造一下。

第一步,在库加载的函数中增加元表的创建。

cpp 复制代码
static const std::string META = "Jiang.user";

int luaopen_userForMetatable(lua_State *L) {
    // 创建元表,存储在注册表中
    luaL_newmetatable(L, META.c_str());
    luaL_newlib(L, userlib);
    return 1;
}

第二步,在创建每个 full userdata 实例时,加上元表。

cpp 复制代码
static int newUserForMetatable(lua_State *L) {
    std::string name = luaL_checkstring(L, 1);
    long long age = luaL_checkinteger(L, 2);

    // 生成一个 User Data 并压入栈中
    auto *user = (User *) lua_newuserdata(L, sizeof(User));
    user->setName(std::string(name));
    user->setAge(age);

    // 将 META.c_str() 的对应表入栈,然后关联到 -2 的表做元表
    luaL_getmetatable(L, META.c_str());
    lua_setmetatable(L, -2);

    return 1;
}

第三步,定义一个检测的元表的宏,然后替换每一处需要获取 full userdata 的代码。

cpp 复制代码
// 定义检测元表宏
#define checkUser(L) (User *)luaL_checkudata(L, 1, META.c_str())

static int introduceForMetatable(lua_State *L) {
    // 替换获取 full userdata 的方法,进行检查是否合法的对象
    User *user = checkUser(L);
    lua_pushstring(L, user->introduce().c_str());
    return 1;
}

static int setNameForMetatable(lua_State *L) {
    // 替换获取 full userdata 的方法,进行检查是否合法的对象
    User *user = checkUser(L);
    std::string name = luaL_checkstring(L, 2);
    user->setName(std::string(name));
    return 0;
}

static int setAgeForMetatable(lua_State *L) {
    // 替换获取 full userdata 的方法,进行检查是否合法的对象
    User *user = checkUser(L);
    long long age = luaL_checkinteger(L, 2);
    user->setAge(age);
    return 0;
}

static int getNameForMetatable(lua_State *L) {
    // 替换获取 full userdata 的方法,进行检查是否合法的对象
    User *user = checkUser(L);
    lua_pushstring(L, user->getName().c_str());
    return 1;
}

static int getAgeForMetatable(lua_State *L) {
    // 替换获取 full userdata 的方法,进行检查是否合法的对象
    User *user = checkUser(L);
    lua_pushinteger(L, user->getAge());
    return 1;
}

编写 Lua 脚本进行运行,检测是否有检查 full userdata 的合法性。

lua 复制代码
local myUser = user.new("江澎涌", 29)

print(pcall(function()
    print(user.introduce(io.stdio))
end))

print(user.introduce(myUser))

会有以下输出

bash 复制代码
false	...CPP_2022/10、userdata/user/2增加元表检查/user.lua:10: bad argument #1 to 'introduce' (Jiang.user expected, got nil)
大家好。我叫江澎涌。今年29岁。请关注我。

5、如何对 userdata 进行对象化操作

在编写 Lua 脚本的时候,会发现 user 的调用并不是对象的调用方式。从之前 《Lua 面向对象》 文章中得知,可以通过元表来实现这一目标。

Lua 会在找不到指定键时调用元表的 __index 方法;而 full userdata 中并没有键,所以 Lua 在每次访问都会调用元表。 基于这一点,可以给 full userdata 设置元表(在上一小节中已经实现),然后将需要调用的方法设置到该元表中,最后将元表的 __index 方法设置成自身,这样就可以在找不到方法的时候就从元表中进行查询方法。

5-1、举个例子

继续改造 User 的例子,主要的改造点在于加载库的方法。

luaopen_userForObj 方法中,分别执行以下两点:

  1. 拷贝一份元表,然后将其设置到 key 为 __index 中,这样达到上述的 full userdata 查找不到方法时,可以在元表中查找。
  2. 将 userlib_function 方法列表设置到元表中。
cpp 复制代码
static const struct luaL_Reg userlib[] = {
        {"new",   newUserForObj},
        {nullptr, nullptr}
};

static const struct luaL_Reg userlib_function[] = {
        {"introduce", introduceForObj},
        {"setName",   setNameForObj},
        {"setAge",    setAgeForObj},
        {"getName",   getNameForObj},
        {"getAge",    getAgeForObj},
        {"__gc",      release},
        {nullptr,     nullptr}
};

int luaopen_userForObj(lua_State *L) {
    luaL_newmetatable(L, META.c_str());
    // 复制元表
    lua_pushvalue(L, -1);
    // metatable.__index = metatable
    lua_setfield(L, -2, "__index");
    // 组册元方法
    luaL_setfuncs(L, userlib_function, 0);
    luaL_newlib(L, userlib);
    return 1;
}

操作完成之后,就可以进行面向对象的编程了。

lua 复制代码
local myUser = user.new("江澎涌", 29)

print(myUser:introduce())
myUser:setName("jiang")
print("姓名:", myUser:getName())
myUser:setAge(28)
print("年龄:", myUser:getAge())

myUser = nil;

collectgarbage();

最后输出

sql 复制代码
大家好。我叫江澎涌。今年29岁。请关注我。
姓名:	jiang
年龄:	28
释放 User

值得注意

User 的析构函数是不会被 Lua 自动调用的,如果需要在 full userdata 被释放时释放 User 的相关资源,则需要像上面改造点那样,设置 __gc 元方法,方法的实现则执行 User 的释放逻辑。

二、light userdata

light userdata 数据类型是一个代表 C 语言指针的值,即是一个 void * 值。

light userdata 数据类型对于 Lua 是一个值并不是一个对象,所以 Lua 只需要通过 lua_pushlightuserdata 将指针压入栈中即可。

1、light userdata 和 full userdata 的区别

light userdata 不是缓冲区,而只是一个指针,并且没有元表。light userdata 不受 Lua 垃圾收集器管理,需要用户自行管理。

  • 因为 light userdata 没有元表,所以在 Lua 使用中无法像前面对 full userdata 一样进行类型判断。
  • full userdata 相较于 light userdata 开销并不会很大。对于给定内存大小,full datauser 与 malloc 相比只是增加了一点开销。

2、light userdata 的用途

light userdata 的真正用途是相等性判断。

  • full userdata 是一个对象,只与自身相等。
  • light userdata 是一个 C 语言指针的值,因此它与所有表示相同指针的 light userdata 数据相等。

可以使用 light userdata 数据在 Lua 语言中查找 C 语言对象。

例如作为 "注册表" 的 key:

在之前 《C 函数中如何保存 Lua 的数据》 的文章中,已经分享了 light userdata 的一种用法,将其他用作 "注册表" 的键,利用了同一 light userdata 每次压入 Lua 栈中都是同样的值,Lua 从"注册表"中获取到的 value 都相同元素。

3、举个例子

在之前 《C 函数中如何保存 Lua 的数据》 的文章中,已经有使用过,就不再进行过多的展示使用流程。

这个例子中通过传入和取出查看 light userdata 在 Lua 中的表现。

第一步:定义一个 User 类。

cpp 复制代码
class User {
private:
    std::string name;
    long long age{};
public:
    User() {
        printf("User 构造。\n");
    }

    ~User() {
        printf("User 析构。\n");
    }

    std::string introduce() {
        std::stringstream info;
        info << "大家好。我叫" << name << "。今年" << age << "岁。请关注我。";
        return info.str();
    }

    void setName(std::string name) {
        this->name = std::move(name);
    }

    void setAge(long long age) {
        this->age = age;
    }

    std::string getName() {
        return this->name;
    }

    long long getAge() {
        return this->age;
    }
};

第二步,创建 User 类,并将他通过 lua_pushlightuserdata 进行压入栈中,打印栈中元素和类型,最后取出元素进行强制转换为 User 指针类型(因为没有元表,所以没有办法可以判断真实类型),然后进行销毁释放。

cpp 复制代码
lua_State *L = luaL_newstate();
luaL_openlibs(L);

User *user = new User();
lua_pushlightuserdata(L, user);

stackDump(L);

User *user1 = (User *) lua_touserdata(L, 1);
printf("%s\n", user1->introduce().c_str());

delete user;
user = nullptr;

lua_close(L);

最后输出

cpp 复制代码
User 构造。
栈顶
^ typename: userdata, value: userdata    
栈底

大家好。我叫。今年0岁。请关注我。
User 析构。

三、写在最后

Lua 项目地址:Github传送门 (如果对你有所帮助或喜欢的话,赏个star吧,码字不易,请多多支持)

如果觉得本篇博文对你有所启发或是解决了困惑,点个赞或关注我呀

公众号搜索 "江澎涌",更多优质文章会第一时间分享与你。

相关推荐
2401_858286112 小时前
52.【C语言】 字符函数和字符串函数(strcat函数)
c语言·开发语言
jiao000014 小时前
数据结构——队列
c语言·数据结构·算法
铁匠匠匠4 小时前
从零开始学数据结构系列之第六章《排序简介》
c语言·数据结构·经验分享·笔记·学习·开源·课程设计
C-SDN花园GGbond4 小时前
【探索数据结构与算法】插入排序:原理、实现与分析(图文详解)
c语言·开发语言·数据结构·排序算法
迷迭所归处5 小时前
C++ —— 关于vector
开发语言·c++·算法
CV工程师小林5 小时前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
Navigator_Z6 小时前
数据结构C //线性表(链表)ADT结构及相关函数
c语言·数据结构·算法·链表
white__ice6 小时前
2024.9.19
c++
天玑y6 小时前
算法设计与分析(背包问题
c++·经验分享·笔记·学习·算法·leetcode·蓝桥杯
姜太公钓鲸2336 小时前
c++ static(详解)
开发语言·c++