欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-05-17 13:37:06
...
《编程机制探析》第二十章 流程控制

本章讲解函数式语言中的流程控制。
在此之前,先让我们把目光投回到命令式语言的世界。目光所及之处,有一片区域特别混乱。
上界派来的观察员大惊,“那是什么鬼地方?怎么和我自家的卧室那么乱?程序,不是应该遵守程序的吗?”
仔细一看,那一片区域正是隶属于“流程控制”的“跳转语句”,俗称“跳转界”。
你跳,我跳,他也跳。大家一起跳。杰克深情地望着露丝,“你跳,我也跳。”
跳转界中,跳得最欢,也跳得最强劲的,非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,我暂时能够提供的思路就是这么多,希望能对大家有一点用处。
相关标签: 编程