vlambda博客
学习文章列表

函数式编程入门——拥抱函数式时代

面向对象和函数式这两种编程范式孰优孰劣的问题,已经争论了数十年。时至今日,很多主流编程语言都给出了自己的结论:两个都要。但是种种迹象表明,现在的确是一个函数式编程的时代,旧语言逐渐引入函数式的特性,新语言在设计之初就考虑更多函数式的设计,这篇文章帮助大家对函数式编程有一个基本的了解。

函数式与面向对象之争

函数式编程的理念其实出现得很早,1958年诞生的lisp就是一种函数式编程语言。但在面向对象这一概念出现之后,函数式语言就处于下风,过去二三十年流行的C++、Java等语言都是面向对象的编程语言。反观函数式语言,几乎从来没有真正成为榜单上主流的编程语言。究其原因,一是面向对象的理念更符合人类的直觉,二是早期函数式编程经常在性能上存在一些问题。

到了现在这个年代,由于计算机硬件的飞速发展,机器硬件的成本已经远远小于人力的成本,编程语言的性能相对而言已经没那么重要了,人们更多关注的是开发的效率。因此,以效率著称的函数式编程范式又一次出现在了人们的视野中。Java从JDK8开始引入了lambda表达式和stream流式计算,这两项函数式相关的特性大幅提升了Java处理数据的效率。JVM上其他的编程语言,如Scala、Kotlin和Clojure等,都较大程度上支持函数式编程。其他主流语言,如python、javascript、swift和go语言等,也都在设计之初就引入了大量函数式的特性。可以这样说,虽然面向对象的编程范式并没有被主流语言所抛弃,但是函数式的理念在开发实践中已经开始占据越来越大的份额。二者合理的整合,可能会成为未来编程语言发展的主流。

函数式编程的基石

和面向对象一样,函数式至今也没有一个公认的定义。但是简单来说,所谓函数式编程,就是在编程语言设计中,把函数作为一种数据类型,它可以和其他数据类型一样作为函数的参数,或者返回值,也可以添加到集合等容器中。可以说,函数式编程中函数是一等公民,享有其他数据类型所具有的全部特性。函数式编程的核心理念是将需求抽象为数学问题,通过一个个函数对数据做各种变换。

不变性

函数式编程的一个基石是不变性。函数式编程要求函数几乎不包含副作用,即不会对输入的参数和环境做出任何改变。为了保证函数的纯度,函数式编程中使用的数据结构是不应该被改变的,这意味着当我们处理数据时,永远不会对原始数据进行改变,而是会生成一个新的副本,在副本的基础上进行修改。不变性最大的问题是占用额外的空间,试想我每次操作一个列表(添加、修改或删除元素),都要复制一份,很快内存就会被占满。因此函数式编程需要一些特殊的数据结构,很多编程语言本身就为我们提供了这样的机制。以Clojure为例,在Clojure中,数据结构会共享相同的部分。这意味着,当我修改一个列表中的元素时,旧列表和新列表的相同部分是同一份数据。这样的设计既保证了不变性,又极大降低了空间的使用。

递归

递归是函数式编程的另一个基石。由于遍历通常会产生副作用,在函数式编程中,经常使用递归代替遍历来解决重复性问题。递归指的是函数对自身的调用,它可以确保数据的不变性,但是同时也经常拥有更高的时间和空间复杂度,非常容易引起调用栈内存的爆炸。解决方案之一是使用尾递归优化,即如果递归调用发生在函数的结尾,我们就可以直接舍弃当前调用栈的剩余部分,直接进入新函数的调用栈,这样可以将时间复杂度降低为线性(O(n)),同时避免了栈内存的溢出。

惰性

惰性仍然是和前两点高度相关的概念。在函数式编程中,由于每一次操作数据结构都会引起数据结构的复制,因此我们应该尽管减少中间计算过程对内存的占用,因此我们需要将很多数据结构设置为惰性的,即只有在需要的时候才进行计算,这样省略了中间的计算步骤,减少了内存的消耗,提高了计算的效率。

函数式编程中重要的函数

map

map是对集合中所有的元素做某种映射,使用场景非常丰富,可以说是函数式编程中最重要的函数。map函数通常接收两个参数,一个是集合,另一个是映射的策略,形式上是一个函数。像map这种把另一个函数作为参数的函数,称为高阶函数。高阶函数在函数式编程中非常重要,极大提升了编程的抽象程度。map可以算是最简单的高阶函数。下面介绍的另外几个重要的函数,也都属于高阶函数。我们可以以对集合[1, 2, 3, 4, 5]中每个元素乘以2为例来理解map函数,伪代码如下:

let result = map(a => 2 * a, [1, 2, 3, 4, 5])// 返回结果为[2, 4, 6, 8, 10]

reduce

reduce属于归约操作,即把一个集合归约为单个值。reduce在使用上比map复杂一些,但是实际上功能也比map广泛得多,甚至map都可以使用reduce实现。reduce通常接收两个或三个参数,一个是原集合,一个是归约的策略。如果有第三个参数的话,第三个参数为用于归约的初始值。用于表示归约策略的函数通常有两个参数,分别代表集合中下一个元素,以及当前规约的中间结果。以对集合[1, 2, 3, 4, 5]求和为例,通过reduce实现的伪代码如下:

let sum = reduce((a, b)=> a + b, 0, [1, 2, 3, 4, 5])// 返回结果为15

其中第一个参数就归约的策略函数,即将归约结果与下一个待归约的元素相加,其中a是归约的中间结果,b是待遍历的下一个元素。第二个参数是归约的初始值,因为是做加法,初始值可以取0。第三个参数为待归约的集合。注意当不提供初始值时,reduce函数会使用集合中的第一个元素作为初始值,然后从第二个元素开始遍历。这时如果集合为空的话,就会抛出错误。

还要注意如果使用的是支持面向对象的语言,map、reduce这样的高阶函数可能是作为集合的或流的方法,如在js中使用reduce,实际上的语法是这样的:

let sum = [1, 2, 3, 4, 5].reduce((a, b) => a + b, 0)

在java中使用reduce的语法类似于:

List<Integer> list = Lists.newArrayList(1, 2, 3, 4, 5);Integer sum = list.stream().reduce(0, (a, b) -> a + b);

(吐槽:Java真是一门啰嗦的语言。)

附:用reduce来实现map的伪代码如下:

function map(f, coll) { return reduce((a, b)=> append(a, f(b)), [], coll)}

这里假设append函数会返回列表追回元素后的新列表。

flatmap

flatmap是将集合中每个元素映射成一个子集合的map函数,它是将集合中的每个元素映射为集合后,将所有映射后的子集合连接在一起,形成一个新集合。flat这个词本身有扁的意思,可以理解为把嵌套集合“拍扁”,将map后的所有元素连接成一个大集合。假设我们现在有集合[1, 2, 3, 4],我们想获取集合中每个元素的2倍和3倍,伪代码如下:

let result = flatmap(a => [2 * a, 3 * a], [1, 2, 3, 4])// 返回结果为[2, 3, 4, 6, 6, 9, 8, 12]

flatmap也可以用于实现map,这说明flatmap比map在功能上更强大:

function map(f, coll) { return flatmap(a => [f(a)], coll)}

filter

filter是另一个比较简单的高阶函数,它用于过滤掉集合中一些不符合条件的数据。它通常接受两个参数,一个是保留参数的策略断言,一个是集合。断言函数通常接收一个参数,代表集合中的元素,返回一个布尔值,代表元素满足什么样的条件才会被保留下来。通常断言为真(返回true)时保留元素,反之弃掉元素。如我们想获取集合[1, 2, 3, 4, 5]中所有的偶数,伪代码如下:

filter(a => a % 2 == 0, [1, 2, 3, 4, 5])

很容易想象,filter函数也可以用reduce轻松实现:

function filter(p, coll) { return reduce((a, b) => p(b) ? append(a, b) : a, [], coll)}

其他函数

上面几个函数是在支持函数式编程的语言中通用的函数。其他函数基本都是在上述几个函数的基础上衍生出来的,具体实现上也通常和编程语言有关系。这里列出一些常见的函数,熟悉这些函数可以一定程度上提高开发的效率。

some、every 这两个函数通常用于判断集合中是否存在满足某些条件的数据。some指某些数据满足指定条件、every指所有数据都满足指定条件。some在有些语言中也被命名为any或anyMatch,every有时被命名为allMatch。有的语言还提供效果相反的方法,如none、noneMatch等。主流语言中some和every的返回值都是布尔类型,但也有时返回满足条件的数据。distinct 用于去除重复数据。take、takeWhile、drop、dropWhile 分别用于取前几个元素、取元素直到不符合指定条件、舍弃前几个元素、舍弃元素直到不符合指定条件。有时drop也叫skip。groupBy 按条件进行分组,聚合成一个mapsort 排序,一般集合处理时都不强调顺序性,因此通常不需要进行排序。确实需要排序时,注意不要进行并行计算。

最佳实践

尽量不要自己编写递归代码。大多数情况可以使用基于map、reduce、filter等函数的组合来完成计算,不需要自己进行递归。特别是reduce函数,非常强大,如果不知道可以用什么内置库函数,就用reduce来实现一套吧。保持函数的简短和纯度。函数越简短、纯度越高就越清晰,函数式编程的精髓就是通过不断测试保证每一个简短的函数都是符合预期的。保持思考。从面向对象到函数式编程,思路需要经历一个转变的过程。在解决问题时,时刻想一想源数据和目标数据的结构是什么样的,通过什么样的映射才能够完成向目标数据的转换。Just do it! 函数式编程绝对是大势所趋,不要犹豫,马上加入这个队伍吧。