前几天,小说君翻了遍一本老书——《Ruby元编程》。
书的内容不多,两百多页。书名虽叫「元编程」,但是书的前三分之二其实就是在讲Ruby的对象模型。
小说君第一次看这本书的时候还是大二,当时也没接触过JS、Lua这种有「原型」(prototype)概念的语言,所以看的时候懵懵懂懂。
不过这次再看,由于已经有了相关背景知识,收获许多,跟各位分享一下。
一说起对象模型,很多同学都会想起曾经被《Inside C++ Object Model》(下文简称《Inside》)支配的恐惧。
这本书不可谓不经典,面试者只需看这一本就能应付大部分C++面试题,面试官也只需看这一本就能在C++这块问倒大部分面试者。
虽然标题是「Object Model」,但是其实这本书只有「The Semantics of Data」一章真正在讲object model——没错,就是讲虚表实现机制和object layout的那章。
如果你忘了编译器怎么处理你写的每一个class,那最好再去翻一遍。
与《Inside》不同,我们今天的主题是对象模型,而不是对象模型的实现细节。
对象模型是什么?
我们还是引用《Inside》书中的定义:
对象模型就是语言层面对OOP的支持。
OOP需要什么支持?
这里就开始出现分歧了:
有些同学第一反应是课本上的教条式定义——封装继承多态。
还有些同学可能会想到接口,想到面向契约编程。
而对于另外一些同学,特别是这几年涌现的JS和Objective-C用户,对OOP的理解也不再局限于上面两点,面向消息也是OOP的一大特征。
这里稍微偏个题,为OOP提供支持是一回事,能拿来实现OOP又是另一回事。
就拿C++或者C#来说,template/泛型也能拿来支持OOP,但是这些跟对象模型无关,更应该说是构建了语言的类型系统。
所以,这些可能出现歧义的内容下文就不再讨论了。
只看定义的话还是太抽象,接下来我们从「模块化」、「运行时dispatch」、「内省」这三个方面,稍微过一下一些常用语言的对象模型。
首先看C++。
C++的对象模型元素比较少,class关键字,访问修饰符,多继承,虚表,有限的类型元信息。
模块化就不用说了,除了字面意义上的模块化,还能用abstract模拟接口。
运行时dispatch是C++对象模型的核心,实现也很简单直接——借助每个实例对象的虚方法表做dispatch。
内省的话是cpp的弱项,不过也有基本的typeid支持。但是要想实现反射就需要借助编译期的一些代码生成手段了,各种流行的实现方式也不是特别优雅。
然后是C#/JAVA这种基于虚拟机的静态类型语言。
这类语言的对象模型就是C++的强化版,runtime实现了更复杂的对象模型机制,但是给用户表现出来的更简单,比如说只能单继承,还有接口这种易理解但又不是特别OO的概念,给所有引用类型增加subclass约束等等。
模块化、运行时dispatch都跟cpp类似。
由于是虚拟机语言,再加上自动生成的类型元数据,所以这类语言的内省机制比cpp强大很多。运行时不仅能拿到类型相关的所有元信息,还能emit代码,动态添加逻辑。
再然后就是Lua/Ruby这类脚本语言。
这两门语言的共同点也很多:鸭子类型、虚拟机脚本语言、prototype-based。
不过,动态类型的语言构建系统的时候,想做到模块化真有种要人老命的感觉。但是,由于应用情景就跟前面几种大不相同,追求模块化反而是舍本逐末。
动态dispatch是这类语言的强项,不论Lua还是Ruby,方法的调用不存在静态决议的情况,都是运行时查表,所以用户不仅可以用各种方式模拟方法调用,还可以任意接管方法调用,做一些比较猥琐的事情。
内省机制的话应该说各种虚拟机语言都差不了太多,运行时对象元信息的获取以及动态代码生成都可以轻易做到。
说了这么半天,小说君觉得不如通过一个实例,来看看不同的对象模型的表达能力究竟几何。
实例的出发点是服务端程序员每天都要打交道的业务情景——RPC。
如果有同学不了解RPC,可以看看这篇,以及这篇,有一个简单的知识背景。
简单地说,一个RPC就是一个特殊的函数,对客户端来说,调用的是signature类似于这样的函数:
void AskTest(int a, uint[] b, string c);
对于服务端的应用层来说,也相当于是注册的某个signature跟这个一样的函数被调用。
然后,我们通常的做法是会在服务端具体处理逻辑之前加一些参数校验逻辑。函数的大概内容就变成了这样:
void AskTest_Handler(int a, uint[] b, string c) { if (a < 0 || a > 10000) { Warn("AskTest param check fail , a ...."); return; } if (b == null || b.Length < 1 || b.Length > 10) { Warn("AskTest param check fail , b ...."); return; } if (string.IsNullOrEmpty(c)) { Warn("AskTest param check fail , c ....") return; } // 具体逻辑 }
在执行handler的具体逻辑之前,服务端需要针对每个参数做无上下文的校验。
这样,具体逻辑链各层级函数执行时就有了契约,比如说不需要每调一层就查下某个参数是否为null。
虽然正确的流程确实应该这样写,但是每个RPC的回调里面都要加上这么大一坨代码也挺烦的,很难维护。
能让机器做的工作绝对不要让人做,这是程序员的一个核心追求。
那针对这个需求,我们在几门常见语言应该分别怎么实现?
先说C++。由于小说君最近几年写过的C++代码不多,可能加起来也不超过一千行,所以也没什么好的解决方案。
C++解决这个问题优雅与否,更多地是取决于RPC的定义与解析形式。
之前讨论对象模型的时候我们说到,C++语言built-in的反射机制较弱,基本没法做什么事情。
所以C++RPC的定义形式通常是借助DSL,比如protobuf的消息定义语言,然后有定制的parser,parse完生成一些自动化代码,相当于做两趟编译。
这样,只要在定义RPC的DSL中加一些语法特性,可以描述每个参数的值域或者一些定制的validator,parser处理一下对应生成validate代码。
协议定义的时候可能类似于这样:
message AskTest { required int32 a = 1 [range(0,10000)]; repeated uint32 b = 2 [not_null] [length(1,10)]; required string c = 3 [not_null] [not_equal("")]; }
这是小说君随便定义的一种比较像protobuf的描述形式,后面的「[ ]」以一种declarative的形式,描述了参数的validator,减少了应用层代码编写工作量。
自动生成代码类似于这样:
void XXX::AskTest_Handler(int a, const vector<uint32_t> &b, const string &c) {
if (a < 0 || a > 10000) { Warn("AskTest param check fail , a ....");
return; }
if (b == nullptr || b.Length < 1 || b.length > 10) { Warn("AskTest param check fail , b ....");
return; }
if (string::IsNullOrEmpty(c)) { Warn("AskTest param check fail , c ....");
return; }
// 具体逻辑 AskTest_Handler_Impl(a, b, c); }
具体的逻辑写在Impl中。
整个实现方式看起来比较简单粗暴,如果有同学有更优雅的解决方案,特别是借助模版元特性的,或者喜欢把C++写成Haskell的,欢迎留言共同探讨。
接下来看C#的。
由于前面我们讨论的方案基本是没有基于任何语言提供的高级机制的,所以换成哪门语言都能照搬实现,包括C#。
当然,既然C#的对象模型更完善,因此也有针对上述方案的优化版。
首先看定义部分,由于C#的反射机制比较强大,而且还支持很多声明式的特性,比如attribute。所以我们不需要DSL,直接用C#来描述RPC的定义:
interface XX { void AskTest( [Range(0,10000)] int a, [NotNull(), Length(1,10)] uint[] b, [NotNull(), NotEqual("")] string c ); }
目前貌似C#只支持在attribute中写常量表达式,如果能写lambda的话会方便很多,比如写一个参数的validator就变成了这样:
[CheckString(s=>!string.IsNullOrEmpty(s))] string para
后面的流程就是反射,加载程序集,根据RPC的接口定义和attribute生成validator代码,后续的流程就大同小异了。
不过,由于是虚拟机语言,我们除了自动生成代码还有至少两种选择:
不再自动生成代码,而是直接修改程序集,把validate代码注入到逻辑中。
运行时直接反射并emit代码,生成相关的validate逻辑。
不管怎么说,由于语言的对象模型进化,我们针对这个问题的解决方案变得更简单,更优雅。
接下来我们看脚本语言。
脚本语言的对象模型通常都很精巧,而且跟前面说的C++和C#套路大不相同——比如C#中class仍然是类型的概念,可以类型推导,可以类型检查。但是在像Lua或Ruby这种基于「原型」实现对象模型的语言中,对象模型与类型系统的分界线已经非常模糊,难以区分。
举个具体的例子。
假如我们要在Lua中搭建一套经典的class-subclass-instance对象模型。但是Lua中并没有class或instance的概念,只有「元表」这一种复杂类型最接近于instance。
在class-subclass-instance对象模型中,三者的关系很容易描述:
class有class作用域的数据和方法。
subclass有上级class的方法,以及自身作用域的数据和方法。
instance有instanciate源的class的方法,同时还有自身作用域的数据。
三种概念都可以用「元表」来描述。
先看看比较传统的描述方式:
class Base { public static int B; public int S; public virtual void Foo() {} } class Derived : Base { public static int D; public override void Foo() {} } // var obj = new Derived();
实际上,「Base」「Derived」「obj」这三个概念,在Lua中应该分别用一个元表来描述。
Lua中的等价写法:
local Base = {} Base.New = function(self, o) o = o or {} setmetatable(o, self) self.__index = self return o end Base.Foo = function () end Base.B = 0 local Derived = Base:New() Derived.D = 1 local obj = Derived:New()
可以看出,在基于原型的语言中,class跟instance没有本质区别。class可以作为对象传递,instance也可以自由扩展field以及方法。
不过由于Lua的元编程机制比较匮乏,所以小说君打算用Ruby介绍下这种对象模型下前面的例子该怎么写。
Ruby的对象模型其实跟Lua差不多,都是基于原型实现的,只不过支持了更多的机制,比如类宏(下面示例代码中的rpc_args_checked)。
看下大概的伪代码:
module CheckedArgs def self.included(base) base.extend M end module M def rpc_args_checked(rpc, validators)
define_method "#{rpc}"do |*args|
end end end
end
class XX include CheckedArgs rpc_args_checked :ask_test {
:a => ->(a){a >= 0 && a < 10000},
:b => ->(b){b && len(b) < 10},
:c => ->(c){c && c != ""}}
end
简单地说,就是XX中定义所有rpc_handler,借助类宏rpc_args_checked,辅助定义加了埋点的各handler。
所谓埋点,就是各参数的校验逻辑。比如例子中就描述了各参数的validator。
rpc_args_checked的逻辑比较复杂,由于篇幅所限,就留给感兴趣的同学自行研究了。
大致流程就是利用ruby的define_method,运行时定义一个ask_test_handler,该方法会首先根据validators校验各参数,再执行后续逻辑。
由于脚本语言中并没有所谓编译期和运行期,所以运行过程中如何修改class、如何修改instance都是不受限制的。
小说君终于放假了,这篇作为今年最后一篇就随便写写,提前祝各位鸡年大吉了!
个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。