用脚本语言扩展程序功能

来源:互联网 发布:网络教育本科升研究生 编辑:程序博客网 时间:2024/05/17 04:38

1.       什么是脚本语言

脚本语言(Scriptlanguagescripting languagescripting programming language)是为了缩短传统的编写-编译-链接-运行(edit-compile-link-run)过程而创建的计算机编程语言。此命名起源于一个脚本“screenplay”。早期的脚本语言经常被称为批次处理语言或工作控制语言。一个脚本通常是解释运行而非编译。脚本语言通常都有简单、易学、易用的特性,目的就是希望能让程序设计师快速完成程序的编写工作。

脚本语言(Scripting language)是计算机编程语言,因此也能让开发者藉以编写出让计算机听命行事的程序。以简单的方式快速完成某些复杂的事情通常是创造脚本语言的重要原则,基于这项原则,使得脚本语言通常比C语言、C++语言或Java之类的系统编程语言要简单容易,也让脚本语言另有一些属于脚本语言的特性:

  • 语法和结构通常比较简单
  • 学习和使用通常比较简单
  • 通常以容易修改程序的解释作为执行方式,而不需要编译
  • 程序的开发产能优于执行效能

脚本语言的种类有工作控制语言和shellGUI脚本(有时也称宏语言)、应用程序定制的脚本语言、Web编程脚本、可嵌入式脚本等等。由于工作经验,本文主要讨论可嵌入式脚本的应用。

关于脚本语言的介绍,可参见维基百科http://zh.wikipedia.org/wiki/%E8%84%9A%E6%9C%AC%E8%AF%AD%E8%A8%80#.E9.80.9A.E7.94.A8.E5.8A.A8.E6.80.81.E8.AF.AD.E8.A8.80

2.       嵌入式脚本语言

这里的嵌入式说的不是指嵌入式系统,而是说脚本语言可以嵌入到宿主程序(一般为编译语言开发)、与宿主程序内部进行通信的语言,例如Lua之于C/C++BeanShell之于Java。以Lua为例,Lua是使用标准C编写的一个嵌入式脚本语言,它的编译器提供了一组标准CAPI,你可以在你的C/C++程序中通过这些API去编译一个Lua脚本、执行脚本、调用脚本中的某一个函数;也可以将C/C++程序中的变量的值赋给Lua脚本中某个变量的值,将C/C++中的函数与脚本中的某个函数对应,这样在执行Lua脚本时,就能够在脚本中读取/设置程序中的变量,调用程序中的函数。说到这里,相信大家已经明白,这里的嵌入式脚本语言就是Lua,而宿主语言就是C/C++

通过上面的说明,相信大家已经清楚了嵌入式脚本的工作原理。不过,既然称为一门语言,嵌入式脚本当然能够单独运行,这里只介绍它的嵌入式这个强大的功能。

3.       使用嵌入式语言

明白了嵌入式脚本语言的原理,你是不是能够感觉到它的强大呢?本章想根据经验说一说它的嵌入性的强大。

3.1.   不修改代码而改变程序的运行逻辑

有过多线程程序开发经验的同事应该知道,多线程程序的调试是一个比较头疼的而事。因为多线程程序运行时,不同线程的运行顺序对程序运行的结果会产生不同的影响,如果设置断点进行调试的话,很难再现持续运行的效果。所以,多线程的调试一般都是通过输出Log信息进行的。

但是,随之而来的一个问题是,既然是写Log,就避免不了要在程序中打上Log的印记(添加Log语句)。在程序还没有达到可以进行ST测试的程度时,调试用的Log信息需要记录的非常详细,所以就需要在很多函数中添加Log语句。但是多人调试的时候,一个人打印出的Log信息未必是另一个人期望的。所以,当另一个人调试时,他又得辛辛苦苦的去删掉前一个人辛辛苦苦添加的Log语句。

另外一种情况是,可以假定你的程序是程序组中的一员,例如你的程序是整个程序组的一个组件(dll),当你需要调试bug时,很有可能条件不允许你启动调试器,此时设置断点之类的操作就变成了一个遥不可及的鸿沟。

在这些情况下,脚本语言的嵌入特性就可以发挥它的强大功能了。以Log问题为例,在不使用脚本语言前,调试过程如下。

1. 添加Log信息

Source.c -->

voidfun1(int p)

{

   LOG(“fun1: p=%d”, p);

   ……                                

}

 

voidfun2(int p1, int p2)

{

   ……

}

 

 

 

 

2. 编译程序

3. 运行,查看Log信息









第二次调试
==========>

1. 删掉第一次添加的Log语句(输出信息比较乱),添加fun2Log语句

Source.c-->

voidfun1(int p)

{

   LOG(“fun1: p=%d”,p);

   ……

}

 

voidfun2(int p1, int p2)

{

   LOG(“fun2: p1=%d,p2=%d”,

p1, p2)

   ……

}

 

2. 编译程序

3. 运行,查看Log信息

 



2. 编译程序

3. 运行,查看Log信息

 




如何,每次调试都要这样改来改去,是不是很麻烦?而且更糟糕的是,你还要时刻准备着别人向你发火——因为你干扰了别人的调试。

如果使用脚本来控制Log的执行过程的话,调试过程就变成下面这个方式。


voidfun1(int p)

{

   LOG(fun1, p);

   ……

}

 




voidfun2(int p1, int p2)

{

LOG(fun2, p1, p2)

   ……

}



====>








====>

Function LOG(func,args)

{

   If(func == “func1”)

print(unpack(args));

}


           ||

           || 第2次调试

           ||

Function LOG(func,args)

{

   If(func == “func2”)

print(unpack(args));

}



上图看起来和不使用脚本的场合上没什么区别,但是请注意,使用脚本后,调试时只要修改脚本就可以了,并不需要编译。也就是说,不是用脚本,调试时的工作流程为:

     修改代码

     编译

     调试

     修改代码

     编译

     调试

     ……

使用脚本后,调试过程如下:

     修改脚本

     调试

     修改脚本

     调试

     ……

这样立刻就可以看到脚本的好处。只要稍微修改脚本,就可以得到不同的Log信息,这是不是很棒呢?而且这个脚本可以放在本地,宿主程序缺了它,编译也没有任何问题。还有一个好处是,你可以在最终成果物中使用一个脚本来控制Log文件的格式。

3.2.   用脚本实现插件式开发

插件式软件现在已经不是什么新鲜物了,不过对于我们大多数人来说,设计一个插件式平台还是一件非常困难的事情。这主要是设计插件式平台需要考虑很多因素:平台与插件间的通信机制、插件的加载机制、插件的卸载机制、插件与插件之间的通信等等。其中最为困难就要数平台与插件间的通信机制的设计了,如果平台和插件接口间的信息过多,那么就会增加插件实现的复杂性;如果信息过少,有可能能使插件实现不了想实现的功能。

此时,我们可以使用嵌入式脚本来实现插件平台,因为嵌入式脚本可以和宿主程序通信,可以调用宿主程序里的变量和函数。以Lua脚本为例,一个简单的插件平台的工作过程如下:

     启动程序(平台)

     加载脚本(插件)

     向脚本中注册变量/函数的引用

     运行脚本      à此时便可以访问当程序中的变量和函数

     卸载脚本(插件)

上面的是不是很像一个Observer模式呢?如果想启动插件,只要调用脚本中一个固定的函数(就像C语言的main函数那样)就可以了,脚本就可以使用宿主程序预先注册进来的变量、类还有对象。脚本还能够准确的判断出未注册的变量、类和对象并做出相应的处理——因为它是动态语言,动态语言的类型是晚绑定的,就像C++的虚函数那样。

使用脚本实现插件平台时,插件是以脚本源代码方式存在的,这就涉及到安全问题,如果软件的安全性要求很高的话,用这种方法是不合适的。不过这是一个实现插件式平台的比较简单的方法,因为如果你想让你的插件功能强大的话,你只需多注册一些信息;如果你想限制插件功能的话,也只需少注册一些信息即可。

最著名的插件式平台当属Eclipse了,它是用Java编写的,它的插件是以jar包的形式存在的。实际上,现在大多数的脚本语言为了提高运行效率,都是先将源代码编译成字节码,然后在虚拟机上执行字节码的。由于这些脚本语言比较小巧,将编译过程和执行过程结合到一起,所以运行时都是以运行源代码方式运行的。如果能够将脚本语言的字节码和它的虚拟机剥离开来的话,那么它就和jar包一样了。从这个方面来理解,脚本语言和Java实际上是殊途同归的。

3.3.   用脚本增加程序模块的独立性

以上说的是脚本和宿主程序以分离的方式进行应用,其实嵌入式脚本既然以嵌入式为设计目的,它自然可以融入到宿主程序中。也就是说,嵌入式脚本编译器的输入可以不是源代码文件,而是内存中的一个字符串,甚至你可以按规则分段输入。

这种应用也有应用实例。例如协议编译器,它的功能大致如下。

协议构造定义文件

描述协议信号结构的文件

 输入
====>

  

编译器   

输出
====>

编解码器源代码




上图中协议构造文件是用来描述信号的结构信息的,如果需要的话,编译器也可以直接运行该构造定义文件实现编解码的功能,但是一来由于构造定义文件是以文字形式存在的,运行时效率较低;二来用户对编解码器的需求不明,也就是说用户可能需要进行编码,也可能需要进行解码,也可能需要对信号数据进行规则检查,所以编译器输出编解码器源代码,用户对这个源代码进行定制、编译以后就能得到自己需要的高效的满足需求的软件。

协议编译器的一个主要功能,就是根据协议构造定义文件中的信息,生成编解码器文件。在编译器系统设计时,为了提高模块独立性,我们会根据逻辑功能设计出一个比较独立的源代码输出模块,它的代码组成大致如下:


--output_module.c

void generate_header()

{

   printf(“#include <stdio.h>/n”);

   printf(“#include /”Signal_%s.h/”/n”,g_parentSignalName);

   printf(“/n”);

   printf(“extern void Signal_%s_encode();/n”,g_signalName);

   ……

}

 

voidgenerate_source()

{

   printf(“#include /”Signal_%s.h/”/n”,g_signalName);

   printf(“void Signal_%s_encode(){/n”);

   printf(“……”);

   printf(“)/n”);

}



到现在为止,一切都水到渠成。这样设计应该是很完美了。不过,实际开发时,会发现以下这样的问题:

       变量的使用。事实上,是不能使用全局变量的,因为要同时一次生成很多文件,全局变量很容易混乱。可以使用一个Hash表来作为参数传给该模块来解决这个问题。

       相同处理。由于程序员的天性,会将相同处理封装起来,例如,#include语句的输出。这样,就将generate_header()这样输出一个文件的函数就被拆分成多个函数。由此也导致该文件的输出格式极难控制——不生成文件的话,光凭看代码很难想想输出后的内容。

       重复输出内容的处理。例如有n个输出语句需要用到g_signalName,那么你就需要在这n个语句出分别写上g_signalName。结合如下所示。

void write_signalInclude(char* signalName)

{

   printf(“#include /”Signal_%s.h/””, signalName);

}

void generate_header()

{

   write_signalInclude(g_parentSignalName);

   ……

}

 

void generate_source()

{

   write_signalInclude(g_signalName);

 

}

 

上面这段代码已经很精简了,应该没有重复代码了。但是,慢慢的,客户要求明确了,信号的父节点不能以“Signal_”开头,要改成“Protocol_”开头,于是就需要重新写一个函数叫做write_protocolInclude(),然后将generate_header()里面的代码替换掉,然后再看看是否有类似的代码……。客户的需求是无限的(有时自己也会在开发后期发现生成的文件命名不规则会带来很大的麻烦),这样修改下去发现代码越来越乱,函数也越来越多。而且糟糕的是,当发生bug时,只有设计者才能清楚的明白问题发生在哪里。

针对这个问题,我们自己开发了一个迷你脚本语言,用来将输出的代码格式与逻辑控制分离开来。它的大致工作流程如下。


构造定义语法树



变换
===>




变量表

SignalName=A



生成
====>

文本模板

#ifndef _$toupper($(SignalName)){$}

#define _$toupper($(SignalName)){$}

 

#include<stdio.h>

#include “Signal_$(SignalName).h”

 

extern Signal_$(SignalName)();

#endif //_$toupper($(SignalName)){$}



上图中,“文本模板”就相当于一个脚本语言的源代码。蓝色部分是脚本语言支持的变量和函数的语法(当然,它还支持很多函数功能,该工具已经提交公司工具库,有兴趣的同事可以参考),虽然比较晦涩,但是还好,它的语法很简单,也比较简短,基本不影响其它部分格式的查看。

有了这个小脚本语言,我们可以得到以下好处:

     格式与数据分开。(是不是有些类似MVC模式^_^)

     脚本语言可以支持丰富的功能,但是表现简单——不会污染输出格式。因为脚本语言里定义了函数,而且脚本编译器也支持添加新的函数,但是在脚本里面的表现却不过是一个函数名而已。

事实上,这样实现,不只是在形式和物理上把格式和数据分开这么简单,它也使得开发人员在思想上也将这两部分分开了,直接的到的好处是,如果输出文件格式变化了(事实上由于编解码器的结构不能很快确定,是经常变更的),可以不需要该模块的设计者(甚至不熟悉新项目的人也可以——只要告诉他最后输出的是什么样子)去修改代码。

4.       小结

实际上,嵌入式脚本已经大行其道。例如Lua已经在很多游戏中使用,最著名的要数WarcraftIII;还有Excel宏脚本,它被称为宏语言,其实从功能上来讲,也属于嵌入式脚本语言。

脚本语言是一种动态语言,因为它不是类型安全的,所以脚本语言的语法一般都和编译语言的语法不同,而且,由于工作的关系,很多脚本语言都是在我们的视线之外的,因此很多人不愿,也不喜欢学习它。但是我还要说,它是值得学习的。使用编译语言开发程序久了,思想很容易固化,学习一些其它知识会进一步开阔你的视野。脚本语言不仅仅是一种工具,它还是一种思想。如果你肯花时间学习一下Lua的话,你就会惊叹它的设计的精巧。它居然只用一个数据结构——表——就实现了数组、哈希表、虚函数;还有,Lua实现了一个小巧的虚拟机,通过阅读它的源代码,你也能够学到什么事虚拟机,什么是字节码;Lua字节码的执行是通过一个栈来执行的,它与宿主程序的通信也是通过这个栈来实现的,学习它的API,还能知道栈是怎么工作的;Lua虽然能够与C/C++宿主程序通信,但是它维护的内存与宿主程序的内存却不发生关系,Lua与宿主程序各司其职,这样的设计岂不是非常精巧?

编译语言和解释语言在运行方式和物理形式上都不一样,这种差异让我们在程序开发中多了很多选择。但是也要适当的把握脚本语言的使用程度,毕竟解释语言与编译语言的运行效率还是有一定差别的。另外,由于脚本语言是动态语言,也很不容易调试。

原创粉丝点击