把C++绑定到Lua脚本的方法很多。但是在C++11之前,都没有太好的办法。比如tolua++,这个工具需要手动编写额外的pkg文件,每次需要手动执行命令生成额外的C++文件,使用比较繁琐,所以逐渐没落了。

而我自己用的是一个自己实现的绑定库,只是这个绑定库比较简单,只能绑定int (*lua_CFunction) (lua_State *L)这种特定格式的函数,和lua的pushcfunction比,也就是多了能绑定类成员函数的功能。所以复杂一点的函数,都需要写一个专门的函数包装一下,太麻烦。

虽然我经常在以C++为底层,Lua为脚本的框架上写代码,但其实很少需要把C++接口绑定到Lua。原因是框架已经很成熟,逻辑大部分都在Lua实现,该有的接口都有了,需要绑定新接口的情况少之又少,所以也就一直这么将就用着。自从前几年项目把C++的标准提高到C++11以后,可以支持parameter pack了,对一些利用parameter pack来实现的Lua绑定库实属眼馋,终于决定要更换自己的绑定库了。

开源的绑定库是很多的,但看了一圈后,发现这些库的实现太复杂。比如sol2,实在是太过复杂了一点,出现问题自己很难调试,里面的很多功能自己也用不到。看了下他们的实现,基本都是利用parameter pack把参数展开,感觉并不复杂,于是动了自己实现一个库的念头。

实现这个库的初始版本并不困难,但修修补补持续了好长时间,现在有时间就整理一下遇到的问题。

C++与Lua的基础交互机制

Lua本身就提供一套完善的C与Lua交互机制,任何一个格式为int (*lua_CFunction) (lua_State *L)的函数都可以利用lua_pushcfunction注册到Lua脚本,从而实现在Lua调用。C++与Lua的交互也是基于这个机制,但它首先要两个问题,一个是C++是有对象的,所以需要能把成员函数函数注册到Lua。比如

class Test
{
public:
    int test(int a, double b, const char *c);
};

这显然是一个成员函数,调用方式为this call,如何把它转换为C方式的int (*lua_CFunction) (lua_State *L)函数呢?众所周知,在Lua中是可以obj:func(a,b,c)这样写的,表示调用obj的func函数,它其实是一个语法糖,等同于obj.func(obj,a,b,c),但是恰好这个机制与C++中的thiscall是非常类似的,obj相当于this指针,它在Lua栈的第一个位置,其他是参数即可。这样问题就解决了。

另一个问题是我们希望注册到Lua的函数并不都是这个格式的,比如没有返回值 ,参数并不是lua_State*等等。比如

int test(int a, double b, const char *c);

怎么自动把参数、返回值都不匹配的函数转换为一个特定格式为int (*lua_CFunction) (lua_State *L)的函数

对于问题1,在C++11之前通常只能手写绑定函数或者用工具自动生成,顶多也只是用一些宏来辅助一下,没法做到参数的自动推导,非常繁琐。而parameter pack允许可变参数作为模板参数,奠定了“自动推导”这个基础。以上面的test函数为例,手写时绑定函数时,是这样的:

int test_binding(lua_State *L)
{
    // 取参数
    int a = lua_tonumber(L, 1);
    // ... 其他参数

    // 返回参数
    lua_pushnumber(L, v);
    return 1;
}

可见,没法自动推导的难点在于函数的参数数量、类型,还有返回值的类型都是不一样的,才没法做到自动。而现在利用parameter pack,可以把参数和返回值取出来

template <typename C, typename Ret, typename... Args> // 返回值、参数都在这里了
class ClassRegister<Ret (C::*)(Args...)>
{
};

ClassRegister<test> cr; // 模板参数传入test函数,将会自动推导出返回值Ret和各个参数的类型、数量。

既然能把参数和返回值取出来,那么意味着整个过程就可以做到自动了,那具体是怎么做到的呢?

首先是返回值Ret,这个比较容易理解。在C++中,返回值只有void和其他类型这两种,所以只需要区分void和其他就行。

        static int caller(lua_State *L, const std::index_sequence<I...> &)
        {
            if constexpr (std::is_void_v<Ret>)
            {
                ((*ptr)->*fp)(lua_to_cpp<remove_cvref<Args>>(L, 2 + I)...);
                return 0;
            }
            else
            {
                cpp_to_lua(L, ((*ptr)->*fp)(
                                  lua_to_cpp<remove_cvref<Args>>(L, 2 + I)...));
                return 1;
            }
        }

其余是参数,参数需要数量和类型才能进一步处理。C++11提供了typename... Args这种写法,当然也提供了遍历它的方式。在这里,由于要从Lua的栈上取值,需要构建一个栈索引,所以用make_index_sequence比较合适。

    template <typename C, typename Ret, typename... Args>
    class ClassRegister<Ret (C::*)(Args...)>
    {
    private:
        static constexpr auto indices =
            std::make_index_sequence<sizeof...(Args)>{};

        template <auto fp, size_t... I>
        static int caller(lua_State *L, const std::index_sequence<I...> &)
        {
            T **ptr = (T **)luaL_checkudata(L, 1, class_name_);

            if constexpr (std::is_void_v<Ret>)
            {
                ((*ptr)->*fp)(lua_to_cpp<remove_cvref<Args>>(L, 2 + I)...);
                return 0;
            }
            else
            {
                cpp_to_lua(L, ((*ptr)->*fp)(
                                  lua_to_cpp<remove_cvref<Args>>(L, 2 + I)...));
                return 1;
            }
        }
};

从上面的代码可以看到,当注册test函数时,用ClassRegister<test> cr来实例化一个ClassRegister,这样test函数的返回值和参数都在ClassRegister的模板参数中了,同时用make_index_sequence根据参数个数生成一个0 1 2 3这样的序列I。然后取参数时,用lua_to_cpp(Args(L, 2 + I) ...)依次从Lua的栈上取值。lua_to_cpp(Args(L, 2 + I) ...)的意思是把参数Args一个个展开,然后以(L, 2 + I)作为参数去调用模板函数lua_to_cpp,代入test函数的参数,就依次调用

template <> inline int lua_to_cpp<int>(L, 2 + 0);
template <> inline double lua_to_cpp<double>(L, 2 + 1);
template <> inline const char *lua_to_cpp<const char *>(L, 2 + 2);

只要我们实现了各个类型的lua_to_cpp函数,那Lua栈的的参数就会被一个个取出来。现在有了this指针,有了函数指针,有了参数,就可以正确调用C++的函数了。

到了这里,一个基础的C++绑定Lua的机制也就完成了。但原理归原理,实际还会遇到许多问题。

lua_CFunction、C函数、成员函数、lua_CFunction成员函数

绑定不同类型参数的函数是实现了,可有时候,我们也希望不要自动推导参数而是手动一个个从Lua栈上取出参数。比如我们要写一个sha1函数,支持传多个字符串,自动把它们拼接起来计算出sha1值。

local val = sha1("abc", "def")
local val2 = sha1("abcdef")

assert(val == val2)

这样,用const char *sha1(const char *str)就不合适,没法实现支持传入任意数量字符串,而用int sha1(lua_State *L)就可以实现。所以需要对lua_CFunction格式的函数进行特殊处理,不自动推导而是直接push到Lua。同样的,成员函数、static函数也是需要做一些特殊处理。最终,写了一串if else来特殊处理不同类型的函数

    template <auto fp> void def(const char *name)
    {
        lua_CFunction cfp = nullptr;
        if constexpr (std::is_same_v<decltype(fp), lua_CFunction>)
        {
            cfp = fp;
        }
        else if constexpr (!std::is_member_function_pointer_v<decltype(fp)>)
        {
            cfp = StaticRegister<decltype(fp)>::template reg<fp>;
        }
        else if constexpr (is_lua_func<decltype(fp)>)
        {
            cfp = &fun_thunk<fp>;
        }
        else
        {
            cfp = ClassRegister<decltype(fp)>::template reg<fp>;
        }

        luaL_getmetatable(L_, class_name_);

        lua_pushcfunction(L_, cfp);
        lua_setfield(L_, -2, name);

        lua_pop(L_, 1); /* drop class metatable */
    }

remove_cvref

在C++中,可以把函数或者变量指定为const,参数可以是引用,比如

int get() const
{
}

void set(Val &v)
{
}

在模板推导中,加不加const和引用,推导出来的类型是不一样的,但对于C++和Lua交互来说,这个类型就是一样的,比如从Lua传进来的this指针,不存在是否为const这个说法。理想情况下,我们可以规定push到Lua的函数,不能加const这种修饰词,参数不作引用。但我在实际使用过程中,偶尔遇到一个函数,既需要在C++中调用,又需要在lua中调用,这时候又不想再多写一个专门push到lua的函数,所以在类型推导过程中往往多加了remove_cvref<Args>这个来去掉修饰词。

这提供了便利,但也增加了风险,假如有些人就直接修改了lua的一些引用呢?程序是真可能会出问题。

构造函数

C++的构造函数是个麻烦事,因为它和其他函数不一样,是没有返回值的,而且构建函数可以有多个的。那这就意味着上面的推导是解决不了这个问题。我最初的想法是每个类push到C++时,简单地调用默认构造函数,参数通过其他函数传入即可。但后来发现不行,比如说有些类是单例,可不希望有人在Lua另外创建一个实例。

最终我觉得比较稳妥的方案是:如果提供了默认构造函数,则使用默认构造函数,否则需要手动指定构造函数。若没有默认构造函数,也没有指定构造函数,则无法在Lua创建一个C++对象。

重载

重载意味着同一个函数名有多个函数,上面通过函数指针直接推导出函数参数和返回值的机制就会失效。目前没有太好的解决方案,可以像Sol2那样提供一个模板物化机制,或者用lambda来包一层。但我的意见是,push到Lua的函数不要有重载,换个函数名。

重载实现起来太过于麻烦,我没有兴趣去做这个。

C++调用Lua函数

需要在C++中调用Lua函数时,我原来一直是手动push参数,再直接调用lua_pcall的,毕竟C++调用Lua的地方总共加起来也没有几处。但是一想到C++绑定Lua的库都实现了,这个不包装一下实在说不过去。C++调用Lua,意味着参数是C++的,那它的类型就是确定了的,这个通过模板就能解决。具体的方案在[Howling at the Moon - Lua for C++ Programmers - Andreas Weis - CppCon 2017](https://github.com/CppCon/CppCon2017/blob/master/Presentations/Howling at the Moon - Lua for C%2B%2B Programmers/Howling at the Moon - Lua for C%2B%2B Programmers - Andreas Weis - CppCon 2017.pdf)上也有说过,我这里就不再说了。


/**
 * 调用lua全局函数,需要指定返回类型,如call<int>("func", 1, 2, 3)。错误会抛异常
 * @param name 函数名
 * @param Args 参数
 */
template <typename Ret, typename... Args>
Ret call(lua_State *L, const char *name, Args... args)
{
#ifndef NDEBUG
    StackChecker sc(L);
#endif

    lua_getglobal(L, "__G_C_TRACKBACK"); // 需要自己在Lua实现trace函数
    assert(lua_isfunction(L, 1));
    lua_getglobal(L, name);

    (lcpp::cpp_to_lua(L, args), ...);

    const size_t nargs = sizeof...(Args);
    if (LUA_OK != lua_pcall(L, (int32_t)nargs, 0, 1))
    {
        std::string message("call ");
        message = message + name + " :" + lua_tostring(L, -1);
        lua_pop(L, 2); // pop error message and traceback

        throw std::runtime_error(message);
    }
    Ret v = lua_to_cpp<Ret>(L, -1);
    lua_pop(L, 2); // pop retturn v and traceback function

    return v;
}

其他问题

  1. 为什么不直接用fp(lua_to_c<Args>(L, ++i), lua_to_c<Args>(L, i), ...)而用make_index_sequence
    上面的代码中,从Lua堆栈取参数时,是依次从栈位置1 2 3...取参数,那为什么不直接使用一个简单的++i呢?

嗯,一开始我确实是这样写的,而且跑起来确实没出问题。但后来在Linux下编译出现'multiple unsequenced modifications to 'i' [-Wunsequenced]'这个警告,我才意识到,lua_to_c是把参数从lua取出,放到C++的栈上作为fp的参数去调用。但在不同平台,参数入栈的顺序是由调用约定决定的,顺序是不一样的,这++i的值就会不一样,程序就要出bug了。

  1. 为什么用函数指针而不用upvalue
    许多C++绑定Lua的库,原始的函数指针是存在push到lua函数的upvalue中,而我写的是放在模板函数的参数auto fp中。我的本意是,通过模板参数调用肯定会比取upvalue更快,在编译时就已确定好,无需要管理。而其他库会放upvalue,是因为他们允许动态绑定,有一套生命周期管理,可以动态创建和释放这些函数。

  2. 异常安全问题
    C++与Lua交互一直有一个问题,C++中的对象是依赖C++本身的异常机制来构造和销毁的,即有错误发生,应该要抛一个异常才行。但是Lua使用的是C的异常机制,调用long jump,这可能会导出一些对象的析构函数没有调用。

当然可以以C++的方式编译Lua,但这没法保证。而我也没找到好的处理方式,也从未见过一了百了,完美的处理方式。但根据我的经验,只要你不是C++调用Lua再调用C++再调用Lua这样穿插着调用,并且在调用的过程中手动创建了对象,而又不愿意用pcall,一般是没有问题的。我可以保证一次库的调用安全,但没法保证多次。

例如,在Lua中调用一个C++函数,其中有一个参数是std::string类型,那它就会创建一个std::string对象。接着发现后面的参数不匹配,这时候会抛一个runtime_error,保证std::string对象,然后在最外层的函数catch这个runtime_error,再调用luaL_error,这样可以保证库接口的安全性。但这个luaL_error的影响,如果回到lua层没有xpcall而导致越过了一些C++代码,那就得由写代码的人负责了。

还有许多的细节,比如如果把一个类注册到Lua,如何把一个已有的对象指针push到Lua而不gc掉等等,这里就不再细说了。原本只想简单地实现,但修修补补了几回,也有一千行代码了,变得比预想中复杂了。整个代码我放在了lcpp.hpp中,有兴趣的可以去看代码。

posted on 2025-10-07 17:10  coding my life  阅读(4)  评论(0)    收藏  举报