开发自己的脚本引擎(二)脚本语法的设计。
vczh
说实话,设计一个脚本的语法是一件很痛苦的事情。一方面你想把你这个脚本搞得很酷,于是就拼命往里头加特性;另一个方面你又不想搞到自己写代码的时候很烦,有拼命的往里头删除特性。到了最后走火入魔了,脚本编得乱七八糟,大脑也被搞得不成样子。于是你心一恨,把大脑给Reset了,忘了自己设计的脚本语法,重新来一个。
其实这个不是说笑,是我亲身体会过的。我自己做的那个脚本引擎,程序写了半年,设计语法搞了一年半,其实说出来不大多数人都不信的。不过事实上我推翻了自己三套语法的设计,到了最后才搞出一个看起来还凑合,引擎也好写的一个语法。
现在开始动手了。你想往脚本里投加一些啥涅?基本的数据类型是一定要的。不过现在有些脚本是无类型的,你想那个变量是什么那个变量就是什么。这种脚本就是执行起来慢了点(类型信息是一种非常有用的有助于提高执行效率的信息,因为它对脚本的行为有限制作用),不过现在的CPU好快,也没什么值得深究的,反正就是看自己的爱好吧。自己功力高了,搞什么都好搞。
数据类型搞好了,心里就蠢蠢欲动要做一些“非基本”的数据类型。什么枚举啦,数组啦,结构啦,类啦,指针啦,引用啦,函数指针啦,啦啦啦……不过这东西取舍起来比较简单,自己掂量一下自己的脚本能够用来干什么,也就差不多了。我自己的那个呢,就有数组、类和元素类型只能是类的模板,类跟Delphi一样使用引用的方法表现,对大家仅供参考。
接着就是行为描述了。如果你想设计一个跟Java长得有点像的东西,if一定要有,for一定要有,while一定要有,do-while也要有,switch也要有,函数重载覆盖(类里头的虚函数)也要有,也就差不多了。如果你不想要类的话,其实也差不了多少(不过对于自己接下来的coding旧“差得了”多少了)。总之分支和循环就是必要的,其他的想做就做,自己用起来舒服也就行啦。
最后就是与宿主程序交互的部分啦。上一篇文章说过可以设计一种函数的声明格式让这个函数在调用的时候就转给宿主程序。其实这样做有一个好处,就是在编译的过程中程序可以拿这个函数的声明来参与类型分析,脚本写起来也好看一点。
在这里先借个地方吹吹水。我自己的那个JoveScript(后来痛苦地发现跟已有的一个东西重名)就简单地在函数声明后再加个关键字,例如:
int GetRoleHP(int RoleID) linking “_GetRoleHP”;
脚本在调用这个函数的时候,就把RoleID和”_GetRoleHP”一起扔出去。宿主程序就根据这个字符串来判断脚本需要的功能,然后就处理,如果还需要的话就把结果返回去。
而且因为我支持了函数重载,函数名在编译后总是会变成乱七八糟的东西,于是为了让宿主程序能够顺利地调用谋个函数,我就用类似的方法为函数搞了一个别名:
int GetSomePrivateStuff(int StuffID) exporting “_GetSomePrivateStuff”;
宿主程序在调用这个函数的时候,用的就不是函数名了,而是后边的这个别名。
吹水结束。
不过在这里还要提醒一个东西。现在很多脚本都是有垃圾回收的功能的。有些是半回收(类似于Delphi的那个),有些是全回收(类似于Java的那个)。当然,你还有另一种选择就是不回收。不过当写脚本的那个人不是程序员的时候,不回收可能会有一点风险。
垃圾回收的这个功能,会影响到语法的一些设计。一个典型的例子就是有new关键字没有delete关键字。而且更深层次地,垃圾回收的存在与否对于脚本数据在内存中的表示以及指令的设计都是有影响的。而且垃圾回收算法写得不好是会严重影响脚本的执行效率的。因此对这个问题应该慎重考虑。
第二个要设计的就是指令的格式。其实如果你的虚拟机是通过考察表达式树来执行的话,这一步是可以免的。但是指令在某种意义上有替你展开递归的功能,也就谈谈吧。
在设计指令的格式之前,自己先要决定号指令的执行方式。你可以选择自己模拟,也可以选择让CPU直接模拟。如果要让CPU直接跑的话,自己就要做一个Just In Time,把脚本编译成机器码,然后想办法让操作系统接受你这个东西往CPU送,还要自己写一个Debugger来捕获脚本传回来的消息完成脚本与宿主程序交流的功能。如果你是这种想法,那么指令就不用设计了,就是x86那套。如果不是,那么就要自己来。
完全依赖于堆栈(动态数据除外,当然这里说的是表达式的执行)的指令是最容易声称也是最容易跑的,譬如说a+b,就可以分解为以下三个步骤:
1:push a
2:push b
3:add//从堆栈弹出两个数字,相加,结果推回堆栈
第二种需要临时寄存器的,就需要一个放置结果的地方,于是可以有以下两种拆分方法:
1:
move result,a
add result,b
2:
add result,a,b
这种类型的指令执行起来可以更快(依赖于你是怎么完成的),但是编译的时候有点儿麻烦。我自己在写的时候就选用了完全依赖于堆栈的那种类型。
基本的格式设计完了之后就要思考函数调用的参数约定。当然这个东西可以随便搞,不过如果需要编译class的时候,设计得巧妙一点的话可以让class的编译结果比较优美,而且用堆栈里头的数据跟宿主程序交流的时候也会比较方便。
最后需要考虑的就是如何让堆栈和数据堆交换数据。一种经常想到的方法就是在指令里边增加一种叫“指针”的概念。其实存在动态数据就存在指针,不过你可以想办法让脚本语法里头不出现指针,用编译器把不是指针的东西变成利用指针来完成的东西。譬如说数组,在堆栈里头就可以保存一个指针,就像C那样做。当然,一般来说动态数组是要纳入垃圾收集的范围的,所以保存的数据除了数据指针之外还要有其他东西,而且这个格式还可以用来释放多维的数组,就是数组的元素也是数组。关于数据在内存中的表示,将在虚拟机的那部分着重介绍。在讲到编译器的时候也会说说。
其实设计这些语法并不是一件什么大事,有重大影响的除了脚本的易用性以外,如果语法太复杂的话自己可能没心情写完整个脚本引擎,那就遗憾了。所以在设计的时候是慎重之再慎重,在保证自己能够完成的情况下,让脚本的元素丰富一点,达到平衡就好了。
下一篇会开始讲编译器的一些事儿。熟悉编译原理的人可以跳过,不过我个人觉得《编译原理》这本书太难看懂了(再做完编译器之前的感觉),于是我在文章里头不会搞太高深的理论,不然我自己都受不了了。而且我自己把下一篇文章的读者定位为具有一定程序经验的人。可以不学编译原理。那个原理没关系,不会也可以做编译器的,唯一的区别就是做出来的不太好看而已。