3.3 单元级控制结构
本节将讨论实现程序单元之间控制流程的机制。最简单的机制是ALGOL 60的分程序,后来的许多语言都采用了这种机制。在程序顺序执行的过程中,遇到一个分程序,就建立一个新的引用环境,并执行这个分程序。更强的机制允许程序员通过显式调用单元(例如函数和过程),把控制从一个单元转移到另一个单元。我们将讨论4类单元级控制结构。
(1)在大多数情况下,被调用单元从属于调用单元。换句话说,调用单元以显式名字调用它的从属单元,而从属单元简单地转移控制返回调用单元,即返回隐式规定的单元。典型的例子是子程序,它执行返回操作把控制返回到主程序,返回的单元是隐含的。
(2)被调用单元也是隐含的,例如异常处理程序(Exception Handler)。一个单元若发生一个异常事件,隐式激活相应的异常处理程序。
(3)各单元以对称的模式组成一组协同程序(Coroutine),在这种情况下,单元之间彼此显式激活。这些单元以交错的方式进行,例如CLU中的重复构造和SIMULA 67中的协同程序。
(4)各单元可组成一组并发(Concurrent)或并行(Parallel)的单元或进程(Process),它们之间不存在调用和返回的概念,而是并行处理,每个单元都被看成独立自主的单元。
下面我们对这4种情况分别加以讨论。
3.3.1 显式调用从属单元
这种类型的调用覆盖所有的子程序,从FORTRAN语言的子程序和函数,到Ada语言的过程都属于显式调用。每个子程序(函数、过程)都有一个名字,通常都由调用语句使用被调用单元的名字来进行调用。
执行调用语句时将控制转向被调用单元,被调用单元执行完后,又将控制返回调用单元,并继续执行紧跟在调用语句后面的语句。
Java语言用对象名进行对象的调用。对象是具有状态和行为的软件模块。一个对象的状态包含在它的成员变量中,它的行为是通过它的函数来实现的。所谓调用对象,实际上是调用它的函数,且通过return语句返回函数值,也可以不返回值(也可返回空值(void))。对象是类的实例,是可执行的。类说明仅仅是创建类的模板,类说明后,可以建立多个实例(对象),类的操作由编程人员在创建对象时提供。对象说明仅仅是声明它的“类型”是哪一个类,用new语句才能创建一个对象。用new语句创建一个对象时,为对象分配一段内存空间,并通过类的构造函数将它初始化。new语句返回一个引用(Referend)赋予一个适当类型的变量,这个引用实际上就是通常语言中的指针,以实现相应的调用。函数调用也被称为发消息(Messa-ges)。
当控制从调用单元转向被调用单元时,还可进行参数传递(Parameter Passing)。参数传递可实现单元之间的通信。当然,单元之间的通信可以通过全局环境来进行,但每次调用允许传递不同的数据,为实现单元之间的通信提供了灵活性,同时也提高了程序的可读性和可修改性。
在大多数程序的子程序调用中,使用位置方法来实现实参与形参的绑定。例如,一个子程序说明为
subprogram S(F1,F2,…,FN); …… end
并且子程序调用是
call S(A1,A2,…,AN)
那么,位置方法暗示实参Ai绑定形参Fi(i=1,2,…,N)。
位置参数绑定方式很方便,但它也有缺点。在参数比较多,又允许多个参数省略的情况下,很容易出错。若在上例中设N=10,且第2,4,5,6,7,9个参数可省略,假若这些参数的实参都省略,那么,必须用“,”来占领相应的位置,这时的程序调用语句是
call S(A1,,A3,,,,,A8,,A10)
其中,省略的实参为空,其后的逗号保留。读者可能已经注意到,上例很可能把逗号的位置弄错,多一个逗号或少一个逗号都会造成形参与实参的绑定错误,同时也降低了程序的可读性。
为了克服上述缺点,有些语言提供了关键字(Keyword)绑定方式,过程调用显式列出相应实参与形参的绑定关系。例如上述调用语句可写成
call S (A1⇒F1,A3⇒F3,A8⇒F8,A10⇒F10)
显然,关键字绑定方式对允许省略参数的过程调用特别有用,省去了许多无用逗号,避免了许多错误。
在参数可省略的情况下,一般都要在过程头做出专门的规定,指出以什么样的特定值来替代省略的实参。
像语句级控制结构中的goto语句会影响程序的可读性一样,在子程序调用中,副作用(Side Effect)和别名(Aliase)也会影响程序的可读性,容易引起程序出错。下面将对此进行讨论。
1.副作用
对非局部环境的修改称为副作用。通常,一个程序单元要在运行时建立一个自己的局部环境(参见第13章),并可以对自己的环境进行引用和修改。同时,也有一些其他单元的环境可被这个单元引用和修改,这种修改就产生了副作用。副作用原则上提供了一种程序单元之间的通信方法,它通过非局部变量来完成。然而,为此需要设置大量的非局部变量,各程序单元可不受限制地访问它们,使得程序难读懂,也难于理解。在这种情况下,每个单元都有可能引用和修改在非局部环境中的变量,这种不受约束的引用和修改容易造成不该发生的错误。
一旦全局变量用于通信,就难于区分所产生的副作用是否是需要的。例如,若单元U1和U2由于疏忽修改了非局部变量X,而X又用于单元U3和U4的通信,显然U1和U2对X的修改产生的副作用是不需要的。这类错误很难发现和消除,因为引起这类错误的征兆很难被发现。一个简单的类型错误就会引起上述问题。如果在编译时检查调用指令,也难查出调用会影响哪些变量,为了弄清调用的影响,通常必须查遍整个程序,因而降低了程序的可读性。
对于大型程序,通常都由若干程序员并行独立开发若干程序单元。在这种情况下,不受约束地访问非局部变量是非常危险的。一种解决方法是使用参数,使它们仅用于调用单元与被调用单元之间的通信。参数传递方法对时间要求严格的应用(例如实时控制)是不合适的,因为引用调用的参数在编译时都编译成间接访问,降低了程序执行效率。另一种方法是,严格限制两个单元共有的非局部变量,只有作为两个单元通信的非局部变量才设置。也可以规定,某些变量仅允许某些单元读,而不允许写。
副作用也用于引用调用(参见13.4节)的参数传递中,副作用体现在对实参的修改。这时,实参是被调用单元的非局部变量。在这种情况下,应特别小心,不要对实参产生不需要的副作用。
在函数子程序中,副作用特别有害。例如
w:=x+f(x,y)+z
其中,f(x,y)是函数,它用在表达式中,以函数名(可带参数)来调用。在Pascal语言中,若实参是引用调用,那么对f的调用就可能引起x和y值的改变,这样,表达式x+f(x,y)与表达式f(x,y)+x的值可能不同。另外,若z是f的全局变量,对f的调用可以引起z值的改变,因此f(x,y)+z与z+f(x,y)的值可能不同。由于副作用的原因,对w赋值的语句的计算结果与计算表达式各项的先后次序有关,影响了程序的可读性,也使表达式不遵从交换律。
除了影响可读性外,副作用还影响到编译程序对某些表达式生成优化的目标代码。例如
u:=x+z+f(x,y)+f(x,y)+x+z
考虑到f的副作用,不能对子表达式x+z提公因子而只计算一次,因为前面的x+y的值可能与后面的x+y的值不相同。甚至f(x,y)也不能只计算一次,由于f的副作用,使得
f(x,y)+f(x,y)≠2*f(x,y)
2.别名
在单元激活期间,若两个变量表示(共享)同一数据对象,则称它们是别名。若两个变量具有别名,那么用一个变量名修改数据对象,另一个共享这个数据对象的变量对这个修改是自动可见的。例如FORTRAN语言的等价语句
EQUIVALENCE(A,B) A=5.4
使A和B绑定同一数据对象,并对它们赋值为5.4,因此语句
B=5.7 WRITE(6,10)A
打印出来A的值为5.7,即对B的赋值同时影响到了A和B。
若过程参数传递方式是引用调用,那么形参和实参共享同一数据对象,引起别名。考虑下列Pascal过程,它不使用任何局部变量来完成交换两个整型变量的值。
procedure swap(var x,y:integer); begin x:=x+y; y:=x-y; x:=x-y end
我们来检查并判断它是否能正确地工作。按一般理解,回答是肯定的。然而,事实上,只有两个参数不是同一变量时,过程的工作才是正确的。例如调用
swap(a,a)
将a置0,因为这时x和y成为别名,在过程中对x和y的赋值都影响同一单元。调用
swap(b[i],b[j])
在下标i=j时,也会出现同样的问题。指针也有别名问题,例如
swap(p↑,q↑)
当p和q指向同一数据对象时,也无法实现交换。
归纳起来,形参和实参共享同一数据对象,过程调用具有重叠(相同)的实参,都会引起别名。
当形参引用调用实参时,它与全局变量表示同一数据对象,或者有重叠的数据对象时,也会引起别名。例如,若swap过程写为
procedure swap (var x:integer); begin x:=x+a; a:=x-a; x:=x-a end
其中,a为全局变量,那么调用
swap(a)
将产生不正确的结果,因为x和a是别名,共享同一数据对象。Pascal的过程或函数的参数传递方式如果是值调用,就不引起别名,这时参数实际上是过程内的局部变量,相应的实参仅在过程的出口受影响。
别名的存在对程序员、程序的阅读者及语言的实现者都是不利的。程序中偶然出现不同名字表示同一对象,使子程序难于理解。这个问题不可能通过检查子程序来发现,若要发现必须检查调用子程序的所有程序单元。作为别名造成的后果,可能使子程序调用产生不期望得到的结果。
别名也影响编译器生成优化的代码,例如
a:=(x-y*z)+w; b:=(x-y*z)+u
其中,若a与x,y或z中任一个是别名,那么,两个赋值语句中的子表达式x-y*z不止计算一次,从而影响了优化。
针对别名的困难性和不安全性,有些语言已限制使用它们。Gypsy和Euclid是最著名的例子,它们是1970年之后基于Pascal设计的语言,主要用于验证系统程序。副作用和别名对程序验证(Program Verification)非常不利,因此这两种语言都排除了这种特性。
定义别名允许在同一单元实例中以两个以上的名字访问同一数据对象,因此可有两种方法来消除别名。一种方法是,完全废除可能引起别名的结构,例如指针、引用调用、全局变量和数组等。这样做的结果使语言的能力受到太大的限制。Euclid语言采用另一种方法,对上述结构限制使用,排除了别名出现的可能性。
参数传递为引用调用时,仅在实参重叠时会引起别名。若实参是简单变量,必须保证它们是可区分的,因此,过程调用
P(a,a)
在Euclid中认为是不合法的。在参数传递中,传递一个数组和一个数组元素也是被禁止的。例如,若过程P的过程头为
procedure P(var x:integer;var y:array[1..10]of integer)
那么,调用
P(b[i],b)
是不合法的,因为b[i]是b的别名。这类不合法的别名可在编译时查出。然而,调用
swap(b[i],b[j])
仅当i=j时才会产生别名,所以,Euclid特别规定上述情况的调用必须满足条件i≠j。对这种情况,由编译器生成这个条件作为合法性断言(Legality Assertion)。在测试阶段,对由编译器生成的合法性断言进行运行时检查。若运行时断言计算为假,执行失败,产生相应的错误信息,合法性断言主要用于验证。事实上,Euclid系统包括一个程序验证器(Program Verifier),若一个Euclid程序被认为是正确的,仅当验证器证明了所有合法断言为真时才成立。
指针可以产生别名,全局变量和过程形参也可以产生别名,Euclid对此都有相应的解决办法,我们在此不再做进一步的讨论。
3.3.2 隐式调用单元——异常处理
前已叙述,子程序和过程是通过名字显式调用的,从而使控制从一个单元转向另一个单元。现在讨论不通过名字显式调用,而隐式地将控制从一个单元转向另一个单元,即异常处理程序的控制转移问题。
异常处理是一种语言机制,它既为不同层次的各种过程间进行通信提供了一种工具,也给出一种与传统过程调用和过程终止不同的特殊控制转移。
异常(Exception)是指导致程序正常执行中止的事件。它靠发信号来引发,用异常条件(Exception Condition)来表示,并发出相应的信号,引发相应的异常。异常引发后,需要进行处理,这种处理由专门的异常处理程序来完成。异常处理程序就是隐式调用的程序单元。
异常事件还不能严格定义,因为我们总是希望,即使出现硬件/软件失败,或偶然发生例外,或无效输入等情况,程序仍要合理地运行。然而什么是正常处理状态,取决于程序设计者所采取的策略和应用的性质。因为,一个异常处理状态并不意味着出现灾难性错误,有的是可以修补的,只有在某种意义下,程序单元无法继续执行时,才会导致程序正常运行的中止。
早期语言中除PL/1外,通常没有专门的异常条件及异常处理程序。许多近期开发的语言提供了异常处理机制,使涉及异常事件的处理独立出来,不包括在程序的主流程中,以保证程序的逻辑按基本算法进行。
有关异常处理的主要问题可归纳如下:
① 异常如何说明,它的作用域是什么?
② 异常如何发生(或如何发信号)?
③ 发出异常信号时,如何控制要执行的程序单元(异常处理程序)?
④ 发生异常时,如何绑定相应的异常处理程序?
⑤ 处理异常之后,控制流程转向何处?
在这些问题中,问题⑤的解决对语言处理异常机制的能力和可使用性有很大的影响。语言设计中可能的基本选择是,相应的异常处理程序执行完之后,允许控制返回发生异常事件的那一执行点。在这种情况下,异常处理程序可对执行的程序进行某些“修补”,终止相应的异常事件,以便程序继续正常地执行。这种方法已在PL/1和Mesa语言中采用,其功能很强,又很灵活,但对不熟练的程序员掌握起来比较困难,程序中可能隐藏了不安全因素,虽然消除了错误征兆,但却未真正排除引起出错的原因。例如,为了处理一个不可访问的操作数的值所引起的异常事件,处理程序可以简单地随意产生一个可访问的值来处理这个异常事件。虽然解决了程序继续执行的问题,但并未真正消除出错的因素。
另一种方法是,终止引起异常的程序单元的执行过程,把控制转向异常处理程序。从概念上说,这意味着引起异常的活动不能恢复;从实现的观点来看,这意味着删除发信号单元的活动记录。Bliss,CLU和Ada等语言采用了较简单的方案。下面分别介绍PL/1,CLU和Ada语言的异常处理机制。
1.PL/1语言的异常处理机制
PL/1是最早设置异常处理的语言,它将异常称为条件(Condition),异常处理程序由ON语句说明。例如
ON<条件><异常处理程序>
其中,条件即异常名;异常处理程序可以是简单语句或分程序。而由语句
SIGNAL<条件>
显式引发一个异常。
语言预定义了一些异常,例如以零为除数的异常ZERODIVIDE。系统提供的处理程序所执行的操作由语言规定。然而,内部异常处理程序执行的操作也可由用户重新定义,只需对该异常名重新说明一次即可,例如
ON ZERODIVIDE BEGIN; …… END;
一个程序单元被激活后,在执行期间遇到一个ON语句时,异常名就与相应的处理程序建立起绑定关系,这种关系一建立,就一直有效,直到遇到该异常的另一个ON语句时,建立新的绑定关系,该异常名原来的绑定关系自然失效。换句话说,该异常改换了处理程序。若在同一分程序中,出现同一异常有多个(大于1个)ON语句,那么每个新绑定关系使前一个绑定关系失效。若在内层分程序中出现与外层相同的异常名的ON语句,这个新绑定关系在内层分程序有效,外层的绑定仅仅是被这个内层绑定“遮住”了,待内层分程序执行终止时,外层的绑定关系才可能恢复。
当自动或由SIGNAL语句引发一个异常时,执行当前绑定于该异常的处理程序,类似于在那一点显式调用子程序而引起执行异常处理程序。因此,除非处理程序另有规定,通常在处理程序执行完后,控制返回发出SIGNAL的那一点。
从上面的讨论可以看出,在程序某点引起的异常与相应异常处理程序的绑定是高度动态的,这样的结构给编程带来了难度。因此,这种结构不太理想。另外,PL/1中的ON语句不允许带参数,即引发异常的程序不能直接与异常处理程序进行通信,因而不得不使用全局变量来进行通信,显然这是不安全的。同时,全局变量并非总是可以使用的,例如,当引发STRIN-GRANGE(指超出串的范围进行访问)异常时,若在作用域内有两个以上的串,就无法使处理程序知晓是哪一个串引发的。在这种情况下,通常使PL/1异常处于无效状态。
PL/1异常处理机制对内部异常设置了“使可能”(Enabling)和“使不能”(Disabling)两种状态。为了与我们的习惯相符,本书将“使可能”称为“允许”,将“使不能”称为“禁止”。用户定义的异常都是允许的,因为他们设置这些异常都要显式发出信号。然而大多数内部异常都默认允许的信号,系统直接绑定于系统提供的标准出错处理程序。为了使一个原来处于允许状态的异常成为禁止(屏蔽)状态的异常,可用显式“NO<异常名>”为前缀的语句、分程序或过程来实现。例如
(NOZERODIVIDE):BEGIN; …… END;
使得内部异常ZERODIVIDE的处理程序在该语句、分程序或过程中失效。前缀的作用域是静态的,它附加在语句、分程序或过程上,其作用域也就是这些语句、分程序或过程。类似地,可以使处于禁止状态的异常成为允许状态的异常。例如
(ZERODIVIDE):BEGIN; …… END;
现在简要讨论PL/1异常处理机制的实现模型。在程序执行期间,当遇到ON语句时,就将条件(异常名)和指向相应处理程序的指针保留在当前活动记录的一个表项中。当给定单元激活单元U时,立即为U建立一个活动记录,该单元对所有ON语句建立的表项都可通过U的活动记录的固定单元进行访问。当引发一个异常时,检索ON语句的表项,从最新的表项开始,直到发现为该条件所建立的ON语句的异常处理程序为止。若所有表项查完,都未发现为该条件所建立的异常处理程序,则采用默认的活动。
这种检索栈上所有活动记录的方法效率非常低。我们知道,仅当引发一个异常时,才会进行检索,为了提高检索的有效性,可增加分程序和ON语句处理的开销。对程序中所有可能引发的异常条件,专门设置一个表,对应条件C的表项是一个指向指针栈的指针,而指针栈的每个指针指向为C建立的相应ON语句的活动记录。所有的栈初值为空,在执行期间遇到一个ON语句时,一个新表项插入到相应的栈项。当分程序活动终止时,在活动期下推的所有栈都必须上托。
2.CLU语言的异常处理机制
CLU语言的异常机制与PL/1语言相比,功能要弱一些,但使用方法更方便,主要表现在两个方面。
(1)当过程P引发一个异常时,只能将其信号传送给调用P的过程。这样做的目的是使程序具有良好的结构,但在表达方面要弱一些。
(2)发信号的过程被终止,且不再恢复。
CLU的异常仅由过程引发,实际上,若一个语句引发异常,包含这个语句的过程立即随异常的引发而返回。过程内可以发信号的那些异常必须在过程头中加以说明,借助于发信号指令在过程中显式引发这些异常。例如过程说明
coca-cola=proc(a:int,s:string)return(int) signals(zero (int),overflow,has-format(string))
表明,该过程含有两个参数,它是一个函数,正常情况下返回一个整数值;非正常情况下,可通过引发所列出的三种异常(zero,overflow和had-format)之一来终止过程。
内部操作能引发一个异常的集合。例如除数为0的除法可以发信号。当引发一个异常时,过程返回到它的直接调用者,因而异常的处理应当由调用者提供的异常处理程序来完成。由此可见,异常处理程序静态绑定于调用者。
异常处理程序由except子句绑定于语句,它的语法形式如下:
<语句>except<处理程序表>end
其中,语句可以是语言的任何语句。如果语句内的一个过程调用引发了一个异常,控制将转向处理程序表。处理程序表的形式如下:
WHEN<异常表 1>:<语句 1> …… WHEN<异常表n>:<语句n>
若引发的异常属于异常表i,那么语句i(即处理程序体)将执行。当处理程序执行结束时,控制将转移到紧跟在附加这个处理程序的语句之后,即执行紧跟在上例end之后的语句。若引发的异常不在任何异常表中,那么重复处理静态包围的语句。若在产生调用的过程内未查到相应的处理程序,CLU专门设置了一个特殊的异常failure,该异常返回的结果是一个串。failure异常无须在过程头列出,每一个过程都可引发该异常,每当未查到相应的处理程序时,过程隐式发信号给语言定义的failure异常,并退出该过程而返回。
下面简单讨论CLU异常处理的实现模型。它的实现容易理解,当对一个异常发信号时,控制返回调用者,就像return那样正常返回。但是,它们的返回点是不同的,return返回紧跟在调用语句后面的语句执行,而发信号时返回相应的异常处理程序。
因此,CLU异常的实现比较容易,它对每一个过程附有一个(固定内容的)处理程序表,用以存储过程中出现的所有与处理程序有关的信息。表中的每个表项的内容如下:
(1)由处理程序处理的异常表。
(2)一对指针,它们指向附有异常处理程序的过程的正文部分(即处理程序作用域)。
(3)一个指向处理程序的指针。
在过程P中引发一个异常时,在P的活动记录内找出调用P的指令地址,这个值用于检索调用者处理程序表,用来确定相应的返回点。
3.Ada语言的异常处理机制
Ada语言的异常处理机制类似于Bliss和Gypsy语言中所使用的方法,在某些方面与CLU语言的处理方法类似。
Ada预定义了若干个异常,若程序员感到不够用,还可以自己定义异常。异常说明与类型说明类似,例如
PECULIAR,BUFFER-FULL,ERROR:exception;
这些自定义异常一经说明便可用于该说明的作用域内,通过raise语句引发异常,并由相应的异常处理程序完成异常处理。
一个程序单元可以显式引发异常,一般形式为
raise<异常名>;
例如
raise HELP;
异常处理程序紧跟在子程序、程序包或分程序之后,并以关键字exception指出。例如
begin…; exception when HELP=>…; when DESPERATE=>…; end;
若引发异常的单元为异常提供处理程序,控制将直接转移到那个处理程序,一旦处理程序执行完后,引发异常的单元也终止。若当前执行的单元U并未提供相应的异常处理程序,那么异常将被传播(Propagation):若U是一个分程序,那么终止U的执行,并在包围U的单元内隐式引发这个异常;若U是一个子程序,那么子程序返回调用单元,并在调用点隐式引发这个异常;若U是一个程序包体,那么异常传播给包含这个程序包说明的单元。
Ada与CLU处理异常的不同之处在于:Ada异常是多级的。换句话说,Ada异常允许引发这个异常的单元U之外的其他单元来处理。
Ada异常处理的实现可用CLU实现的处理程序表来完成。其基本区别在于,不一定需要直接调用者提供异常处理程序,而需要穿过由过程激活的动态链(参见第13章)为所要求的处理程序建立一个表项。
4.C语言的异常处理
C语言中实现异常处理的方法是将用户函数与出错处理程序紧密地结合起来,但是这将造成出错处理使用的不方便和难以接受。
可以使用C标准库的assert()宏作为出错处理的方法。为了在运行时检查错误,assert()被allege()函数所取代。allege()函数对一些小型程序很方便,对于复杂的大型程序,所编写的出错处理程序也将更加复杂。
若错误问题发生时在一定的上下文环境中得不到足够的信息,则需要从更大的上下文环境中提取出错处理信息,下面给出了C语言处理这类情况的三种典型方法。
(1)出错信息可通过函数的返回值获得。如果函数返回值不能用,则可设置一个全局错误判断标志(标准C语言中errno()和perror()函数支持这一方法)。正如前文提到的,由于对每个函数调用都进行错误检查,这十分烦琐并增加了程序的混乱度。
(2)可使用C标准库中一般不太熟悉的信号处理系统,利用signal()函数(判断事件发生的类型)和raise()函数(产生事件)。由于信号产生库的使用者必须理解和安装合适的信号处理系统,所以应紧密结合各信号产生库,但对于大型项目,不同库之间的信号可能会产生冲突。
(3)使用C标准库中非局部的跳转函数:setjmp()和longjmp()。setjmp()函数可在程序中存储一典型的正常状态,如果进入错误状态,longjmp()可恢复setjmp()函数的设定状态,并且状态被恢复时的存储地点与错误的发生地点紧密联系。
5.C++语言的异常处理
异常处理是C++语言的一个主要特征,它提出了出错处理更加完美的方法。
(1)出错处理程序的编写不再烦琐,也不需将出错处理程序与“通常”代码紧密结合。在错误有可能出现处写一些代码,并在后面加入出错处理程序。如果程序中多次调用一个函数,在程序中加入一个函数出错处理程序即可。
(2)错误发生是不会被忽略的。如果被调用函数需发送一条出错信息给调用函数,它可向调用函数发送描述出错信息的对象。如果调用函数没有捕捉和处理该错误信号,在后续时刻该调用函数将继续发送描述该出错信息的对象,直到该出错信息被捕捉和处理。
如果程序发生异常情况,而在当前的上下文环境中获取不到异常处理的足够信息,我们可以创建一个包含出错信息的对象并将该对象抛出当前上下文环境,将错误信息发送到更大的上下文环境中。这称为异常抛出。如:
throw myerror("something happened");
myerror是一个普通类,它以字符变量作为其参数。当进行异常抛出时我们可使用任意类型变量作为其参数(包括内部类型变量),但更为常用的办法是创建一个新类用异常抛出。
关键字throw的引入引起了一系列重要的相关事件发生。首先是throw调用构造函数创建一个原执行程序中并不存在的对象。其次,实际上这个对象正是throw函数的返回值,即使这个对象的类型不是函数设计的正常返回类型。对于交替返回机制,如果类推太多有可能会陷入困境,但仍可看作是异常处理的一种简单方法,可通过抛出一个异常来退出普通作用域并返回一个值。
因为异常抛出同常规函数调用的返回地点完全不同,所以返回值同普通函数调用具有很小的相似性(异常处理器地点与异常抛出地点可能相差很远)。另外,只有在异常时刻成功创建的对象才被清除掉(常规函数调用则不同,它使作用域内的所有对象均被清除)。当然,异常情况产生的对象本身在适当的地点也被清除。
另外,我们可根据要求抛出许多不同类型的对象。一般情况下,对于每种不同的错误可设定抛出不同类型的对象。采用这样的方法是为了存储对象中的信息和对象的类型,所以别人可以在更大的上下文环境中考虑如何处理我们的异常。
如果一个函数抛出一个异常,它必须假定该异常能被捕获和处理。正如前文所提到的,允许对一个问题集中在一处解决,然后处理在别处的差错,这也正是C++语言异常处理的一个优点。
如果在函数内抛出一个异常(或在函数调用时抛出一个异常),将在异常抛出时退出函数。如果不想在异常抛出时退出函数,可在函数内创建一个特殊块用于解决实际程序中的问题(和潜在产生的差错)。由于可通过它测试各种函数的调用,所以被称为测试块。测试块为普通作用域,由关键字try引导:
C++的异常处理语句的格式如下:
try { 语句; } catch(类型1[变量名1]){语句;} catch(类型2 [变量名2]){语句;} …… catch(类型n [变量名3]){语句;}
如果没有使用异常处理而是通过差错检查来探测错误,即使多次调用同一个函数,也不得不围绕每个调用函数重复进行设置和代码检测。而使用异常处理时不需做差错检查,可将所有的工作放入测试块中。这意味着程序不会由于差错检查的引入而变得混乱,从而使得程序更加容易编写,其可读性也大为改善。
异常抛出信号发出后,一旦被异常处理器接收到就被销毁。异常处理器应具备接受任何一种类型的异常的能力。异常处理器紧随try块之后,处理的方法由关键字catch引导。
每一个catch语句(在异常处理器中)就相当于一个以特殊类型作为单一参数的小型函数。异常处理器中标识符就如同函数中的一个参数。如果异常抛出给出的异常类型足以判断如何进行异常处理,则异常处理器中的标识符可省略。
异常处理部分必须直接放在测试块之后。如果一个异常信号被抛出,异常处理器中第一个参数与异常抛出对象相匹配的函数将捕获该异常信号,然后进入相应的catch语句,执行异常处理程序。catch语句与switch语句不同,它不需要在每个case语句后加入break用以中断后面程序的执行。
终止与恢复在异常处理原理中含有两个基本模式:终止与恢复。假设差错是致命性的,当异常发生后将无法返回原程序的正常运行部分,这时必须调用终止模式(C++支持)结束异常状态。无论程序的哪个部分只要发生异常抛出,就表明程序运行进入了无法挽救的困境,应结束运行的非正常状态,而不应返回异常抛出之处。
另一个为恢复部分。恢复意味着希望异常处理器能够修改状态,然后再次对错误函数进行检测,使之在第二次调用时能够成功运行。如果要求程序具有恢复功能,就希望程序在异常处理后仍能继续正常执行程序,这样,异常处理就更像一个函数调用—C++程序中在需要进行恢复的地方如何设置状态(换言之就是使用函数调用,而非异常抛出来解决问题)。另外也可将测试块放入while循环中,以便始终装入测试块直到恢复成功得到满意的结果。
可以不向函数使用者给出所有可能抛出的异常,但是这一般被认为是非常不友好的,因为这意味着他无法知道该如何编写程序来捕获所有潜在的异常情况。当然,如果他有源程序,他可寻找异常抛出的说明,但是库通常不以源代码方式提供。C++语言提供了异常规格说明语法,我们可以利用它清晰地告诉使用者函数抛出的异常的类型,这样使用者就可方便地进行异常处理。这就是异常规格说明,它存在于函数说明中,位于参数列表之后。
异常规格说明再次使用了关键字throw,函数的所有潜在异常类型均随着throw而插入函数说明中。所以函数说明可以带有异常说明如下:
void f()throw(toobig,toosmall,divzero);
而传统函数声明:
void f();
意味着函数可能抛出任何一种异常。
如果是
void f()throw();
这意味着函数不会有异常抛出。
为了得到好的程序方案和文件,为了方便函数调用者,每当写一个有异常抛出的函数时都应当加入异常规格说明。
(1)unexpected()
如果函数实际抛出的异常类型与我们的异常规格说明不一致,将会产生什么样的结果呢?这时会调用特殊函数unexpected()。
(2)set_unexpected()
unexpected()是使用指向函数的指针来实现的,所以可通过改变指针的指向地址来改变相对应的运算。这些可通过类似于set_new_handler()的函数set_unexpected()来实现,set_unexpected()函数可获取不带输入和输出参数的函数地址和void返回值。它还返回unex-pected指针的先前值,这样可存储unexpected()函数的原先指针值,并在后面恢复它。为了使用set_unexpected()函数,必须包含头文件except.h。
对于避免阻碍程序执行是十分必要的。
如果函数没有异常规格说明,任何类型的异常都有可能被函数抛出。为了解决这个问题,应创建一个能捕获任意类型的异常的处理器。这可以通过将省略号加入参数列表中来实现这一方案。
catch(…) { cout<<"an exception was thrown"<<endl; }
为了避免漏掉异常抛出,可将能捕获任意异常的处理器放在一系列处理器之后。
在参数列表中加入省略号可捕获所有的异常,但使用省略号就不可能有参数,也不可能知道所接收到的异常为何种类型。
有时需要重新抛出刚接收到的异常,尤其是在无法得到有关异常的信息而用省略号捕获任意的异常时。这些工作通过加入不带参数的throw就可完成:
catch(…) { cout<<"an exception was thrown"<<endl; throw; }
如果一个catch句子忽略了一个异常,那么这个异常将进入更高层的上下文环境。由于每个异常抛出的对象是被保留的,所以更高层上下文环境的处理器可从抛出来自这个对象的所有信息。
总之,各种语言的异常处理机制不尽相同,但这些异常处理都具有很大的用处。首先,它们可以处理预料中的错误,特别是由于某种原因造成的硬件中断;其次,可以重复试验各种操作(每次试验改变异常处理程序),并可执行某些善后处理工作。合理地使用异常处理,可提高程序的质量和调试效率。
6.Java语言的异常处理
Java语言支持异常处理。异常事件的引发称为抛出(Throw ),与异常处理程序的绑定称为捕捉(Catch)。
throw语句抛出一个异常事件,实际上是建立一个可抛出的(Throwable)对象,其格式如下:
throw<可抛出对象名>;
有可能抛出异常事件的Java语句应放在try语句中,其格式如下:
try{ 有可能抛出异常事件的一条或多条Java语句; } catch语句绑定并执行相应的异常处理程序,其格式如下: catch (参数) {(语句);}
其中,参数可以是一个类,也可以是一个接口。
若try语句带有多个catch语句,则当一个异常事件被抛出时,系统将执行与参数相匹配的第一个catch语句。
异常处理程序执行完后,最后要执行finally语句,其格式如下:
finally {(语句);}
finally语句完成异常处理之后的一些现场清理工作,然后执行随后的语句。
3.3.3 SIMULA 67 语言协同程序
实现两个或两个以上程序单元之间交错执行的程序称为协同程序。例如,设有单元C1和C2,由C1先开始执行,当执行到C1的“resume C2”命令时,显式激活C2,将C1当前执行点的现场保存起来,将控制转向C2的执行点;若C2又执行到某个“resume C1”命令时,将C2当前执行点的现场保存下来,恢复C1的执行,并将控制转移到C1的执行点(即上次激活C2的那一点),继续执行下去。
C1和C2似乎在并行地执行,我们将这种执行称为伪并行(Pseudo Parallel)。实际上,C1和C2是在交错地执行,它是并行的一种低级形式。
常规的子程序机制不能描述并行执行的程序单元,CLU和SIMULA 67等语言设置了描述这种交错执行过程的机制。在这一节中将介绍SIMULA 67语言的协同程序。协同程序是一个类实例,一般形式为
class<类名>(参数); <参数说明>; begin <说明>; <语句表 1>; detach; <语句表2> end
若设类为x,变量y1和y2是对x的引用,那么可写成
y1:-new x(…);y2:-new x(…)
当遇到一个new时,建立类的一个新实例,并执行类体。若遇到detach语句时,控制返回产生new的单元。作为detach的结果,单元活动的行为就是恢复协同程序,然后依次恢复其他协同程序。图3-2说明了两个协同程序之间的控制转移关系。
图3-2 协同程序之间的控制转移关系
现在举出4人玩纸牌游戏的例子来说明协同程序的工作过程。构造一个模拟4人玩牌游戏的程序,对每一人(方)设计一个程序单元,共有A、B、C和D共4个程序单元。每一程序单元活动之后,应当激活下一单元。每当一个单元被激活时,它立即执行,这时的执行点是上次它将控制转移到下方时将要执行的那一点,假定每一方都使用同样的策略,程序可描述如下:
begin boolean gameover;integer winner; class player(n,hand);integer n; integer array hand(1:13); begin ref(player)next; detach; while not gameover do begin出牌; if gameover then winner:=n else resume (next) end; end ref(player)array P(1:4);integer i; integer array cards(1:13); for i:=1 step 1 until 4 do begin第i家拿牌; p(i):-new player (i,cards) end; for i:=1 step 1 until 3 do p(i).next:-p(i+1); p(4).next:-p(1); resume P(1); 打印胜利者(winner) end
程序的第一个循环(for i:=1 step 1 until 4 do …)建立玩牌的4方;第二个循环(for i=1 step 1 until 3 do…)把各方同它的下一方链接起来;而第4方链接第1方;然后恢复第1方,游戏开始。按照链接建立的次序,相继恢复下一方,第4方的下一方为第1方。在出牌中判断胜利者,若判出胜利者,则将gameover置为真,将胜利者置于变量winner中,待协同程序实例终止时,控制返回激活这组程序单元的程序,然后由主程序打印优胜者的名单,并结束游戏。
Java语言不支持协同程序。
3.3.4 并发单元
协同程序建立以交错方式执行的活动模型是十分恰当的。但是,在许多应用中,建立系统的并行执行模型也是很有用的。在这种模型中,系统由一组并发单元以并行方式执行(无论实际上它们是否以并行方式执行)。这种机制在操作系统领域尤为重要。为了描述并发单元,对在程序单元之上执行的基本机器的物理体系结构进行抽象是必要的。机器可以是多处理机(每个处理机分配一个程序单元),也可以是多道程序的单处理机。并发系统的并行性概念不是基于程序单元的执行速度而建立的,它是建立在各程序单元并行活动的基础之上的,因而允许单处理机或多处理机实现并行。事实上,在多处理机上,各个程序单元在各自的处理机上执行,与几个单元共享一个处理机相比,它们的执行速度有很大差别。
协同程序是描述并发程序单元的一种低级语言构造,它们通过一组并发单元显式交错执行,用来模拟在单处理机上的并行性。因此,它们不能描述一组并发单元,仅是一种通过模拟并行性而共享处理机的特殊方法。许多近期的语言提供了一些描述并行性的专门机制。
Java的线程(Thread)实现程序单元的并发执行。线程可以看成是一个程序的控制流程中的程序单元。若一个单元一个单元地执行,可以看成是单线程(Single-thread)的;若几个单元同时执行,称为多线程(Multi- thread)。要实现多线程同步运行并共享资源,需要相应的同步机制。Java使用监视器(MonitorS)实现同步。监视器是一个上层机制,它限定在同一时刻只能有一个线程执行被监视器保护的程序单元。监视器赋予每一线程一把锁(Lock),它可以锁定(Lock)一个线程,也可以为一个线程解锁(Unlock,这通过wait,notify和notifyall语句来实现,从而能十分有效地把控制从一个线程转移到另一个线程。wait语句使当前线程处于等待状态,直到其他线程用notify或notifyall将它唤醒。notily语句选择一个正在等待获取监控器的当前线程并且唤醒它。当然,这里存在选择的策略问题,通常使用优先关系。notifyall语句唤醒实例对象的等待线程中的所有线程。
尽管并行性正成为语言的一个重要方面,但是它的主要促进因素和原则都来自于传统的操作系统领域。下面的例子有助于弄清并发程序设计的基本问题和概念。设某个系统包含两个并行活动:一个生产者的生产活动和一个消费者的消费活动。生产者生产一系列的“值”,并依次将它们存放在某个缓冲区N中;消费者以生产者相同的次序从缓冲区移出这些值。这个模式表达了操作系统的许多功能,如文件的输入和输出。可用两个程序对上述活动模式化,从概念上讲,它们是不终止的程序。生产者和消费者的程序可刻画为
repeat生产一个元素;
存放这个元素到缓冲区;
forever
repeat从缓冲区移出一个元素; 对该元素执行某个运算; forever
两个单元有一个共同目标,从生产者传递数据到消费者,它们合作重复这个活动。为了使这两个单元对两个活动的速度变化不敏感,设置了缓冲区来缓冲。然而,为了保证合作操作,无论生产者、消费者处理的速度快还是慢,程序员都必须保证不会向已满的缓冲区写数据,或从空缓冲区读数据。并发程序设计语言提供同步语句(Synchronization Statement),它允许程序员在必要时延迟一个单元的执行,以便同别的单元正确合作。在上例中,如果发生向已满的缓冲区再存数据,就必须延迟生产者的程序单元,直到消费者至少取出一个数据。类似地,若缓冲区已空,消费者欲从缓冲区取数据,就必须延迟消费者程序单元,直到生产者至少向缓冲区存入一个数据。
若生产者和消费者都能合法地访问缓冲区,还会出现另一种更微妙的同步问题。例如,设t表示所存项目总数,append是生产者向缓冲区存数的操作,remove是消费者从缓冲区取数的操作,这两个操作都要修改t的值,可以执行相应操作来实现。
① t:=t+1
② t:=t-1
假定①和②是这样实现的:
读t到一个专用寄存器;
更新专用寄存器的值;
将专用寄存器的值写到t;
其中,所谓更新是对①加1,对②减1。因此,形如①和②的动作是不可分的机器指令,即这样的动作一旦执行,就必须在执行完其他动作后才能开始执行。因为它们是不可分的,所以①和②只能交错执行。
若t的值在执行一对操作append和remove之前为m,假若①和②是可分的,那么执行完这一对操作之后的结果可能是m,m+1或m-1,显然只有m才是正确的结果。为了保证正确性,必须确保执行 ① 时不能开始执行 ②,反之亦然。因此,① 和 ② 必须以互斥(Mutual Exclusion)的方式执行,①或②就像不可分的操作一样。
现在能够说明关于并行性抽象的要求,以及用来定义这样的抽象对语言构造的要求。一个并发系统可看成一个进程的集合,每一进程可用一程序单元表示。若各个进程的执行在概念上是可重叠的(即正在执行的进程尚未终止,另一个进程可能开始执行),那么这些进程是并发的。若进程P1,P2,…,PN期望的活动彼此不交错的话,那么它们是不相交(Disj oint)的。不相交的各个进程不访问任何共享对象,一个进程执行的结果与其他进程无关,特别是各个进程执行的速度是任意的,彼此没有关系。然而,进程通常是交错的,这是因为存在如下情况:
① 竞争——进程之间通过竞争得到共享资源,因为这些资源是以互斥的方式使用的。
② 合作——进程之间通过合作以达到共同的目标。
仅当在各个进程的基本活动中保持某种优先关系,它们才能正确地交错执行。这样的关系定义了各个活动之间的一个优先次序。在例子中,若用Pi和Ci分别表示生产者和消费者的第i个元素,那么正确的合作要求为
Cj→Pj+N and Pj→Cj for all j
其中,符号“→”读为优先于。
语言为了实现进程之间的同步,需要提供同步语句(或称原语)以实现进程之间的通信。基本的同步机制有信号灯、管程和会合。这三种机制适合于并发进程的“共享存储器”模式,即适合于访问公用存储器的并发进程。其他业已开发出来的并发程序设计范例或许更加适合分布式系统。远程过程调用扩充了过程调用的概念,在这种情况下,调用和被调用单元处于不同的进程。同步是由调用者等待被调用者的返回信息来实现的。消息传递范例是类似Small-talk这样的语言对消息处理方式在并发进程上的扩充。消息可用来通信和同步。详细研究各种模式和机制已超出本书的范围,此处,只对信号灯、管程和会合做进一步的讨论。
信号灯(Semaphore)概念是由Dij kstra提出来的,后来作为同步原语引入到ALGOL 68语言中。信号灯是一个数据对象,该数据对象采用一个整数值(s),并可用原语P和V对它进行操作(ALGOL 68相应使用down和up操作)。信号灯在说明时,以某个整数值对它初始化。
原语P和V操作的定义是
P(s):if s>0 then s:=s-1 else挂起当前进程 V(s);if信号灯上有挂起的进程 then唤醒进程 else s:=s+1
原语P和V是不可再分的原子操作,即两个进程不能同时对同一信号灯执行P和V操作。基本的实现方法是,必须保证P和V的行为类似于基本机器指令。
信号灯需要满足下列要求:
(1)有一相关数据结构,它记录在该信号灯上挂起的进程标识。
(2)当原语需要唤醒一个进程时,要有选择唤醒哪一个进程的策略。
通常,信号灯的数据结构是一个先进先出的队列。也可以对进程赋予优先数,基于这样的优先数可设计出更为复杂的策略。
下面用类Pascal语言,基于信号灯概念,写出“生产者-消费者”问题的程序如下:
const n=20; shared var 缓冲区长度n,缓冲区项目总数t; semaphore mutex:=1;{用于保证互斥,并发进程共享变量,初值为 1} in:=0;{缓冲区项目数,初值为0,并发进程共享变量} spaces:=n;{缓冲区自由空间数,初值为n,并发进程共享变量} process producer; var i:integer; repeat producer(i); p(spaces);{等待自由空间} p(mutex);{等待缓冲区可用} 添加项目i到缓冲区; v(mutex);{终止访问缓冲区} v(in);{缓冲区项目数加 1} forever end producer; process consumer; var j:integer; repeat p(in);{等待缓冲区项目} p(mutex);{等待缓冲区可用} 从缓冲区移出一个项目到j; v(mutex);{终止访问缓冲区} v(spaces);{缓冲区增加一个自由空间} forever end consumer
由关键字process和end包围的代码段可并发地处理;shared var说明可由进程并发地访问变量。信号灯spaces和in用来保证访问缓冲区时逻辑上(缓冲区满)的正确性。特别是,当缓冲区已满还要试图添加一个新项目时,spaces(缓冲区可用空间量)挂起producer。类似地,若试图从空缓冲区取项目时,in(已在缓冲区的项目数)挂起consumer。
信号灯mutex用来强制互斥地访问缓冲区,是互斥操作append和remove所需要的,因为这两个操作可并发地修改t(缓冲区项目总数)的值。变量t是前面讨论互斥问题时引入的。
使用信号灯进行程序设计,要求程序员对每个同步条件关联一个信号灯。信号灯是一个非常简单而又低级的机制,使用它们可能导致程序设计和理解的困难。另一方面,对信号灯很少做静态检查,编译器不可能检查出对信号灯的不正确使用,找出使用错误几乎是不可能的,因为只有编译程序知道程序的语义才能检查出对信号灯的不正确使用。因此,对不熟练的程序员来说,使用信号灯需要自我约束,在访问共享资源之前,不能忘记执行一次P操作,释放资源时不要忽略执行一次V操作。
为了解决同步问题而使用信号灯,比互斥问题更糟糕。在“生产者-消费者”例子中,当缓冲区已满时,通过执行P(spaces)操作,进程producer把自己挂起,这就要求在每次消费之后,程序员要记住写一个V(spaces)操作,否则生产者可能变为永远被封锁。
PL/1语言是首先把并发单元称为任务的语言。一个过程若与它的调用者并发地执行就可产生一个任务,任务可以赋予优先数。任务通过使用事件来达到同步的目的,事件类似于信号灯,但它只能取0(布尔常数0)值或1(布尔常数1)值,且只能取两者中的一个值。对事件E完成操作WAIT(E),表示对信号灯的P操作;完成操作COMPLETION(E),表示对信号灯的V操作。
PL/1语言实际上对信号灯概念进行了扩充,它允许WAIT操作对几个命名事件或整型表达式E操作。例如,WAIT(E1,E2,E3)(1)表示对事件E1、E2和E3中任一个的等待。
ALGOL 68语言以并行子句来描述并发进程,它用成分语句详细说明并发,并以模式sema的数据对象为信号灯提供同步。
并发单元的实现已超出本书的范围,在此不再进行更深入的讨论。