游戏中的使用脚本语言已经成为了一个标准应用。脚本语言能够在游戏开发中扮演一个重要的角色,并且让数据结构化,计划事件,测试和调试这些工作更加容易。脚本语言也能够允许像美术,策划这些非程序专家通过一个高层的抽象脚本来为游戏编写代码。这个抽象层的一部分也能够允许提供给玩家来定制整个游戏。
从程序员的角度上来看,把一个脚本语言嵌入到游戏中最主要的问题是如果为脚本语言提供对宿主对象的访问(通常是C/C++对象)。在选择一个脚本语言的时候有两个关键的特性:嵌入相关问题和绑定相关问题。而这些是Lua语言的一些设计的初衷。可是,Lua语言并没有提供任何自动创建绑定的工具,因为这是出于另外一个设计初衷:Lua只是提供机制,而不是策略。
因而,就有许多种策略可以用来在Lua中绑定宿主对象。每一种策略都有它的优点和缺点,游戏开发者必须在得到在脚本环境中所需要的功能需求之后确定最好的策略。一些开发者可能只是把C/C++对象映射成简单的数值,但是其他人可能需要实现运行期类型检查机制,甚至是在Lua中扩展宿主的应用。另外一个需要处理的重要问题是,是否允许Lua来控制宿主对象的生命周期。在这篇文章中,我们将探究使用Lua的API来实现不同的宿主对象绑定策略。
绑定函数
为了说明不同策略的实现,让我们考虑把一个简单的C++类绑定到Lua中。实现的目标是在Lua中实现对类的访问,因此允许脚本通过导出的函数来使用宿主所提供的服务。这里主要的想法是使用一个简单的类来引导我们的讨论。下面讨论的是一个虚构游戏中的英雄类,有几个将会被映射到Lua中的公用方法。
class Hero{
public:
Hero( const char* name );
~Hero();
const char* GetName();
void SetEnergy( double energy );
double GetEnergy();
};
要把类方法绑定到Lua中,我们必须使用Lua的API来编写绑定功能。每一个绑定函数都负责接收Lua的值作为输入参数,同时把它们转化成相应的C/C++数值,并且调用实际的函数或者方法,同时把它们的返回值给回到Lua中。从标准发布版本的Lua中,Lua API和辅助库提供了不少方便的函数来实现Lua到C/C++值的转换,同样,也为C/C++到Lua值的转换提供了函数。例如,luaL_checknumber提供了把输入参数转换到相对应的浮点值的功能。如果参数不能对应到Lua中的数值类型,那么函数将抛出一个异常。相反的,lua_pushnumber把给定的浮点值添加到Lua参数栈的顶端。还有一系列相类似的函数来映射其他的基本的Lua类型和C/C++数据类型。我们目前最主要的目标提出不同的策略来扩展标准Lua库和它为转换C/C++类型对象所提供的功能。为了使用C++的习惯,让我们创建一个叫做Binder的类来封装在Lua和宿主对象中互相转化值的功能。这个类也提供了一个把将要导出到Lua中的模块初始化的方法。
class Binder
{
public:
// 构造函数
Binder( lua_state *L );
// 模块(库) 初始化
int init( const char* tname, const luaL_reg* first );
// 映射基本的类型
void pushnumber( double v );
double checknumber( int index );
void pushstring( const char s );
const char* checkstring( int index );
….
// 映射用户定义类型
void pushusertype( void* udata, const char* tname );
void* checkusertype( int index, const char* tname );
};
类的构造函数接收Lua_state来映射对象。初始化函数接收了将被限制的类型名字,也被表示为库的名称(一个全局变量名来表示在Lua中的类表),并且直接调用了标准的Lua库。例如,映射一个数值到Lua中,或者从Lua映射出来的方法可能是这样的:
void Binder::pushnumber( double v )
{
lua_pushnumber( L,v );
}
double Binder::checknumber( int index )
{
return luaL_checknumber( L,index );
}
真正的挑战来自把用户自定义类型互相转换的函数:pushusertype和checkusertype。这些方法必须保证映射对象的绑定策略和目前使用中的一致。每一种策略都需要不同的库的装载方法,因而要给出初始化方法init的不同实现。
一旦我们有了一个binder的实现,那么绑定函数的代码是非常容易写的。例如,绑定函数相关的类的构造函数和析构函数是如下代码:
static int bnd_Create( lua_state* L ){
LuaBinder binder(L);
Hero* h = new Hero(binder.checkstring(L,1));
binder.pushusertype(h,”Hero”);
return i;
}
static int bnd_Destroy( lua_state* L ){
LuaBinder binder(L);
Hero * hero = (Hero*)binder.checkusertype( 1, “Hero” );
delete hero;
return 0;
}
同样的,和GetEnergy和SetEnergy方法的绑定函数能够像如下编码:
static int bnd_GetEnergy( lua_state* L ){
LuaBinder binder(L);
Hero* hero = (Hero*)binder.checkusertype(1,”Hero”);
binder.pushnumber(hero->GetEnergy());
return 1;
}
static int bnd_SetEnery( lua_State* L ){
LuaBinder binder(L);
Hero* hero = (Hero*)binder.checkusertype(1,”Hero”);
Hero.setGetEnergy( binder.checknumer(2) );
return 1;
}
注意绑定函数的封装策略将被用于映射对象:宿主对象使用对应的check和push方法组来进行映射,同时这些方法也用于以接收关联类型为输入参数。在我们为所有的绑定函数完成编码。我们可以来编写打开库的方法:
static const luaL_reg herolib[] = {
{ “Create”, bnd_Create },
{“Destroy”, bnd_Destory },
{“GetName”, bnd_GetName},
…
};
int luaopen_hero( lua_State *L ) {
LuaBinder binder(L);
Binder.init( “hero”, herolib );
return i;
}
绑定宿主对象和Lua数值
把C/C++对象和Lua绑定的方法就是把它的内存地址映射成轻量的用户数据。一个轻量的用户数据可以用指针来表示(void *)并且它在Lua中只是作为一个普通的值。从脚本环境中,能够得到一个对象的值,做比较,并且能够把它传回给宿主。我们要在binder类中所实现的这个策略所对应的方法通过直接调用在标准库中已经实现的函数来实现:
void Binder::init( const char *tname, const luaL_reg *flist ){
luaL_register( L, tname, flist );
}
void Binder::pushusertype( void* udata, const char* tname ){
lua_pushlightuserdata( L, udata );
}
void *Binder::checkusertype( int index, const char* tname ){
void *udata = lua_touserdata( L, index );
if ( udata ==0 ) luaL_typerror( L, index, tname );
return udata;
}
函数luaL_typerror在上面的实现中用于抛出异常,指出输入参数没有一个有效的相关对象。
通过这个映射我们英雄类的策略,以下的Lua便是可用的:
Local h = Hero.Create(“myhero”)
Local e = Hero.GetEnergy(h)
Hero.SetEnergy(h, e-1)
Hero.Destroy()
把对象映射成简单值至少有三个好处:简单,高效和小的内存覆盖。就像我们上面所见到的,这种策略是很直截了当的,并且Lua和宿主语言之间的通信也是最高效的,那是因为它没有引入任何的间接访问和内存分配。然而,作为一个实现,这种简单的策略因为用户数据的值始终被当成有效的参数而变得不安全。传入任何一个无效的对象都将回导致宿主程序的直接崩溃。
加入类型检查
我们能够实现一个简单的实时的类型检查机制来避免在Lua环境中导致宿主程序崩溃。当然,加入类型检查会降低效率并且增加了内存的使用。如果脚本只是用在游戏的开发阶段,那么类型检查机制可以在发布之前始终关闭。换句话说,如果脚本工具要提供给最终用户,那么类型检查就变得非常重要而且必须和产品一起发布。
要添加类型检查机制到我们的绑定到值的策略中,我们能够创建一个把每一个对象和Lua相对应类型名字映射的表。(在这篇文章中所有提到的策略里,我们都假定地址是宿主对象的唯一标识)。在这张表中,轻量的数据可以作为一个键,而字符串(类型的名称)可以作为值。初始化方法负责创建这张表,并且让它能够被映射函数调用到。然而,保护它的独立性也是非常重要的:从Lua环境中访问是必须不被允许的;另外,它仍然有可能在Lua脚本中使宿主程序崩溃。使用注册表来存储来确保它保持独立性是一个方法,它是一个全局的可以被Lua API单独访问的变量。然而,因为注册表是唯一的并且全局的,用它来存储我们的映射对象也阻止了其他的C程序库使用它来实现其他的控制机制。另一个更好的方案是只给绑定函数提供访问类型检查表的接口。直到Lua5.0,这个功能才能够被实现。在Lua5.1中,有一个更好的(而且更高效)方法:环境表的使用直接和C函数相关。我们把类型检查表设置成绑定函数的环境表。这样,在函数里,我们对表的访问就非常高效了。每一个函数都需要注册到Lua中,从当前的函数中去继承它的环境表。因而,只需要改变初始化函数的环境表关联就足够了――并且所有注册过的办定函数都会拥有同样一个关联的环境表。
现在,我们可以对binder类的执行类型检测的方法进行编码了:
void Binder::init(const char* tname, const luaL_reg* flist){
lua_newtable(L); //创建类型检查表
lua_replace(L,LUA_ENVIRONINDEX ); // 把表设置成为环境表
luaL_register( L,tname, flist ); //创建库表
}
void Binder::pushusertype(void *udata, const char* tname){
lua_pushlightuserdata(L,udata); //压入地址
lua_pushvalue(L,-1); //重复地址
lua_pushstring(L,tname); //压入类型名称
lua_rawset(L,LUA_ENVIRONINDEX); //envtable[address] = 类型名称
}
void* Binder::checkusertype( int index, const char* tname ){
void* udata = lua_touserdata( L,index );
if ( udata ==0 || !checktype(udata, tname) )
luaL_typeerror(L,index,tname);
return udata;
}
面代码使用一个私有的方法来实现类型检查:
int Binder::checktype(void *udata, const char* tname){
lua_pushlightuserdata(L,udata); //压入地址
lua_rawget( L, LUA_ENVIRONINDEX); //得到env[address]
const char* stored_tname = lua_tostring(t,-1);
int result = stored_tname && strcmp(stored_tname, tname) ==0;
lua_pop(L,1);
return result;
}
通过这些做法,我们使得绑定策略仍然非常高效。同样,内存负载也非常低――所有对象只有一个表的实体。然而,为了防止类型检查表的膨胀,我们必须在销毁对象的绑定函数中释放这些表。在bnd_Destroy函数中,我们必须调用这个私有方法:
void Binder::releaseusertype( void* udata ){
lua_pushlightuserdata(L,udata);
lua_pushnil(L);
lua_settable(L,LUA_ENVIRONINDEX);
}
虽然有tolua++, luabind等等, 不过自己手动绑定还是有助于更深的了解lua的机制, 以及锻炼自己如何使用lua提供的现有机制来实现自己的需求[部分内容来自网络, 我这里就是做一些总结和扩展, 感谢分享知识的人:)].
定义目标, 有一个c++类
class Foo
{
public:
Foo(int value) {
_value = value;
printf(“Foo Constructor!\n”);
}
~Foo() {
printf(“Foo Destructor!\n”);
}
int add(int a, int b) {
return a + b;
}
void setV(int value) {
_value = value;
}
int getV() {
return _value;
}
int _value;
};
现在写如下这样一个lua文件test.lua, 想用文件中的方式访问Foo对象, 该如何实现?
ff = Foo(3)
v = ff:add(1, 4) // v = 5
ff:foo()
ff:setV(6)
ff2 = Foo(4)
print(ff:getV()) // v = 6
print(ff2:getV()) // v = 4
要求:
1. Foo() 可以创建一个c++对象, 并返回给lua一个对象的引用ref.
2. lua中可以使用ref:function(arg, …)的形式调用c++对象的方法.
这里有两个问题, 第一, 不同于c++中的对象创建和对象方法调用, 创建和调用方法的参数都是来自于lua中, 而且方法调用的返回值也是要传回给lua的, 而lua和c++是靠lua_State栈来交换数据的, 所以必须使用一个wrapper类, 将Foo类包裹起来, 解决参数数据源和返回值数据去向的问题
class FooWrapper : public Foo {
public:
Foo(lua_State* L) : Foo(luaL_checknumber(L, -1)) {
}
int add(lua_State* L) {
int a = luaL_checknumber(L, -1);
int b = luaL_checknumber(L, -2);
int res = Foo::add(a, b);
lua_pushnumber(L, res);
return 1;
}
int setV(lua_State* L) {
int v = luaL_checknumber(L, -1);
Foo::setV(v);
return 0;
}
int getV(lua_State* L) {
lua_pushnumber(L, Foo::getV());
}
};
这样, FooWrapper就成为lua和c++对象的一个通信界面, 里面本身不实现任何逻辑, 只实现数据通信, 转发调用. 这样就解决了数据流的来源和去向问题.
第二, 调用的发起者问题, 在c++中, 调用对象的方法本质上就是函数调用, 而在lua中调用c++对象的方法, 有几个要注意的地方:
1. 需要在lua中调用的方法 func 必须导出到lua中.
2. lua调用对象方法的时候, 必须能够获取到该对象, 因为必须使用 obj->(*func)(L) 这样的形式调用成员函数.
3. 在lua中, 把func 和 obj 关联起来.
其中, 解决1的方法是lua提供的, 通过压入c 闭包到lua中就可以实现函数的导出, 这个是比较简单的.
对于2, 一般lua中持有c++对象是使用userdata来实现的(userdata 类型用来将任意 C 数据保存在 Lua 变量中. 这个类型相当于一块原生的内存, 除了赋值和相同性判断, Lua 没有为之预定义任何操作. 然而, 通过使用 metatable (元表), 程序员可以为 userdata 自定义一组操作. userdata 不能在 Lua 中创建出来, 也不能在 Lua 中修改. 这样的操作只能通过 C API, 这一点保证了宿主程序完全掌管其中的数据. metatable 中还可以定义一个函数,让 userdata 作垃圾收集时调用它 — lua 5.1 参考手册).
好了, 现在函数可以导入到lua中, c++对象也可以导入到lua中, 唯一剩下的就是如何关联, 这个方法有几种, 下面可以用代码来说明
方法1
创建c++对象的时候, 创建一个表tt = {} tt[0] = obj [userdata] tt[1 ...] = func1, func2, …
struct RegType {
const char* name;
int (FooPort::*mfunc)(lua_State* L);
};
class LuaPort {
public:
static void RegisterClass(lua_State* L) {
// 导出一个方法创建c++, 因为创建c++对象是在lua中发起的
lua_pushcfunction(L, &LuaPort::constructor);
lua_pushglobal(L, "Foo");
// 创建userdata要用的元表(其名为Foo), 起码要定义__gc方法, 以便回收内存
luaL_newmetatable(L, “Foo”);
lua_pushstring(L, “__gc”);
lua_pushcfunction(L, &LuaPort::gc_obj);
lua_settable(L, -3);
}
static int constructor(lua_State* L) {
// 1. 构造c++对象
FooWrapper* obj = new FooWrapper(L);
// 2. 新建一个表 tt = {}
lua_newtable(L);
// 3. 新建一个userdata用来持有c++对象
FooWrapper** a = (FooWrapper** )lua_newuserdata(L, sizeof(FooWrapper*));
*a = obj;
// 4. 设置lua userdata的元表
luaL_getmetatable(L, “Foo”);
lua_setmetatable(L, -2);
// 5. tt[0] = userdata
lua_pushnumber(L, 0);
lua_insert(L, -2);
lua_settable(L, –3);
// 6. 向table中注入c++函数
for (int i = 0; FooWrapper::Functions[i].name; ++i) {
lua_pushstring(L, FooWrapper::Functions[i].name);
lua_pushnumber(L, i);
lua_pushcclosure(L, &LuaPort::porxy, 1);
lua_settable(L, -3);
}
// 7. 把这个表返回给lua
return 1;
}
static int porxy(lua_State* L) {
// 取出要调用的函数编号
int i = (int)lua_tonumber(L, lua_upvalueindex(1));
// 取tt[0] 及 obj
lua_pushnumber(L, 0);
lua_gettable(L, 1);
FooWrapper** obj = (FooWrapper**)luaL_checkudata(L, –1, “Foo”);
lua_remove(L, -1);
// 实际的调用函数
return ((*obj)->*(FooWrapper::Functions[i].mfunc))(L);
}
static int gc_obj(lua_State* L) {
FooWrapper** obj = (FooWrapper**)luaL_checkudata(L, –1, “Foo”);
delete (*obj);
return 0;
}
};
这个方法的主要部分是把obj 和 obj的函数组织成lua中的一张表, 思路比较简单, 但是有一个问题就是新建一个obj时, 都要在新建一个表并在里面加导出所有的方法, 感觉这样是冗余的.
方法2
和方法1类似, 但是用过使用元表, 来避免方法1中重复注册方法的问题
这里只列出不一样的地方
static void Register(lua_State* L) {
lua_pushcfunction(L, LuaPort::constructor);
lua_setglobal(L, “Foo”);
luaL_newmetatable(L, “Foo”);
lua_pushstring(L, “__gc”);
lua_pushcfunction(L, &LuaPort::gc_obj);
lua_settable(L, -3);
// ———– 不一样的地方
// 创建一个方法元表
lua_newtable(L);
// 指定__index方法
int meta = lua_gettop(L);
lua_pushstring(L, “__index”);
lua_pushvalue(L, meta);
lua_settable(L, –3);
// 注册所有方法
for (int i = 0; FooWrapper::Functions[i].name; ++i) {
lua_pushstring(L, FooWrapper::Functions[i].name);
lua_pushnumber(L, i);
lua_pushcclosure(L, &LuaPort::porxy, 1);
lua_settable(L, -3);
}
// 把这个表放入元表以便后用, 起名为methods
lua_pushstring(L, “methods”);
lua_insert(L, -2);
lua_settable(L, -3);
}
static int constructor(lua_State* L) {
// 1. 构造c++对象
FooWrapper* obj = new FooWrapper(L);
// 2. 新建一个表 tt = {}
lua_newtable(L);
// 3. 新建一个userdata用来持有c++对象
FooWrapper** a = (FooWrapper** )lua_newuserdata(L, sizeof(FooWrapper*));
*a = obj;
// 4. 设置lua userdata的元表
luaL_getmetatable(L, “Foo”);
lua_pushvalue(L, -1);
lua_setmetatable(L, -3);
// ————不一样的地方
// 5. tt[0] = userdata
lua_insert(L, -2);
lua_pushnumber(L, 0);
lua_insert(L, -2);
lua_settable(L, -4);
// 6. 绑定方法元表
lua_pushstring(L, “methods”);
lua_gettable(L, -2);
lua_setmetatable(L, -3);
lua_pop(L, 1);
// 返回表
return 1;
}
这样的话, 只是在注册类型的时候把函数导入到lua中, 在以后的每次创建对象时, 只要将方法表值为其元表就可以了, 这样就避免了多次导入函数
但是这个方法还是有问题, 不够简洁, 其实本身userdata就可有有元表, 用这个元表就可以了.
方法3
直接使用一个表做 userdata 的元表, 方法表等等.
static void Register(lua_State* L) {
lua_pushcfunction(L, LuaPort::construct);
lua_setglobal(L, “Foo”);
luaL_newmetatable(L, “Foo”);
lua_pushstring(L, “__gc”);
lua_pushcfunction(L, &LuaPort::gc_obj);
lua_settable(L, -3);
// —– 不一样的
// 把方法也注册进userdata的元表里
for (int i = 0; FooWrapper::Functions[i].name; ++i) {
lua_pushstring(L, FooWrapper::Functions[i].name);
lua_pushnumber(L, i);
lua_pushcclosure(L, &LuaPort::porxy, 1);
lua_settable(L, -3);
}
// 注册__index方法
lua_pushstring(L, “__index”);
lua_pushvalue(L, -2);
lua_settable(L, -3);
}
static int constructor(lua_State* L) {
FooWrapper* obj = new FooWrapper(L);
FooWrapper** a = (FooWrapper**)lua_newuserdata(L, sizeof(FooWrapper*));
*a = obj;
luaL_getmetatable(L, “Foo”);
lua_setmetatable(L, -2);
return 1;
}
static int porxy(lua_State* L) {
int i = (int)lua_tonumber(L, lua_upvalueindex(1));
FooPort** obj = (FooPort**)luaL_checkudata(L, 1, “Foo”);
return ((*obj)->*(FooWrapper::FunctionS[i].mfunc))(L);
}
这个方法是最简洁的.
其实方法很简单, 也不至一两种, 主要是体会一下lua的"提供机制, 而不是策略"的思想. 这样就能用它做出更多的符合自己项目的工具来.