为什么我们需要知道“函数式编程”?
说在前面
注意,本文所讨论的函数式编程,并不等同于函数式编程“语言”,而是这么一个思想和概念,相信看到最后你或许能够明白这句话。
问题
首先是关于计算机领域需要知道的一些事情,那就是硬件。
由于硬件发展已经快要到达物理极限了,也就是说摩尔定律已经慢慢开始失效,由于我并不是硬件相关的专家,所以也无法确定这是不是真的,但我们假设这就是真的。
摩尔定律失效过后会带来什么影响呢?那就是我们编写的程序再也无法像以前那样,只要等上18月还是多久(这个不是重点),就可以让我们的处理能力或是性能提升一倍。
所以,当硬件的发展慢慢放缓,而我们业务规模增长超过单机极限后,唯一的办法就是将程序分布到不同的计算机上面运行,通过多个计算机来水平提升我们的处理能力。
状态的一致性
当我们把程序放到多个计算机上运行,形成集群,其中多个节点如果同时访问、修改同一个状态,就会造成数据一致性问题,这也是分布式程序为什么这么难写的原因之一。
同样的事情其实发生在我们在编写多线程应用程序的时候,当我们在使用命令式或者面向对象语言的时候,我们可以直接对状态进行修改,比如有一个变量,我们随时可以给他赋值。
当然如果在任意时刻,只有一个线程能够访问和修改这个变量,这就没有问题。
但是别忘了其他线程也能够访问这个变量,当多个线程同时读取、修改变量的时候,如果没有任何的保护措施(比如锁),那么就有可能会出现状态被错误的读取和计算,甚至是被覆盖。
所以:
- 多线程访问的是变量。
- 分布式集群访问的是数据库或者缓存。
有什么区别呢?除了存放的位置不一样,基本上遇到的问题是一样的,那就是必须要协调和控制好,并发所带来的状态一致性问题(无论是变量、还是数据库、缓存,我统称它们为状态)。
我们的程序拥有对状态的完全控制权,在任何时候任何地方都能够修改状态,可以想象,如果我们没有对状态进行有效的管理的话,就很容易造成混乱,维护性大大降低。
其实就跟咱们一开始学编程的时候喜欢使用全局变量一样,但是现在的问题更棘手。
如果说全局变量的影响是平面的,我们只需要线性的去梳理修改这些状态代码的先后顺序就能够解决BUG的话。
那么加上并发竞争,这个影响就是3D的,排查BUG以及组织程序的复杂程度整整被提高了一个维度,因为空间与时间不再是一一对应的关系了。
我之前写的好几篇文章,几乎都是跟分布式以及一致性相关的主题,现在本文又提到了这些问题,很多重复的内容我就不赘述了,有兴趣的可以翻翻我之前的文章。
关于硬件,我留下一个问题给有兴趣的小伙伴,那就是为什么显卡的计算能力大大超过了CPU?
函数式编程当中的纯函数
难道就没有一个行之有效的办法来解决这些令人棘手的问题吗?
如果你经常阅读博客或者关注最新的技术文章和框架的话,你会发现,很多框架都开始慢慢支持以及完善从“同步”发展到“异步”的这个过程中了。
什么是同步?
我们每天正在编写的代码是符合人脑思维顺序的,从上到下依次执行,比如x = 2,然后x = x * x,很容易就得出最后x == 4的结论。
其中x就是一个变量,可能储存在CPU的寄存器当中,也可能储存在堆内存中,但这不是重点,它们都在同一个计算机上。
同步就是依次执行,按照我们所编写代码的顺序逐步执行完所有的代码,我们利用分支判断以及循环语句来控制执行的线路,依赖的是之前被计算好的状态变量。
什么是异步?
同步的缺陷很明显,由于是严格按照先后顺序执行代码的,这也是我们预期的方式。
但是一旦涉及到IO操作,比如文件、网络,整个程序的运行就会被阻塞,因此多线程可以帮助我们,将阻塞的操作与当前的流程分离开来,等读取完后再去执行相关的操作。
我们可以通过回调或者事件的方式来异步的进行处理,所谓异步,简单粗暴的理解就是我们调用了一个函数,他不会立马得到结果,而同步就可以得到结果,哪怕时间再长,我们也等(阻塞)。
可以想象异步增加了代码的复杂程度,因为本来同步是直接返回结果的,异步就需要我们在另一个处理单元等待唤醒然后继续操作。
另外,同步的代码只能在一个CPU当中执行,而多线程异步则可以利用计算机上面其他的CPU,使其并行执行提高效率。
想象一下如果我们有一个函数,它不会修改任何状态,仅仅是对参数进行计算,然后返回计算结果,然后我们将这个函数分布到不同的计算机上去执行,是不是就能不受制于单台计算机天花板的影响了呢?
由于这个函数不会修改任何状态,不会有任何的副作用,所以它可以在任何地方执行而不需要依赖其他的条件,这种函数被称之为纯函数。
纯函数就像是一个可以被随时移动到不同地方去执行的单元。
因此,纯函数就可以被当做一个异步等待唤醒的处理器,我们不知道它会在什么时候被执行,但我们可以放心,因为它不会导致副作用,也不需要依赖其他的前置条件。
你或许注意到了本文所讲的内容其实就是Actor模型,没错,你可以认为每一个Actor就是一个个纯函数。
但无论如何,你是自由的,你可以在actor执行单元里面做任何事情,但是请记住纯函数不得引发任何的外部状态修改,这是原则也是根本,因为我们不想要副作用。
所以在纯函数式编程“语言”里面,根本就没有赋值操作,不过是描述对输入进行处理,然后返回结果而已,这能够让我们少犯一些错误。
现在,处理器我们有了(纯函数),它可以被当做异步处理单元在任何计算机上面执行,那么状态呢?
命令式vs声明式
注意我并不会介绍函数式编程的所有特性,有关这方面的资料我相信已经存在了。
什么是命令式?什么又是声明式?
举个例子,还记得你为什么用Spring吗?最基本的就是因为你需要依赖注入。
我只需要声明一个Bean他需要依赖哪些类型的实例,Spring会为我们找到并且传入,这就是声明式,而如果你自己进行实例化操作,那么你需要去找到这些依赖,这就是命令式。
Don’t call us, we’ll call you.
由于纯函数不会修改状态,他只是简单的输入(参数)-> 计算 -> 输出(返回)IO单元,因此也就没有了赋值操作。
由于纯函数不会修改状态,他只是简单的输入(参数)-> 计算 -> 输出(返回)IO单元,因此也就不需要赋值操作(对外部状态进行修改)。
如果说传统编程是对状态进行操作(修改),那么函数式编程语言就恰好相反,它不会修改状态,它只是描述计算的过程。
因此,传统的编程对状态的修改是命令式的,函数式编程对状态的修改则是描述声明式的。
如果无法理解这句话,想想Java8里面的Stream API以及Lambda表达式吧,分解过后的状态转换、过滤、收集以声明的方式进行调用,更加直观方便,而且可以并行执行而无需修改其余代码。
实现函数式编程的具体语言、虚拟机执行环境以及框架,将会负责状态的维护,以及编排分布你的纯函数,在某一个地方某一个时间被激活执行。
不要认为我所讲的只是函数式编程“语言”,实际上只要遵循相关的规则,使用框架也是一样的,重要的是这些规则概念背后所代表的意义。
就像那句话怎么说来着?
就算是使用C语言,我们也能够进行面向对象编程。但如果不懂面向对象,就算使用Java、C++,那也跟使用C语言没有区别。
或许我们会使用这些高级语言的功能特性,但是却无法理解为什么需要这些特性,我们只是按照语言的规范来实现我们的需求。
本末倒置的一个后果就是,我们依赖特定的编程语言而非依赖我们的思维以及经验。
Erlang、Lisp、Scala、Akka、Vert.x、Reactor、RxJava等等等等,不管是语言、框架或者工具库,都能帮助我们减少进行异步编程所要的工作量,但你或许会问:这有什么用呢?为什么我们需要异步呢?
答案就是,我们需要开发分布式应用程序,它们能够在不同的计算机上面运行,形成集群以提供大规模的并发应用服务,但同时,我们也需要完全利用好每一台计算机上的资源。
因此,我们需要将资源的控制交给这些框架,交给它们背后所支撑的理论与实践,这样我们就能够站在巨人的肩膀上。
但函数式编程不是银弹
因为跨网络的优化以及状态的管理,因此跟业务场景相关的偏好设定仍然无法被忽略。
我们需要根据我们自己的情况来进行组织,只有这样才能最大化的避免由于当前计算机体系架构所固有的缺陷而引发的问题,以最小的代价换取利用资源。
函数式编程能够让我们放弃一些权力,来换取规则下的和平,但它终究不过是一种工具,如果我们能够在适当的场景利用好这个工具,就能够使我们的工作更加有效。
采用任何技术都无法脱离对原始业务的洞悉,只有这样,我们才能够构建出最佳匹配的应用程序服务。
脱离应用场景的使用,不仅会使得后期维护成本上升,还会使得架构的演化遭遇巨大的挑战,除非你真的明白在做什么,否则我们可能永远也无法得到有效的改善。
最后
如果我们遵循函数式编程的一些规范约束,就能够减少一些错误,因为正是由于这些规范约束的存在,才使得我们避免陷入泥潭而无法自拔。
越来越多的框架都开始支持以及完善异步编程,就像Spring 5所推出的WebFlux,使用Reactor(https://projectreactor.io)作为基础支撑,带来的就是全异步化的声明式编程范式。
再例如Vert.X(https://vertx.io)这个让人用的上瘾的强大工具,当你浏览了越来越多的新开源项目,或者是已经存在的开源项目开始慢慢过渡的转变趋势。
你会发现,很多知识概念都是通用且可以互相转换的,异步、分布式、非阻塞、事件驱动、反应式等等…
而这些就是面向未来的编程知识,无论使用何种语言、框架或者工具库。