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

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

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); }

用迭代则是这样:

int gcd(int a,int b){ while(a!=b){ if(a>b) a=a-b; else b=b-a; } return a; } 尾递归

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

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; } }

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

应用序和正则序求值

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

但是我们来看一个例子:

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

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

惰性求值

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

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

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

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

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

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

小结

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

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wpfgwf.html