把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;
}
其他問題
- 為什么不直接用
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
-
為什么用函數指針而不用upvalue
許多C++綁定Lua的庫,原始的函數指針是存在push到lua函數的upvalue中,而我寫的是放在模板函數的參數auto fp中。我的本意是,通過模板參數調用肯定會比取upvalue更快,在編譯時就已確定好,無需要管理。而其他庫會放upvalue,是因為他們允許動態綁定,有一套生命周期管理,可以動態創建和釋放這些函數。 -
異常安全問題
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中,有興趣的可以去看代碼。
浙公網安備 33010602011771號