函数式编程的一些心得与体会
本篇内容主要分为这三个部分。
1.什么是函数式编程2.如何编写3.优缺点
什么是函数式编程
首先,到底什么是函数式编程呢?函数式编程跟命令式编程一样,是一种编程范式。命令式关注的是解决问题的步骤,函数式编程关注的内容则是数据之间的映射关系。
举个例子,假如你现在去 Google 面试,面试官让你把二叉树翻转。几乎不假思索,(你啪的一下,很快啊)就写了出来。
好,我们可以看看这个代码究竟代表着什么?其含义很明确,先判断节点是否为空,然后翻转左子树,然后是右子树,最后左右交换。
这就是命令式编程,你要完成什么事情,就要把能完成这个事情的步骤一一列举,然后让机器去运行这些步骤。
这时候,如何使用函数式编程来实现呢?翻转二叉树本质上是这么一种映射:
F(Tree(left, right)) = Tree(F(right), F(left));
根据这个思路,我们可以写出这样的代码。
这段代码同样也能达到翻转二叉树的效果,然而却体现了一种跟命令式不同的思维模式——通过描述“旧树 - 新树”之间的映射。函数式的代码这种“对映射的描述"肯定不仅仅是可以用于表达二叉树。也可以表达各种计算机中的数据结构中的映射。那么该如何进行函数式编程呢?
如何编写
在这里,我给出了三个具有代表性思路。
1.纯函数2.管理副作用3.数据不变性
首先是纯函数,它基本上都会第一时间出现在各种函数式编程的教材。纯函数的性质只有两个:
1.输出取决于输入参数2.不产生副作用
多说无谓,举一个简单的例子,假设你在2017年8月份前现在接到了一个财会软件的需求,要求你根据某位员工的工资,计算他的个人所得税。忽略繁杂的五险一金的计算,你可以得到这样一个简单的函数。
很明显,这就是一个纯函数,输出只依赖于输入参数(工资)无论你是 5k 还是10k,税收都是从 3.5k 开始计算,除此以外没有做任何操作以及副作用。
然而到了2018年8月,你接到通知,工资的起征点从9月份开始有了新的变化,从 3500 变成 5000 。需求很简单,你用键盘飞快地敲出了这样的代码。
仔细思考一下上面的代码就会发现,上面的代码无法处理边界问题,如果会计在9月份结算8月份的工资,难不成还要修改电脑日期吗?这很明显是一个bug,主要原因是由于引入了包含副作用的 I/O 函数(today),所以会导致输出不再依赖于输入参数,反而依赖于外部时钟。所以导致了原本的纯函数变得不再纯洁,以至于你需要对副作用进行控制。
我在这需要给出副作用的定义。
1.执行 I/O 操作2.改变输入参数3.抛出异常4.全局状态(指函数作用域外的状态)突变
刚刚的问题对应于这里的第一种情况。之前已经说过,纯函数的输出只能依赖于输入参数,所以,转换一下思路,把产生副作用的内容当作参数传入,这样就让纯函数保持纯洁。today 的本质是通过系统的时钟来获取,那么它的每次调用就会导致全局状态突变。回到问题,其实只要我们把年月作为参数传入即可。
通过上述函数,无论我在什么时候特点的时间进行运算,只需要带上这位员工工资所在月份和年份,那么即可得到正确的结果。当然 I/O 处理可能会涉及到异步的问题,解决这种问题的方法在 C#里叫 Task,在 JS 里叫 Promise,本质上都是同一套名为单子的解决方案。
再来举个例子,如果你参与了某个电商平台管理系统的设计,当订单中的物品被修改的时候,需要重写计算总价,并获取所有商品数量为零的订单行。
这里的问题对应于第二种情况,改变输入参数。调用者依赖该函数来执行副作用,被调用者需要依赖于调用者来初始化列表。因此这两种方法都必须知道另一种方法的实现细节,因此无法孤立的推理出这些方法。
解决这里的耦合问题也很容易,把所有计算信息返回给调用者,可轻松避免这种副作用。
这里既可以定义一个二元组用于返回特定对应的数据,也可以分别写成两个纯函数,一个用于计算总价,一个用于获取 linesToDelete。
对于异常处理以及全局状态变量的突变的问题,函数式编程都有很好的解决方案。
在 C# / Java 这类类型定义不完整的语言中,可以通过定义 Either 类来决定异常的返回内容。而不用关心或决定是否抛出异常。
全局状态变量突变的问题,主要会涉及到共享内存以及相关线程问题,其合理的解决方案是并发消息传递——实际上就是一个消息队列并发处理请求,并更新程序状态。这个解决方案无法在如此短的时间内说清楚,想要继续了解这部分内容可以自行搜索。
那么,并发消息传递中,”更新程序状态“所需要的技术基础便是来自于数据不变性,不可变数据有以下两个特点:
1.纯洁性:任何时候读取数据都是同样的输出。2.原子性:不可变
所谓纯洁性,就是任何时刻读取该数据都是同样的输出;而线程安全,则来自于众所周知的原因,数据拷贝是无竞争的。
优缺点
最后,总结一下内容函数式编程的优缺点,函数式编程的好处:
1.更加简洁、易读、易于测试的代码。2.更好的支持并发。
但是,函数式编程也存在一些不可调和的问题:
1.难以调试的代码(调用栈过长,找不到目标代码,并且容易爆栈)。2.大部分语言都难以支持一些高级类型(并、或),或类型推倒。3.无法严格避免共享状态可变。
那么,在认识到这些问题的情况下,函数式编程与业务结合方面,刻意的让整个程序无状态化也不现实,开发人员应该倾向于把业务系统模型无状态化,用更加通俗的说法,就是让业务逻辑变成纯函数,变得可预测。
最后
文中部分内容引用自以下文章和书籍。
[1] 什么是函数式思维 - nameoverflow[1]
[2] 函数式编程的核心思想 - 廖雪峰[2]
[3] C# 函数式编程——编写更优质的C#代码 - Enrico Buonanno
[4] Haskell 趣学指南 - Miran Lipovaca
References
[1]
什么是函数式思维 - nameoverflow: https://www.zhihu.com/question/28292740/answer/100284611[2]
函数式编程的核心思想 - 廖雪峰: https://www.liaoxuefeng.com/article/1260118907809920