最近在弄一些跟Lua相关的小玩意, 在异常处理上遇到了一些问题.
Lua是一门小巧的, 用纯C写的语言。不过也支持按照C++编译。在可以使用makefile的环境下,指定CC为g++即可(clang可能会给出warning,表明正在将.c
后缀的文件当作.cpp
)。在VS下需要【配置 -> C/C++ -> 高级 -> 编译为】,然后选编译为C++(或者直接在命令行中添加/TP)
在C中,异常处理是基于setjmp(...)
和longjmp(...)
的。在C++中,异常处理是基于try
,throw
和catch
关键字实现的。setjmp
和longjmp
本质上是通过操作栈指针来实现变量回收和控制流跳转的。由于C中所有数据结构都是trivial,C中不存在析构函数这一说,从而没有问题。但是C++有析构函数和虚表,longjmp
这种单纯的控制流跳转不会引起堆栈退解(stack unwinding),因而析构函数无法正常被执行,从而可能导致内存泄漏等问题。CppReference指出,如果setjmp
和longjmp
分别被替换成catch
和throw
时会引起析构函数的执行,那么原longjmp
的行为是undefined的。不过有的编译器会对longjmp
进行魔改,使其能够触发stack unwinding。但那是非标准行为了。
Lua通过LUAI_TRY
,LUAI_THROW
两个宏来实现异常处理. 当Lua以C语言形式被编译时,宏被展开为setjmp
和longjmp
. 当Lua以C++语言形式被编译时,宏展开为try...catch
和throw
.
当C++ API希望抛出一个Lua异常时(即通过lua_error
抛出),由于lua_error
是个longjmp
,栈上的局部变量没法被正确析构,所以可能需要借助try...catch
手动触发堆栈解退。考虑到C++库函数也可能抛出异常,因此可以这么写:
class A
{
public:
A() { cout << "A ctor " << this << endl; }
~A() { cout << "A dtor " << this << endl; }
};
class LuaError : public std::exception
{
public:
LuaError(const std::string& str) : _what(str) {
cout << "LuaError ctor " << this << endl;
}
~LuaError() { cout << "LuaError dtor " << this << endl; }
virtual const char* what() const override {
return _what.c_str();
}
private:
std::string _what;
A x;
};
int test(lua_State* L)
{
try {
A a;
throw LuaError("Error in C API");
}
catch (LuaError& e) {
cout << "Lua Error catched. " << e.what() << endl;
return luaL_error(L, e.what());
}
catch (std::exception& e) {
cout << "STD exception catched." << e.what() << endl;
return luaL_error(L, e.what());
}
catch (...) {
cout << "General exception catched." << endl;
return luaL_error(L, "General Error in C API.");
}
}
int main()
{
auto L = luaL_newstate();
luaL_openlibs(L);
lua_register(L, "test", test);
cout << "test: " << test << endl;
luaL_dostring(L, "a,b=pcall(test) print(a,b)");
lua_close(L);
return 0;
}
运行结果是:
test: 002756C7
A ctor 00CFDAEB
A ctor 00CFDA04
LuaError ctor 00CFD9DC
A dtor 00CFDAEB
Lua Error catched. Error in C API
LuaError dtor 00CFD9DC
A dtor 00CFDA04
false Error in C API
从中可以看出,在test
的try
块中的A被构造,当运行到throw
时,构造了一个异常对象,然后析构掉try
块中的其他变量,随后控制流转到catch
块,对于带有what()
方法的异常,调用其what()
方法获取异常说明,传入luaL_error
并跳转回Lua Kernel。 在跳转之前,异常对象也被正确析构。 因此在C++ API层中,没有对象被泄露。
其实在Lua users上也有一个类似的写法。 感兴趣的话可以去看一下。
把Lua当成C++编译或许是更好的方法,但这仅限于你的代码没有引用到其他C模块。大部分Lua的扩展都是C写的,或者至少遵循C ABI。除非扩展开源并且你有心情去在同一编译器下再编译一次,C++的ABI可不是闹着玩的(滑稽)
另外有说法称,将Lua以C++编译会显著增大程序大小,并拖慢运行效率。主要争议点在于C++异常处理非常缓慢。
当lua以c++编译时,事情看起来简单了很多。可以直接throw了,lua也会如愿的截获这个异常。但事情真的这么完美么?
翻一翻Lua源代码 (ldo.c),能够看到LUAI_TRY
的C++版实现:
/* C++ exceptions */
#define LUAI_THROW(L,c) throw(c)
#define LUAI_TRY(L,c,a) \
try { a } catch(...) { if ((c)->status == 0) (c)->status = -1; }
#define luai_jmpbuf int /* dummy variable */
没错,catch(...)
确实能够捕捉所有异常,但是Lua并不管捕捉到的异常到底是什么. 假如将test
函数改写成:
int test(lua_State* L)
{
A a;
throw runtime_error("Here is the exception.");
return 0;
}
那么运行结果将会变为:
test: 013956DB
A ctor 006FDA47
A dtor 006FDA47
false function: 013956DB
pcall的第二个返回值变成了一个function?而这个function刚好是test自身?看起来好像很神奇,但其实lua只是将发生异常时栈顶的元素当作异常对象返回了而已. 如果我们在抛出异常前已经向栈中存入一些元素,那么运行结果也会发生改变。
int test(lua_State* L)
{
A a;
lua_pushinteger(L, 123);
throw runtime_error("Here is the exception.");
return 0;
}
test: 00BE56DB
A ctor 008FDBBB
A dtor 008FDBBB
false 123
如果看luaL_error
的实现 (lauxlib.c) ,会发现其本身也是构造了一个字符串放在了栈顶,然后调用lua_error
.
/*
** Again, the use of 'lua_pushvfstring' ensures this function does
** not need reserved stack space when called. (At worst, it generates
** an error with "stack overflow" instead of the given message.)
*/
LUALIB_API int luaL_error (lua_State *L, const char *fmt, ...) {
va_list argp;
va_start(argp, fmt);
luaL_where(L, 1);
lua_pushvfstring(L, fmt, argp);
va_end(argp);
lua_concat(L, 2);
return lua_error(L);
}
使用luaL_error
是没问题的,问题在于我们需要保证自己的代码不要抛出异常。换句话说,不要让C++异常泄露到Lua VM中。或者说,给C++函数指定nothrow
属性。前者无非就是像前文一样套上try...catch
,后者对于C++函数来说… 并不靠谱。
C/C++宿主通过luaL_loadstring
等方法载入一段Lua代码放到栈上,并调用lua_call
,lua_pcall
或lua_pcallk
调用(其中lua_pcall
是个展开到lua_pcallk
的宏)
如果通过lua_pcallk
调用,则代码运行在保护模式下。在保护模式下,即使Lua一侧发生了异常,也只是将lua_pcallk
的返回值设为非0,并将异常放在栈上。
如果通过lua_call
调用,则代码运行在非保护模式下。此时,如果lua一侧发生了异常,会将异常传递到与lua_call
同层的空间中。如果此lua_call
是由lua_pcall
或pcall
调用的C API,那么对应的返回到该调用点。如果lua_call
运行在主函数中,或者上层没有异常处理,那么lua会调用通过lua_atpanic
设置的函数。 并在该函数返回之后调用abort
结束程序。
例如如下的代码会导致程序终止:
int main()
{
auto L = luaL_newstate();
luaL_openlibs(L);
luaL_loadstring(L, "error('just an error')");
lua_call(L, 0, 0);
lua_close(L);
}
PANIC: unprotected error in call to Lua API ([string "error('just an error')"]:1: just an error)
在源码luaD_throw
中可以更清晰的看到这个流程;
l_noret luaD_throw (lua_State *L, int errcode) {
if (L->errorJmp) { /* thread has an error handler? */
L->errorJmp->status = errcode; /* set status */
LUAI_THROW(L, L->errorJmp); /* jump to it */
}
else { /* thread has no error handler */
global_State *g = G(L);
L->status = cast_byte(errcode); /* mark it as dead */
if (g->mainthread->errorJmp) { /* main thread has a handler? */
setobjs2s(L, g->mainthread->top++, L->top - 1); /* copy error obj. */
luaD_throw(g->mainthread, errcode); /* re-throw in main thread */
}
else { /* no handler at all; abort */
if (g->panic) { /* panic function? */
seterrorobj(L, errcode, L->top); /* assume EXTRA_STACK */
if (L->ci->top < L->top)
L->ci->top = L->top; /* pushing msg. can break this invariant */
lua_unlock(L);
g->panic(L); /* call panic function (last chance to jump out) */
}
abort();
}
}
}