在前面的章节,我们介绍了关于表达式解析的相关知识,并进行了相关实践。表达式解析的重要性是毋庸置疑的,但是只有表达式可能我们只能进行一部分工作(但可能在有的场合已经足够了,特别是仅需要对一些逻辑表达式进行解析的场合,例如笔者遇到的一款安全事件管理类软件,HP公司的Arcsight――其中有一个过滤器对象便是如此),但在多数情况下我们不仅需要解析表达式,而且需要对脚本的流程进行控制,包括条件语句和循环语句,另外还有最为常用的赋值语句。
在阐述上述语句是如何解析之前,我们先来了解一下后续章节中会包含哪些内容:
1.关于注释的处理
2.关于回车换行的处理
3.关于变量声明语句的解析
4.关于赋值语句的解析
5.关于条件语句的解析
6.关于循环语句的解析
7.关于函数返回语句的解析
本章会首先讨论前四种情况的相关脚本语言的解析原理和并给出相关范例。
在讨论之前,我们先来顺便讨论下如何处理解析后脚本的行号对应问题:因为中间代码一般肯对比原始代码的行数多,而且为了便于调试以及处理出错问题,所以中间代码需要和原始的代码需要有一定的行号对应关系,我们建议中间代码一般采用如下形式(中间代码形式在表达式的解析部分就进行过一定的探讨,但不太完整):
中间代码行号,原始代码行号,指令
当然,其实原始代码行号也可以放到最前面,这个就看具体是如何实现的。
另外,可能需要注意的一点是,其实一条语句或表达式可以跨行书写,那么如果解析的话,原始行号应该算哪一行呢?不同的语言编译器或解释器均有不同的做法,有的是对应的开始行,而有的则是结束行(我们的做法是后者)。
关于指令部分,在处理所有复合语句前就必须明确几种:
1.函数申明(一般高级语言编译器中是不会有的)
2.变量申明(在一般的编译器中这是伪指令,但这并不影响我们在脚本解释器中将其作为真正的指令来使用)
3.赋值(类似汇编语言中的“Mov”,但在本系统中的实现就是使用等号来标识赋值指令,而且我们不会像汇编语言那样搞得这么复杂,即我们将运算也当作赋值语句或条件语句的一部分)
4.条件跳转(在处理“if”或“if-else”中使用)
5.无条件跳转(在处理条件语句或循环时使用)
6.返回(在处理函数调用返回时使用,它实际上也和无条件跳转语句类似,只不过它有将返回值进栈和获取返回地址的操作)
任何语言,无论是高级语言、脚本语言还是汇编语言,它们都允许在程序文件中添加注释,显然注释并不是程序的一部分,但我们还必须谨慎处理它们,否则可能会存在错误(特别是和回车换行混用时)。
从分类看,注释一般只包括两种类型:
其一是单行注释,如一般Linux/Unix中bash脚本的注释就为“#”打头,脚本语言Perl也是如此,而C/C++可以使用“//”作为单行注释,在Oracle中则使用“―”(两个减号)进行注释;需要说明的一点是单行注释既可单独作为一行,也可以将其放置在行末,类似如下的情况(以Perl为例):
#Comment
或者
$a =$b+$c; #Comment
在C/C++中也是如此;
其二是多行注释,即使用明确的标识将注释包含在其中,例如Perl中的多行注释如下所示:
=Comment
…
=cut
而在C/C++中则使用“/*…*/”对多行注释进行处理(Oracle也是如此)。
需要注意的一点是,多行注释一般也可以从某行代码的末尾开始。
在处理注释的时候,一定要注意可能程序文件中也会出现相关的注释标记符号,特别是在字符串常量中。
本文所讨论的注释风格只限于第一种,即只支持单行注释,而且约定注释均是以“#”号打头;至于多行的注释风格,其实在理解了如何处理复合语句后,增加对其的支持也是非常容易的(只需要使用一点点技巧)。
其实,在前面讨论表达式解析的时候就已经提供一点关于回车换行(读者应该比较清楚回车和换行在ASCII中是不同的字符,而且一般Linux/Unix系统只支持回车,而在Windows系统中却还有换行符,不知道这是为什么?)的处理,但由于那不是表达式处理的主要部分,故未能深入讨论之。
在许多脚本语言或高级语言中,回车换行都不会作为代码解析或编译的界符,例如下面的语句:
a=b+
c;
这其实是一个合法的语句,甚至下面的语句也是合法的:
c=
a+
(22+
array[12+a+b])+
25.2+
fun(f(a+b),
sin(a),b,c,log(d))+(x+(y+z));
只要保证回车换行是介于算符(或界符)和算符(或界符)之间,或者是介入算符和一般的操作数之间,而如下的语句就是非法的:
a=variable_
1+variable_2;
这是由于回车换行出现在了变量“variable_1”中,虽然看起来好像没有什么问题,但一般的编译器或者解释器会将回车换行作为变量或常量的一部分,从而违反了相关定义――一般变量名中只能包含大小写英文字母、数字及下划线,这在我们的系统中也是这样定义的。
另外,在有些系统中是支持在行末使用“\”来连接不同的程序行,但在我们实现的系统中并不打算支持。
一般,在强类型语言中,变量的申明是必不可少的,这是由于需要告诉编译器变量的类型以便于编译后的代码能恰如其分地预留空间。
我们知道,对于函数申明的局部变量是保存在堆栈上的,而且编译系统会根据不同的CPU字长进行对齐(这是为了加快对于内存的存取速度,当然这样做会造成浪费一定的空间);幸好,我们讨论的是脚本语言的解析故不需要处理这些繁琐的细节。
另外,在强类型语言中,变量的类型还是非常丰富的,例如在C/C++中,存在什么字节类型(char)、短整型(short)、整型(int)、长整型(long)等等,它们的字长会由于不同的硬件平台、操作系统而不尽相同,但我们也不会处理如此复杂的场景。
如上所述,我们不想也不愿意处理特别复杂的变量申明语句,但是需要强调的一点是变量还是需要申明的(有些脚本语言中就不需要,例如一些操作系统自带的、常用的脚本语言bash,又如远程登录、命令执行脚本expect等),申明的格式如下:
var variable,@array;
其中,“var”是系统保留字,就是告诉解析程序这是一个变量申明语句,如果需要在一个申明语句中申明多个变量,则应用逗号将它们分隔开来,语句是以分号为结束标识。其中,如果需要申明数组的话,需要在变量的前面加上“@”符号,以与普通变量区分开来。
关于变量的名称及长度的合法性检查,我们已在表达式解析一章进行了详细的解释,这里不再赘述。
另外,变量申明语句是可以出现在脚本程序的任意位置的(这和传统的C语言不一样,而传统C语言是必需将申明语句放置在函数的开始部分),而且变量之间是可以重名的;那么如果发生重名了,应该怎么办?如下述语句:
subfoo()
{
var a=1,b=2;
if ( a == 1 )
{
var b=3;
a = a+b;
}
}
那么,运行上述脚本,我们会发现,虽然变量b被申明了两次,其中一次的变量初值为2,而在“if”语句中申明的变量b则被赋了初值为3,结果a的值为4,而不是3。也就是说,在变量重名时,解析器或编译器是使用所谓的“就近原则”(一般其它的编译器也都是这么做)。
在一般的高级语言编译器中,变量的申明是不会被翻译成机器指令的,因为也没有这样的机器指令,但它们会将一些相关信息保存在可执行代码中,当然前提是在编译选项中打开调试开关,否则也无法获得这些变量的具体信息(主要是变量名和堆栈实际地址的对照)。
但我们其实可以不受这些约束,因为我们处理的是脚本而不是让它们直接生成机器代码,故在处理变量申明时,解析程序依然会生成变量申明的中间代码,原申明如下:
var variable,@array;
被翻译成下述中间代码形式(省略了原脚本行号及中间代码行号,下同):
decl variable,0
decl @array,1
从上述语句的翻译可以看出,对于变量申明语句而言,无论你在一个语句中申明了多少变量,我们均会把它们拆分成一行申明一个变量的形式;而在中间语句中的“0”、“1”则表示这个变量在其所在函数局部堆栈中的相对位置,此位置从0开始,如下面还有其它变量的申明则会按顺序下去。
对于在复合语句中申明的变量(如在“if”语句或“while”语句中申明的变量),也是遵照此种原则,请看下例:
subfoo()
{
var a,b,c;
…
if ( a>0 )
{
var d,e,f;
…
}
…
}
则其变量申明部分会被翻译成如下中间代码:
decl a,0
decl b,1
decl c,2
…
decl d,3
decl e,4
decl f,5
…
可以看出这个变量的序号实际上所以就是以其函数为“界”的,如果换成其他函数则这个索引值就又会从0开始。
结合前面讨论过的表达式解析中关于变量的处理,那么实际上也可以被直接翻译成如下形式:
decl a, %sstack[0]
decl b, %sstack[1]
decl c, %sstack[2]
…
decl d, %sstack[3]
decl e, %sstack[4]
decl f, %sstack[5]
…
其中,%sstack就是所谓的符号栈,实际上就是堆栈。
在这里,需要强调的一点是,这个索引一定是相对值,因为脚本的运行也是以函数为单位的,我们并不能预先知道这些函数的调用顺序。
另外,由于我们实际上建立了局部变量和堆栈的对应值表,故可以使用调试程序观察各个变量在运行时的值的变化,这个问题我们会在脚本运行时的处理再讨论,这里不再深入。
几乎在所有语言中都支持数组,我们也不例外,而且在我们实现的系统中,数组实际上是动态的(为了简化用户的使用),这个和C/C++语言不同(C/C++语言本身不支持动态数组,如需支持则需要另外开发,例如C++的标准模板库中的vector,实际上就是动态数组的一个实现版本),但是在我们上面给出的示例中却发现,即使是数组怎么也只占领了堆栈中的一个位置?
其实,可以说这个堆栈中的变量申明只能是一个指针或者是一个“钥匙”,它只是访问数组的一个指示,真正的数组元素是存储在别的地方,这个实际上是在脚本运行时才需要处理,而在解析时则不需要特别关注。
我们会使用一种特殊的哈希结构来存储这些数组元素,而这个哈希的键就是堆栈中的数组变量的另外一个名字(可能是一个UUID),而哈希的值中存储的是数组中的所有元素,当函数退出时,我们就会在哈希中销毁这个数组所存储的所有数据,这看起来好像这样:
var @array;
在实际运行中会变为:
我们经常会在变量申明的时候,会直接对其进行赋值,对于这种情况,在解析此类语句时实际上会将其拆分为变量申明和赋值语句(包括对于数组的赋值),如下:
var a=1,b=”hello”,@array=(1,2,3,4,5);
会被翻译成如下中间代码:
decl a,0
a=1
decl b,1
b=”hello”
decl @array,2
array[0]=1
array[1]=2
array[2]=3
array[3]=4
array[4]=5
前面对于变量的的解析问题基本讨论完毕,但还有一个问题需要阐述清楚,就是变量的作用域问题,这个问题的处理在脚本的解析过程中也起着举足轻重的作用;我们还是用之前讨论过的一段代码为例(在后面增加一行代码“b=b+1;”),即:
sub foo()
{
var a=1,b=2;
if( a == 1 )
{
var b=3;
a = a+b;
}
b=b+1;
}
对于上述代码,我们知道在“if”语句中申明的变量b的初值就是3,这和前面申明的变量b是两码事,这就说明后面申明的变量b的作用域(scope)就在“if”语句内,而一旦代码离开了这个作用域,那么就不起作用了,这是在我们对变量申明语句中必须重视的一点,上述代码会被解析成类似如下内容:
decl a, %sstack[0]
a=1
decl b, %sstack[1]
b=2
#行号1和行号2分别是条件为true和false的跳转行号,在条件语句的解析中会给出解释;jc是条件跳转指令
jc,a==1,行号1,行号2
decl b, %sstack[2]
%sstack[0]= %sstack[0]+ %sstack[2]
%sstack[1]= %sstack[1]+1
请注意观察上述中间代码的粗体部分,这充分地说明了变量在不同的作用域中其实是不同的东西。
关于全局变量的确切定义其实有许多版本,我们并不打算对其再进行深入地讨论,但它们却是是一类特殊的变量(我们认为不属于任何一个函数的变量就可称作全局变量),在我们的实现中,其实和普通的局部变量并无二致,但在实际处理时,还需要给用户提供一些便利,这个便利是什么呢?
可以认为全局变量在代码或脚本文件的任何地方申明都是可以的,即使是在某个函数中引用了全局变量,而这个全局变量是在文件后面部分定义的也可以,而不要让解析器给出类似“未申明的变量”这种警告或错误提示(关于这点和解析函数倒是有异曲同工之妙),那么在解析脚本时,我们就需要对整个文件扫描完毕后才能给出哪些是未定义的变量,这一点在编译技术中可称作“滞后”;早期的C语言编译器是不支持这种技术的,但现在则基本都支持(这点不是太清楚,可能有些嵌入式系统的编译器还是如此),除非你要加入一些特殊的选项。
另外,一般的高级语言编译器中,都可以对全局变量或函数增加一个修饰字以表明它们是否可以导出,我们也不例外,当然可以导出的变量和函数会在中间代码文件中占有一席之地,实际上就是表明它们的名称、相对地址,这个在后续的章节中再行讨论(多文件解析)。
关于导出全局变量(或称作文件变量)的声明方式如下:
exportvar a,b
中间代码如下:
declexport a,0
declexport b,1
其实,关于赋值语句的解析是没有太多的内容可以阐述的,因为这个在说明表达式解析时已经覆盖的差不多了,唯一不同的一点是:因为是赋值语句,故不要忘了在后面增加一个分号而表明语句结束了,其它的就当作是赋值表达式解析好了。
另外,就是需要注意对于表达式左值的合法性校验――必须是已申明的变量,而不能是其它的什么;还需要注意的一点是需要对标量还是向量进行检查(除非特殊场合,一般都是标量)。
最后,还有一种语句形式我们都没有单独列出,不是忽略了,而是感到它们其实和表达式的解析也并无不同,即表达式语句,所以这种形式就不单独讨论了,它们一般都是用于函数调用,特别是不需要返回值(如各类输出函数等)或者参数中就包含了输出参数的场景,否则就没有什么太大用处。