浅谈JavaScript的函数式编程

164 阅读10分钟

先举个简单的例子感受下函数式编程

一个实现加法和乘法的程序:2*(4+0)+ 4*2

用普通的命令式编程思想实现:

var a = 4 + 0;
var b = 2 * a;
var c = 4 * 2;

var result = b + c // 16

使用函数式编程思想实现:

var add = function(x, y) { 
    return x + y 
};
var multiply = function(x, y) {
    return x * y 
};
var a = 4;
var b = 2;
var c = 0;

var result = add(multiply(b,add(a, c)), multiply(a, b)); // 16

两种编程思想的对比,命令式编程是那种让“某某去做某事”的方式,而函数式编程是告诉电脑要“要怎么做某件事” 然后当某人要做这件事时调用即可,不会事先规定谁去做这件事。仔细观察,你会发现这里包含了数学里面的几种算术规律知识:

// 结合律 (x+y)+z = x+(y+z)
add(add(x, y), z) == add(x, add(y, z));
// 交换律 x+y = y+x
add(x, y) == add(y, x);
// 同一律 x+0 = x
add(x, 0) == x;
// 分配律 x(y+z) = xy + xz
multiply(x, add(y,z)) == add(multiply(x, y), multiply(x, z));

不过最后获取result的调用函数嵌套有点让人费解。然后我们根据这些数学规律对它进行转换

// 原有代码
add(multiply(b,add(a, c)), multiply(a, b));
// 应用同一律,去掉多余的加法操作(add(a, c) == a)
add(multiply(b, a), multiply(a, b));
// 再应用分配律
multiply(b, add(a, a));

通过这个例子,估计大家能感受到数学那种味道,其实函数式编程就是数学,只是这门数学刚好能用到编程上,它是通过数学里面的“范畴论”来的,它认为世界上所有的概念体系,都可以抽象成一个个的"范畴"(category)。所有根据某个关系形变而来的成员组成的集合就是一个范畴,简单的理解就是"集合 + 函数"。就像是数学中的 y = f(x), x通过形变关系f函数得到对应的y。

以下是函数式编程的几个知识点。

1. 函数是"第一等公民"

所谓"第一等公民",指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

举例来说,下面代码中的print变量就是一个函数,可以作为另一个函数的参数。

  var print = function(i){ console.log(i);};

  [1,2,3].forEach(print);

2.纯函数

一个函数如果依赖外部环境,那么它的行为就会变得不可预测。因为你不知道外部环境什么时候会被改变,有句话貌似流传已久,很直接地指出了副作用给代码组织可能带来的问题:可共享可修改的变量是所有罪恶的根源。

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。可以简单理解成一个对一个函数传入同一个入参,它只会输出一个同样的返回,它的输出不会因为外部变量或者执行环境所影响,举个例子:

外部变量是否能影响输出

// 不纯的
var tips = 'JavaScript'
var fn1 = (str) => {
	console.log(str + '' + tips)
} 

fn1('hello') // hello JavaScript
tips = 'Java'
fn1('hello') // hello Java

// 纯的
var fn2 = (str) => {
	var tips = 'JavaScript'
    console.log(str + '' + tips)
}
fn1('hello') // hello JavaScript

slice 和 splice都能用作截取数组的方法

var xs = [1,2,3,4,5]; // 纯的 
xs.slice(0,3); //=> [1,2,3] 
xs.slice(0,3); //=> [1,2,3] 
xs.slice(0,3); //=> [1,2,3] 
// 不纯的
xs.splice(0,3); //=> [1,2,3] 
xs.splice(0,3); //=> [4,5] 
xs.splice(0,3); //=> []

我们说 slice 符合纯函数的定 义是因为对相同的输入它保证能返回相同的输出。而splice 却会嚼烂调用它的 那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。

3.柯里化(curry)

curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处 理剩下的参数。

一个简单的例子:

// 实现一个x+y的函数
// 普通的实现方法
var add = (x,y) => {
	return x + y
}

// 柯里化
var curryAdd = (x) => {
   return (y) => {
    	return x + y
    }
}
add(1,2) // 3
curryAdd(1)(2) // 3

柯里化的实现原理其实就是闭包,将多参数的函数拆分成多个返回的function,每传递一个参数调用函数,就返回一个新函数处理剩余的参数,这样实现了调用和实现的分离。说到这里再联系上面的纯函数,可能你会发现这就是遵守了纯函数的规则,它们接受一个输入返回一个输出,哪怕输出是另一个函数,它也是纯函数。

将函数柯里化的时候可以使用一个函数库,lodash 函数库,不熟悉的朋友可以看一下 lodash 的官网:www.lodashjs.com/

var curry = require('lodash').curry;

var match = curry(function(what, str) {
  return str.match(what);
});

var replace = curry(function(what, replacement, str) {
  return str.replace(what, replacement);
});

var filter = curry(function(f, ary) {
  return ary.filter(f);
});

使用柯里化后的函数

//匹配空格
match(/\s+/g)("hello world");
// [ ' ' ]

// 引出一个 hasSpace 的函数变量,暂存用
var hasSpaces = match(/\s+/g);
// function(x) { return x.match(/\s+/g) }

// 使用这个 hasSpace 去做一些相关的处理
hasSpaces("hello world");
// [ ' ' ]

hasSpaces("spaceless");
// null

// 现在我们知道了它的返回值,我们可以通过其他函数做进一步的处理。比如筛选出一个有空格的数组值
filter(hasSpaces, ["tori_spelling", "tori amos"]);
// ["tori amos"]

// 保存这个 findSpace 的函数变量
var findSpaces = filter(hasSpaces);
// function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }

// 最终使用,获取包含空格的项
findSpaces(["tori_spelling", "tori amos"]);
// ["tori amos"]

4.函数的组合(compose)

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"(compose)。

var compose = function(f,g) {
	return function(x) {
		return f(g(x));
	};
};

f 和 g 都是函数, x 是在它们之间通过“管道”传输的值。

一个操作数组的程序例子:

const reverseList = (arr) => {
	return arr.reverse()
}

const getHeader = (arr) => {
	return arr.slice(0,1)
}

const toUpperCase = (arr) => {
	return arr.map(ele => {
		return ele.toUpperCase()
	})
}

// 反转数组和变成大写
const reverseAndUpperCase = compose(reverseList, toUpperCase)
reverseAndUpperCase(["a","b","c"]) // ["C","B","A"]

// 截取数组最后一项
const getLastOpt = compose(getHeader, reverseList)
getLastOpt(["a","b","c"]) // ["c"]

// 变成大写后截取最后一项
const getUpperCaseLastOpt = compose(getHeader, compose(reverseList, toUpperCase))
getUpperCaseLastOpt(["a","b","c"]) 

在 compose 的定义中, 因为函数的执行是从内到外的,所以g 将先于 f 执行,因此就创建了一个从右到左的数据 流。这样做的可读性远远高于嵌套一大堆的函数调用,如果不用组合, getUpperCaseLastOpt 函 数将会是这样的:

const getUpperCaseLastOpt = (arr) => {
	return getHeader(reverseList(toUpperCase(arr)))
}

看完compose的方法的组合的函数,你可能发现了,我们不管是先反转数组和变成大写,还是先获取最后一项再变成大写,得到的结果返回都是一样的,这个特性就是结合律,符合结合律意味着不管你是把 g 和 h 分到一组,还是把f 和 g 分到一组都不重要。 所以截取最后一项并变成大写可以这样写:

compose(getHeader, compose(reverseList, toUpperCase))

compose(compose(toUpperCase, getHeader), reverseList)

compose(compose(getHeader, reverseList), toUpperCase)

合成也是函数必须是纯的一个原因。因为一个不纯的函数,怎么跟其他函数合成?怎么保证各种合成以后,它会达到预期的行为?

// 前面的例子中我们必须要写两个组合才行,但既然组合是符合结合律的,我们就可
//以只写一个,而且想传给它多少个函数就传给它多少个,然后让它自己决定如何分组。
const getUpperCaseLastOpt = compose(toUpperCase, getHeader, reverseList);
lastUpper(['a', 'b', 'b']);

// 至于稍微复杂些的可变组合,可以在类似 lodash、underscore 以及 ramda 这样的类库中找到它们的常规定
义。

5.函子(Founctor)

我们知道函数式编程就是通过管道把数据在一系列纯函数之间传递的程序,前面的都只是简单的变量处理,但是开发中肯定会有更复杂的操作,例如异常处理、异步操作等,这时候就可以用到函子了。

其实函子可以看作是一个容器,往里面放入值,让容器自己去运行我们自定义的函数方法。 举个例子:

//先实定义一个容器
const Container = function (x) {
	this._value = x
}

// 定义一个初始化容器的方法
// 这里定义of方法其实是new 方法的语法糖,可以认为执行这方法是将值放入容器的操作
Container.of = function (x) {
	return new Container(x)
}

// Container.of('Hello JS')

//一旦容器里有了值,不管这个值是什么,我们就需要一种方法来让别的函数能够操
作它。
Container.prototype.map = function(f){
	return Container.of(f(this._value))
}

Container.of(2).map((x) => {return x + 2}).map((x) => {return x * 3})

几个著名的函子:

Maybe 函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。Maybe 函子就是为了解决这一类问题而设计的。简单说,它的map方法里面设置了空值检查。

const Maybe = function (x) {
	this._value = x
}

Maybe.of = function (x) {
	return new Maybe(x)
}

Maybe.prototype.map = function(f) {
    return this._value ? Maybe.of(f(this._value)) : Maybe.of(null)
}

Maybe.of("abcdefg").map((x) => {return x.match(/a/ig)})
// Maybe(['a'])
Maybe.of(null).map(match(/a/ig))
// Maybe(null)

Either 函子

条件运算if...else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。 Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

const Either = function (left, right) {
	this.left = left
    this.right = right
}

Either.of = function (left, right) {
	return new Either(left, right)
}

Either.prototype.map = function(f) {
    return this.right ? Either.of(this.left, f(this.right)) : Either.of(f(this.left), this.right)
}
Either.of(1,2).map((x) => {return x+1})
Either.of(1).map((x) => {return x+1})

函数式编程的好处

组合性,一个函数只负责一个功能, 多个函数组合起来就有强大的功能。不过个人觉得函数式编程的组合的使用场景对于平时的业务开发用的还是比较少,像f= compose(f,g,h)这样的组合可能在一些框架底层可能会用到,应用层面的使用场合较少。

数据不可变性,也就是数据不能被改变,对于面向对象编程的思维来说,数据被封装在对象中,所有的修改历史都被隐藏起来,不易于debug, 而对于函数式编程来说,数据是不可变的,你传入一个值到一个函数中,函数只会返回这个值对应的形变后的值,原本的数据是不可变的。

纯函数,函数的输出只和输入有关系,这样的程序的结果是可预期的,这既利于单元测试也利于减少bug

总的来说,函数式编程的好处有:

1、函数被拆成了一个个具有单一功能的小函数

2、硬编码被干掉了,变得更加灵活

3、使用了组合函数、高阶函数来灵活的组合各个小函数

4、职责越单一,复用性会越好,这些小函数,我们都可以在其他地方,通过组合不同的小函数,来实现更多的功能。

5、增加代码的可读性,随着代码量的增多,代码的读写对我们造成了困扰,使用函数式编程可将功能点拆分和抽象。

6、方便调试,这得益于函数式编程是纯函数、无副作用,每次传入一个参数,得到的结果是唯一的,不会被外部变量或者共用变量导致得到的结果不是我们预想中的结果。

函数式编程存在的一些问题

1、与面向对象编程相比,可能用的人太少。可能是入门门槛的问题,现在大多数的编程还是面向对象的。

2、函数式编程通常会大量的使用柯里化,理论上会影响性能(因为都是闭包)进而影响体验。

3、习惯了命令式编程,转变成函数式编程会有点困难。习惯了命令式编程的人可能会先用命令式的方式想清楚,再改成函数式。