昨天在 review 我公司一个正在开发的项目客户端代码时,发现了一些坏味道。
客户端框架创建了一个简单的对象系统,用来组织客户端用到的对象。这些对象通常是有层级关系的,顶层对象放在一个全局集里,方便遍历。通常,每帧需要更新这些对象,处理事件等等。
顶层每个对象下,还拥有一些不同类别的子对象,最终成为一个森林结构,森林里每个根对象都是一颗树。对象间有时有一些引用关系,比如,一个对象可以跟随另一个对象移动,这个跟随就不是拥有关系。
这种设计方法或模式,是非常常见的。但是在实现手法上,我闻到了一丝坏味道。
由于类别很多,所以代码中充斥着模仿静态语言如 C++/C# 般的大量构造和析构函数。我一直对同事说 Lua 不是 C++ 大部分同事也认同我的观点,但落到实处,却逃不出过去的很多经验。
比如在这次具体问题上,为什么要实现一套带构造和析构函数的类别系统呢?核心驱动力是因为大部分逻辑对象是和场景关联在一起的,并且引用了 U3D Engine 中的 C# 对象,依赖 lua 的 gc 系统去回收资源延迟太大。而往往大部分时候,我们都可以明确的知道一个对象从场景中移除,几乎没有别的地方在引用它,所以需要立刻释放资源。
而临时对象很多,设计人员又想实现一套对象的 table 再利用的方案,让释放掉的对象在可能的情况下,能重新在新创建对象时再利用起来,减少 lua gc 的负担。
为了做到这点,代码框架模仿了 C++/C# 中的常见手法,在构造函数里建立对象的层级关系(子对象有一个叫 owner 的域指向父对象),在析构函数里调用其拥有的对象的 ondestroy 函数,一级级回收。对于非拥有关系,比如前面举例的 follow ,再给出 unfollow 函数用于解除引用。
最终的结果是,每个新的类中,都有十几行雷同的代码做这些枯燥的事情,而且还偶发 bug 。bug 主要出现在一些引用关系没有解对,引用了死对象(当对象被重用时,就错误引用了新对象),或是不该释放的对象被提前释放了,等等。
我认为在代码基中出现大量雷同的、和具体业务不相关的代码,还分布在不同的源文件中,这是极坏的味道:因为它相当于制定了一套复杂的约定,让开发人员遵守,而且这些机械性的代码内聚性很低,容易出错。
让我们重新分析一下需求。
核心问题是:对象的频繁生成和释放在实际测试中已经出现了问题:过多的占用临时内存,以及引擎内资源未能及时释放。
围绕这点,设计出来的框架的味道不太好,没有充分发挥 lua 的动态特性。
而实际上,我们需要的一个工作在 lua 虚拟机中的,更小集合的对象生命期管理系统。这套系统最好是内聚性高,不要侵入真正的业务代码,它能正确的管理对象树和弱引用(类 follow)关系。
针对它,我重新设计了一套简单的类型系统。这套系统支持开发人员预定义对象类型,把引用关系描述在类型定义中,并适当的留出简单成员变量的位置。让对象在释放后,可以尽可能的复用数据结构,避免重复构造新的表,依赖 gc 回收临时表。
比如在 test.lua 中我定义了这样一个类型:
ts.foo { _ctor = function(self, a) self.a = a end, _dtor = function(self) print("delete", self) end, a = 0, b = true, c = "hello", f = ts.foo, weak_g = ts.foo, }
这个类型名叫 foo ,它描述了 a b c 三个简单类型的成员,分别是数字、布尔量、和字符串。并定义了默认值 0 true "hello" ,它们将在构造函数之前被赋值成默认值。
其中还定义了 f 和 g 两个引用成员。f 是一个强引用,引用类型也是 foo ;g 是一个弱引用,用 weak_ 前缀修饰。
我们可以为 foo 定义出构造函数 ctor ,这个 ctor 会传入 table self ,使用者不必关系 self 从哪里来,到底是新构造的表,还是过去释放的对象的表的再利用。框架会保证在 ctor 被调用前,其成员都赋值为默认值;其中的引用变量都将被赋为 false ,可以在构造函数里进一步赋值。注:这里是 false 不是 nil ,是希望可以在 self 中保留一个 slot 。
框架不会给 self 附加 metatable ,这样对使用者最为灵活,如果需要,可以在 ctor 中加上自己需要的 metatable 。
构造 foo 类的对象可以调用 ts.foo:new(...) ,它将在框架内的 root 集内添加一个新的 foo 对象,并返回。
如果想继续构造 foo 下的 f ,可以使用 ts[f].f(...) 。这个函数调用会调用对应的构造函数,并不需要指明构造类型,这是因为类型定义中已经指明了 foo.f 的类型。
这里使用了一个比较奇怪的语法:ts[f].f ,我们应该理解成对 f.f 的引用进行操作。这里并没有对 foo 对象设置 metatable 来提供更漂亮的语法糖,这是因为希望把 metatable 的弹性留给使用者。而且明确写 ts[对象].字段 可以显示的提示这段代码将对 “对象.字段” 的引用进行修改。
修改引用,让其引用到一个新对象可以用 ts[f].g = f 。这会把 f 的一个弱引用赋给 f.g 。强引用也能这样写 ts[f].f = f 。
那么,写 ts[f].f = ts.foo:new(...) 和写 ts[f].f(...) 有什么区别呢?
前者通过调用 ts.foo:new 构造出一个 foo 对象,然后赋给了 f.f 。但是它会在 root 集内也添加一个这个新对象,当日后从 root 集移除 f 时,这个新对象依旧被 root 集引用。
而后者也是构造了一个新的 foo 对象赋给 f.f ,但它不会在 root 集添加这个对象,并且新对象中会自动生成一个 .owner 字段指向 f 。
如果想清除一个引用,可以写 ts[f].f = nil 。不过再次读 f.f 的时候,会发现值是 false 而不是 nil 。这是为了在数据结构中保持一个 slot ,也可以确保用户加的 metatable 可以正确工作。
如果只想用读取 f 下属对象,就直接写 f.f 或 f.g 即可。不过这里 g 是一个弱引用,所以通常使用前应该做一次判断 if f.g then ... end 。
从 root 集移除一个对象,可以用 ts.delete(f) 。但是这个 delete 操作绝对不会触发对象的终结函数 dtor ,它做的仅仅是把对象从 root 集中移除。
上面反复谈到了 root 集。这是个很有用的集合,比如,你可以简单理解为,它就是场景,而构造出来的对象都默认放在了场景中。
我们可以用 for obj in ts.each() do ... end 来遍历这个集合,取出所有的对象处理。也可以单独删选一种类型的对象遍历: for obj in ts.each(ts.foo) do ... end 。
当一些对象移除 root 集,或是对象树内部的引用关系改变后,你可以调用 ts.collectgarbage() 来寻找哪些对象已经不再被引用,框架会用一个 mark-sweep 算法把不再被 root 集引用的对象回收再利用。在回收前,如果对象有 dtor ,也会调用。
另外,每个对象都有一个唯一的数字 id ,可以用 obj._id 获得。及时对象被收回,id 也不会重复。所以、当你在这个系统外想引用系统内的对象时,就应该用 id 来保持一个弱引用。之后,可以通过 ts.get(id) 来转换为真正的对象。如果对象已经被回收, ts.get 会返回 nil 。
ts.type(obj) 可以获得一个对象的类型名字,如果对象不是这个系统内的对象,则返回 nil 。