浅谈Lua和C++异常处理

最近在弄一些跟Lua相关的小玩意, 在异常处理上遇到了一些问题.

Lua是一门小巧的, 用纯C写的语言。不过也支持按照C++编译。在可以使用makefile的环境下,指定CC为g++即可(clang可能会给出warning,表明正在将.c后缀的文件当作.cpp)。在VS下需要【配置 -> C/C++ -> 高级 -> 编译为】,然后选编译为C++(或者直接在命令行中添加/TP)

在C中,异常处理是基于setjmp(...)longjmp(...)的。在C++中,异常处理是基于trythrowcatch关键字实现的。setjmplongjmp本质上是通过操作栈指针来实现变量回收和控制流跳转的。由于C中所有数据结构都是trivial,C中不存在析构函数这一说,从而没有问题。但是C++有析构函数和虚表,longjmp这种单纯的控制流跳转不会引起堆栈退解(stack unwinding),因而析构函数无法正常被执行,从而可能导致内存泄漏等问题。CppReference指出,如果setjmplongjmp分别被替换成catchthrow时会引起析构函数的执行,那么原longjmp的行为是undefined的。不过有的编译器会对longjmp进行魔改,使其能够触发stack unwinding。但那是非标准行为了。

Lua通过LUAI_TRYLUAI_THROW两个宏来实现异常处理. 当Lua以C语言形式被编译时,宏被展开为setjmplongjmp. 当Lua以C++语言形式被编译时,宏展开为try...catchthrow.

当Lua以C编译时

当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

从中可以看出,在testtry块中的A被构造,当运行到throw时,构造了一个异常对象,然后析构掉try块中的其他变量,随后控制流转到catch块,对于带有what()方法的异常,调用其what()方法获取异常说明,传入luaL_error并跳转回Lua Kernel。 在跳转之前,异常对象也被正确析构。 因此在C++ API层中,没有对象被泄露。

其实在Lua users上也有一个类似的写法。 感兴趣的话可以去看一下。

当Lua以C++编译

把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++函数来说… 并不靠谱。

关于Lua Panic…

C/C++宿主通过luaL_loadstring等方法载入一段Lua代码放到栈上,并调用lua_calllua_pcalllua_pcallk调用(其中lua_pcall是个展开到lua_pcallk的宏)

如果通过lua_pcallk调用,则代码运行在保护模式下。在保护模式下,即使Lua一侧发生了异常,也只是将lua_pcallk的返回值设为非0,并将异常放在栈上。

如果通过lua_call调用,则代码运行在非保护模式下。此时,如果lua一侧发生了异常,会将异常传递到与lua_call同层的空间中。如果此lua_call是由lua_pcallpcall调用的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();
    }
  }
}

你可能感兴趣的:(浅谈Lua和C++异常处理)