最近开始看《JS 函数式编程指南》这本书,因为函数式编程这个概念在平时的工作学习中会时不时的听到或遇到,总觉得是挺高大上的东西,现在的三大框架 vueangularreact 都有涉及或用到函数式编程的思想,比如最近公开的 vue3 的源码及 Function-based API 新写法, angular 里使用的 库rxjsreact 的高阶组件和 redux 等等,于是就想借鉴整理一些知识出来,以便加深下印象。

一. 定义

函数式编程,维基百科的定义是:

函数式编程(英语:functional programming)或称函数程序设计、泛函编程,是一种编程范式,它将计算机运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算(lambda calculus)为该语言最重要的基础。而且,λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。

比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。

二. 几个特性

1. 函数是一等公民

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

2.只用“表达式”,不用“语句”

“表达式”(expression)是一个单纯的运算过程,总是有返回值;“语句”(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。

3. 没有副作用

指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

副作用可能包含,但不限于:

更改文件系统
往数据库插入记录
发送一个 http 请求
可变数据
打印/log
获取用户输入
DOM 查询
访问系统状态

4. 不可变量

在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了,在函数式编程中变量仅仅代表某个表达式。这里所说的’变量’是不能被修改的。所有的变量只能被赋一次初值。

5. 引用透明

指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

三. 函数式编程常用核心概念

3.1 纯函数

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

比如 slicesplice


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);
//=> []

再看一个例子


// 不纯的,函数calculate的结果取决于外部变量discount,
// 而discount有可能在其他地方被改变,导致结果不可预测
var discount = 0.5
var calculate = function (price) {
  return price * discount;
}

// 纯的
var calculate = function (price) {
  var discount = 0.5;
  return price * discount;
}

3.2 柯里化(curry)

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


// 柯里化之前(我们平时常用写法)
function add(x, y) {
  return x + y;
}

add(1, 2); // 3

// 柯里化之后
function add(x) {
  return function (y) {
    return x + y;
  }
}

add(1)(2); // 3

柯里化之后的做法是,定义了 add 函数,它接受一个参数 x 并返回一个新的函数(通过闭包的形式记住了 add 的第一个参数 x )。
我们看到调用的时候是需要执行两次,确实有点麻烦,所有就有curry函数使这类函数的定义和调用更加容易。
比如lodash库的提供的 curry 函数,当然也可以自己写


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

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


var map = curry(function(fn, ary) {
  return ary.map(fn);
})

调用的时候


var replaceFn = replace(/\s+/g, '-');

replaceFn('Spider Man') // Spider-Man 


var replaceSpaces = map(replaceFn);
// function(ary) { return ary.map(function(a) {return a.replace(/\s+/g, '-') })}


replaceSpaces(['Spider Man', 'The Lion King'])
// ['Spider-Man', 'The-Lion-King']

这里表明的是一种“预加载”函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数。

curry函数也是纯函数,每传递一个参数调用函数,就返回一个新的函数去处理剩余的参数,就是一个输入对应一个输出。哪怕输出是另一个函数,它也是纯函数。当然curry函数也允许一次传递第一个参数,但这只是出于减少()执行的方便。

3.3 组合(compose)

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"(compose)。函数组合就是一种将已被分解的简单任务组织成复杂的整体过程
合成两个函数的简单方法如下:


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

fg 都是函数,函数组合就是把这两个函数(“管道”)连接起来,让 x 是在它们之间通过。 fg 都必须是纯函数。 g 将先于 f 执行,因此就创建了一个从右向左的数据流。

var toUpperCase = function(x) {
 return x.toUpperCase();
};

var exclaim = function(x) {
  return x + '!';
};

var shout = compose(exclaim, toUpperCase);

shout('hello world');
// => "HELLO WORLD!"

组合还必须满足结合律,三个函数的话如下(注意顺序):

var associate = compose(f, compose(g, h)) == compose(compose(f, g), h)
== compose(f, g, h);
// true

完整的compose函数可以在lodashunderscore以及ramda等类库中找到,也可以自己模拟实现,这有篇文章实现compose的五种思路,可以看看

3.4 point-free

意思是函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。
本质就是使用一些通用的函数,组成各种复杂运算

// 非pointfree,因为有参数word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
}

// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

另一个例子:


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

var prop = (p, obj) => obj[p]; // 将读取obj的属性p封装成函数
var propFn = curry(prop); // 将prop函数柯里化

var propStatus = propFn('status'); // 生成读取status属性的函数
var isFinish = s => s === 1; // 判断值是否为1的函数

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

var findData = filter(compose(isFinish, propStatus))

var data = [
  { id: 1, status: 0},
  { id: 2, status: 1},
  { id: 3, status: 0},
]

findData(data);
// [
// { id: 2, status: 1}
// ]

data 是传入的值, findData 是处理这个值得函数。定义 findData 的时候,没有提到 data 这个参数,这就是Pointfree

简单说, Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值。这样做有很多好处,它能够让代码更清晰和简练,更符合语义,更容易复用,测试也变得轻而易举。不过你也要警惕,pointfree 就像是一把双刃剑,有时候也能混淆视听。并非所有的函数式代码都是 pointfree 的。可以使用它的时候就使用,不能使用的时候就用普通函数。

另:如果你接触过 angular 里用的 rxjs ,使用过里面的 pipe 操作,会发现的确是用到了这个思想。

3.5 声明式代码

平时工作中可能大多数都是命令式代码,命令式很具体的告诉计算机如何一步一步的执行某个任务。
而声明式不同,它意味着我们要写表达式,而不是一步一步的指示。
SQL就是一种声明式编程,它没有“先做这个,再做那个”的命令,有的只是一个指明我们想要从数据库取什么数据的表达式。至于如何取数据是由它自己决定的。
我们看一个例子:


var data = [
  { id: 1, name: 'a'},
  { id: 2, name: 'b'},
  { id: 3, name: 'c'},
]

// 命令式,告诉计算机一步一步执行
var ids = [];
for (var i = 0; i < data.length; i++) {
  ids.push(data[i].id)
}

// 声明式,用表达式来描述程序逻辑,
// 指明的是 做什么 ,而不是 怎么做
var ids = data.map(d => d.id);

// [1, 2, 3]

再看一个例子

// 命令式
var authenticate = function(formData) {
  var user = getUser(formData); // 根据输入的form数据查询这个user
  return logIn(user); // 登录(可能返回身份验证标识等等)
}

// 声明式
var authenticate = compose(logIn, getUser)

虽然命令式的版本并不一定就是错的,但还是硬编码了那种一步接一步的执行方式。而 compose 表达式只是简单地指出了这样一个事实:用户验证是 getUserlogIn 两个行为的组合。这再次说明,声明式为潜在的代码更新提供了支持,使得我们的应用代码成为了一种高级规范(high level specification)。

因为声明式代码不指定执行顺序,所以它天然地适合进行并行运算。它与 纯函数 一起解释了为何 函数式编程 是未来并行计算的一个不错选择——我们真的不需要做什么就能实现一个并行/并发系统。

针对 并行 ,后面会再找点例子来解释下,这里有个并行讨论的问题为什么声明性语言往往适合于并行执行-知乎,mark~

四. 流行的函数式编程库

Lodash.jsUnderscore.jsramdaRxjsCycle.js

这一篇先整理这么多,有时间再整理一下 容器函子 (functor)相关


参考文章

1.《JS 函数式编程指南》
2. 函数式编程入门教程-阮一峰
3. Pointfree 编程风格指南-阮一峰

最后修改:2019 年 10 月 24 日
如果觉得我的文章对你有用,请随意赞赏或留下你的评论~