伍.2.1 什么是函数式编程?

01.程序的本质

程序是什么?看一张图:

程序由输入数据、运算、输出结果三部分组成。输入与输出属于I/O,都有成熟的代码去实现了。而运算那部分会根据需求而千变万化,这部分是可以独立出来的。如果运算里面包括I/O的代码和逻辑,就应该挪走到输入数据、输出结果那边。

那么,如果把运算独立出来,会是什么效果?嗯,至少是让三个部分之间耦合更少一点了,方便代码组织和维护。

而把程序的运算部分独立出来,既可以使用函数式编程,又可以使用命令式编程来实现。

笔者对命令式编程(面向对象编程主要为命令式编程)比较熟悉,也对函数式编程有所实践,综合考虑之后,认为函数式编程实现程序的运算部分会更畅快一些。

02.函数式编程的定义

函数式编程是以调用函数为主的软件开发风格。

没错,我们可能很早就开始使用函数了,但是,别以为那就是函数式编程的全部了。

函数式编程会改变我们处理日常工作时的思路,让我们使用函数来抽象作用在数据之上的控制流与操作,从而在系统中消除副作用并减少对状态的改变。

请注意我重点标识了“抽象”二字,其实编程功力比拼到最后就是拼抽象能力了。虽然上面这段话可能不好理解,但没关系,后面会慢慢展开阐述。

03.函数式编程与命令式编程

函数式编程是相对命令式编程而言的,二者都属于编程范式,最大的不同在于:

命令式编程关心解决问题的步骤,函数式编程关心数据的映射。

所谓命令式编程,也就是说你要做什么事情,得把达到目的的步骤详细描述出来,然后交给机器去运行。这也正是命令式编程的理论模型“图灵机”的特点,一条写满数据的纸带,一条根据纸带内容运动的机器,机器每动一步都需要纸带上写着如何达到。

来看一个例子:统计小组5个学生体重之和。即使是刚入门的前端工程师也可以很快写出如下代码:

let studentsWeight=[49,50,43,55,64];//存放每个学生的体重
let sum=0;
for(let i=0;i<studentsWeight.length;i++){
sum+=studentsWeight[i];
}
console.log(sum);//>> 261

通过一个for循环,然后引入临时变量studentsWeigth和sum,让sum与每个学生体重相加,最后打印结果。这就是典型的命令式编程,你要做什么事情,得把达到目的的步骤详细的描述出来,然后交给机器去运行。在这个过程中,引入了临时变量studentsWeigth和sum,并且for循环一次,sum的值便被改变一次,这些都是副作用

那么,不用这种方式,函数式编程会如何做呢?如下:

console.log([49,50,43,55,64].reduce((a,b)=>a+b));//>> 261

哇,惊人的简洁清爽!上面的这段代码主要表达要实现什么(调用reduce函数求和),而不是在描述具体的实现的步骤,也没有循环的代码。具体的细节已经封装到reduce函数里面,我们看不到也暂时不用关心。这样就可以把程序员注意力从程序的步骤控制、变量的维护等繁琐的事情中解放出来,去专注于要实现什么。而且中间没有副作用。

关于“副作用”的更多阐释,引用自《Java函数式编程》:

当我说“没有副作用”的时候,我是指没有可观测到的副作用。函数式的程序是由接收参数并返回值的函数复合而成的,仅此而已。你并不关心函数内部发生了什么……但是在实际上,程序是为完全非函数式的计算机而编写的。所有的计算机都基于相同的命令式范式,所以函数就是如下黑盒: ■ 接收一个(单独的)参数。 ■ 内部做一些神秘的事情,例如改变变量的值,还有许多命令式风格的东西,但是在外界来看并没有什么东西。 ■ 返回一个(单独的)值。 这只是理论。实际上,函数不可能完全没有副作用。函数会在某个时候返回一个值,而这个值可能是变化的。这就是一个副作用。它可能会造成一个内存耗尽的错误,或者是堆栈溢出的错误,导致应用程序崩溃,这在某种意义上就是一个可观测到的副作用。并且它还会造成写内存、寄存器变化、加载线程、上下文切换和其他确实会影响外界观测的这类事情。 所以函数式编程其实是编写非故意的副作用的程序,我的意思是,副作用是程序预期结果的一部分。非故意的副作用也应该越少越好。

那么,数据的映射又是什么意思呢?还是上面的例子,如果要求给每个学生体重单位由公斤变成克,函数式编程该怎么弄呢?如下代码:

[49,50,43,55,64].map((a)=>a*1000);// [49000, 50000, 43000, 55000, 64000]

这段代码体现的思维,就是旧体重数组新体重数组映射。在这里,映射就是由函数map和它的参数(也是一个函数)体现,不然如何实现旧数组到新数组的映射呢?那么,前面说的“函数式编程关心数据的映射”,其实就是关心函数(有点废话 😄 ,但能方便理解)

如果看到这里还是云里雾里的话,别着急,可能因为我们对函数式编程的一些基础概念没有了解,接着往下看。

04.纯函数

所谓的纯函数就是,对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。比如下面代码:

let arr=[1,2,3,4,5];
arr.slice(0,2);//这是纯函数,因为只要参数一样,输出总一样
arr.slice(0,2);//输出结果与上一句相同
arr.splice(0,2);//非纯函数,参数一样,但是输出变化了
arr.splice(0,2);//输出变化了,与上一句结果不同

另外Date.now()也是不纯的,他总是依赖时间来返回不同的结果。

纯函数是函数式编程至关重要的前提,也即函数的输出只能取决于函数的参数输入。

我们写代码的时候,可能会因为代码严重依赖环境,有时候调用同一个函数,输入相同的参数,导致结果居然不同,更不用说由此带来的各种副作用了。

而采用函数式编程,确定的输入决定了确定的输出,这意味着只要参数对了,结果一定在预期中。也就是说,函数式编程没有无法重现的bug

“啥意思?说人话!”

“举个例子,假设你的程序里统共有ABCDEFG函数,其中ABCDEF是纯函数,而函数G是不纯的。若ABCDEF调试不通过,bug在任何时候是必现的;若ABCDEF调试通过,则你的程序出了bug,只有可能是G出了问题,如此可简单粗暴地快速定位问题所在。”

纯函数的代码写出来,只有通过与不通过,没有中间状态。在这样的前提下,单元测试相对容易实现,能极大地增强程序员的代码自信。想象一下,你自己的纯函数的代码若调试通过了,交上去就几乎是无bug的成果,内心会多么自豪!

05.柯里化(Currying)

关于柯里化的基础知识,请回顾“JavaScript函数柯里化”。

那么柯里化和函数式编程又有什么联系呢?等你看完下面的“函数组合”我再来回答这个问题。

06.函数组合(Compose)

知道了纯函数和柯里化之后,我们可能会写出这样的“包菜式”代码:

fn1(fn2(fn3(x)));

虽然这的确是函数式编程风格的,但它某种意义上是“丑陋”的。为了解决这种包菜式的函数嵌套的问题,我们需要用到“函数组合”。

//将多个函数组合为一个
let compose = (fn1, fn2) => (x => fn1(fn2(x)));
let add3 = x => x + 3;
let mul2 = x => x * 2;
compose(mul2, add3)(2); //>> 10

上面的compose可以把任何两个纯函数结合到一起。当然你也可写出组合3个、4个……函数的compose代码。这里我曾经看到过一段比较巧妙组合函数的实现代码:

function compose(...fns){
return function(x){
return fns.reduce(function(arg,fn){
return fn(arg);
},x);
}
}
let add3 = x => x + 3;
let mul2 = x => x * 2;
compose(mul2, add3)(2); //>> 7
// 思考一下为什么是7而不是10?如何改造让结果是10?

注意参与组合的函数必须是纯函数。因为如果不纯,那就有副作用,组合前后的结果可能不一样。

这样,我们便可以把各种微小功能的纯函数组合起来,形成一个功能强大的操作。

到这,我们应该明白了,因为柯里化可以把多个参数精简为传一个参数的函数,也就是说,多参数的函数,精简到最后,都可以只传一个参数,这之后就可以帮助实现函数组合(上面例子中,组合后的函数都统一只传了一个参数),以便像搭积木一样的将微小纯函数组合起来实现功能强大的操作。

“喔喔,原来这就是柯里化的妙用之一?”

“是呀!”

07.函子(Functor)

定义

在函数式编程中,Functor(函子)可以说是一个很基础的概念。

当然了,还有一个更基础的概念是函数(function)。在函数式编程的世界里,函数它实际上表达的是一个对象到另一个对象的映射关系(我在这里使用了对象一词,因为作为一个Java程序员,对象可以说是最熟悉的概念)。上面提到的第一个对象,指的是函数的输入参数;而另外一个对象,则是指函数返回的结果;映射关系指的是函数。

函数处理的是对象,而函子则处理的是范畴。

所谓“范畴”,原本来自“范畴论”,从编程的角度来理解,我们可以理解成高阶对象。什么是“高阶对象”呢? 实际上就是一个容器,或者类型(指typeclass,非面向对象编程里面的class)。 比如: 一个int类型的对象是一个简单对象,那么一个int数组则是一个更为高阶的对象。

那么,函子表达的是范畴到范畴的映射关系。此时,我们已经知道所谓的映射关系,就是函数而已。

举例:下面Java代码中的IM属于简单对象,Employee属于高阶对象。

有时间的话,学一下Haskell这种比较纯的函数式语言会更好。然而笔者也没学过Haskell,而且在工作的时候,你所使用的语言取决于公司和团队大多数人。Java是一门使用广泛的语言,好在笔者多年从事Android(Java)开发。而又因为JavaScript不是强类型语言,Java 8的Stream API增加了对函数式编程的支持。因此,为方便举例说明一些基础概念,本文可能会临时借用Java 8来举例说明,并会用/*Java*/特别标明。

/*Java*/
class Communication
{
private int type;
}
//简单对象
class IM extends Communication
{
private String code;
}
//高阶对象
class Employee
{
private int emplNo;
private String firstName;
private String lastName;
private Communication communication;
}

再来看看这个简单的add函数:

/*Java*/
public int add(int someNumber)
{
return someNumber + 3;
}

函数add是对简单的int对象做映射;那么对应的函子应是对int数组这个高阶对象(或者说容器)做映射,具体说来就是对int数组的每一个元素做加3的映射。所以,函子可以把一个作用于简单对象的函数,应用到一个高阶对象。如果把上面的add函数变换成对数组每一个元素加3,如下代码,Java8的Stream对象就是一个函子。

/*Java*/
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
list.stream().map(i -> i + 3).forEach(System.out::println);

上面的代码多了个map方法是做什么的?没错, 函子约定会实现 map这个特定的方法(或称为函数),名称可能不一定是map,但是功能是一样的。

为方便理解,下面再以图例来说明如何从函数到函子的:

(1) 简单对象一般为值类型:

(2) 简单对象和函数之间的关系如下图:

(3) 高阶对象是一个容器(下图中的context),包含其他对象:

(4) 对于高阶对象,不能直接应用函数,需要先打开这个容器:

(5) 因此,定义一个新的方法 map来解决在高阶对象上使用函数的问题:

这个实现了map方法的类型类(typeclass),或者说是容器,我们就称之为函子。

函数式编程中的类型类(typeclass)是定义行为的接口。 如果一个类型是某个类型类的实例,那么这个类型必须实现所有该类型类所定义的行为。不要因为有“类”这个字就把类型类与面向对象中的类混淆, 他们是完全不同的概念。类型构造器能够接收其他类型为参数,创建出新的类型。举个例子,Java的List即为接收一个类型参数的类型构造器, 当类型参数为Int时,List类型构造器的返回类型为List<Int>,当类型参数为String时,返回类型为List<String>。 与类型构造器相对的概念是值构造器,比如Integer(2)。

所谓的map方法,就是将高阶对象这个容器打开,取出里面的简单对象,然后再对简单对象应用map方法传递过来的函数,再把应用函数后的结果又包装成高阶对象,最后将这个高阶对象返回。

函子的作用

这么绕一圈儿弄出个函子的概念有什么用呢?

函子并不刻意造出来的,是先有了用法然后总结归纳出来的基础概念。

有了函子,让只能处理简单对象的函数可以处理高阶对象,而且想要什么结果,传入对应的函数给函子就行,传入不同的函数,产生不同的结果,比较灵活。换句话来说,我们让函子自己来运行这个函数,这样函子就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常出色的特性。而且代码也十分简洁,在后续文章,会发现函数式编程的代码一向表现得更简洁一些。

读者可能会说,“喔,原来JavaScript里面的Array,自带了map方法,已经实现了函子的概念,而Array也就是函子”,的确,没错!

代码实现

class Functor {
constructor(val) {
this.val = val;
}
map(f) {
return new Functor(f(this.val));
}
}

约定:函子是具有map方法的容器。该map方法将容器里面的每一个值,映射到另一个容器。

用法示例

(new Functor(2)).map(function (v) {
return v+ 1;
});
//>> Functor {val:3}
(new Functor('coffe')).map(function(s) {
return s.toUpperCase();
});
//>> Functor {val:'COFFE'}
// _是loadash的用法,具体参见https://www.lodashjs.com/docs/lodash.curry
(new Functor('coffe')).map(_.concat(' 1891')).map(_.prop('length'));
//>> Functor {val:10}

上面的代码带有newnew关键字是面向对象编程的产物。函数式编程使用of来替换new生成一个新的容器。

class Functor {
constructor(val) {
this.val = val;
}
map(f) {
return new Functor(f(this.val));
}
//ES6静态方法
static of(val) {
return new Functor(val);
}
}

那么前面的例子就可以改成:

Functor.of(2).map(function (v) {
return v+ 1;
});
//>> Functor {val:3}

是不是看起来更加函数式了? 😃

08.加强版函子(Applicative)

如果理解了函子,就很容易理解加强版函子了。

函子是将简单对象放到了容器(context)里,处理这种简单对象的函数通过map方法传进来;加强版函子更进一步,把函数也放进了容器。

这样就可以让加强版函子A里面的函数,去处理函子B里面的值。

这么做了之后,意味着函数和函数处理的数据(简单对象或高阶对象)都被高度抽象化了。高度抽象化的结果就是高度的灵活性、可扩展性、可维护性。

代码实现

加强版函子一般约定会实现ap方法。

class Ap extends Functor {
ap(F) {
return Ap.of(this.val(F.val));
}
}

从上面代码可以看出,加强版函子也是函子。但是要注意,其方法ap的参数不是函数,而是另一个函子。

用法示例

//todo

09.单子(Monad)

聪明的你可能会问:“既然函子是容器,函子里面可以包含函子吗?”

“可以!函子是可以嵌套的。”

但是嵌套的函子取值很不方便。比如:

Functor.of(Functor.of(Functor.of(1)));

要取出里面的值,要调用三次val:

Functor.of(Functor.of(Functor.of(1))).val.val.val;//>> 1

这样不够优雅,因此就出现了单子这种类型的函子。

单子的作用是,总是返回一个单层的函子。 它有一个flatMap方法,与map方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。

代码实现

class Monad extends Functor {
join() {
return this.val;
}
flatMap(f) {
return this.map(f).join();
}
}

上面代码中,如果函数f返回的是一个函子,那么this.map(f)就会生成一个嵌套的函子。所以,join方法保证了flatMap方法总是返回一个单层的函子。这意味着嵌套的函子会被铺平(flatten)。

细心的读者可能会发现,原来本书“壹.1.1”介绍新版ECMAScript特性时,讲到了数组的Array.prototype.flatMap,因为数组也实现了flatMap,因此数组也是单子。

10.函数式编程的好处

其实前面已经穿插的讲了很多函数式编程的好处,本篇最后再总体归纳一下。

首先,最直观的角度来说,函数式风格的代码可以写得更简洁,大大减少了程序员的键盘敲击输入量。

其次,函数式的代码是“对映射的描述”,它不仅可以描述数组这样的数据结构之间的对应关系,任何能在计算机中体现的东西之间的对应关系都可以描述——比如函数和函数之间的映射;比如外部操作到 GUI 之间的映射(就是现在前端热炒的 FRP——响应式编程的一种范式)。它的抽象程度可以很高,这就意味着函数式的代码可以更方便的复用。

再次,函数式编程将细节封装到函数内部隐藏起来,不过多的依赖外部环境,让程序员专注业务的实现,优化的时候,可以专注优化函数内部实现即可,甚至无需改动外部代码,可以让代码更容易维护。

另外,因为在函数式编程里,函数是纯函数,没有副作用,不改变函数之外的变量,不会造成死锁,因此是多线程安全的,所以函数式编程可以方便地进行并行计算。这个特性对于充分利用多核CPU尤其重要。然后,函数式编程没有副作用,只要保证接口不变,内部实现是外部无关的。所以,可以在运行状态下直接升级代码,不需要重启,也不需要停机。Erlang语言早就证明了这一点,它是瑞典爱立信公司为了管理电话系统而开发的,电话系统的升级当然是不能停机的。

还有,可以惰性求值、缓存计算以加快多次执行的速度。

最后,将代码写成这种样子可以方便用数学思维进行科学研究。

以上具体的好处,将在后续文章中逐一演示。

参考文献