
Dora SSR 中的 Lua 绑定优化实践:基于 tolua++ 的深度改造
tolua++
是一个用于将 C++ 代码与 Lua 脚本语言进行绑定的工具,它通过自动生成绑定代码,使得开发者可以方便地在 Lua 中调用 C++ 类和函数。tolua++
提供了对 C++ 类和对象的更好支持,尤其是对 C++ 特性(如继承、模板、重载等)的兼容性。它简化了 C++ 与 Lua 之间的交互,使得 C++ 开发者能够轻松地将现有的代码暴露给 Lua 脚本,从而实现更灵活的脚本化控制和扩展。
tolua++
是一个历史悠久的框架,它最早发布于 2003 年 4 月,并且最后一次更新是在 2014 年 4 月。作为一个 C++ 与 Lua 绑定的工具,它曾经在游戏开发和脚本化应用中发挥了重要作用,尤其是在需要将 C++ 功能暴露给 Lua 脚本语言时,提供了一个高效的解决方案。
尽管 tolua++
的出现已久,但由于它的稳定性和广泛的应用,它依然被很多项目所使用,不过在当下新起的开发项目中,还是推荐开发者选择更现代的绑定工具(如 Sol2
、LuaBridge
等),但是 tolua++
仍然因其简洁性和易用性在一些场景下也保持着使用价值。
Dora SSR 在 tolua++
的基础上进行了深度改造,精简了绑定代码生成,提高了可维护性,并扩展了绑定能力,涵盖更多 Dora 引擎组件和 API 调用场景。同时,针对现代开发需求优化了 tolua++
,增强对新版本 Lua 适配性,下面我们就来介绍一些 Dora SSR 项目中所做 Lua 绑定优化的故事细节。
一、类型继承的优化
在 Dora SSR 中,我们使用 tolua++ 将 C++ 代码与 Lua 脚本进行交互。为了在 Lua 中访问 C++ 对象,tolua++ 采用了一些巧妙的机制来映射 C++ 的类和方法到 Lua 中。为了让这些机制更易于理解,我们先从 Lua 的特性入手,逐步解释其背后的原理。
1. Lua 中的对象模型
Lua 本身支持一些基本的 C 对象操作,比如存储和调用 C 函数、基本数据类型、指针等。然而,C++ 对象要比这些复杂得多,尤其是C++ 中的类信息,包括成员变量、方法以及继承关系。为了在 Lua 中实现面向对象(OO)编程,Lua 提供了叫做 metatable 机制。
-
Metatable:可以将其理解为一个类型的定义,其中包含该类型的成员和方法。当给一个 Lua 对象设置了一个 metatable 时,这个对象就成为了该类型的一个实例。例如,如果一个 metatable 定义了
move
方法,那么设置了这个 metatable 的 Lua 对象就可以调用move
。 -
继承:metatable 本身也可以设置另一个 metatable,这样新的 metatable 就可以继承父 metatable 中的成员和方法。这种机制类似于 C++ 中的类继承关系。
2. C++ 对象在 Lua 中的表示
当一个 C++ 对象进入 Lua 时,tolua++ 会将其转换为一个 Lua Userdata。Userdata 是一个特殊的 Lua 数据类型,它可以存储 C++ 对象的指针。接着,tolua++ 会给这个 Userdata 设置一个 metatable,这个 metatable 包含了该 C++ 对象的所有可调用方法。通过这种方式,Lua 中的 Userdata 就能够调用 C++ 对象的成员和方法,从而实现对 C++ 对象的操作。
3. 类型继承与 tolua_super
表
在 C++ 中,类之间可能存在复杂的继承关系,比如 Object <- Node <- Sprite
。为了在 Lua 中正确处理这些继承关系,tolua++ 除了设置与 C++ 类型继承一致的 metatable 链外,还引入了一个叫做 tolua_super
的表。
tolua_super
表:这个表记录了每个 metatable 对应的父类链。它的主要作用是帮助 Lua 判断一个类型是否是另一个类型的子类。为什么需要这个功能呢?因为在某些情况下,同一个 C++ 对象可能会以多种形式通过不同的接口在 Lua 中被获取,并且它们在被获取位置显示的类型可能不同。
举个例子,在 Dora 中,假设我们创建了一个 Sprite
对象,它继承自 Node
,而 Node
又继承自 Object
。当我们通过 getChildByTag
获取这个对象时,它的类型可能是 Node
;而通过 getChildren
获取时,它的类型可能是 Object
。然而,这个对象在最初创建时实际上是 Sprite
类型的。为了避免重复创建对象,同一个 C++ 对象在 Lua 中表示时只应该对应一个 Lua Userdata。那么,这个 Userdata 的 metatable 应该设置为什么类型呢?
答案就应该是对象创建之初实际的类型,也就是继承链中最靠后的子类——在这个例子中就是 Sprite
。因为 Sprite
类型可以满足所有调用该 C++ 对象的场景。因此,每次 C++ 对象被 Lua 获取时,tolua++ 应该检查类型的继承关系,将 Lua 中传入的 C++ 对象升级为更靠后的子类。tolua_super
表就是被用来帮助完成这一功能。
图示说明
通过这种机制,Dora 中使用的 tolua++ 确保了 C++ 对象在 Lua 中的类型继承关系与 C++ 中保持一致,同时也优化了对象的处理效率,避免了重复创建和管理多个 Userdata。
而在原版 tolua++ 中,C++ 对象的类型原先并没有被正确的检查继承链来确认实际类型,所以会造成同一个 C++ 对象在不同程序位置被注册为多个不同类型的 Lua 对象,这个也是 Dora 所做的一个很重要的优化点。
二、对 tolua.cast
方法的优化
在原版 tolua++ 中,有一个关键方法叫做 tolua.cast
。这个方法的官方实现的效果类似于 C++ 中的 static_cast
,它可以将一个 Lua Userdata 的 metatable 强制设置为目标类型。然而,在官方的实现中没有进行任何安全检查,这意味着如果开发者误判了对象的实际类型或父类,程序可能会崩溃,并且这种错误难以排查。在弱类型的 Lua 中,使用这种方法尤其容易出错。
1. tolua.cast
的问题与改进思路
尽管 tolua.cast
存在风险,但我认为它在 Lua 中仍然有很大的实用价值。如果能将其功能改进为类似 C++ 中的 dynamic_cast
转换,即在转换时进行对象继承链的类型检查,如果目标类型不是实际类型的父类,则返回 nil
,那么这个方法就会更加安全和易用。
要实现这一点,最大的难点在于如何确定一个任意 C++ 对象的实际类型。特别是在 Lua 中,我们只能通过 Userdata 获得一个 void*
指针,以及该对象在进入 Lua 时被识别的类型(即其 metatable)。例如,一个实际类型是 Sprite
的对象,在 Lua 中可能仅被识别为 Object
类型。那如何在 Lua 中准确地识别它的实际类型呢?
2. 初步思路:利用 C++ 的 typeid
关键字
C++ 标准中提供了 typeid
关键字,可以用来获取类型信息。通过 typeid
,我们可以获得每个 C++ 类的唯一 hash_code
。虽然 typeid
无法直接作用于 void*
指针,但对于指向一个游戏引擎通用基类 Object
类型的指针,它就有发挥空间啦。
例如,拿到一个 obj
是一个 Object*
类型的指针,我们可以通过 typeid(*obj).hash_code()
获取该对象的实际类型的哈希值。然后,通过 typeid(Sprite).hash_code()
获取 Sprite
类的哈希值并进行比对,从而判断 object
的实际类型是否为 Sprite
。
幸运的是,在 Dora SSR 中,几乎所有重要对象都继承自 Object
。对于没有继承 Object
的单例类和没有继承关系的数据对象类,我们可以进行特殊处理,比如跳过类型转换直接返回转换失败。
3. 具体实现与挑战
在实际实现中,我们发现两个问题:
-
Lua 中不需要完全识别所有 C++ 类型
在 C++ 中,我们经常通过类继承而不是组合来扩展功能。例如,Dora SSR 中的一系列动作类(如
PropertyAction
和Delay
等多个类)都直接继承自Action
。这些动作类的主要功能是创建动作对象,并由Node
执行和控制。由于Node
提供了统一的接口来管理这些动作对象,因此在 Lua 中区分它们是PropertyAction
还是Delay
并没有太大意义。基于这一点,我们不再为这些派生类逐一导出差异化的 C++ 类型,而是将它们统一导出为基类类型。这样,数十种继承链上的动作类最终只需在 Lua 中导出一种类型,从而减少大量代码量。 -
typeid
获取的类型与 Lua 中的实际类型不一致由于我们将派生类统一导出为基类类型,
typeid
获取的实际类型可能与对象在 Lua 中的类型不符。这一点需要在实现中进行特殊处理,以确保类型转换的正确性和安全性。
通过以上改进,我们就可以实现将 tolua.cast
从类似 static_cast
的功能升级为类似 dynamic_cast
的行为,使其在 Lua 中更加安全和实用。
图示说明
4. 优化 typeid
的性能问题与自定义类型系统
在改进 tolua.cast
方法时,还发现了一个与 typeid
相关的性能问题。起初,起初我们猜测 typeid().hash_code()
的返回值是由编译器预先计算好的,但最终发现 hash_code()
是通过对 typeid().name()
返回的字符串实时计算得到的。这个字符串比如在 MSVC 中通常包含命名空间、模板类型和类名称,长度可达 20 到 40 个字符。hash_code()
会对这个字符串进行 FNV 哈希计算(包括遍历每个字符并进行异或和乘法运算),这个过程在频繁调用时会带来明显的性能开销。例如,在遍历一个 Node
节点树并进行大量类型转换时,这种计算会导致大量的不必要的性能损耗。
此外,typeid()
返回的是一个名为 type_info
的对象,而 name()
是 type_info
的一个成员方法。实际在跨动态链接库访问创建对象时,&typeid(*node)
和 &typeid(Node)
返回的 type_info
对象的地址可能会不相同。并且 typeid(*node).name()
和 typeid(Node).name()
返回的字符串地址也会不同。这意味着,直接利用 type_info
对象的地址进行优化会为未来带来潜在不易处理的大问题。
自定义高效类型系统
为了解决上述问题,我们决定设计一个专属 Lua 使用的高效类型识别系统。核心思路是利用 C++ 的虚函数机制和模板函数来为每个类型生成唯一的类型标识符。具体实现如下:
-
全局类型标识符生成
首先,定义一个全局变量
doraType
来生成唯一的类型标识符:
extern int doraType; // 全局类型标识符
然后,通过模板函数为每个类型生成唯一的 `type` 值:
template <class T>
int DoraType() {
static int type = doraType++; // 为每个类型生成唯一的标识符
return type;
}
-
类型标识符的继承机制
为了让每个类都能够返回自己的类型标识符,我定义了两个宏:
DORA_TYPE_BASE
和DORA_TYPE_OVERRIDE
。前者用于基类,后者用于派生类。
#define DORA_TYPE_BASE(type) \
public: \
virtual int getDoraType() const { \
return DoraType<type>(); \
}
#define DORA_TYPE_OVERRIDE(type) \
public: \
virtual int getDoraType() const override { \
return DoraType<type>(); \
}
-
在类中应用类型标识符
通过上述宏,我们可以为每个类添加类型标识符的获取方法。例如:
class Object {
...
DORA_TYPE_BASE(Object) // Lua 获取类型为 Object
};
class Node : public Object {
...
DORA_TYPE_OVERRIDE(Node) // Lua 获取类型为 Node
};
class Delay : public Action {
...
DORA_TYPE_OVERRIDE(Action) // 让 Lua 识别为 Action
};
-
验证类型标识符
通过以下代码可以验证类型标识符的正确性:
Object* obj = Node::create();
assert(obj->getDoraType() == DoraType<Node>()); // 验证类型为 Node
obj = Delay::create();
assert(obj->getDoraType() == DoraType<Action>()); // 验证类型为 Action
自定义类型系统的优势
通过自定义类型系统,我们实现了以下目标:
- 高效性:与
typeid().hash_code()
相比,直接返回预先计算好的类型标识符,避免了实时哈希计算的性能开销。 - 准确性:通过虚函数机制,确保每个对象都能够返回正确的类型标识符。
- 灵活性:可以根据需要在基类和派生类中灵活定义类型标识符,避免了
typeid
在跨动态链接库时不符合预期的行为。
最终,这种方法不仅为 Lua 提供了高效的类型识别机制,还为 tolua.cast
方法的改进奠定了基础,使其在 Dora 的 Lua 环境中更加安全和实用。
三、对象的生命周期管理
在 C++ 绑定 Lua 时,比较关键的问题之一就是如何管理对象的生命周期,确保 C++ 和 Lua 系统能协调地控制对象的存续。
Dora SSR 最早是重写 Cocos2d-x 而来的项目。在早期的 Cocos2d-x 和 tolua++ 绑定方案中,C++ 和 Lua 各自管理自己的对象生命周期。C++ 对象采用引用计数管理,而 Lua 访问 C++ 对象时,会将其封装为 Userdata
类型的 Lua 对象,并交由 Lua 的垃圾回收(GC)系统管理。
然而,这种方式存在一个潜在问题:当 Lua 代码通过 Userdata
访问 C++ 对象时,该对象仍受 C++ 系统的管理,可能在未通知 Lua 的情况下被销毁,导致 Lua 继续引用一个无效的 C++ 对象,进而引发错误。
为了解决这个问题,Cocos2d-x 最初采用了一种弱引用机制。当 C++ 对象销毁时,系统会检查它是否仍被 Lua 引用。如果是,则会将 Userdata
内的 C++ 指针置为空,这样 Lua 仍然持有 Userdata
,但不会再指向无效的 C++ 对象。
然而,这种方案也带来了一定的不便。比如,在 Lua 端如果希望长期持有某个 C++ 对象,必须在获取对象时手动调用 retain()
增加引用计数,并在不再使用时调用 release()
释放引用。这增加了手工编码的管理成本,也可能引入内存泄漏风险。
图示说明
在我们的 Dora SSR 项目中,我认为在一个具备完整 GC 机制的 Lua 语言环境下,不应该再需要手动管理对象的生命周期。而要改进这一点,其实并不复杂,核心问题在于 Lua 获取 C++ 对象时是否能自动执行 retain()
,并在对象不再使用时自动执行 release()
。换句话说,我们只需明确 Lua 何时获取和何时停用 C++ 对象,并在这些关键时刻正确管理引用计数。
在 Lua 中,获取 C++ 对象的关键时机是创建 Userdata
并包装 C++ 指针,即当 C++ 对象进入 Lua 系统时。而停用 C++ 对象的时机通常取决于程序逻辑,但最终的确定时机则是当 Userdata
不再被 Lua 代码引用,并被 GC 回收时。
基于此,我们可以采用一个简单而有效的策略:
- 在
Userdata
创建时,对 C++ 对象执行retain()
,确保它在 Lua 端可用时始终存活。 - 当
Userdata
被 Lua GC 释放时,触发release()
,确保 C++ 对象在没有任何引用后被正确销毁。
这样一来,C++ 对象的生命周期管理就变得清晰且自动化。它始终会在有 Lua 代码使用时被强引用存活,而当所有引用消失后,自然地被释放,不再需要开发者手动管理生命周期。
图示说明
四、进一步避免重复创建绑定对象
在实际应用中,C++ 和 Lua 之间的交互关系相当复杂。同一个 C++ 对象可能会在 Lua 系统的多个地方被引用。因此,每次 Lua 引用 C++ 对象时,都不必重新创建新的 Lua Userdata 对象。为了解决这个问题,tolua++ 设计了一个名为 tolua_ubox
的表,用于存储每个 C++ 对象对应的 Lua Userdata。这个 tolua_ubox
是一个全局的弱引用表,它不会对 Userdata 产生强引用,不会影响到对象的生命周期。
当一个新的 C++ 对象进入 Lua 系统时,首先会在 tolua_ubox
中查找是否已经存在对应的 Userdata。如果存在,直接复用现有对象;如果不存在,则创建一个新的 Userdata 并保存到表中。
原始的实现方式是使用 C++ 对象指针作为键,Lua Userdata 作为值,通过哈希表存储和查询它们之间的关系。虽然哈希表查询速度较快,但我认为可以进一步优化性能。比如,当一个对象被 Lua 引用时,可以为它分配一个唯一的整数 ID,并以这个 ID 在 tolua_ubox
中存储对应的 Userdata。这样,我们可以使用 C 数组的方式来存储和查询数据,进一步提高效率。
然而,这种方法也带来了一个问题:ID 是递增的整数,每当一个新的 C++ 对象进入 Lua 系统时,ID 就会增加。如果 ID 一直递增,最终会导致问题。因此,当对象被销毁时,我们会将它的 ID 返还为“空闲 ID”。在为新对象分配 ID 时,优先使用空闲 ID。如果没有空闲 ID,则会继续递增分配新的 ID。
这种优化方案能够有效提升系统的效率,同时解决了 ID 增长的潜在问题。
图示说明
五、对特殊对象的生命周期管理
在 Dora SSR 中,有两类特殊的 C++ 对象不采用引用计数进行管理:
-
单例对象:
这类对象通常是在首次使用时创建,并在程序结束时释放。在 Lua 中引用单例对象时,只需创建对应的 Userdata 即可直接使用。当 Userdata 销毁时,无需进行额外的处理。
-
值对象:
如
Vec2
、Rect
、Color
等,这类对象通常通过值拷贝传递,而非通过引用共享。在 Lua 引用值对象时,会在堆上创建一份拷贝供 Lua 使用。当 Lua 不再使用该拷贝时,该对象会被自动销毁,其生命周期完全由 Lua 管理。
1. 原版 tolua++ 的问题
在原始的 tolua++ 中,并未对 C++ 引用对象、值对象和单例对象进行区分。所有对象的管理依赖于用户自定义的两个方法:
pushXXX
:由 tolua++ 调用,用于将对象推入 Lua 系统。collectXXX
:在垃圾回收时调用,用于销毁对象。
此外,所有创建的 Userdata 对象都会被保存在 ubox
表中进行缓存,以便在 C++ 对象进入 Lua 系统时进行查询。然而,这种设计存在一定的资源浪费:
- 值对象:每次进入 Lua 系统的都是一个新的拷贝,无需缓存。
- 单例对象:只需进入 Lua 系统一次,并可以在 Lua 的全局环境中保存和重复使用,无需生命周期管理。
2. Dora 的优化
为了解决上述问题,Dora 的 Lua 绑定版本移除了对值对象和单例对象的缓存机制,从而减少了不必要的资源开销,提升了系统效率。通过这种方式,Dora 实现了对特殊对象生命周期的精细化管理,优化了资源利用率,同时简化了开发者的使用体验。
图示说明
六、补全绑定对象生命周期的管理场景
在之前的介绍中,我们了解到 Dora SSR 的内存管理系统通过 Object
绑定对象,实现了 C++ 引用计数与 Lua GC 的协同工作。然而,在实际开发中,我们可能会遇到一些特殊的使用场景,导致内存管理出现意料之外的问题。以下代码片段就展示了一个典型的案例:
-- 核心伪代码如下
local world = PhysicsWorld()
local body = Body(...)
local joint = Joint(body, ...)
body.joint = joint
world:addChild(body)
-
代码意图
这段代码的目的是创建一个物理刚体 (
body
) 和一个物理连接关节 (joint
),并将joint
对象绑定到body
上,最后将body
添加到游戏世界 (world
) 中。 -
预期效果
joint
关节将body
对象按照预期效果固定住,游戏正常运行。 -
实际效果
joint
关节在创建后确实将body
对象固定住,但经过一段时间的运行后,joint
对象竟然自动销毁了,导致原本固定的body
掉落了下来。这表明joint
对象并未真正添加到对象的引用关系链中,最终被 GC 机制清理掉了。 -
问题根源
问题出在 Dora SSR 的内存管理系统 C++ 对象和 Lua 对象的生命周期并非完全同步。
- C++ 对象: 使用引用计数管理实时更新的生命周期。
- Lua Userdata 对象: 由 Lua 的 GC 系统管理定期执行清理的生命周期。
在我们的代码中:
body
对象同时是一个 C++ 对象和一个 Lua Userdata 对象。world:addChild(body)
仅仅通过 Lua 代码调用 C++ 接口,将body
的 C++ 对象添加到world
的子节点树中,使body
的 C++ 对象部分得到了引用。- 但
body
在 Lua 层面上创建的 Userdata 对象并没有被引用,导致引用关系链从body
开始就断开了。 body.joint = joint
设置的 Lua 对象到joint
对象的引用关系,由于body
的 Userdata 对象未被引用,最终joint
对象也无法被引用,从而被 GC 机制清理掉。
图示说明
Lua 环境的独立对象会随着 GC 清理一同带着 joint C++ 对象销毁。
解决方案:
为了解决 joint
对象被预期外的自动销毁的问题,我们需要确保 joint
的 C++ 实体也被正确地引用,并挂载到 C++ 系统的引用关系链中。通过以下方案的补充可以实现这一目标:
-- 在 C++ 中建立 body 对 joint 的引用
body.data.joint = joint
-- 获取 joint
local myJoint = body.data.joint
这里,body.data
是 C++ 对象上的一个容器接口,其生命周期与 body
一致。通过将 joint
存储到 body.data
容器中,我们确保了 joint
的 C++ 实体被 body
引用,从而避免了 joint
在 Lua 层面的 Userdata 对象被 GC 机制清理掉。这样做新的问题又带来的新的问题,实际 joint 的 C++ 对象因为要作用于 body 对象,内部也持有一个 body 的强引用,所以我们得到了环状的引用关系。
这时我们只有再建立了一个对应情形下破除引用关系环的机制,那就是 Dora 游戏场景树的清理事件机制。这个机制有点像 QT 框架的内存管理模式,就是通过一个 C++ 中树形的对象引用模型来管理对象,并在要释放对象时从根节点出发遍历子节点树来主动释放整个内存结构中被引用的对象。
我们把破除前面案例提到类似环形引用的时机,也一并放到游戏场景树的清理事件中。当为示例代码中的对象 world 调用 world:removeFromParent()
时,节点清理的事件就会逐级传递到 body 对象,然后 body 对象就开始主动清理自己所引用的子对象,包括自带 data 容器中的内容。这时我们的引用环就自动被破除了,避免了内存泄漏的发生。
总结与展望
Dora SSR 项目深度改造 tolua++
优化 Lua 绑定的细节还有很多,本文仅展示了其中的关键部分。从类型继承的优化到 tolua.cast
方法的改进,从对象生命周期管理到特殊对象的精细化处理,这些优化措施显著提升了系统的性能、稳定性和易用性。然而,这仅仅是优化跨语言绑定工作的一部分,我们还面临着诸多挑战和改进空间。
然而,尽管已经取得了显著的进展,我们仍然认识到在 C++ 与 Lua 绑定领域仍有许多挑战和改进空间。例如,随着 Lua 语言的不断发展和新版本的推出,我们需要持续更新和优化绑定工具以保持兼容性。此外,对于更复杂的编程场景以及进行性能优化,还需要进一步探索和改进。
展望未来,我们计划继续探索更高效的绑定机制,以进一步提升性能和减少资源占用。同时,我们也将关注社区中新的绑定工具和技术,如 Sol2 和 LuaBridge,并考虑将它们所创新的优势引入到我们的项目中。此外,我们还将致力于开发更友好的开发工具和文档,帮助开发者更轻松地进行 C++ 与 Lua 的交互开发。
总之,Dora SSR 的 Lua 绑定优化实践展示了在现有工具基础上进行深度改造的可能性和价值。我们相信,通过持续的努力和创新,C++ 与 Lua 的结合将为游戏开发和脚本化应用带来更多的可能性和更强大的功能。