« WM_CREATE 引起的 bug 一则 | 返回首页 | Lua 5.2.2 中的一处 Bug »

树结构的一点想法

数据结构中的树结构在抽象复杂事物时非常常见,在图形引擎中,多用于场景以及 sprite 的层级管理。在 GUI 相关的模块中也是必备的结构。其它领域,比如对程序源码本身的解释翻译,以及对数据文件的组织管理,都离不开树结构。

我觉得,这是因为一个对象,除了它自身的属性(例如大小、形状、颜色等)之外,还需要一些外部属性(例如位置、层次、方向等)需要逐级继承。每个对象都可以分解成更细的对象组合而构成、这些对象在组成新的对象后,它们的聚合体又体现出和个体的相似性(至少具有类似的外部属性)。这使得采用树状数据结构最容易描述它们。

我最近的一些工作代码做了很多这方面的工作,回想这些年里,我不只一次的思考类似的问题(参看 2009 年的一篇 blog),而每次最后解决问题的代码的都有些不同,编程风格也有一些变化。总结一下这段时间的思考,今天再写这么一篇 blog 。

树结构的基本操作无非是遍历整棵树、遍历一层分支、添加节点、移动节点、删除节点这些。但在大部分应用环境下,我们最多用到的只是遍历,而非控制树的结构本身。

所以我认为,遍历应该是对外的 API 、而结构控制则应该是内部的 API 。也就是说,作为一个模块,对外不用显露其实现用到的数据结构,而只关心怎样取得模块内部的状态。这时,合适的遍历接口足以。典型的文件系统的接口就是这样做的:我们可以用 / 连接目录和文件名变成一个完整的路径名,用它打开一个文件;而不必一级级取得目录对象,再从中获得文件对象。

相较于结构控制的 API (增加、删除、移动节点这些),更重要的是树的持久化。因为最终用户关心的是最终的数据,以及怎样使用这些数据,而不大关心数据的构建过程。常见的做法是把树状结构的数据呈现为一个 XML 文件,或是生成一张 Lua 表,然后一次加载它,得以在内存中重建树结构。持久化数据的格式不重要,你可以根据性能需要优化它,也可以因为健壮性而采用易读的文本。在大多数应用需求里,一旦树被重建为编辑产生它时构建的样子,就不会再修改它了。无论多简单的结构,把构建过程直接用代码的形式写在源文件中,是最不值得做的事情。早期的 GUI 程序或许还会一行行调用 API 把窗口以及布局搭建起来,而现代 GUI 框架几乎都为界面布局定义一套 DSL ,鼓励设计人员独立描述它们了。

我认为,即使是动态性要求很高的场合,最好也定义出数据格式,便于和使用了树结构的模块交互。比如在 GUI 程序中,我们不建议把一个按钮被按下的行为注入到那个按钮对象中,而是从外部捕获按钮被按下的消息,忽视掉界面的层次结构而直接处理这些消息。为了做到这一点,我们可以定义出类似 HTML 的语言来定位界面上的按钮。

少量修改已经事先创建好的对象的需求也是普遍存在的。往往对象的结构很复杂,但可以调整的部分却很少。有一些支持对象的语言中,直接提供了蓝图对象的概念。比如早期网络游戏常用的 LPC ,让程序员实现一个对象,再给出方法复制它。

我最近读到 狂刃 的图形引擎也用了类似的方法。它在编辑器中编辑出需要的怪物/粒子等对象,然后持久化到文件中。运行时,加载这些数据构造出编辑得到的对象,再根据需要的数量 clone 它们。

对于那些需要对象中需要少量修改的部分,按蓝图复制出一个复制体,再根据需要修改即可。这是图形引擎常见的需求,比如动画节点当前播放的帧号就是一个随时间变化的节点属性,需要在运行期修改的。


我想再谈一点实现上的细节以及优化。

采用了树结构组织起来的数据体,往往就意味着复杂且零碎的数据片段。因为每个结点下都可能有很多子节点,而子节点上保存的对象类型也可能不同,最终是大量不同尺寸内存片的组合体。但实际上,整棵树的绝大部分是不变的。

如果我们在编辑器里编辑出一个复杂的怪物,以它为蓝图在运行期 clone 出多份的话,会发现,这些克隆体之间的共有数据远多过易变量。所以我觉得,把这些易变量和不变量分开储存可能是个更好的方案。易变量(比如每个节点的当前空间状态等)可以平坦化储存,不变量虽然在逻辑层次上是一棵树,但在编辑完成时就决定了它是怎样的,完全可以持久化为连续的数据,并还原到连续内存中,且作为一个对象来管理。作为公有的蓝图,也不必真的复制为多个克隆体,而只需要指针引用即可。

在我最近的另一个项目中,我用 C + Lua 实现了类似的结构,发现代码没有我一开始想的那么复杂。Lua 和 C 的层次还是能分的很清楚。一些需要靠字符串索引储存的数据,我放到 Lua 表中,而 C 中的数据结构则专注于树结构的表述。这样不会在 Lua 中保存太多零碎的 table ,在 C 结构中保有高效的树结构索引能力,又可以把比较麻烦的字符串管理扔给 Lua GC 。而且在很多情况下,对于字符串常量,Lua 语言比 C 语言要高效的多。

Comments

个人觉得,底层用c来写,而应用层用c++,使用lua扩展逻辑。底层应该是清晰的高效的数据结构算法,用c来写再好不过。
应用层应该从业务容易抽象、维护的角度来实现,这一层可以借助c++的stl。
然后通过lua扩展应用。
c由你来写。
c++由做产品架构的人来写。
lua由脚本策划或程序来写。
这样你做到了运行效率,开发效率,根据人的能力来承担不同层面的任务。

@zzl

C 里做类型反射轻而易举. 写个很简单的程序就可以分析 .h 文件里的 struct 定义了.

因为 C 的 struct 定义语法非常简单, 很容易解析.

如果结合 lua 就更简单了. lua 成熟的 C 代码分析库一大堆,简单好用.

设计抽象度太高了,用c+lua实现比较困难,c++更好。

对于c来讲类型系统太弱,由于c struct的关系类型信息完全丢失,无法实现类的property的遍历和反射。

而数据序列化至关重要的一点就是运行时类型信息不可丢失,所以必须要用lua来保存类型信息,用运行时反射来实现序列化。不过这样就必须设计c/lua接口,状态维护,通信,运行时错误处理等功能,带来了实现/性能/移植/复用上的问题。

c++本身不支持运行时的反射,比如c++中用Base*指向Derived*就造成了类型信息丢失,一旦class A中有一个Base*成员,A就很难正确反射出Derived*,如果用虚函数/rtti实现,要写一大堆boilerplate不仅罗嗦而且容易出错。

所以现代c++实现类的property以及反射必须是完全静态的,必须用到很多模板编程。比如class A里的成员不再用Base*,改用variant<Base*, Derived1*, Derived2*>。
这样做损失了二进制扩展性,但是支持了类的反射,一定条件下是完全可以接受的。

一旦保证了类型信息不丢失,c++可以用“一组”模板函数处理非常复杂的类型。
比如对于任意类、stl中的任意容器、以及他们之间的任意组合和继承(不包括虚继承)实现:
1. 序列化
2. 可视化(比如类在qt里以树状结构出现,可自由修改成员变量)
3. 数据交换(和lua/python/c)
我实现过前2个,整个序列化代码不超过400行,可视化更少。
要知道这是对任意类之间的任意继承和组合,是真正的write once use everywhere。

场景树也可以这么干,我写过一个简单的版本。

除非写代码生成器,c+lua很难比拟template c++。

@Tim Shen 更确切的说是:fp中的 Persistent data structure

把游戏关卡数据用树状结构来描述是不适合的。虽然从逻辑上讲游戏关卡是一棵树,但是树的数据编辑比较低效,不适合批量操作,我更喜欢在编辑时用多张二维数据表来描述关卡数据,这样只要用一个表格编辑器和一个预览工具就可以很方便的开发出编辑器,用外键引用就可以在载入后重建出树结构。

狂刃的游戏引擎哪里有?拿开源的修改的吗?

无责任瞎猜...看上去在朝函数式编程里的immutable list发展? clone并修改一个list, 就是保留并重用原来的部分, 然后拥有各自不同的部分.

sf

Post a comment

非这个主题相关的留言请到:留言本