PL真有意思(四):控制流

前言

对大多数计算模型而言,顺序都是基本的东西,它确定了为完成所期望的某种工作,什么事情应该最先做,什么事应该随后做,我们可以将语言规定顺序的机制分为几个类别:

  • 顺序执行
  • 选择
  • 迭代
  • 过程抽象
  • 递归
  • 并发
  • 异常处理和推断
  • 非确定性

对于不同类别的语言对不同类别的控制流的重要性也不尽相同,比如顺序执行相比于函数式对于命令式则更加重要。而命令式中更倾向用迭代,函数则更强调递归

表达式求值

在讨论控制流之前先讨论下表达式的问题,先明确两个概念:运算符通常是指那些采用特殊语法形式的内部函数(比如+-*/等),运算对象指的是运算符的参数(如2+3,2和3就是运算对象),那么运算符和运算对象的组合就是表达式。一般根据运算符出现的位置(相对于运算对象而言),可以分为3类表示形式:前缀、中缀和后缀。比如Lisp就运用前缀语法:

1
2
(+ 1 3 4 6)      
(* (+ 1 7) 8)

大多数命令式语言对二元运算符都使用中缀记法,而对一元运算符和其它函数使用前缀激发。但是像Lisp就全部统一使用中缀记法

优先级和结合性

大多数程序设计语言都提供丰富的内部算术。在用中缀方式(没有括号)写出就可能出现歧义。所以就需要优先级和结合性来解决歧义性,但是我觉得

妈的你写括号就完事儿了

而且不同语言的优先级和结合性也不尽相同

赋值

在纯函数式语言中,程序的基本组成部分是表达式,计算也仅是对表达式求值。任何一个表达式对于整个计算的影响也仅限于这个表达式所处的上下文环境。

而命令式语言的情况与此截然不同,计算通常是通过对内存中变量值的一系列修改操作来完成,赋值就是这种修改的最基本手段。每一次赋值都表示一个值被放入一个对应的变量中。

一般来说,如果语言中的一个结构除了返回一个值供其外围环境所使用,还能以其他方式影响后续计算(并最终影响程序输出),那么我们就说这种结构有副作用。而副作用也是命令式语言里最核心的部分

而在纯函数语言中没有任何的副作用,表达式的值只依赖于输入

但是现在许多语言都是混合的,像Python和Ruby主要是命令式的,但是也提供了很多的函数式的特征,现在连Java都提供了对函数式的支持

引用和值

考虑一下下面的C语言的赋值:

1
2
d = a;
a = b + c;

第一个语句中,赋值语句右部引用了a的值,并希望把这个值放入d。第二个语句左部引用了a的位置,希望把b+c的结果放进去。这两种解释(值和位置)都是可行的,因为c语言中变量就是能保存值的命名容器,所以我们会说类似的语言的变量是值模型。由于指示位置的表达式被放在赋值语句的左部,所以这种指示位置的表达式成为左值表达式。表示一个值的表达式称为右值。在变量的值模型下,同一表达式也可能是左值或者右值,比如(a=a+1),左部的a是左值,用于表示存放结果的位置;右部的a是右值,用于代表a具体所指的值。

在采用了变量的引用模型的语言中,这种左值和右值的差异就更加明显了。

1
2
3
b = 2;
c = b;
a = b + c;

在值模型语言中程序员会说:“把2放入b,然后复制到c,然后用它们两个的值相加,把结果4放入a。”。;

在引用模型语言中的程序员会说:“让b引用2,让c也引用2,然后把这两个引用送给+运算,并让a引用算出的结果,也是4“。

而在Java中,对于内部类型使用值模型,而类使用引用模型

装箱

对于内部类型使用值模型,就无法以统一的方式将它们传给要求类类型的参数的方法,所以这里就需要一个装箱过程

比如Java提供的Integer类

1
Integer i = new Integer(12);

多路赋值

我们知道赋值操作有右结合性,这使得我们可以写出a=b=c的简练代码,在一些语言中(Ruby,Go,Python)我们可以进一步这样写:

1
2
3
4
5
6
7
a, b = 1, 2;
//上面的语句结果就是a等于1,b等于2。

a, b = b, a;
//交换两个值,如果没有这种语言特性,那么就需要引入临时变量了。

a, b , c = funx(d, e, f);

这种记法也消除了大多数程序设计语言中函数的非对称性,这些语言可以允许任意多个参数,但只能返回一个返回值。但是其实在Python中的返回多个值,就是将多个值封装为元组,在赋值的时候又拆箱而已

初始化

并不是所有语言都提供声明变量时指定初始值的方式,但是至少有这几点可以证明提供初始值的机制是有益的

  • 局部静态变量需要一个初始值才能使用
  • 使用静态分配的变量,可以由编译器放入全局内存,避免了在运行时赋予吃数值所造成的开销
  • 可以避免意外的使用未初始的变量

如果声明时没有明确的给定变量的初始值,语言也可以给定一个默认值。像C、Java和C#也都提供了类似的机制

动态检查

除了可以指定默认值之外,还可以采用另外一种方式,将对为初始化的变量的使用作为动态语义错误,在运行时捕获这种错误。但是在运行时捕获所有使用到未初始化的情况的代价非常高

定义性赋值

在Java和C#中提供了一种定义性赋值的表示形式,意思就是由编译器来检查在达到一个表达式的所有可能控制路径上,都必须为这个表达式中的每个变量赋过值

构造函数

许多面向对象语言都提供了为用户定义的类型的自动初始化方法,也就是构造函数

在C++中,还区分了初始化和赋值,它将初始化赋值解释为调用变量所属类型的构造函数,以初始值作为调用参数。在没有强制的情况下,赋值被解释为调用相关类型的赋值运算符,如果没有定义赋值运算符,就默认将赋值右部的值简单的按位复制过来

区分初始化和赋值的好处是,可以区分在赋值前是不是需要先释放空间

表达式的顺序问题

虽然优先级和结合性规则定义了表达式里二元中缀运算符的应用顺序,但却没有明确说明特定运算符的各运算对象的求值顺序。举例来说,如下表达式:

1
a - f(b) - c * d

根据结合性可知a-f(b)将在第二个减法前执行,根据优先级可知第二个减法的右运算对象是cd这个整体而不是c。但是如果没有进一步的规则描述,我们无法得知a-f(b)是否在cd之前运行。诸如此类:对于f(a,g(b),c)这个子程序调用,我们也不知这三个参数的求值顺序。

求值顺序之所以重要:

  • 副作用:如果f(b)这个子程序可能会修改c的值,那么a-f(b)-cd的求值结果将依赖f(b)和cd哪一个先执行;类似的,如果g(b)修改了a或者c的值,那么f(a,g(b),c)的结果也是依赖于参数的求值顺序。

  • 代码改进:子表达式的求值顺序对于寄存器分配和指令调度都有重要的影响。比如(ab+f(c)),我们可能会希望在执行ab之前调用f(c)。因为如果先计算乘法,则在调用f(c)之前就要先保存起来乘积,因为f(c)可能会用光所有的寄存器。

短路求值

对于布尔表达式,如果编译器可以对其执行短路求值,那么它生成的代码可以在表达式前一半的结果可以确定整个表达式的值的情况下跳过后一半的计算。

比如(a<b) and(b<c),如果a>b,那么完全没必要去检查b是否小于c就可以确定这个表达式一定为假。在一些特殊情况下,短路求值可节省大量时间,比如if(func&&func())。实际上这种情况下短路求值已经改变了布尔表达式的语义,如果非短路求值,那么在func不存在的情况下去执行func(),程序是会抛出错误的。

我们常见的语法表现形式是&&和||这种布尔运算符身兼多职,既是布尔运算符又会触发短路求值,但是有一些语言针对短路求值是有单独的语法形式的,比如Clu语言中布尔运算符是and和or,短路运算符是cand和cor。这是为何呢,因为有些代码逻辑是不需要这种短路求值的优化的。

结构化和非结构化的流程

汇编语言中的控制流通过有条件的或无条件的跳转(分支)指令来完成,早期的高级语言模仿这种方式(如Fortran),主要依赖goto来描述大部分非过程化控制流,比如下面代码:

1
2
3
if A < B goto label1;

label1;

但是如今goto像在Java、Clu和Eiffel里已经完全被禁止了,在其它语言也是受限了或者只是为了向前兼容而已

goto的结构化替代品

对于goto被废弃,各种使用goto的地方也被结构的方案给代替了

  • 循环中的退出和继续

break和contiune这两个关键字大家应该很熟悉了

  • 从子程序提前返回

return

  • 多层返回

上面的两个问题都可以有很好的替代品,但是对于多层返回就会比较麻烦一点。return或”局部的goto“只能在子程序中返回,如果遇到多层嵌套的子程序,想从内层的子程序返回来结束外围子程序的执行,那return和局部的goto就无能为力了。这种情况下,语言实现要保证能恰当的恢复栈上的子程序调用信息,这种修复工作称为”回卷”,为完成此事,不仅必须释放需要跳出的所有子程序的栈帧,还要执行一些信息管理工作,比如恢复寄存器内容。

Common Lisp提供了return-from语句来明确指定需要退出的词法外围函数或嵌套块,还可以提供一个返回值:

Common Lisp和另外一个语言Ruby中还内置一个throw/catch语法来支持这种多层返回,注意这种结构并不是所谓的异常处理,而是一种多层返回的语法结构,直白点说是一种功能强大的变相”goto“,看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//定义一个方法
def search_file(filename,pattern)
file=File.Open(filename)
//遍历文件每一行
file.each{|line|
//根据parrern匹配模式查找,如果匹配就返回到定义found标签的位置
throw :found,line if line=~/#{pattern}/
}
end

//用catch定义一个found标签
math=catch:found do
serach_file("f1",key)
serach_file("f2",key) //如果f2文件找到了则就会返回line至math
serach_file("f3",key)
”not fount“ //找不到就执行到此处了
end

print match
  • 错误和异常

多层返回的概念假定了被调用方知道调用方期的是什么,并且能返回一个适当的值。还存在一种情况,其中深层嵌套的子程序中发生了一些情况,导致无法继续执行下去,而且因为没有足够的环境信息,甚至无法合适的结束自己的工作,这种情况下,唯一能做的就是”退回去“,一直回退到能够恢复执行的地方,这种要求程序退回去的条件通常称为叫做”异常“。常见的结构化的异常处理和多层返回有很大的相似性,两者都需要从某一个内层上下文回退到外层的上下文。具体的差异则是多层返回是内层的上下文正常的完成计算然后根据需要返回正确的值,然后转移到外层上下文,并不需要后续处理。而异常中的内层上下文已经是无法进行正常的计算,必须以一种非正常的退出一直回卷,然后触发某个特殊的处理流程直到catch到它。

  • 继续

如果进一步推广上一小节中造成栈回卷的非局部goto概念,则可以定义一种称为继续(Continuations)的概念。从底层来看,一个继续是由一个代码地址与其关联的一个引用环境组成的,如果跳转到这个地址,就该恢复这个引用环境。从抽象层面看,它描述一个可能由此继续下去的执行上下文。在Scheme和Ruby中,继续是基本的一等公民,我们可以利用这种机制有效的扩充流程控制结构集合。

Scheme中支持继续由一个通常称为call-with-current-continuation的函数实现,有时简称”call/cc”。该函数有一个参数f,f也是一个函数;”call/cc”调用函数f,把一个记录着当前程序计数器和引用环境的“继续(暂时称为是c)c”传递给f,这种”继续c”由一个闭包来表示(通过参数传递的子程序的表示的闭包并无不同)。在将来任何时候,f都可以调用c,然后可以用c来重新建立其保存的上下文。一般的应用情况是我们把这个c赋值给一个变量,则可重复的调用它,甚至我们可以在f中返回它,即使f已经执行完毕,仍然可以调用c。

顺序执行

选择

现在大部分命令式语言中采用的选择语句,都是从Algol 60引进过的 if…then…else 的某种演进变形:

1
2
3
4
5
if condition then statement
else if condition then statement
else if condition then statement
...
else statement

短路条件

虽然 if…then…else 语句的条件是一个布尔表达式,但是通常没有必要求出这个表达式的值放入寄存器。大部分机器都提供了条件分支指令(如上面提到的IL指令brtrue.s),因为这个表达式求值的目的并不是为了值,而是为了跳转到合适的位置。这种看法使得可以对短路求值的表达式生成高效的代码(称为跳转码)。跳转码不但可以用于选择语句,也可用在“逻辑控制的循环”中。如下面代码:

1
2
3
4
5
6
if((A>B)&&(C<D)||(E!=F)){
then_clause
}
else{
else_clause
}

在不使用短路求值的Pascal中,生成的代码大致如下(它会计算每个表达式的结果并放入寄存器r1…,然后再决定跳转):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
     r1=A
r2=B
r1=r1>r2
r2=C
r3=D
r2=r2>r3
r1=r1&r2
r2=E
r3=F
r2=r2!=r3
r1=r1|r2
if r1=0 goto L2
L1: then_clause
goto L3
L2: else_clause
L3:

跳转码的情况于此不同,它不会把表达式的值存入寄存器,而是直接跳转(只用到了r1和r2两个寄存器,明显也不会针对整个表达式进行求值,比上面的要高效一些):

1
2
3
4
5
6
7
8
9
10
11
12
13
     r1=A
r2=B
if r1<=r2 goto L4
r1=C
r2=D
if r1>r2 goto L1
L4: r1=E
r2=F
if r1=r2 goto L2
L1: then_clause
goto L3
L2: else_clause
L3:

case/switch语句

对于if else结构来说,如果嵌套的层数过多、或者是用于判断的条件表达式是基于一些有限的简单值(或编译时常量),那么出现了一种更为优雅的语法结构“case语句”,有很多ifelse都可以很轻松的改写成case/switch语句

对于case/switch的优势还不只是语法上的优雅,有时还可以生成更高效的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
T: &L1
&L2
&L3
&L4
&L5
&L6
L1: clause_A
goto L7
L2: clause_B
goto L7
L3: clause_C
goto L7
L4: clause_D
goto L7
L5: clause_E
goto L7
L6: clause_F
goto L7
L7:

这样其实T就是一个地址跳转表

迭代

迭代和递归是计算机能够重复执行一些操作的两种机制;命令式语言倾向于使用迭代、函数式语言则更看重递归。大多数语言中的迭代都是以循环的形式出现的,和复合语句中的语句一样,迭代的执行通常也是为了副作用,也就是修改一些变量的值。根据用何种方式控制迭代的次数来看,循环有两个主要变种”枚举控制的循环”和“逻辑控制的循环”。前者是在给定的某个有限的集合中执行,后者则是不确定要执行多少次(直到它所依赖的表达式结果被改变)。对于这两种结构,大多数的语言都提供了不同的语法结构来表示。

枚举控制的循环

枚举控制的循环最初来自Fortran的do循环,

1
2
3
do i = 1, 10, 2
...
enddo

等号后面的表达式分别是i的初始值,边界值和步长

像这种枚举循环可以说的不多,但是如果前几次迭代的执行会导致迭代的次数或下标值的发生变化,那么我们就需要一个更通用的实现

思考几个问题:

  • 控制是否可以通过枚举之外的任何方式进入和离开循环呢?
  • 如果循环体修改了用于计算循环结束边界的变量,会发生什么?
  • 如果循环体修改了下标变量,会发生?
  • 程序是否可以在循环结束后读取下标变量,如果可以,它的值将是什么?
  1. 现在的大多数语言都提供了,break类似的机制来离开循环。Fortran IV允许通过goto跳入到一个循环中,但是这个通常被认为是一个语言缺陷
  2. 同样的,在大多数语言中,边界值只在第一次计算,并且保存在一个临时寄存器中,所以对于之后的修改并不会起作用
  3. 早期的大部分语言都禁止在枚举控制的循环中修改下边变量。但是刚刚试验了一下,许多的语言好像都放开了这个禁止,也就是按照修改后的正常逻辑继续执行
  4. 首先这是一个语言实现的问题,现在的大多数语言应该都是将循环下标的作用域限定在循环体内了,所以出了循环体是访问不到的

当然在之后出现了C的for循环

1
2
3
for (int i = first; i < last; i += step) {
...
}

这样有关结束条件、溢出和循环方向的问题全都交由程序员来掌控

迭代器

上面描述的循环都是在算术值的序列上迭代。不过一般而言,我们还希望可以在任何定义的良好的集合的元素上迭代。在C++和Java里叫做迭代器

真迭代器

Clu,Ruby等语言允许任何容器对象提供一个枚举自己元素的迭代器,这种迭代器就像是允许包含yield语句的子程序,每次yield生成一个循环下标

在Python里就可以这样写

1
2
for i in range(first, last, step):
...

在被调用时,这个迭代器算出循环的第一个下标值,然后通过yield语句返回给调用者,yield语句很像return,但是不同的是再每次循环结束后控制权会再次的交给迭代器,重新开始下一次yield,直到迭代器没有元素可yield为止才结束for循环。从效果上看迭代器就像是另外一个控制线程,有它自己的程序计数器,它的执行与为它提供下标值的for循环交替执行,这一类通常称为真迭代器。

迭代器

在许多面向对象语言里,用了更加面向对象的方法来实现迭代器。它们的迭代器就是一个常规对象,它提供了一套方法,用来初始化、生成下一个下标值和检测结束条件

1
2
3
4
5
BinTree<Integer> myTree;

for (Integer i : myTreee) {

}

上面的这段代码其实是下面这段的一个语法糖

1
2
3
for(Iterator<Integer> it = myTree.iterator();it.hasNext();) {

}

用一级函数来实现迭代器

实现是将循环的体写成一个函数,用循环的下标作为函数的参数,然后将这函数作为参数传递给一个迭代器

1
2
3
4
5
6
7
(define uptoby
(lambda (low high step f)
(if (<= low higt)
(begin
(f low)
(uptoby (+ low step) high step f))
'())))

不用迭代器的迭代

在那些没有真迭代器或者迭代器对象的语言中,还是可以通过编程方式实现集合枚举和使用元素之间的解耦的,用C语言做例子:

1
2
3
4
5
6
7
8
9
10
tree_node *my_tree;    
tree_iter ti:
...
for(ti_create(my_tree,&ti);
!ti_done(ti);
ti_next(&ti)){
tree_node *n=ti_val(ti);
...
}
ti_delete(&ti);

逻辑控制的循环

和枚举循环相比,逻辑控制的循环关注点只在结束条件上

前置检测

由Algol W引进,后来被Pascal保留

1
while cond do stat

后置检测

这种的循环体不管是否满足循环条件,都至少会执行一次循环体。如C语言的do while语句

1
2
3
4
do{
line=read_line();
//...代码
} while line[0]!='$';

中置检测

中置检测一般依赖if

1
2
3
4
for(;;){
line=read_line();
if line[0]!='$' break;
}

递归

递归和上述讨论的其他控制流都不同,它不依赖特殊的语法形式,只要语言允许函数直接或间接的调用自身,那么就是支持递归的。大部分情况下递归和迭代都可以互相用对方重写的。

迭代和递归

早期的一些语言不支持递归(比如Fortan77以前的版本),也有一些函数式语言不允许迭代,然而大部分现代语言都是同时支持两者的。在命令式语言中,迭代在某种意义上显得更自然一些,因为它们的核心就是反复修改一些变量;对于函数式语言,递归更自然一些,因为它们并不修改变量。如果是要计算gcd(更相减损法),递归更自然一些:

1
2
3
4
5
int gcd(int a,int b){
if(a==b) return a;
else if (a>b) return gcd(a-b,b);
else return gcd(a,b-a);
}

用迭代则是这样:

1
2
3
4
5
6
7
int gcd(int a,int b){
while(a!=b){
if(a>b) a=a-b;
else b=b-a;
}
return a;
}

尾递归

经常有人说迭代比递归效率更高,其实更准确的说法应该是,迭代的朴素实现的(无优化)效率通常比递归的朴素实现的效率要高。如上面gcd的例子,如果递归的实现确实是实实在在的子程序调用,那么这种子程序调用所带来的栈的分配等的开销确实要比迭代要大。然而一个“优化”的编译器(通常是专门为函数式语言设计的编译器),常常能对递归函数生成优异的代码,如上面的gcd尾递归(尾递归函数是指在递归调用之后再无其他计算的函数,其返回值就是递归调用的返回值)。对这种函数完全不必要进行动态的栈分配,编译器在做递归调用时可以重复使用当前的栈空间,从效果上看,好的编译器可以把上面递归的gcd函数改造为:

1
2
3
4
5
6
7
8
9
10
11
12
int gcd(int a,int b){
start:
if (a==b) return a;
else if (a>b){
a=a-b;
goto start;
}
else{
b=b-a;
goto start;
}
}

即使是那些非尾递归函数,通过简单的转换也可能产生出尾递归代码。

应用序和正则序求值

在上述的讨论中,我们都假定所有参数在传入子程序之前已经完成了求值,但是实际中这并不是必须的。完全可以采用另外一种方式,把为求值的之际参数传递给子程序,仅在需要某个值得时候再去求它。前一种在调用前求值的方案称为应用序求值;后一种到用时方求值的方式称为正则序求值。正则序求值在宏这个概念中是自然而然的方式,前面讨论的短路求值、以及后面要讨论的按名调用参数也是应用的正则序求值,一些函数式语言中偶尔也会出现这种方式。

但是我们来看一个例子:

1
#define MAX(a,b) ((a)>(b)?(a):(b))

如果我这么调用MAX(i++,j++),导致i和j都执行两次++,产生了两次副作用,这是我们不愿意看到的结果。总结来说,只有在表达式求值不会产生副作用的情况下正则序才是安全的。

惰性求值

从清晰性和高效的角度看,应用序求值通常会比正则序合适一些,一次大部分语言都采用如此的方式。然而也确实有一些特殊情况下正则序更高效一些,而应用序会造成一些错误出现,这种情况的出现时因为一些参数的值实际上并不会被需要,但是还是被求值了,应用序求值有时也成为非惰性求值,比如下面的JavaScript代码就会是一个死循环:

1
2
3
4
5
function while1() {
while (true) { console.log('死循环')}
}
function NullFunction() { }
console.log(NullFunction(1,2,3,while1()));

Scheme通过内部函数delay和force提供可选的正则序求值功能,这两个函数提供的实际上是惰性求值的一种实现

惰性求值最常见的一种用途就是用来创建无穷数据结构

1
2
3
(define naturals
(letrec ((next (lambda (n) (cons n (delay (next (+ n 1)))))))
(next 1)))

这样就可以用Scheme表述所有的自然数

小结

本篇首先从表达式开始,介绍了表达式(语句)中的一些基本概念;然后从讨论了从汇编时代到结构化程序设计时代语言中的控制流程的演进以及发展;有了前面两个基础,后面就详细的介绍了程序中的三大基本流程控制结构顺序、选择、循环(递归和迭代)。