第六章 函数式编程
在许多方面,函数式编程的概念早于编程本身。这个范式是基本建立在20世纪30年代Alonzo Church发明的λ演算上。
整数的平方
为了解析函数式编程,最好的方式还是举些例子。让我们来研究个简单的问题:输出前25个数的平方列表。
public class Squint {
public static void main(String args[]) {
for (int i=0; i<25; i++)
System.out.println(i*i);
}
}
Clojure是Lisp方言的一种,也是函数式的,以上问题可以用Clojure描述
(println (take 25 (map (fn [x] (* x x)) (range))))
如果不晓得Lisp语言,可能上述代码看起来有点怪,以下为注释
(println ;___________________ 输出
(take 25 ;___________________ 前25个数
(map (fn [x] (* x x)) ;______ 平方起来
(range)))) ;_________________ 的整数串
很明显,println,take,map,range都是函数。在Lisp中,可以将函数放在括号中表示调用函数,比如(range)调用range函数。
表达式(fn [x] (* x x))是匿名函数,调用乘法。传入的参数x用了两次,即计算平方。
再看看整个过程,我们从最里面的函数开始:
- range函数的返回是个从0开始的无限整数列表
- 这个列表传给了map函数,map函数将调用匿名的乘方函数处理这个列表的的每个元素,并且返回一个存储这些平方结果的新的无限列表
- 这个平方列表再传给了take函数,结果返回了平方列表的前25个元素的新列表
- println函数将输出这个平方列表的前25个元素的新列表
如果你觉得无限列表这个概念很难处理,别担心,其实在无限的列表中只有前25个元素被真正的创建,因为只有在访问到时才会真的去处理。
如果你对以上任存疑惑,可以学习Clojure和函数式编程,非常值得深入,但这并非本书的主题。
以上的例子看得出Clojure和Java语言一些地方非常不同。
Java程序用的是可变变量,即在程序运行时可以改变变量的值:循环控制中的变量i不断累加。在Clojure程序里,比如变量x被初始化后就不再改变了。
我们可以推论个激动人心的结论:函数式编程里的变量不再改变。
不变性和架构
为什么这个结论是架构思考里是重要的?为什么架构师应该关心变量的可变性?答案很简单:所有的竞争状况,死锁状况,同步更新问题都是因为变量是可变的。如果变量不再被更新,就不会出现竞争状况,同步更新问题。没有因为可变变量的锁,也就不会发生死锁。
换句话说,我们面临的所有并发程序(多核多线程)的问题,如果没有可变变量,就不再出现了。
作为架构师,你应当十分关心并发问题,确保自己设计的系统在多核多线程下依旧表现得健壮。当然随之而来的问题:那不变性是否是最佳实践?
如果你右无限大的存储资源和无限快的计算资源,那是肯定的。但并没有这样的资源,做一些妥协,不变性不失为最佳实践。让我们来看一些妥协方案。
分离可变性的部分
一种普遍的妥协方案是把应用或者其中的服务分离成可变和不可变的组件。不可变的组件就能完全用函数式的方式,不再有可变的变量了。但是它和其他组件可不会是完全的函数式的方式了,需要考虑可变变量的状态了图6.1。
图6.1 可变状态和事务性存储
一些组件需要考虑并发问题下可变变量的状态问题,常见的方法是用事务性存储来解决并发更新和资源竞争的状况。
事务性存储简单来说就是数据库对待磁盘的数据一样 来存储这些可变变量。
一个简单的Clojure中atom的例子
(def counter (atom 0)) ; initialize counter to 0
(swap! counter inc) ; safely increment counter.
这个例子里,定义变量counter为atom类型,atom是一种特殊类型,swap!函数可以子啊非常严格的条件下强制更新这个类型的变量。
swap!函数,如以上代码所示,两个入参:可变的atom变量,更新atom变量值的函数,例子代码counter atom即把被inc计算后的新值存回atom变量,inc这个函数只是简单的自增。
swap!函数的实现是传统的比较并交换(CAS)算法,读取counter的值传入inc函数,当inc返回,counter变量被锁住做这样的操作:比较刚才传入给inc函数的counter值和目前的counter的最新值,如果相等,那么counter变量更新为inc返回的值,然后释放在counter上的锁,否则,锁释放,读取counter额值传给inc,重复以上操作。
atom足以应对简单的应用场景,但你不能指望在要考虑多个独立的变量的场景,避免同步更新和死锁的问题。当然得用上其他合适的办法。
良好的应用结构拆分成不变和不可变的组件,这种拆分应有合适的措施来保证可变变量的正确状态。
架构师应当明智的抽离避免不了的可变组件中的代码到不可变组件中。
事件溯源
现在来看,处理器性能和存储的限制越来越小,当前处理器每秒上亿次执行指令,和上亿字节大小的RAM参与已经很常见了。处理器越快,存储越大,我们需要的可变变量就越少。
来看一个简单的例子,一家银行的维护客户账户余额的应用,在客户储蓄或取现余额将发生变化。
如果现在不再存储客户的余额值,我们只存储每笔交易,但客户想要了解自己的余额,我们只要简单地把这个账户的所有交易加起来,这么一来,就不再需要存储可变的余额值了。
哈哈,这么做听起来很荒谬,尤其时间一长,交易记录将越来越多,处理总数的时间将变得越来越难以满意,那么为了保持这种只记录交易记录方式的应用永远运作,我们真得需要无限得存储和处理速度啦。
但我们可能也不需要系统永远运作。因为我们可能有足够的存储空间和足够的处理能力使应用程序在合理生命周期内运作。
这是时间溯源的想法,它是一种将存储交易,不存储状态变量的策略。当需要状态值时,我们可以简单的从所有交易里处理,进而获取状态值。
当然,我们可以优化,比如我们可以每天日期计算所有到当前切点的状态值并且将值存储起来,在需要状态信息时,只有需要从上个切点的交易开始处理。
考虑这种方案的数据存储需求:足够存储了。实际场景中,离线数据增长相当快,向陛下万亿字节也显小。这种方案的数据存储我们更是不在话下。
更重要的时,数据存储不再有删除和更新操作了,即我们的应用不再时增查改删(CRUD),而是增查(CR)。进一步,由于没有了删除和更新,那也不再考虑同步更新的问题了。
如果我们右足够的存储和计算资源,我们就可以让应用完全不可变:完全函数式!
听起来荒谬,但如果你记得这种方案,代码将使系统运行十分有帮助。1
小结
总结以下:
- 结构化编程描述控制的直接转移
- 面向对象编程描述控制的间接转移
- 函数式编程描述变量不可变
三种范式帮助我们扔掉编程的坏实践,约束一些我们写代码的方式,并没有给我们带来新的力量和能力。
我们从这半个世纪的经验学到了不去踩的坑。
而我们不得不面对这样的事实:软件并不是迅速进步的技术,今天所提的软件法则和1946年图灵在电子计算机执行的第一行代码的那个年代相差无几。设备工具变了,硬件变了,软件还是那个软件。
软件,一组计算机程序的顺序,条件,循环,跳转的组合,仅此而已。
1. 原文:If this still sounds absurd, it might help if you remembered that this is precisely the way your source code control system works. ↩