使用JavaScript实现“真·函数式编程”-2

上一篇文章使用JavaScript实现“真·函数式编程”本来以为可以一次性写完的,结果话痨本色,没办法,继续填坑,这篇应该可以完结了,讲道理嘛。

这篇当中将介绍如何在纯函数式的限制之下实现“局部变量”和“状态”。

2. 实现局部变量

首先考虑这样一段代码

这是一段典型的命令式编程的代码,它最主要的问题就是局部变量name

在上一篇文章的第一个实现对数组遍历的例子当中我们已经对“顺序执行”初窥门径,通过构造了一个two_steps函数,实现两个步骤(函数)顺序执行。

在这个构造过程当中,我们得到一个重要的思路,就是在函数式语言当中,如果你想“获得”一个什么东西,就构造一个新的函数,把它参数化。对于two_steps的例子而言,“想获得”的是一个step2,就把它参数化了。

所以当需要“获得”局部变量的时候,自然而然我们会想到,把要拿的东西参数化就OK了,于是我们可以简单的这么构造:

local函数接收两个参数,a是要“捕获”的值,f是接收或者说消费这个值的函数,用它来改造上面的代码

上文当中将getName()的结果“捕获”作为后面函数的参数,实现了“局部变量”name。把上面的函数按照“真·函数式编程”规则改写:

不难发现我们这个local其实就是two_steps的简化版,区别在于two_steps的第一个step1是一个函数,而local则是一个值,如果用two_steps实现local那么就是:

看,我们这个local的风格,看起来非常像JS当中的“回调”的方式——事实上,因为像Haskell这样的纯函数式语言没有顺序执行,你可以认为每一行代码执行顺序是不一定的,这非常类似于在JS中我们遇到了海量异步操作的时候:异步操作的执行顺序是不一定的,所以才会用回调函数来保证“异步操作->处理结果”这个顺序。回调是一种非常朴素,非常好理解,但写起来却反人类的异步编程方式。我一直不批判浏览器和node.js里把API都用回调风格来定义,因为它很原始,大家都懂,至于后来的如Promise这些方式,也可以用回调的API轻松封装出来,咸甜酸辣,五仁叉烧,任君挑选。

OK,扯远了,也许你觉得上面的例子太过简单,下面我们来看这篇文章中真正重点的内容。

3. 实现状态

以下的例子基本上都源自从陈年译稿——一个面向Scheme程序员的monad介绍文中搬运和改造,我从这篇文章获得了巨大的启发,也先对作者和译者表示感谢。

我们写程序的过程当中常常回用到自增ID这种东西,如果在JS里要实现一个自增ID,可能会这么写

好嘛,绕了一圈,又回到刚才的话题了,局部变量(这次在闭包里面而已,本质是一样的),和二次赋值。但是经过前文的启发很容易就能用参数化的方式来解决这个问题

也就是

我们把局部变量参数化,然后把返回值改成了一个数组(因为JS里没有元组,所以只能用数组暂代),这样在需要guid的时候,需要把之前的返回值的第二个值作为参数传进去;而整个程序则需要使用一个初始值(我们管叫“种子”)来启动它。

现在假如我们有3个名字,分别要对它们用guid来编号并且输出,也就是说需要连续执行3次guid,这里涉及到的就是顺序执行以及guid的状态参数传递:

也许你已经被后面谜一般的这一堆括号所惹毛了,如果你能忍着继续看下去的话也许可以真的获得这篇文章的乐趣(捂脸

对于没有副作用的函数(纯函数),不需要带上state这个参数,而对于有副作用的函数——我们称之为“操作”——这里体现为调用了guid函数的,就需要带上state这个参数。

看,state就是我们所说的“状态”,整个过程中,都把它(用数组的第二个值)揣着,当一个函数需要状态的时候就传给它,它用完了又捡回来揣着。

看起来没什么不对,但是guid(state)这个函数总是给人隐隐的不爽:stateguid自身的状态,却需要counting这个消费者在整个调用过程当中帮它传递,这是不爽的,因此不妨把guidstate参数定义为curry形式:

进而counting中的local的第一步不再是一个已算出的值,而是一个curry了第一个参数(空),需要第二个参数stateguid函数,这就是curry函数的精妙之处,它让函数可以“部分地”被执行,从而能够实现演算——我们把整个演算过程像代数推导一样列出来,最后把值代入就能计算出结果,是不是很像中学的时候解代数题、物理题什么的?

于是,用了新的guid以后,local就不能应用于guid了,使用two_steps改写一下

这时候你会觉得更烦了,因为这次虽然我们不需要给guid()主动传递state了,但在连续多次调用two_steps的时候,却需要把state1state2two_steps传递下去,能不能构造一个新的two_steps函数,让它能够透明地传递state参数呢?

答案是显然的,回顾一下上文中two_steps的定义和实现:

我们想想,two_stepsparam参数作用无外乎是作为“状态”传给step1,它的定义是curry化的,如果two_steps不传第三个参数,获得的就是一个内容为step1-then-step2的“部分函数”这个函数接收param参数,返回step2的结果。要让two_steps能够继续透明地使用这种“部分函数”,就是说two_steps的结果可以继续被two_steps组合,我们可以对step1step2函数的类型进行限定

其中State是状态的类型,Value是返回值的类型,在guid的例子里面,这两者都是Number。这样结合起来,新的two_steps——我们给它一个新名字叫begin——的类型限定就是

对吧,begin的两个参数step1step2都是State -> [Value, State]类型,这跟它在只curry前两个参数,剩余param参数时的那个部分函数step3的类型(函数签名)是一样 的。

从中抽取出一个模式:State -> [Value, State],我们用一个泛型来抽象它叫做M,不难发现,guid() -> Number -> [Number, Number]也就是() -> M类型(其State也是Number类型),用这个泛型可以把begin描述成:

这样我们可以顺利的推出begin的实现

简化之,结果是

这和two_steps如出一辙,区别只在于,对于step2,它丢弃了step1所产生的Value,而只保留了它所产生的State

然而我们还是需要Value啊!说丢就丢这也太不负责任了吧!这时候自然想到“再加一个中间层”,我们设计这样一个函数:它的第二个参数消费step1所产生的Value,返回step2,这个step2再去消费step1所产生的State。把这个函数命名为bind,它的类型描述如下

使用M泛型来描述它就是

看,当使用bind来结合两个操作step1build_step2的时候,step1消费掉State种子,产生一个返回值Value(并且可能产生了新的状态State)。紧接着build_step2消费了step1所返回的Value,并且它返回一个新的M也就是step2bind函数会像begin那样,把step1所产生的新State作为参数传给step2,并且返回其结果。于是我们终于构造出了bind的实现:

转换成“真·函数式编程”,这里利用local实现局部变量:

不难发现,beginbind的一个特例,用bind来构造它的话就是

非常的直白,忽略step1产生的Value,继续调用step2。现在使用bind来改进上面的多个带状态的顺序执行的代码

注意上面的代码里面我们定义了一个returns函数,它接收一个Value,并且返回一个M。毫无疑问,M是比Value更“高阶”的类型,因为M当中含有State,而Value不含。

bind函数作用于M类型,因此需要returns,于是通过returns将一个Value转成M;而main函数是返回Value类型的,则通过[0]来将一个M抛弃State转回Value

还记得刚才那句话吗:

对于没有副作用的函数(纯函数),不需要带上state这个参数,而对于有副作用的函数——我们称之为“操作”——这里体现为调用了guid函数的,就需要带上state这个参数。

因为main函数抛弃了最终的State,所以它不在有副作用,又变成纯函数了;而正是因为它抛弃了State,它自身也变成无状态的了,所以毫无疑问重新调用main(0)就会让guid清零重新开始——局部变量,作用域,生命周期,有没有发现命令式编程里面的概念在这里体现出来了?

<h2 id=”那么,M到底是什么?”>那么,M到底是什么?

从面相对象的角度去理解,我们可以说,M是一个封装了State在里面的“操作”。从函数式编程的角度去理解,我们理解为Value是一个“值”,而M是一个“计算”。

对上面的东西做一个小结

  1. returns函数将一个Value“提升”成M类型。
  2. beginbind函数将两个M绑定顺序关系(beginbind的简化版)。bind函数中的build_step2将会有一个“临时”的机会获得Value,但是用完以后又必须回到M
  3. [0]M“降级”回Value——这个过程将会不可逆地丢失掉State

我们上面的returnsbind这一对函数就实现了一个Monad——准确的说是State Monad。在Haskell里面,我们的returns叫做return,我们的bind叫做bind或者运算符>>=。这张图

via《道可叨 | Monad 最简介绍》

是我所见到的一个非常形象的描述。

除了State Monad外还有很多种Monad,例如Maybe帮助Haskell实现了非常自然的错误处理,IO帮助Haskell实现了IO。在Monad的帮助之下Haskell更实现了do notation这种“顺序执行”的语法糖,可谓是Haskell的核心之一。

4. 总结

到现在为止,循环有了,局部变量有了,状态也有了,可以说基本上已经没有写不出的程序了。当然,正经的程序是不可能这么写的,所以这两篇文章也就是分享一下我个人的学习心得,玩玩所谓“真·函数式编程”,以及——最重要的——还是装逼。

好了,最后,我也不想说什么了,只能深吸口气,紧闭眼睛,一言(tú)以蔽之:

1 4 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部