本文较长,总共分为三大部分:(对于函数式编程以及其优势有必定理解的童鞋,能够直接从 第二部分 开始阅读)javascript
第一部分:首先会经过实际代码介绍一下什么是函数式编程以及使用它的意义。前端
第二部分:我会着重介绍一下函数式编程中最重要的两个方法:柯里化和函数组合,以及他们的使用方法和实践经验。java
第三部分:实战篇,主要是经过一个实战让你们对这种编程范式有一个更深入的理解。nginx
最后会总结一下函数式编程的优势和局限,并给出一些建议。git
早在 1950 年代,随着 Lisp 语言的建立,函数式编程( Functional Programming,简称 FP)就已经开始出如今你们视野。es6
而直到近些年,函数式以其优雅,简单的特色开始从新风靡整个编程界,主流语言在设计的时候无一例外都会更多的参考函数式特性( Lambda 表达式,原生支持 map ,reduce ……),Java8 开始支持函数式编程。github
而在前端领域,咱们一样能看到不少函数式编程的影子:ES6 中加入了箭头函数,Redux 引入 Elm 思路下降 Flux 的复杂性,React16.6 开始推出 React.memo(),使得 pure functional components 成为可能,16.8 开始主推 Hook,建议使用 pure function 进行组件编写……正则表达式
这些无一例外的说明,函数式编程这种古老的编程范式并无随着岁月而褪去其光彩,反而越发生机勃勃。算法
另外还有一些例子能证实函数式编程也适应于大型软件的编写:数据库
WhatsApp:经过 Erlang,WhatsApp 能够支持 9 亿用户,而其团队中只有 50 名工程师。
Discord:使用 Elixir,相似方式的 Discord 每分钟处理超过一百万个请求。
于我我的而言,函数式编程就像第三次工业革命,前两次分别为命令式编程(Imperative programming)和面向对象编程(Object Oriented Programming)。
概念说的再多也不够例子直观
Talk is cheap, show me the code
假设咱们有这么个需求,咱们登记了一系列人名存在数组中,如今须要对这个结构进行一些修改,须要把字符串数组变成一个对象数组,方便后续的扩展,而且须要把人名作一些转换:
['john-reese', 'harold-finch', 'sameen-shaw']
// 转换成
[{name: 'John Reese'}, {name: 'Harold Finch'}, {name: 'Sameen Shaw'}]
复制代码
用传统的编程思路,咱们一上来就能够撸代码,临时变量,循环走起来:
const arr = ['john-reese', 'harold-finch', 'sameen-shaw'];
const newArr = [];
for (let i = 0, len = arr.length; i < len ; i++) {
let name = arr[i];
let names = name.split('-');
let newName = [];
for (let j = 0, naemLen = names.length; j < naemLen; j++) {
let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
newName.push(nameItem);
}
newArr.push({ name : newName.join(' ') });
}
return newArr;
复制代码
完成,这几乎是全部人下意识的编程思路,彻底的面向过程。你会想我须要依次完成:
这样固然能完成任务,最后的结果就是一堆中间临时变量,光想变量名就让人感到崩溃。同时过程当中掺杂了大量逻辑,一般一个函数须要从头读到尾才知道它具体作了什么,并且一旦出问题很难定位。
一直以来,我也没以为这样编程有什么问题,直到我遇到了函数式编程。咱们来看一看一个 FPer 会如何思考这个问题:
String 数组
到 Object 数组
的转换:convertNames :: [String] -> [Object]
复制代码
String -> Object
的转换,那我须要有这么个函数实现这种转换:convert2Obj :: String -> Object
复制代码
至于这种转换,能够轻松想到须要两个函数完成:
capitalizeName
:把名称转换成指定形式genObj
:把任意类型转换成对象若是再细想一下,capitalizeName
其实也是几个方法的组合(split
, join
, capitalize
),剩下的几个函数都是很是容易实现的。
好了,咱们的任务完成了,能够 运行代码
const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();
const genObj = curry((key, x) => {
let obj = {};
obj[key] = x;
return obj;
})
const capitalizeName = compose(join(' '), map(capitalize), split('-'));
const convert2Obj = compose(genObj('name'), capitalizeName)
const convertName = map(convert2Obj);
convertName(['john-reese', 'harold-finch', 'sameen-shaw'])
复制代码
你能够先忽略其中的 curry
和 compose
函数(后面 会介绍)。只是看这个编程思路,能够清晰看出,函数式编程的思惟过程是彻底不一样的,它的着眼点是函数,而不是过程,它强调的是如何经过函数的组合变换去解决问题,而不是我经过写什么样的语句去解决问题,当你的代码愈来愈多的时候,这种函数的拆分和组合就会产生出强大的力量。
以前咱们已经初窥了函数式编程,知道了它的魅力,如今咱们继续深刻了解一下函数式编程吧。
其实函数咱们从小就学,什么一次函数,二次函数……根据学术上函数的定义,函数便是一种描述集合和集合之间的转换关系,输入经过函数都会返回有且只有一个输出值。
因此,函数其实是一个关系,或者说是一种映射,而这种映射关系是能够组合的,一旦咱们知道一个函数的输出类型能够匹配另外一个函数的输入,那他们就能够进行组合。还记得以前写的 convert2Obj
这个函数:
const convert2Obj = compose(genObj('name'), capitalizeName)
复制代码
它实际上就完成了映射关系的组合,把一个数据从 String
转换成了 String
而后再转换成 Object
。数学好的童鞋就知道,这就是数学上的复合运算:g°f = g(f(x))
在咱们的编程世界中,咱们须要处理的其实也只有“数据”和“关系”,而关系就是函数。咱们所谓的编程工做也不过就是在找一种映射关系,一旦关系找到了,问题就解决了,剩下的事情,就是让数据流过这种关系,而后转换成另外一个数据罢了。
我特别喜欢用流水线去形容这种工做,把输入当作原料,把输出当作产品,数据能够不断的从一个函数的输出能够流入另外一个函数输入,最后再输出结果,这不就是一套流水线嘛?
因此,如今你明确了函数式编程是什么了吧?它其实就是强调在编程过程当中把更多的关注点放在如何去构建关系。经过构建一条高效的建流水线,一次解决全部问题。而不是把精力分散在不一样的加工厂中来回奔波传递数据。
这是函数式编程得以实现的前提,由于咱们基本的操做都是在操做函数。这个特性意味着函数与其余数据类型同样,处于平等地位,能够赋值给其余变量,也能够做为参数,传入另外一个函数,或者做为别的函数的返回值,例如前面的
const convert2Obj = compose(genObj('name'), capitalizeName)
复制代码
经过上面的例子能够看出来,函数式编程大多时候都是在声明我须要作什么,而非怎么去作。这种编程风格称为 声明式编程 。这样有个好处是代码的可读性特别高,由于声明式代码大多都是接近天然语言的,同时,它解放了大量的人力,由于它不关心具体的实现,所以它能够把优化能力交给具体的实现,这也方便咱们进行分工协做。
SQL 语句就是声明式的,你无需关心 Select 语句是如何实现的,不一样的数据库会去实现它本身的方法而且优化。React 也是声明式的,你只要描述你的 UI,接下来状态变化后 UI 如何更新,是 React 在运行时帮你处理的,而不是靠你本身去渲染和优化 diff 算法。
所谓惰性执行指的是函数只在须要的时候执行,即不产生无心义的中间变量。像刚才的例子,函数式编程跟命令式编程最大的区别就在于几乎没有中间变量,它从头至尾都在写函数,只有在最后的时候才经过调用 convertName
产生实际的结果。
这是函数式编程的核心概念:
为了实现这个目标,函数式编程提出函数应该具有的特性:没有反作用和纯函数。
反作用这个词咱们可算听的很多,它的含义是:在完成函数主要功能以外完成的其余副要功能。在咱们函数中最主要的功能固然是根据输入返回结果,而在函数中咱们最多见的反作用就是随意操纵外部变量。因为 JS 中对象传递的是引用地址,哪怕咱们用 const
关键词声明对象,它依旧是能够变的。而正是这个“漏洞”让咱们有机会随意修改对象。
例如: map
函数的原本功能是将输入的数组根据一个函数转换,生成一个新的数组:
map :: [a] -> [b]
复制代码
而在 JS 中,咱们常常能够看到下面这种对 map
的 “错误” 用法,把 map
看成一个循环语句,而后去直接修改数组中的值。
const list = [...];
// 修改 list 中的 type 和 age
list.map(item => {
item.type = 1;
item.age++;
})
复制代码
这样函数最主要的输出功能没有了,变成了直接修改了外部变量,这就是它的反作用。而没有反作用的写法应该是:
const list = [...];
// 修改 list 中的 type 和 age
const newList = list.map(item => ({...item, type: 1, age:item.age + 1}));
复制代码
保证函数没有反作用,一来能保证数据的不可变性,二来能避免不少由于共享状态带来的问题。当你一我的维护代码时候可能还不明显,但随着项目的迭代,项目参与人数增长,你们对同一变量的依赖和引用愈来愈多,这种问题会愈来愈严重。最终可能连维护者本身都不清楚变量究竟是在哪里被改变而产生 Bug。
传递引用一时爽,代码重构火葬场
纯函数算是在 “没有反作用” 的要求上再进一步了。相信你已经在不少地方接触过这个词,在 Redux 的三大原则中,咱们看到,它要求全部的修改必须使用纯函数。
Changes are made with pure functions
其实纯函数的概念很简单就是两点:
不依赖外部状态(无状态): 函数的的运行结果不依赖全局变量,this 指针,IO 操做等。
没有反作用(数据不变): 不修改全局变量,不修改入参。
因此纯函数才是真正意义上的 “函数”, 它意味着相同的输入,永远会获得相同的输出。
如下几个函数都是不纯的,由于他们都依赖外部变量,试想一下,若是有人调用了 changeName
对 curUser
进行了修改,而后你在另外的地方调用了 saySth
,这样就会产生你预料以外的结果。
const curUser = {
name: 'Peter'
}
const saySth = str => curUser.name + ': ' + str; // 引用了全局变量
const changeName = (obj, name) => obj.name = name; // 修改了输入参数
changeName(curUser, 'Jay'); // { name: 'Jay' }
saySth('hello!'); // Jay: hello!
复制代码
若是改为纯函数的写法会是怎么样呢?
const curUser = {
name: 'Peter'
}
const saySth = (user, str) => user.name + ': ' + str; // 不依赖外部变量
const changeName = (user, name) => ({...user, name }); // 未修改外部变量
const newUser = changeName(curUser, 'Jay'); // { name: 'Jay' }
saySth(curUser, 'hello!'); // Peter: hello!
复制代码
这样就没有以前说的那些问题了。
咱们这么强调使用纯函数,纯函数的意义是什么?
便于测试和优化:这个意义在实际项目开发中意义很是大,因为纯函数对于相同的输入永远会返回相同的结果,所以咱们能够轻松断言函数的执行结果,同时也能够保证函数的优化不会影响其余代码的执行。这十分符合测试驱动开发 TDD(Test-Driven Development ) 的思想,这样产生的代码每每健壮性更强。
可缓存性:由于相同的输入老是能够返回相同的输出,所以,咱们能够提早缓存函数的执行结果,有不少库有所谓的 memoize
函数,下面以一个简化版的 memoize
为例,这个函数就能缓存函数的结果,对于像 fibonacci
这种计算,就能够起到很好的缓存效果。
function memoize(fn) {
const cache = {};
return function() {
const key = JSON.stringify(arguments);
var value = cache[key];
if(!value) {
value = [fn.apply(null, arguments)]; // 放在一个数组中,方便应对 undefined,null 等异常状况
cache[key] = value;
}
return value[0];
}
}
const fibonacci = memoize(n => n < 2 ? n: fibonacci(n - 1) + fibonacci(n - 2));
console.log(fibonacci(4)) // 执行后缓存了 fibonacci(2), fibonacci(3), fibonacci(4)
console.log(fibonacci(10)) // fibonacci(2), fibonacci(3), fibonacci(4) 的结果直接从缓存中取出,同时缓存其余的
复制代码
自文档化:因为纯函数没有反作用,因此其依赖很明确,所以更易于观察和理解(配合后面介绍的 [类型签名](#hindly-milner 类型签名)更佳)。
更少的 Bug:使用纯函数意味着你的函数中不存在指向不明的 this,不存在对全局变量的引用,不存在对参数的修改,这些共享状态每每是绝大多数 bug 的源头。
好了,说了这么多,接下来就让咱们看看在 JS 中如何使用函数式编程吧。
若是说函数式编程中有两种操做是必不可少的那无疑就是柯里化(Currying)和函数组合(Compose),柯里化其实就是流水线上的加工站,函数组合就是咱们的流水线,它由多个加工站组成。
接下来,就让咱们看看如何在 JS 中利用函数式编程的思想去组装一套高效的流水线。
柯里化的意思是将一个多元函数,转换成一个依次调用的单元函数。
f(a,b,c) → f(a)(b)(c)
复制代码
咱们尝试写一个 curry
版本的 add
函数
var add = function(x) {
return function(y) {
return x + y;
};
};
const increment = add(1);
increment(10); // 11
复制代码
为何这个单元函数很重要?还记得咱们以前说过的,函数的返回值,有且只有一个嘛? 若是咱们想顺利的组装流水线,那我就必须保证我每一个加工站的输出恰好能流向下个工做站的输入。所以,在流水线上的加工站必须都是单元函数。
如今很好理解为何柯里化配合函数组合有奇效了,由于柯里化处理的结果恰好就是单输入的。
常常有人搞不清柯里化和部分函数应用 ( Partial Function Application ),常常把他们混为一谈,其实这是不对的,在维基百科里有明确的定义,部分函数应用强调的是固定必定的参数,返回一个更小元的函数。经过如下表达式展现出来就明显了:
// 柯里化
f(a,b,c) → f(a)(b)(c)
// 部分函数调用
f(a,b,c) → f(a)(b,c) / f(a,b)(c)
复制代码
柯里化强调的是生成单元函数,部分函数应用的强调的固定任意元参数,而咱们平时生活中经常使用的实际上是部分函数应用,这样的好处是能够固定参数,下降函数通用性,提升函数的适合用性。
// 假设一个通用的请求 API
const request = (type, url, options) => ...
// GET 请求
request('GET', 'http://....')
// POST 请求
request('POST', 'http://....')
// 可是经过部分调用后,咱们能够抽出特定 type 的 request
const get = request('GET');
get('http://', {..})
复制代码
一般咱们不会本身去写 curry
函数,现成的库大多都提供了 curry
函数的实现,可是使用过的人确定有会有疑问,咱们使用的 Lodash,Ramda 这些库中实现的 curry
函数的行为好像和柯里化不太同样呢,他们实现的好像是部分函数应用呢?
const add = R.curry((x, y, z) => x + y + z);
const add7 = add(7);
add7(1,2) // 10
const add1_2 = add(1,2);
add1_2(7) // 10
复制代码
其实,这些库中的 curry
函数都作了不少优化,致使这些库中实现的柯里化其实不是纯粹的柯里化,咱们能够把他们理解为“高级柯里化”。这些版本实现能够根据你输入的参数个数,返回一个柯里化函数/结果值。即,若是你给的参数个数知足了函数条件,则返回值。这样能够解决一个问题,就是若是一个函数是多输入,就能够避免使用 (a)(b)(c)
这种形式传参了。
因此上面的 add7(1, 2)
能直接输出结果不是由于 add(7)
返回了一个接受 2 个参数的函数,而是你恰好传了 2 个参数,知足了全部参数,所以给你计算告终果,下面的代码就很明显了:
const add = R.curry((x, y, z) => x + y + z);
const add7 = add(7);
add(7)(1) // function
复制代码
若是 add7
是一个接受 2 个参数的函数,那么 add7(1)
就不该该返回一个 function 而是一个值了。
所以,记住这句话:咱们能够用高级柯里化去实现部分函数应用,可是柯里化不等于部分函数应用。
一般,咱们在实践中使用柯里化都是为了把某个函数变得单值化,这样能够增长函数的多样性,使得其适用性更强:
const replace = curry((a, b, str) => str.replace(a, b));
const replaceSpaceWith = replace(/\s*/);
const replaceSpaceWithComma = replaceSpaceWith(',');
const replaceSpaceWithDash = replaceSpaceWith('-');
复制代码
经过上面这种方式,咱们从一个 replace
函数中产生不少新函数,能够在各类场合进行使用。
更重要的是,单值函数是咱们即将讲到的函数组合的基础。
上面咱们借助 curry
,已经能够很轻松的构造一个加工站了,如今就是咱们组合成流水线的时候了。
函数组合的目的是将多个函数组合成一个函数。下面来看一个简化版的实现:
const compose = (f, g) => x => f(g(x))
const f = x => x + 1;
const g = x => x * 2;
const fg = compose(f, g);
fg(1) //3
复制代码
咱们能够看到 compose
就实现了一个简单的功能:造成了一个全新的函数,而这个函数就是一条从 g -> f
的流水线。同时咱们能够很轻易的发现 compose
实际上是知足结合律的
compose(f, compose(g, t)) = compose(compose(f, g), t) = f(g(t(x)))
复制代码
只要其顺序一致,最后的结果是一致的,所以,咱们能够写个更高级的 compose
,支持多个函数组合:
compose(f, g, t) => x => f(g(t(x))
复制代码
简单实现以下:
const compose = (...fns) => (...args) => fns.reduceRight((val, fn) => fn.apply(null, [].concat(val)), args);
const f = x => x + 1;
const g = x => x * 2;
const t = (x, y) => x + y;
let fgt = compose(f, g, t);
fgt(1, 2); // 3 -> 6 -> 7
复制代码
考虑一个小功能:将数组最后一个元素大写,假设 log
, head
,reverse
,toUpperCase
函数存在(咱们经过 curry
能够很容易写出来)
命令式的写法:
log(toUpperCase(head(reverse(arr))))
复制代码
面向对象的写法:
arr.reverse()
.head()
.toUpperCase()
.log()
复制代码
链式调用看起来顺眼多了,然而问题在于,原型链上可供咱们链式调用的函数是有限的,而需求是无限的 ,这限制了咱们的逻辑表现力。
再看看,如今经过组合,咱们如何实现以前的功能:
const upperLastItem = compose(log, toUpperCase, head, reverse);
复制代码
经过参数咱们能够很清晰的看出发生了 uppderLastItem 作了什么,它完成了一套流水线,全部通过这条流水线的参数都会经历:reverse
-> head
-> toUpperCase
-> log
这些函数的加工,最后生成结果。
最完美的是,这些函数都是很是简单的纯函数,你能够随意组合,随意拿去用,不用有任何的顾忌。
其实有些经验丰富的程序猿已经看出来一些蹊跷,这不就是所谓管道 ( pipe
) 的概念嘛?在 Linux 命令中常会用到,相似ps
grep
的组合
ps -ef | grep nginx
复制代码
只是管道的执行方向和 compose (从右往左的组合 ) 好像恰好相反,所以不少函数库(Lodash,Ramda)中也提供了另外一种组合方式:pipe
(从左往右的组合)
const upperLastItem = R.pipe(reverse, head, toUppderCase, log);
复制代码
其实函数式编程的理念和 Linux 的设计哲学很像:
有众多单一目的的小程序,一个程序只实现一个功能,多个程序组合完成复杂任务。
函数组合的好处显而易见,它让代码变得简单而富有可读性,同时经过不一样的组合方式,咱们能够轻易组合出其余经常使用函数,让咱们的代码更具表现力
// 组合方式 1
const last = compose(head, reverse);
const shout = compose(log, toUpperCase);
const shoutLast = compose(shout, last);
// 组合方式 2
const lastUppder = compose(toUpperCase, head, reverse);
const logLastUpper = compose(log, lastUppder);
复制代码
这个过程,就像搭乐高积木同样。
因而可知,大型的程序,均可以经过这样一步步的拆分组合实现,而剩下要作的,就是去构造足够多的积木块(函数)。
在使用柯里化和函数组合的时候,有一些经验能够借鉴一下:
由于咱们的输出一般是须要操做的数据,这样当咱们固定了以前的参数(咱们能够称为配置)后,能够变成一个单元函数,直接被函数组合使用,这也是其余的函数式语言遵循的规范:
const split = curry((x, str) => str.split(x));
const join = curry((x, arr) => arr.join(x));
const replaceSpaceWithComma = compose(join(','), split(' '));
const replaceCommaWithDash = compose(join('-'), split(','));
复制代码
可是若是有些函数没遵循这个约定,咱们的函数该如何组合?固然也不是没办法,不少库都提供了占位符的概念,例如 Ramda 提供了一个占位符号(R.__
)。假设咱们的 split
把 str
放在首位
const split = curry((str, x) => str.split(x));
const replaceSpaceWithComma = compose(join(','), split(R.__, ' '));
复制代码
函数组合有个使用要点,就是中间的函数必定是单输入的,这个很好理解,以前也说过了,由于函数的输出都是单个的(数组也只是一个元素)。
当遇到函数出错的时候怎么办?咱们想知道在哪一个环节出错了,这时候,咱们能够借助一个辅助函数 trace
,它会临时输出当前阶段的结果。
const trace = curry((tip, x) => { console.log(tip, x); return x; });
const lastUppder = compose(toUpperCase, head, trace('after reverse'), reverse);
复制代码
现有的函数式编程工具库不少,Lodash/fp 也提供了,可是不是很推荐使用 Lodash/fp 的函数库,由于它的不少函数把须要处理的参数放在了首位( 例如 map
)这不符合咱们以前说的最佳实践。
这里推荐使用 Ramda,它应该是目前最符合函数式编程的工具库,它里面的全部函数都是 curry
的,并且须要操做的参数都是放在最后的。上述的 split
,join
,replace
这些基本的都在 Ramda 中能够直接使用,它一共提供了 200 多个超实用的函数,合理使用能够大大提升你的编程效率(目前个人我的经验来讲,我须要的功能它 90%都提供了)。
如今你已经基本学会了全部的基础概念,那让咱们来实战一下吧!
假设我如今有一套数据:
const data = [
{
name: 'Peter',
sex: 'M',
age: 18,
grade: 99
},
……
]
复制代码
实现如下几个经常使用功能:
我这边提供如下 Ramda 库中的参考函数:
// 对象操做(最后一个参数是对象),均会返回新的对象拷贝
R.prop('name') // 获取对象 name 字段的值
R.propEq('name', '123') // 判断对象 name 字段是否等于‘123’
R.assoc('name', '123') // 更新对象的'name'的值为'123'
R.pick(['a', 'd']); //=> {a: 1, d: 4} // 获取对象某些属性,若是对应属性不存在则不返回
R.pickAll(['a', 'd']); //=> {a: 1, d: 4} // 获取对象某些属性,若是对应属性不存在则返回`key : undefined`
// 数组操做
R.map(func) // 传统的 map 操做
R.filter(func) // 传统的 filter 操做
R.reject(func) // filter 的补集
R.take(n) // 取出数组前 n 个元素
// 比较操做
R.equals(a, b) // 判断 b 是否等于 a
R.gt(2, 1) => true // 判断第一个参数是否大于第二个参数
R.lt(2, 1) => false // 判断第一个参数是否小于第二个参数
// 排序操做
R.sort(func) // 根据某个排序函数排序
R.ascend(func) // 根据 func 转换后的值,生成一个升序比较函数
R.descend(func) // 根据 func 转换后的值,生成一个降序比较函数
// 例子:
R.sort(R.ascend(R.prop('age'))) // 根据 age 进行升序排序
// 必备函数
R.pipe() //compose 的反向,从前日后组合
R.compose() // 从后到前组合
R.curry() // 柯里化
复制代码
能够想一想看,若是是你会如何写这些函数,我这里提供了一个 codepen 的模板,能够在这里写你的答案,会自动测试。
(个人答案放在文章后面,请先思考完再看)
以前咱们遇到了相似这样的说明:
:: String -> Object
复制代码
这叫类型签名,最先是在 Hindley-Milner 类型系统中提出来的。
你也能在 Ramda 的官网上看到相似的类型签名:
引入它的好处显而易见,短短一行,就能暴露函数的行为和目的,方便咱们了解语义。有时候一个函数可能很长,光从代码上很难理解它到底作了什么:
const replace = reg => sub => str => str.replace(reg, sub);
复制代码
而加上类型签名,咱们至少能知道每一步它作了哪些转换,最后输出一个什么样的结果。
例如这个 replace ,经过类型签名咱们知道它接受一个 正则表达
式和两个 String
,最后会返回一个 String
。
// replace :: Regex -> String -> String -> String
const replace = reg => sub => str => str.replace(reg, sub);
复制代码
这样的连续箭头看起来可能很头疼,其实稍微组合一下能够发现,它就是柯里化的意思:先传一个 正则表达式
会返回一个函数,若是再传一个 String
,也会返回函数……直到你输入了最后一个 String
,就会返回一个 String
的结果。
// replace :: Regex -> (String -> (String -> String))
复制代码
同时类型签名能够避免咱们在合并函数的时候输入和输出的类型不一致。
例如 join 函数经过类型签名很明显是传入一个 String 的配置,而后就能够将一个 String 数组
转换成 String
。
// join :: String -> [String] -> String
const join = curry((sep, arr) => arr.join(sep));
复制代码
一样,下面这个函数,它接受一个 String
,而后通过 strLen 转换能返回一个 Number
。
// strLen :: String -> Number
const strLen = str => str.length();
复制代码
那咱们很容易知道,以上两个函数彻底能够组合,由于他们输入和输出类型一致,经过组合咱们能够完成一个 String 数组
到 Number
的流水线。
const joinDash = join('-');
const lengthWithDash = compose(strLen, joinDash);
lengthWithDash(['abc', 'def']); // 7
复制代码
固然还有时候你的函数可能不是接受特定的类型,而只是作一些通用的事情,此时咱们能够用 a, b, c…… 这些来替代一些通用类型,例如 map
,它传入一个能够把 a 转换成 b 的函数,而后把a 数组
转换成b 数组
。
// map :: (a -> b) -> [a] -> [b]
var map = curry(function(f, xs){
return xs.map(f);
});
// head :: [a] -> a
var head = function(xs){ return xs[0]; }
复制代码
如今你就学会了类型签名的使用了,咱们推荐你写的每一个函数都加上类型签名,方便他人,方便本身。
我以前提过一下 Pointfree 这种编程风格,它其实就是强调在整个函数编写过程当中不出现参数(point),而只是经过函数的组合生成新的函数,实际数据只须要在最后使用函数的时候再传入便可。
// Pointfree 没有出现须要操做的参数
const upperLastItem = compose(toUpperCase, head, reverse);
// 非 Pointfree 出现了须要操做的参数
const upperLastItem = arr => {
const reverseArr = arr.reverse();
const head = reverseArr[0];
return head.toUpperCase();
}
复制代码
咱们在使用函数式编程的时候,其实天然就会造成这种风格,它有什么好处呢?
刚开始使用这种编程风格确定会有不少不适应,可是当你能合理运用这种编程风格后确实会让代码更加简洁和易于理解了。可是凡事无绝对,学了 Pointfree 这种风格并不意味着你要强迫本身作到一个参数都不能出现(好比不少基础函数,他们自己的编写就不是 Pointfree 的),函数式编程也不是全部场合都彻底适用的,具体状况具体分析。
记住,你学习各类编程范式的最终目的都是为了让本身的编码更加高效,易懂,同时减小出错几率,不能由于学了一种编程范式,反而致使本身的编程成本大大增长,这就有点本末倒置了。
当你写完函数,你能够看一下,你写的函数是否是足够的通用?若是我如今需求由获取男性用户变成获取全部的女性用户,若是我如今要取全部年龄前 10 名的用户,你的函数是否能够很好的复用呢?答案的 codepen 地址,我这里的答案也不必定是最优的,只是提供一个思路(就像 update
,你能够不用 map
,而用 R.update
直接更新数组元素)。
若是在不看答案前,你能写出全部这些操做,那说明你对函数的组合应用的很好了!
前面介绍了不少函数式编程的概念能够总结出函数式编程的优势:
所以,若是用一句话来形容函数式编程,应该是:Less code, fewer bugs
。由于写的代码越少,出错的几率就越小。人是最不可靠的,咱们应该尽可能把工做交给计算机。
一眼看下来好像函数式能够解决全部的问题,可是实际上,函数式编程也不是什么万能的灵丹妙药。正由于函数式编程有以上特色,因此它天生就有如下缺陷:
性能:函数式编程相对于指令式编程,性能绝对是一个短板,由于它每每会对一个方法进行过分包装,从而产生上下文切换的性能开销。同时,在 JS 这种非函数式语言中,函数式的方式必然会比直接写语句指令慢(引擎会针对不少指令作特别优化)。就拿原生方法 map
来讲,它就要比纯循环语句实现迭代慢 8 倍。
资源占用:在 JS 中为了实现对象状态的不可变,每每会建立新的对象,所以,它对垃圾回收(Garbage Collection)所产生的压力远远超过其余编程方式。这在某些场合会产生十分严重的问题。
递归陷阱:在函数式编程中,为了实现迭代,一般会采用递归操做,为了减小递归的性能开销,咱们每每会把递归写成尾递归形式,以便让解析器进行优化。可是众所周知,JS 是不支持尾递归优化的(虽然 ES6 中将尾递归优化做为了一个规范,可是真正实现的少之又少,传送门)
……
所以,在性能要求很严格的场合,函数式编程其实并非太合适的选择。
可是换种思路想,软件工程界历来就没有中止过所谓的银弹之争,却也历来没诞生过什么真正的银弹,各类编程语言层出不穷,各类框架突飞猛进,各类编程范式推陈出新,结果谁也没有真正的替代谁。
学习函数式编程真正的意义在于:让你意识到在指令式编程,面向对象编程以外,还有一种全新的编程思路,一种用函数的角度去抽象问题的思路。学习函数式编程能大大丰富你的武器库,否则,当你手中只有一个锤子,你看什么都像钉子。
咱们彻底能够在平常工做中将函数式编程做为一种辅助手段,在条件容许的前提下,借鉴函数式编程中的思路,例如:
最后,仍是那句老生常谈的话:
没有最好的,只有最适合的
但愿你们在实际项目中,能根据本身的需求选择最适合本身的编程范式,也但愿经过学习这种新的编程范式,可让咱们在二进制的世界行走得更加游刃有余。
why you should learn functional programming