要写过多少代码才能得到哪怕一点真谛?
多少年过来,我在潜意识的去追求复杂的东西。比如我自幼好玩游戏,从小到大,一直觉得玩过的游戏过于简单(无论是电子游戏还是桌面游戏),始终追寻更复杂规则的游戏,供我沉浸进去。或许是因为,有了更高的理解和控制复杂度的能力,就可以更为轻松的驾御复杂性。
这很好的解释了 2000 年到 2004 年我对 C++ 的痴迷。还有对设计模式的迷恋。
Eric S. Raymond 说:尽量不要去想一种语言或操作系统最多能做多少事情,而是尽量去想这种语言或操作系统最少能做的事情——不是带着假想行动,而是从零开始。禅称为“初心”(beginner's mind)或者叫“虚心”(empty mind) 。
代码写多了,问题见过了,甚至是同一问题解决多了。模式这种东西自在心底,不必拿出来。时时的从零去想,总能重新明白一些道理。
为什么说语言重要也不重要,算法和数据结构重要也不重要。对要解决的问题的领域的理解很重要(即明白真正要做什么)。理解了,我们才可以用面向对象,用模式去套问题;可理解了,我们又不真的需要这些繁杂的抽象。
闲话放一边,今天想谈谈树结构的管理。
这个问题来自游戏引擎里对对象的管理。有时也出现在 GUI 模块中。
我以前总结过,所谓面向对象,就是可以用统一的方法对不同的对象进行同样的操作。而这个过程,需要我们把对象的同质引用放到一个容器中。这个容器,绝大多数情况下,由一个集合结构即可胜任。
而有些复杂的问题领域中,我们又需要树型结构的容器。有时是为了优化(比如在 3d 引擎中,用树结构描述对象的位置相对关系,以及用于裁减),有时是为了分类(比如把所有 npc 放到一颗子树下,而把 item 放到另一颗下)。
本质上,容器的处理异于普通对象,而树结构即容器之容器。把容器之容器看成一个单体,有利于问题的简化。
处理这种复杂数据结构,动态语言相对于 C/C++ 这样的静态语言,有比较明显的优势。但是在性能方面又有明显的劣势。权衡之下,我们需要做的是采用 C 去实现底层细节,而动态语言做高层管理,并控制粒度,减少控制频率。
据我个人观察和实践,虽然最终游戏 engine 管理的树结构非常繁杂。但 C/C++ 部分运转起来之后,需要特别控制的节点层次并不多。
但是,若想充分利用动态语言的动态性,在子树构建阶段,又非常有可能触及比较复杂的树层次。只到构建子树完毕,大多数中间层次和节点永远都不会再被特别控制。
具体的例子有 3d 粒子系统,人物换装系统等等。如果用动态语言去描述那些小部件的搭建过程会比(在 C/C++ 里)较轻松。但是搭建完毕后,可能持续引用的节点并不多。大部分中间节点留在 C 层自我运转就足够了。
这就是我上面说的粒度问题。在构建的局部阶段,我们需要局部的细粒度。而在全局控制阶段,我们则需要全局的粗粒度。
如果一贯的保持细粒度,对于动态语言很可能发生性能问题。而增加动态语言和 C 底层的结合度,也更有对象生命期管理的麻烦事。如果只是麻烦倒也罢了,随之带来的 gc 的负担往往也不可小窥。
我现在的解决方案:
把树节点分为匿名和具名两类。匿名节点只在构建期对上下文可见。具名节点,可以以路径名(相对或绝对)方式引用。
如果以 lua 为实作,接口类似这个样子。
create()
with(function(self)
-- do something with self
end)
create("name")
with("name",function(self)
-- do something with node "name"
end)
create 方法可以在当前的位置创建一个子节点,可以给这个子节点起名,也可以匿名。
with 方法可以引用一个节点(如果给出名字,则找到具名节点,如果不给名字,则引用最近创建的一个匿名节点),并执行一个代码块(以 closure 方式给出),在这个代码块中可以引用这个节点对象 self ,操作 self 的各种属性和方法。但是 self 不可以传递到代码块之外。即不能被外面引用。这点在运行期通过锁机制保证。如果想长期引用一个节点,必须通过节点名。
除此之外,提供两个销毁节点的方法:
clear (name) 清除具名节点下的所有子节点。匿名节点不提供清理方法。
delete (name) 删除具名节点本身以及其下的子树,同样对匿名节点无效。
不提供任何枚举子节点和遍历子树的接口。(那些是 C 层次的事情)
为什么这样设计?仅仅这几个接口足够了吗?
这样,我们保证了动态语言层和 C 层关系的足够简单。尤其是规避了复杂的生命期管理。所有的节点都通过 create 构建,到 clear/delete 消亡。动态语言层有能力得到所有生命期信息。
而动态语言层没有移动子树和直接的持久引用特定树节点对象的能力,这向 C 层担保了绝对不会有悬空指针。具体到使用 lua 做封装时,我们简单的用 lightuserdata 引用 C 对象即可。
为什么提供 clear 和 delete 两种销毁节点的形式?这是因为我们没有枚举和遍历的接口,没有这两个接口,可以方便维持动态语言层上的对象粒度。
clear 用于插槽(slot) ,比如人物换装。模型的手上就可以有一个叫 hand (或 left_hand)的 slot ,供我们把武器的模型插上去。更换武器时,clear hand 这个 slot ,然后在其上重新创建新对象即可。
名字相对路径的支持,使得在特定位置创建子树可以做为通用模块。
delete 用于动态生成的对象本身。比如我们可以把场景中的 npc 挂在 root.npc 的子树下。再为每个 npc 以 id 为名创建节点。销毁 npc 即 delete root.npc.xxx (xxx 为 id)
匿名节点的设计,可以把大量复杂的子树构建过程交给动态语言去完成。
with 方法可以为上层程序员提供一个安全屏障。最主要是节点有效的保障。
上周末有同事问我,要不要增加移动子树的接口。比如有时我们需要把主角模型从一个地图层移到另一个地图层(比如从高楼上跳下)。我觉得这可能是个伪需求。
显示上的约束条件(模型依附在那个位置),和场景树的管理应该分离。比如不可以因为人物围着桌子跑,就把人的模型挂在桌子的坐标系内。
但另一些时候,我们需要让人物跨场景,则完全可以在旧场景中删除,然后在新场景中创建。在显示的表现上也可以做到不让用户察觉。
当然,也可能因为优化需要,或是实现简化的需要,我们需要增加这样一个方法。
move (name, parent)
把一个具名节点移动到另一个具名节点之下。
这最后一个方法或许是个可选项。
今天写了 400 多行 lua 代码,始终没什么感觉。看来周末就是应该休息一下。
最近想提高一点团队的工作效率,大家开会决定,以后每天最晚到岗时间提前到上午 10:30 。对我还是有点痛苦的 :) 我这几年,一直都习惯于中午再起床了。不过坚持了一周,感觉还行。也自然而然的把自己的下班时间提前到了 1:00 左右,睡眠似乎没受太大影响。
另,为近期的工作安排花了日程图,大大的挂在墙上。好象花点时间搞点形式主义出来,还真有点效果。 :D