《编程机制探析》第二十章 流程控制

《编程机制探析》第二十章 流程控制

本章讲解函数式语言中的流程控制。
在此之前,先让我们把目光投回到命令式语言的世界。目光所及之处,有一片区域特别混乱。
上界派来的观察员大惊,“那是什么鬼地方?怎么和我自家的卧室那么乱?程序,不是应该遵守程序的吗?”
仔细一看,那一片区域正是隶属于“流程控制”的“跳转语句”,俗称“跳转界”。
你跳,我跳,他也跳。大家一起跳。杰克深情地望着露丝,“你跳,我也跳。”
跳转界中,跳得最欢,也跳得最强劲的,非C语言莫属。C语言支持goto label, set jump, long jump等指令,几乎可以任意跳转到任何一个地方。这是个跳槽高手。往外跳,往回跳。小跳,大跳,蹦着高的跳。有时候,跳得太远,就不知所踪了。那真的是,跳出三界外,不在五行中。
许是C语言跳得太欢了,引起了上界的不满。于是,后来的语言中,取消了这些臭名昭著的跳转语法。
于是乎,跳转界一片愁云惨淡。
我们来看一段条件指令的内层跳到外层的代码。下面的代码描述了跳转的一些可能的极端情况。
If(a == 1) {

If(b == 1){
…..
If(c == 1){
…..
// 需要直接跳到最外层的nextJob()
}
bNextJob();

// 需要直接跳到最外层的nextJob()
} // end if(b == 1)

aNextJob();
} // end if(a == 1)

nextJob();

如果没有goto, label我们只能用flag来做些标志,一层一层地跳出来。
If(a == 1) {
int aJumpOutFlag = 0; // 标志是否应该跳出a块到外面


If(b == 1){
If(c == 1){

// 需要直接跳到最外层的nextJob()
aJumpOutFlag = 1; // 设置跳出a块跳到最外面的标志
}

if(aJumpOutFlag != 1){
bNextJob();

   // 需要直接跳到最外层的nextJob()
aJumpOutFlag = 1; // 设置跳出a块跳到最外面的标志
}
} // end if(b == 1)

If(aJumpOutFlag != 1){
aNextJob();
}
} // end if(a == 1)

nextJob();

我们看到,每一层都需要判断aJumpOutFlag,这种方法简直令人难以容忍。
“自从没有了筋斗云,交通基本靠走,通讯基本靠吼,这样的日子没法过了!”
跳转界中怨声载道。
别急。我们还有其他的解决方案。我们可以用三个神奇的咒语(magic word),实现从内向外的任意层次跳转。这三个咒语分别是break、continue。嗯,就是这些了。
“等等。”有人不干了,“你不是说,有三个咒语吗?怎么才有两个?你不识数吗?”
咳,不是我不识数,而是因为……第三个咒语太强大了。以至于我都不敢说出它。
它(聚光灯瞬间亮起),就是终极跳转语句——return!
一般的情况下,我们不敢用它。一旦用了它,一切都结束了——云开雾散,换了新篇。
我们一般不敢劳动return它老人家,我们只用性情相对比较温和的break和continue。
我们来看一个例子:
while(a == 1) {

If(b == 1){
…..
If(c == 1){
…..
// 需要直接跳到最外层的nextJob()
break;
}
bNextJob();

// 需要直接跳到最外层的nextJob()
break;
} // end if(b == 1)

aNextJob();
break;
} // end if(a == 1)

nextJob();

注意,除了最后一个break,前面的两个break都可以用continue来代替,效果是一样的。
我们看到,活用循环体中中的break、continue,可以实现灵活的跳出控制。
在支持label(在循环体前加标签)的语言中,我们还可以在多层嵌套循环中实现更加灵活的跳转控制,想跳出到那一层就跳到那一层。
layer1:
while(…) {
….
Layer2:
while(…) {

while(…) {
….
break layer1
….
break layer2
….
}

}

}
遗憾的是(或者说,幸运的是),函数式语言不支持任何类似的跳转语句。在函数式编程中,这些跳转技巧全都失去了用武之地,我们只能老老实实地分析流程,一条条、一支支地写好代码。
诗云,旁门左道不可取,人间正道是沧桑。就是这个道理。
感慨完毕,继续探讨。先看Haskell的流程控制。
Haskell的函数体内,只允许存在一个表达式,意即,只允许存在一棵语法树的树根。
放眼望去,从这个树根上蔓延出去的枝枝杈杈全都是同一种类型——条件分支。
在命令式语言中,流程控制语法比较多样,语法树上可能有三种类型的枝杈——条件分支、顺序分支、循环分支。
在函数式语言中,就只剩下两种——条件分支、顺序分支。
由于Haskell不支持顺序分支,就只剩下了一种——条件分支。
我们再来看ErLang。
ErLang的函数体中,最后执行的那条语句,必然是一个表达式,用来产生返回值。这点和Haskell相似。
但ErLang支持顺序语句,在ErLang的语法树上,除了条件分支意外,还有顺序分支。流程控制就比Haskell复杂了许多。
不过,ErLang代码中,顺序语句并不多见,而且大都是赋值语句,类似于Haskell的let…in结构中的变量定义部分。
我们来看具体代码——fibonacci常规问题的尾递归解法。
我先给出我的解法。让我们来看一看,一个命令式程序员是如何用他的命令式思维,写函数式程序的。由于Haskell不支持顺序语句,难以写出命令式效果,我这里就用ErLang代码。
fibonacci(CurrentStep , Result1, Result2, N) ->
Result = Result1 + Result2, % f(i) = f(i-1) + f(i – 2)
if(CurrentStep >= N)
Result
else % j = i +1
NextResult1 = Result, % f(j - 1) = f(i) ,
NextResult2 = Result1; // f(j - 2) = f(i – 1),
fibonacci(CurrentStep + 1, NextResult1, NextResult2, N)
end.
这种写法几乎就是命令式语言版本的翻版。在函数式程序员看来,这种写法肯定是丑陋无比,不堪入目。没办法,水平所限,思维所限。不过,这种写法有一个好处就是,命令式程序员看起来不太费劲。
现在,我们来解决fibonacci通用问题,即:
f(1) = 1
f(2) = 1

f(k) = 1
f(n) = f(n – 1) + f(n – k)
用ErLang来表达就是:
fibonacci(N, K) when (N <= K) -> 1;
fibonacci(N, K) -> fibonacci(N - 1, K) + fibonacci(N – K) .
现在,我们要把这个算法改成尾递归。
在讲解“树形递归”的章节中,我们已经讨论过fibonacci通用问题的尾递归和循环解法,使用了一个叫做LinkedQueue的队列结构。那个队列结构允许头部和尾部增删元素,实际上是一个双向列表结构。由于函数式语言的变量不能重复赋值,在函数式语言中,这种双向列表结构是不可能实现的。
我们只能采取折中方案,使用List保存所有的中间结果。
当然,List结构是一个栈结构,不是队列结构。我们需要把List尾部当做队列头部来使用,把List头部当做队列尾部来用。
每次计算完成之后,就在List头部(相当于队列尾部)增加当前的计算结果。
当计算需要f(n-1)时,直接取出List的头部元素(相当于队列尾部)。当计算需要f(n – K)时,就需要List头部向后遍历,获取第k个元素。我给这个遍历起个名字,叫做K遍历。在每一次递归中,K遍历都会发生一次。无论从空间效率还是时间效率来说,这种方法都远远不如对应的命令式语言的尾递归算法。
Fibonacci通用问题尾递归的ErLang代码。
假设nth是一个List函数,取得List第n个元素。
fibonacci(CurrentStep, Results, N, K){
Result1 = nth(Results, 1), % f(i – 1)
ResultK = nth(Results, K – 1), % f(i – k). 假设nth是一个函数,取得List第n个元素。
Result = Result1 + ResultK; % f(i) = f(i-1) + f(i – k)
if(CurrentStep >= K)
Result
else
fibonacci(CurrentStep + 1, [Result | Results], N, K)
end.

这个算法中,k遍历的问题一直困扰着我。由于fibonacci通用问题是我自己扩展出来的,我找不到这个问题的相关资料。我找到的fibonacci的尾递归函数式解法,都是fibonacci常规问题(即k=2)的解法。
我设想了一种双List交替的方案,即同时维护两个List。一个List头部指向f(n-1);一个List头部指向f(n - k)。这种方案可以节省空间,把一个长度为n的缓存List变成两个长度为k的List,但是,时间上却没有改进。
虽然我没有找到答案,但我根据常识判断,由于变量不可变的限制,函数式编程找不到解决k遍历的有效解法。这个论断还有待考证。
读者可能会问,为什么函数式编程不允许变量可变呢?
第一,是出于数学模型的考虑。函数式编程中,每个名字都代表着数学公式中的符号,并不直接对应内存结构,尽管在实现上,每个名字可能确实对应着内存结构。
第二,是出于并行计算的考虑。这点对我们来说,更具有现实意义。
要理解“变量不可变”在并行计算上的优势,我们需要从“状态”这个词谈起。
状态(State)是计算机编程模型中非常重要的概念。关于状态的两个形容词——Statefull(有状态的)和Stateless(无状态的),多见于各种资料书籍。
何为状态?这东西还真不好定义。我只能这么来描述它:状态就是可以改变的共享变量。这个共享变量的来源有两种可能。第一种可能,共享变量是程序内部自定义的数据结构,这叫做内部状态;第二种可能,共享变量是程序外部的环境资源,比如文件、数据库、显示器、键盘、打印机、网络服务、其他进程提供的各种服务,等等,这叫做外部状态,也叫做I/O(Input/Output,输入/输出)。
内部状态的产生方式只有一种,那就是程序本身定义的共享的可变的数据结构。这类数据结构大量见于各种命令式语言中。从内部状态的角度来说,所有命令式语言都是statefull。不过,我们可以在statefull语言中,写出stateless的程序代码,只要我们避免使用共享的可变的数据结构即可。
stateless的程序代码有什么好处呢?其优势主要在于并行计算。现代的计算机CPU更新换代,进入了多核时代,多个处理核心可以同时执行多个线程。
有一个关于线程的名词,叫做线程安全(Thread Safe)。什么叫做线程安全呢?这是代码的一种特质。在线程模型中,代码都是在线程中运行的。没有共享冲突、不用产生排队调度的,就叫做线程安全的代码。反之,存在共享冲突、需要排队调度的代码,就叫做线程不安全的代码。
共享冲突是怎样产生的?当然是共享资源引起的。没有共享资源,就没有共享冲突。共享资源是什么,就是状态(state)。因此,stateless就是线程安全的代名词。stateless的程序代码,就意味着线程安全的代码。Statefull的代码就是线程不安全的代码。
在命令式语言中,我们可以写出stateless的代码。那么,函数式语言呢?
从内部状态的角度说,我们用函数式语言,只能写出stateless的程序代码。因为函数式语言就不支持可变的变量,自然也就没有所谓的状态。所以,如果只考虑内部状态的话,函数式程序天生就是stateless的。
但是,事情没这么简单。程序不仅有内部状态,还有外部状态。
程序对外部状态的读写,涉及到于外部进程(包括系统进程和其他应用程序)之间的数据交换,一般都需要经过一种叫做I/O(输入/输出)的编程接口。因此,外部状态通常简写为I/O。
函数式编程可以没有内部状态,但是,却不可以没有I/O。没有I/O的程序没有任何意义。一个程序自己在内存中跑了一圈,结束之后,运行结果也跟着消散了,没有传输到任何I/O设备上。谁也不知道,他干了什么。春梦了无痕。这样的程序有什么用?
任何有实际意义的程序中必然有I/O。我们能做的是,把I/O部分尽可能地和其他代码分开。I/O部分,没有办法,天生就是statefull的。我们只能在非I/O代码上做文章。
理论上,非I/O代码都可以写成stateless。在命令式语言中,我们可以通过避免内部状态来写出stateless的非I/O代码。在函数式语言中,我们写出来的非I/O代码自然就是stateless的。stateless的非I/O代码有什么好处呢?前面说了,就是便于被多个处理核心同时运行,即并行。
近些年来,函数式编程有升温的势头,这和多核时代并行计算的兴起不无关系。可见,硬件基础决定软件上层建筑哪。
ErLang作为一门工业级语言,自然是支持I/O调用的。Haskell作为一门学术性语言,有权利任性一把,在消除I/O方面付出了很大的努力。这意味着,我们想用Haskell处理I/O时,也得付出很大的努力。
在普通的Haskell函数中,是不允许有I/O操作的。这种特性,被Haskell程序员骄傲地称为“Pure”(纯的)。
在Haskell中,想要调用I/O,就必须用到一种叫做Monad的类型。
有些资料把Monad翻译为“单子”。这并不是“订单”、“单据”之类的意思,而是数学中的概念。无论是“Monad”,还是“单子”,我都不明其意,干脆还是用原文Monad。
Monad是世界上极为难懂的概念之一。有人如是说。我深以为然。
有人还说,如果一个人没有经历过任何思考上的痛苦,就理解了Monad,那他一定没有真正理解Monad。
此话,我不以为然。因为在学习Monad的过程中,我经历了非常多的思考上的痛苦,我到现在也没有理解Monad。
这句话应该换成:去它的Monad,这就不是常人能弄懂的玩意儿。让它成为那些高人自娱自乐的玩具吧。
这里,我只能给出我的大概理解。
Monad是Haskell类型系统的一种自定义类型。要想全面理解Monad,需要先了解Haskell的类型系统定义。
作为一个命令式程序员,我表示,Haskell的类型系统不容易理解。里面有些概念和命令式语言中即遥相呼应,又似是而非。面向对象语言的类型系统(主要是指泛型)固化了我的思路,不仅没有帮助我,反而徒增了很多困扰。
我觉得,对于命令式程序员来说,要想真正理解Haskell类型系统,应该从具体函数类型开始,一步步向上抽象,把具体类型抽离出去,
Monad也是如此。我们应该从具体的Monad应用场景入手,一步步向上抽象,最后得出最为抽象的Monad类型定义。
Monad的主要应用场景是什么?是提供状态和顺序操作。
提供状态,就不用说了,I/O操作本身就是外部状态。至于顺序操作,Haskell不支持顺序语句,但是,I/O操作又必须保证一定的顺序,所以,我们需要一种结构来迫使程序顺序执行。
最简单的思路是这样:把所有的需要顺序执行的函数,全都放到一个List里面,依次调用。
为了保证函数之间能够传递数据,我们把前一个函数的返回值,作为参数传给下一个函数。这就构成了函数执行链。
这种思路很像是复合函数的用法。
Haskell中的复合函数来自于数学,其含义也和数学中的复合函数一致。
当函数f的返回值类型和函数g的参数类型一致时,这两个函数就可以复合成一个函数:
h = g.f
g和f之间用一个英文句号 . 来相连。
从复合函数的定义就可以看出,能够复合的两个函数的要求很严格:只有一个参数,只有一个返回值;前一个函数的返回值类型和后一个函数的参数类型必须是一致的。
怎么理解复合函数呢?实际上,(g.f) x 就相当于g(f(x))。复合函数就相当于这样一个构造函数的函数:
composite_functions g f = \x -> g (f x)
多个函数复合就是这样:
call_functions [] x = x
call_functions (f : fs) x = f (call_functions fs x)
composite_functions fs = \x -> call_functions (fs x)
其中,call_functions接受一个List作为参数,List中的每一个元素都是函数,而且都是(a->a)这样的函数,即所有函数的参数类型与返回值类型相同。
call_functions就从尾到头,依次调用每个函数,并把结果作为参数,传给下一个函数。这里没有使用尾递归算法,没有List反转,看起来好像是从头到尾调用,但实际上,这是个递归调用,只有递归到了最深层时,真正的调用才发生。退出栈的时候,就相当于一步步从尾到头开始调用。
除了复合函数之外,还有一种现成的算法是能够支持函数的链式调用。是什么算法?
没错,就是Reduce算法,也叫做Fold算法。在这个算法中,所有的函数依次执行,并且把中间结果值传递到下一次递归调用。
无论是复合函数,还是Reduce算法,对函数的参数和返回值的类型都有要求——前一个函数的返回值和下一个函数的参数的类型是一样的。这就需要一个包装类型把各种类型都包装成同一种类型。这也是Monad的功用之一。
以上的这种函数链思路,虽然简单,但是不够灵活,无法处理所有情况。比如,函数调用链有可能是树形结构;多个函数可能需要同时访问一组变量定义;调用过程中需要错误处理;等等。
为了对应各种可能的情况,Monad就应运而生。Monad的主要功用就是灵活地将函数编制在一起,并形成一个结构灵活的函数链结构。
可见,Monad也是用来控制流程的一种方案。
Monad的bind方法,就是把两个函数组合成一个函数调用链。Monad的return、fail等方法,实际上是函数链中的特例函数。
Monad中的条件分支语句,if、else、case等,都是某个具体函数内部的流程定义。这样,整个Monad流程看起来就像一棵布满了顺序语句的语法树。从这个角度来看,Monad可以看做是一种控制流。
如果从数据传递的角度看,大体上来看,数据总是参数流向返回值,再从返回值流向参数,这也是一种数据流。
对于理解Monad,我暂时能够提供的思路就是这么多,希望能对大家有一点用处。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值