函数式编程

最初给自己定的计划是一个月更新一篇博客,但是执行下来才发现自己还是太 naive,由于进入了新的项目,所以现在每天都是工作24小时的状态🙃。。。。但是人总是要学习的,不然和咸鱼有什么区别,所以还是咬咬牙抽出自己为数不多的个人时间来接触新的东西。

函数式编程思想已经存在几十年了,算不上新的技术,但是在命令式编程大行其道的今天,了解不同的编程思想还是有助于开阔自己的视野,也能够吸收其中的精华来让代码写得更好。

初次接触函数式编程的小伙伴可能会发出以下素质三连:

啥函数啊?我不正在用函数编程吗?大佬你到底在说些啥啊?

emmmmm,函数式编程的名字确实很容易招致误解,但绝对不是写几个函数运行下就是函数式编程了🤣。函数式编程作为一种编程范式有着它自己独特的设计理念和风格,想要彻底讲清楚可能需要几本书的篇幅了。所以本文会着重介绍函数式编程的核心特性,帮助大家对函数式编程有一个大体的认识。

朝花夕拾

时间要追溯到20世纪30年代,普林斯顿大学的数学家阿隆左·丘奇(Alonzo Church)正在进行”可计算问题”的研究:你怎样判断一个数学问题是可以被计算的?证明和推导的过程是否可以自动化?阿隆左·丘奇为此发明了 lambda 演算(相关科普可以参考这篇文章或者它的译文):

lambda 演算由函数构成,函数可以作为另外一个函数的输入或输出,一系列的函数调用最终会形成一个表达式链,这个表达式链最终可以求得一个值,通过这个过程便能够对问题进行推导演算。

“可计算问题”是当时数学领域的思潮,除了阿隆左·丘奇,还有很多优秀的数学家对此感兴趣,其中就包括阿兰·图灵(Alan Turing)。他提出了另一种模型来解决”可计算问题”,也就是图灵机:

图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,该机器由以下几个部分组成:

  • 一条无限长的纸带 TAPE。纸带被划分为一个接一个的小格子,每个格子上包含一个来自有限字母表的符号,纸带可以无限伸展。

  • 一个读写头 HEAD。该读写头可以在纸带上左右移动,它能读出当前所指的格子上的符号,并能改变当前格子上的符号。

  • 一套控制规则 TABLE。它根据当前机器所处的状态以及当前读写头所指的格子上的符号来确定读写头下一步的动作,并改变状态寄存器的值,令机器进入一个新的状态。

  • 一个状态寄存器。它用来保存图灵机当前所处的状态。

虽然 lambda 演算与图灵机在后来被证明是等价的,但两个理论的命运却大不相同。受限于当时的硬件技术以及经济条件,lambda 演算难以在工业上落地,历史最终选择了图灵机理论,而在此基础上的冯·诺依曼架构也成为第一台计算机的设计方案,汇编语言、C语言等编程语言都是基于此架构被发明出来的。

20世纪50年代,MIT 的教授 John McCarthy 发布了 LISP(List Processor) 语言,在冯·诺依曼计算机上实现了 lambda 演算,之后函数式编程才开始出现在计算机领域。

函数式编程的本质

函数式编程是对 lambda 演算的实现,当你使用函数式编程时,就像 lambda 演算那样,你要将你的程序视为一个巨大的表达式,你需要用一个又一个的函数构建出这样的表达式,而表达式的求值过程就是程序的运行过程。

但函数式编程并没有100%还原lambda 演算,毕竟它是数学领域的模型,并不是为计算机领域贴身打造的,现有的函数式编程语言也对其进行了一定程度的扩展,例如支持 I/O 操作等等,使其更贴近于生产实践,所以如果你不是一个原教旨主义者,你大可将函数式编程看成是一种编程理念,将其运用到合适的地方去,而非一成不变地遵守教条。

Wiki 上对函数式编程的定义如下:

In computer science, functional programming is a programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data.

简单翻译下,函数式编程就是将计算机的运算过程视为数学函数的运算(表达式求值),并且避免状态以及数据的改变。想要理解上述定义,需要我们理解几个关键词:函数、不变的数据以及状态。

纯函数

由于函数式编程基于 lambda 演算,所以它的函数是数学意义上的函数(mathematical function),这和我们在命令式编程中用到的函数不同,而后者称为 subroutine 更恰当一些。同志们可以回想一下高中时代的数学知识:函数 f 表示一种运算法则、一种映射关系,对于给定的输入 x,都有唯一确定的输出 y 与之对应,对于相同的输入,永远会得到相同的结果,这就是数学意义上的函数。而在计算机领域中,我们称之为纯函数,它的主要特征如下:

  • 相同的输入对应着相同的输出(也被称为引用透明性)

  • 函数不受外部因素影响

  • 函数的执行过程也不会改变外部状态

下面是纯函数与非纯函数的例子:

纯函数是函数式编程中最基本的元素,你可以将它作为参数,也可以将它作为返回值,还可以将不同的纯函数组合起来进行运算,总而言之,它被用来构建函数式编程中的一切。

不可变数据

在函数式编程中,只有常量,没有变量,这是函数式编程的风格。熟悉命令式编程的同志们可能会有些困惑,为什么要这样设计?这就需要理解函数式编程和命令式编程背后的思想。

命令式编程是对计算机的抽象,它的变量代表计算机的存储单元,它的命令语句代表对存储单元的存取以及运算,它的控制语句代表计算机底层硬件的跳转指令,它的一切最终都会对应到一条条计算机指令上去。在命令式编程中,x = x + 1 是很常见的做法,这个语句的意思是将 x 所代表的存储单元的内容取出来,执行 +1 操作,然后再放回原来的存储单元,这是没有问题的。

然而函数式编程是对数学模型的抽象,你要站在数学的角度来看待它,这样才容易理解它的做法。函数式编程的变量指的是数学中的变量,更准确地说,它是一个符号、一个名称,在数学家看来,x = x + 1 根本不能成立,命令式编程的赋值模型在这里站不住脚,所以在函数式编程中,变量一经创建后便不允许修改。

状态

函数式编程强调我们使用状态无关、没有副作用的纯函数来编写程序。什么是副作用呢?比如说你在函数运行的时候,修改了全局变量、读写了数据库、修改了某某文件的内容等,这些都是副作用,都会改变状态。为什么函数式编程要极力回避状态的改变呢?因为不稳定的状态会带来不确定性,而不确定性则会导致程序的失败。

回忆一下自己阅读他人代码或是第三方开源库的经历,倘若他写的函数中大都是些局部变量,那读起来还算轻松,如果他还使用了全局变量,那可就要打起十二分精神来看了。你首先要搞懂这个全局变量是用来做什么的,其次还要 Ctrl + F 在整个工程中搜一下还有哪些地方用到了全局变量,是怎么用的,相互之间会不会造成什么影响,再倒霉一点,他还用了多线程,你还要了解它是不是线程安全的,这还是遇到一个全局变量所要做的工作,如果遇到多个,工作量直接翻倍,简直是闻者伤心见者流泪😡。

设想一下,如果项目中到处都是类似于上面的代码,那么随着时间的推移、功能的迭代, bug 将会层出不穷,项目维护和扩展的成本都在急剧的增大,最终只会走向一个结果————重构。这里大家就会理解,为什么项目中的老代码几乎没人敢去碰,就因为这不是状态无关的代码,稍有改动便会牵一发动全身,造成严重的后果。

函数式编程所使用的状态无关的纯函数就可以避免以上的问题,因为纯函数给我们带来了确定性。纯函数将状态的改变控制在了函数的内部,它不会影响外部的状态,它的执行过程也不会被外部状态影响,无论你在何时何地执行它,它都会给你输出同样的结果,因此你在任何时候都可以放心大胆的用它,这就是纯函数的好处。

但是还是要说一句,副作用要怎么处理,是不是就完全抛弃了?当然不是,如果程序完全没有副作用,那它和废品也没啥区别,因为它什么都做不了。我们没有办法离开状态,变化是必然的,程序运行到最后也必须要产生结果,我们最终还是要在某一地方响应状态的改变。我们利用函数式编程能够做到的是,将程序的副作用控制在尽可能小的范围内、控制在特定的代码模块中,与程序其他状态无关的模块隔离开,让难以控制的状态变得可控。

函数式编程的特性

除了上面介绍的函数式编程的基本概念,还有一些很重要的特性需要我们理解,其中一个便是高阶函数。

高阶函数

高阶函数是指将函数作为参数或是返回值的函数,在函数式编程中你会经常与它打交道。下面是一个简单的例子:

假如有3个函数,它们的作用分别是把参数+1、+2、+3,我举的例子就是这么简单😋,不用高阶函数的话,就会像上面那样定义3个函数,每个函数都很简单,但又很啰嗦,如果之后有+4、+5、… +100的需求,上述的函数定义会充斥整个文件。有没有更为统一的方法呢?这就需要高阶函数了:

我们创造了高阶函数 addBy,它的内部定义了用来做加法的 sum 函数,并将 sum 函数当做返回值。sum 函数会保留 addBy 函数的参数 value 作为加法的一部分,这样的话 sum 函数就会对传入的参数直接进行 +value 操作,方便我们的使用。既便以后会有 +4、+5的需求,我们也可以使用 addBy 函数轻松定制出来。

上面是高阶函数用作函数模板的一个例子,高阶函数还有很多其他的用途,例如依赖注入等等,灵活的使用它会极大地便利我们使用函数式编程。

柯里化

柯里化是 Currying 音译过来的,Wiki 上有这样的定义:

In mathematics and computer science, currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument.

简单来说就是将接受多个参数的函数转化成接受一个参数的函数。为什么要这样做呢?因为函数式编程基于 lambda 演算,而 lambda 演算中的函数只接受一个参数,但是如果我想要传入多个参数要怎么办呢?这时候柯里化技术就登场了。假设我有这样一个需求,一个函数接受3个参数:i、j、k,我需要把它们相加并将结果返回:

那现在我要将其转换成只接受一个参数的函数,柯里化技术要怎么做呢?那首先是定义一个接受 i 参数的函数,然后在内部返回一个接受 j 参数的函数,最后这个函数再返回一个接受 k 参数的函数,就跟套娃娃一样:

关于柯里化技术的背景,大家可以参照 Wiki 了解下,而且因为用的人多了,柯里化技术也玩出了很多花样,包括惰性求值、参数复用等,感兴趣的话可以看下这篇文章,本文就不多讲了。

map && reduce

如果你想要更进一步的贯彻函数式编程思想,你首先要做的就是使用 map、reduce 来替换程序中的循环控制语句。为什么呢?因为函数式编程要求使用表达式来进行运算,表达式是要有输入和输出的,而循环控制语句并不具备这一点,不能称为表达式。

map 以及 reduce 的用法如下:

相对于传统的循环控制语句,map 以及 reduce 要显得更为简洁、易读,它们其实就是经过高度抽象、提炼出来的循环控制语句的模板,你只要传进去一组数据以及相应的迭代操作即可,不需要写一堆麻烦的 for、while 语句以及中间的临时变量,map、 reduce 不仅使得编程更为高效,而且也更加符合函数式编程风格。以下代码出自这篇文章,方便大家更好的体会 map && reduce 的高效:

pipeline

pipeline 技术是指流水线技术,它可以将多个函数串联起来,前一个函数的输出可以作为下一个函数的输入,就像工厂里的流水线一样,我们的输入经过多个函数的处理后,最终会得到我们想要的输出结果。

函数式编程要求我们将一个又一个的纯函数组合成表达式来进行运算,如果没有流水线技术,我们很容易就写出如下的包菜式代码:

而使用了 pipeline 技术后,只要这样做:

pipeline 技术的代码如下:

上面的示例代码来自于这里,我自己写的例子怕是太简单会误导大家,所以在网上众多的资料中摘取了这段质量较好的代码示例来帮助大家理解🤣。

总结

函数式编程讲到这里就告一段落了,相信读到这里,大家已经对函数式编程的思想有了大概的认识,但是如果想要更深入的了解函数式编程的话,选择一门函数式编程语言来实践是最容易的方法了,当然,lambda 演算的学习也是必不可少的,这会让你更加深刻的认识函数式编程思想。

以下是一些参考资料:

总算写完了,可累死我了🤣🤣🤣😳😳。

发表评论

Close Menu