前端基础—带你理解什么是函数式编程

751 阅读26分钟

框架总览

  • 😁 引言

  • 😁 什么是函数式编程?

  • 😁 函数是纯函数

    • 🏆 什么是纯函数
    • 🏆 函数的副作用
    • 🏆 使用纯函数的优点
      • 🌰 可测试性
      • 🌰 可缓存性
      • 🌰 可移植性
    • 🏆 纯函数的特点
      • 🌰 不可变性
      • 🌰 引用透明
      • 🌰 声明式编程
  • 😁 函数是第一等公民

    • 🏆 闭包
    • 🏆 高阶函数
    • 🏆 函数柯里化(Currying)
    • 🏆 函数合成(compose)
  • 😁 结合使用

  • 😁 扩展


1.引言

函数式编程的历史已经很悠久了,但是最近几年却频繁的出现在大众的视野,很多不支持函数式编程的语言也在积极加入闭包匿名函数等非常典型的函数式编程特性。大量的前端框架也标榜自己使用了函数式编程的特性,好像一旦跟函数式编程沾边,就很高大上一样,而且还有一些专门针对函数式编程的框架和库,比如:RxJS、cycleJS、ramdaJS、lodashJS、underscoreJS等。函数式编程变得越来越流行,掌握这种编程范式对书写高质量和易于维护的代码都大有好处,所以我们有必要掌握它。


2.什么是函数式编程?

2.1维基百科定义:

函数式编程(英语:functional programming),又称泛函编程,是一种编程范式,它将电脑运算视为数学上的函数计算,避免了状态的变化和数据的可变。

2.2 简单理解函数式编程的思想

在函数式编程之前,我们一直接触的是面向对象的编程思想,两者的区别:

  • 面向对象的世界里我们是把事物抽象成对象,然后通过封装继承多态来演示他们之间的关系。
  • 函数式的世界里把世界抽象成事物事物之间的关系,用这种方式实现世界的模型。

面向对象的编程更有人情味一些更社会化。比如你想要买一辆汽车,想要托人帮忙砍价。恰好你同学的朋友的大表哥的二嫂子在4s店工作。你就需要:

import "同学"
import "朋友"
import "大表哥"
import "二嫂子"

然后再调用二嫂子.砍价();

函数式编程则更“冰冷”一些,像是工厂里的流水线不停的运转,只要你放入材料就可以在出口处拿到产品。而且它对外部环境没有依赖,只需要将流水线搬走就可以在任何地方生产。不需要找同学的朋友的大表哥的二嫂子来帮忙了。

相对于面向对象编程(Object-oriented programming)关注的是数据而言,函数式编程关注的则是动作,其是一种过程抽象的思维,就是对当前的动作去进行抽象。

比如说我要计算一个数 加上 4 再乘以 4 的值,按照正常写代码的逻辑,我们可能会这么去实现

function calculate(x){
    return (x + 4) * 4;
}
console.log(calculate(1))  // 20

这是没有任何问题的,我们在平时开发的过程中会经常将需要重复的操作封装成函数以便在不同的地方能够调用。但从函数式编程的思维来看的话,我们关注的则是这一系列操作的动作,先「加上 4」再「乘以 4」。 如何封装函数才是最佳实践呢?如何封装才能使函数更加通用,使用起来让人感觉更加舒服呢?函数式编程中的合成(compose)或许能给我们一些启发。之后会讲到。

函数式编程具有两个基本特征

  • 函数是纯函数
  • 函数是第一等公民

函数式编程具有两个最基本的运算

  • 柯里化(Currying)
  • 合成(compose)

3.函数是纯函数

3.1 什么是纯函数

当我们想要理解函数式编程时,需要知道的第一个基本概念是纯函数,但纯函数又是什么鬼?

咱们怎么知道一个函数是否是纯函数?这里有一个非常严格的定义:

  • 1.此函数在相同的输入值时,总是产生相同的输出。(引用透明)
  • 2.它不会引起任何副作用。(不可变性)

首先来看第一点:函数的返回结果只依赖于它的参数

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

将一个函数反复执行,其中的值已经被改变了,从而影响后面的函数操作。一个纯函数是无论什么输入,都只对应输出一个唯一值。

这就是纯函数的第一个条件: 函数的返回结果只依赖于它的参数

接下来解释第2点:函数执行中没有副作用

副作用指的是: 在计算结果的过程中,系统状态的一种变化, 或者与外部事件进行观察的交互。 再看一个例子:

var min = 21;

// 不纯函数
var ckeck = function(age){
    return age >= min;
}

在上面的代码中, 由于我们定义的变量在我们的函数作用域之外,导致这个函数称为“不纯”函数

// 纯函数
var check = function(age){
    var min = 21;
    return age >= min;
}

上面的代码,我们只计算了作用域内的局部变量, 没有任何作用域外部的变量被改变, 因此这个函数是 “纯函数”

除了修改外部的变量, 一个函数在执行过程中还有很多方式产生外部可观察的变化,比如说调用 DOM API修改页面, 或者你发送了Ajax请求, 还有调用window.reload刷新浏览器, 甚至是console.log往控制台打印数据也是副作用。

3.2 什么是函数副作用?

函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情,比如:

  • 1、修改了一个变量
  • 2、直接修改数据结构
  • 3、设置一个对象的成员
  • 4、抛出一个异常或以一个错误终止
  • 5、打印到终端或读取用户输入
  • 6、读取或写入一个文件
  • 7、在屏幕上画图

函数副作用会给程序设计带来不必要的麻烦,给程序带来十分难以查找的错误,并且降低程序的可读性,严格的函数式语言要求函数必须无副作用。

3.3 使用纯函数的优点

为什么要煞费苦心地构建纯函数?因为纯函数非常“靠谱”,执行一个纯函数你不用担心它会干什么坏事,它不会产生不可预料的行为,也不会对外部产生影响。不管何时何地,你给它什么它就会乖乖地吐出什么。如果你的应用程序大多数函数都是由纯函数组成,那么你的程序测试、调试起来会非常方便。

3.3.1 🌰 可测试性

综上所述,这个就很简单了,我们不需要关心其它外部的信息,只需要给函数特定的输入,再断言其输出就好了 一个简单的例子是接收一组数字,并对每个数进行加 1 这种沙雕的操作。

let list = [1, 2, 3, 4, 5];
const incrementNumbers = (list) => list.map(number => number + 1);

接收numbers数组,使用map递增每个数字,并返回一个新的递增数字列表。

incrementNumbers(list); // [2, 3, 4, 5, 6]

对于输入[1,2,3,4,5],预期输出是[2,3,4,5,6]。

3.3.2 🌰 可缓存性

纯函数可以根据输入来做缓存

// 下面的代码我们可以发现相同的输入,再第二次调用的时候都是直接取的缓存
let squareNumber  = memoize((x) => { return x*x; });
squareNumber(4);
//=> 16
squareNumber(4); // 从缓存中读取输入值为 4 的结果
//=> 16
squareNumber(5);
//=> 25
squareNumber(5); // 从缓存中读取输入值为 5 的结果
//=> 25

怎么实现呢? 我们接着看下面的代码

const memoize = (f) => {
  // 由于使用了闭包,所以函数执行完后cache不会立刻被回收
  const cache = {};
  return () => {
    var arg_str = JSON.stringify(arguments);
    // 关键就在这里,我们利用纯函数相同输入相同输出的逻辑,在这里利用cache做一个简单的缓存,当这个参数之前使用过时,我们立即返回结果就行
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};

3.3.3 🌰 可移植性

由于纯函数是自给自足的,它需要的东西都在输入参数中已经声明,所以它可以任意移植到任何地方。

并且纯函数对于自己的依赖是 诚实的,这一点你看它的 形参 就知道啦正所谓 形参起的好,注释不用搞(双押!)纯函数就是这么个正直的小可爱~

// 不纯的
var signUp = function(attrs){
    // 一些副作用操作
    var user = saveUser(attrs);
    welcomeUser(user);
}
// 纯的
// 相同的输入总是返回一个函数
var signUp = function(Db, Email, attrs){
    return function(){
        //一些副作用操作
        var user = saveUser(Db, attrs);
        welcomeUser(Email, user);
    }
}
3.4 纯函数的特点
3.4.1🌰 不可变性

不可变性是纯函数的一个特点: 当数据为不可变数据(纯函数希望数据为不可变数据)时,他的状态在纯函数创建之后不能更改。

作为前端开发者,你会感受到JS中对象(Object)这个概念的强大。我们说“JS中一切皆对象”。最核心的特性,例如从String,到数组,再到浏览器的APIs,“对象”这个概念无处不在。

JS中的对象是那么美妙:我们可以随意复制他们,改变并删除他们的某项属性等。但是要记住一句话:

“伴随着特权,随之而来的是更大的责任。” (With great power comes great responsibility)

这样所以修改对象,随之而来的就是副作用。这与函数式编程的思想是相违背的,所以函数式编程提出了不可变数据的概念。

不可变数据是指那些创建后不能更改的数据。与许多其他语言一样,JavaScript 里有一些基本类型(String,Number 等)从本质上是不可变的,但是对象就是在任意的地方可变,比如:

let arr = [1, 2, 3, 4, 5];
arr.splice(1, 1); // 返回[2];
console.log(arr); // [1, 3, 4, 5];

这是我们常用的“删除数组某一项”的操作。好吧,他一点问题也没有。

问题其实出现在“滥用”可变性上,这样会给你的程序带来“副作用”。先不必关心什么是“副作用”,他又是一个函数式编程的概念。

我们先来看一下代码实例:

const student1 = {
    school: 'Baidu',
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    const newStudent = student;
    newStudent.name = newName;
    newStudent.birthdate = newBday;
    return newStudent;
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}

我们发现,尽管创建了一个新的对象student2,但是老的对象student1也被改动了。这是因为JS对象中的赋值是“引用赋值”,即在赋值过程中,传递的是在内存中的引用(memory reference)。具体说就是“栈存储”和“堆存储”的问题。

不可变数据的强大和实现

我们说的“不可变”,其实是指保持一个对象状态不变。这样做的好处是使得开发更加简单,可回溯,测试友好,减少了任何可能的副作用。

那么我们避免副作用,创建不可变数据的主要实现思路就是:一次更新过程中,不应该改变原有对象,只需要新创建一个对象用来承载新的数据状态。

我们使用纯函数(pure functions)来实现不可变性。纯函数指无副作用的函数。 那么,具体怎么构造一个纯函数呢?我们可以看一下代码实现,我对上例进行改造:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    return {
        ...student, // 使用解构
        name: newName, // 覆盖name属性
        birthdate: newBday // 覆盖birthdate属性
    }
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}

需要注意的是,我使用了ES6中的解构(destructuring)赋值。 这样,我们达到了想要的效果:根据参数,产生了一个新对象,并正确赋值,最重要的就是并没有改变原对象。也可以使用Object.assign来实现。

同样,如果是处理数组相关的内容,我们可以使用:.map, .filter或者.reduce去达成目标。这些APIs的共同特点就是不会改变原数组,而是产生并返回一个新数组。这和纯函数的思想不谋而合。

但是,再说回来,使用Object.assign请务必注意以下几点: 1)他的复制,是将所有可枚举属性,复制到目标对象。换句话说,不可枚举属性是无法完成复制的。 2)对象中如果包含undefined和null类型内容,会报错。 3)最重要的一点:Object.assign方法实行的是浅拷贝,而不是深拷贝。 第三点很重要,也就是说,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个属性对象的引用。这也就意味着,当对象存在嵌套时,还是有问题的。比如下面代码:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
    friends: {
        friend1: 'ZHAO Wenlin',
        friend2: 'CHENG Wen'
    }
}

const changeStudent = (student, newName, newBday, friends) => Object.assign({}, student, {name: newName, birthdate: newBday})

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2); 
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15", friends: Object}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10", friends: Object}

student2.friends.friend1 = 'MA xiao';
console.log(student1.friends.friend1); // "MA xiao"

对student2 friends列表当中的friend1的修改,同时也影响了student1 friends列表当中的friend1。这个时候就需要进行深拷贝了。上面提到的Object.assign就是典型的浅拷贝。如果遇到嵌套很深的结构,我们就需要手动递归。这样做呢,又会存在性能上的问题。

比如我自己动手用递归实现一个深拷贝,需要考虑循环引用的“死环”问题,另外,当使用大规模数据结构时,性能劣势尽显无疑。我们熟悉的jquery extends方法,某一版本(最新版本情况我不太了解)的实现是进行了三层拷贝,也没有达到完备的deep copy。

总之,实现不可变数据,我们必然要关心性能问题。针对于此,我推荐一款已经“大名鼎鼎”的——immutable.js类库来处理不可变数据。

他的实现既保证了不可变性,又保证了性能大限度优化。原理很有意思,下面这段话,我摘自camsong前辈的文章

Immutable实现的原理是Persistent Data Structure(持久化数据结构),也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。 同时为了避免deepCopy把所有节点都复制一遍带来的性能损耗,Immutable使用了Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。

感兴趣的读者可以深入研究下,这是很有意思的。如果有需要,我也愿意再写一篇immutable.js源码分析。

3.4.2 🌰引用透明

如果一个函数对于相同的输入始终产生相同的结果,那么我们就说它是引用透明

接着实现一个square 函数:

const square = (n) => n * n;

给定相同的输入,这个纯函数总是有相同的输出。

square(2); // 4
square(2); // 4
square(2); // 4
// ...

将2作为square函数的参数传递始终会返回4。这样咱们可以把square(2)换成4,我们的函数就是引用透明的。

基本上,如果一个函数对于相同的输入始终产生相同的结果,那么它可以看作透明的。

有了这个概念,咱们可以做的一件很酷的事情就是记住这个函数。假设有这样的函数

const sum = (a, b) => a + b;

用这些参数来调用它

sum(3, sum(5, 8));
sum(5, 8) 总等于13,所以可以做些骚操作:

sum(3, 13);

这个表达式总是得到16,咱们可以用一个数值常数替换整个表达式,并把它记下来。

3.4.3 🌰 声明式编程

先统一一下概念,我们有两种编程方式:命令式和声明式。

我们可以像下面这样定义它们之间的不同:

  • 命令式编程:命令“机器”如何去做事情(how),这样不管你想要的是什么(what),它都会按照你的命令实现。 声明式编程:告诉“机器”你想要的是什么(what),让机器想出如何去做(how)。

声明式编程和命令式编程的代码例子

举个简单的例子,假设我们想让一个数组里的数值翻倍。

我们用命令式编程风格实现,像下面这样:

var numbers = [1,2,3,4,5]
var doubled = []
for(var i = 0; i < numbers.length; i++) {
  var newNumber = numbers[i] * 2
  doubled.push (newNumber)
}
console.log (doubled) //=> [2,4,6,8,10]

我们直接遍历整个数组,取出每个元素,乘以二,然后把翻倍后的值放入新数组,每次都要操作这个双倍数组,直到计算完所有元素。

而使用声明式编程方法,我们可以用 Array.map 函数,像下面这样:

var numbers = [1,2,3,4,5]
var doubled = numbers.map (function (n) {
  return n * 2
})
console.log (doubled) //=> [2,4,6,8,10]

map利用当前的数组创建了一个新数组,新数组里的每个元素都是经过了传入map的函数(这里是function (n) { return n*2 })的处理。

用上面的定义方式来分析:

  • 命令式:for循环遍历数组,关注运行原理,强调How
  • 声明式:Array.map遍历数组,关注输出结果,强调What

在一些具有函数式编程特征的语言里,对于 list 数据类型的操作,还有一些其他常用的声明式的函数方法。例如,求一个list里所有值的和,命令式编程会这样做:

var numbers = [1,2,3,4,5]
var total = 0 for(var i = 0; i < numbers.length; i++) {
  total += numbers[i]
}
console.log (total) //=> 15

而在声明式编程方式里,我们使用reduce函数:

var numbers = [1,2,3,4,5]
var total = numbers.reduce (function (sum, n) {
  return sum + n
});
console.log (total) //=> 15

reduce函数利用传入的函数把一个list运算成一个值。它以这个函数为参数,数组里的每个元素都要经过它的处理。每一次调用,第一个参数(这里是sum)都是这个函数处理前一个值时返回的结果,而第二个参数(n)就是当前元素。这样下来,每此处理的新元素都会合计到sum中,最终我们得到的是整个数组的和。

同样,reduce函数归纳抽离了我们如何遍历数组和状态管理部分的实现,提供给我们一个通用的方式来把一个list合并成一个值。我们需要做的只是指明我们想要的是什么。


4.函数是第一等公民

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

// 赋值
var a = function fn1() {  }
// 函数作为参数
function fn2(fn) {
    fn()
}   
// 函数作为返回值
function fn3() {
    return function() {}
}

下面这些术语都是围绕这一特性的应用:

4.1 闭包

闭包是js开发惯用的技巧,什么是闭包?闭包就是一个函数,这个函数能够访问其他函数的作用域中的变量

在函数式编程的过程中。函数内定义了局部变量并且返回可缓存的函数. 变量在返回的函数内也是可被访问的, 此处创建了一个闭包

// test1 是普通函数
 function test1() {
  var a = 1;
  // test2 是内部函数
  // 它引用了 test1 作用域中的变量 a
  // 因此它是一个闭包
  return function test2() {
    return a + =1;
  }
 }
var result = test1();
console.log(result()); // 2 
console.log(result()); // 3
console.log(result()); // 4

变量a 被一直保存在内存中,没有被垃圾回收机制回收掉。函数式编程中两个最基本的运算:柯里化和合成都使用到了闭包

关于闭包的详细解释,请看: 彻底搞懂JS闭包各种坑


4.2 高阶函数

高阶函数:以函数作为参数的函数,结果return一个函数。 上面关于高阶函数的概念其实是错误的.高阶函数只要满足参数或返回值为函数就可以成为高阶函数,而非一定要同时满足才成立)

简单的例子:

function add(a,b,fn){
    return fn(a)+fn(b);
}
var fn=function (a){
  return a*a;
}
add(2,3,fn); //13

为什么要使用高阶函数(使用高阶函数的意义)

  • 高阶函数是用来抽象通用问题的
  • 抽象可以帮我们屏蔽细节,只需要关注我们的目标
  • 使代码更简洁

举例:比如我们现在需要去遍历一个数组,按照面向过程的方式时我们需要使用一个for循环,定义一个循环变量,判断循环条件等操作;如果此时我们使用高阶函数对遍历这个步骤进行抽象,如上边实现的forEach函数,我们此时只需要知道forEach内部帮我们实现了循环,然后传递数据给forEach。(前边的filter函数也是如此)

编写一个自己的高阶函数

当我们玩了很多ES6自带的高阶函数后,就可以升级到自己写高阶函数的阶段了,比如说用函数式的方式写一个节流函数, 节流函数说白了,就是一个控制事件触发频率的函数,以前可以一秒内,无限次触发,现在限制成500毫秒触发一次

function throttle(fn, wait=500) {
    if (typeof fn != "function") {
        // 必须传入函数
        throw new TypeError("Expected a function")
    }
    
    // 定时器
    let timer,
    // 是否是第一次调用
    firstTime = true;
    
    // 这里不能用箭头函数,是为了绑定上下文
    return function (...args) {
        // 第一次
        if (firstTime) {
            firstTime = false;
            fn.apply(this,args);
        }
        
        if (timer) {
            return;
        }else {
            timer = setTimeout(() => {
                clearTimeout(timer);
                timer = null;
                fn.apply(this, args);
            },wait)
        }

    }
}

// 单独使用,限制快速连续不停的点击,按钮只会有规律的每500ms点击有效
button.addEventListener('click', throttle(() => {
    console.log('hhh')
}))
常用的高阶函数
  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort ......

以及现在常用的redux中的connect方法也是高阶函数。

拿map展示下

var pow = function square(x) {
    return x * x;
};

var array = [1, 2, 3, 4, 5, 6, 7, 8];
var newArr = array.map(pow); //直接传入一个函数方法
console.log(newArr); //  [1, 4, 9, 16, 25, 36, 49, 64]
console.log(array);  // [1, 2, 3, 4, 5, 6, 7, 8]

map实现

var arr = [1,2,3];

var fn = function(item,index,arr){
  console.log(index);
  console.log(arr);
  return item*2;
};

Array.prototype.maps = function(fn) {
  let newArr = [];
  for (let index = 0; index < this.length; index++) {
    newArr.push(fn.call(this,this[index],index,this));
  }
  return newArr;
}

var result = arr.maps(fn);
4.3 函数柯里化(Currying)

函数柯里化是函数式编程中高阶函数的一个重要用法

柯里化是指这样一个函数(假设叫做createCurry),他接收函数A作为参数,运行后能够返回一个新的函数。并且这个新的函数能够处理函数A的剩余参数。

这样的定义不太好理解,我们可以通过下面的例子配合解释。

有一个接收三个参数的函数A。

function A(a, b, c) {
    // do something
}

假如,我们有一个已经封装好了的柯里化通用函数createCurry。他接收bar作为参数,能够将A转化为柯里化函数,返回结果就是这个被转化之后的函数。

var _A = createCurry(A);

那么_A作为createCurry运行的返回函数,他能够处理A的剩余参数。因此下面的运行结果都是等价的。

_A(1, 2, 3);
_A(1, 2)(3);
_A(1)(2, 3);
_A(1)(2)(3);
A(1, 2, 3);

函数A被createCurry转化之后得到柯里化函数_A,_A能够处理A的所有剩余参数。因此柯里化也被称为部分求值。

在简单的场景下,可以不用借助柯里化通用式来转化得到柯里化函数,我们凭借眼力自己封装。

例如有一个简单的加法函数,他能够将自身的三个参数加起来并返回计算结果。

function add(a, b, c) {
    return a + b + c;
}

那么add函数的柯里化函数_add则可以如下:

function _add(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        }
    }
}

下面的运算方式是等价的。

add(1, 2, 3);
_add(1)(2)(3);

当然,靠眼力封装的柯里化函数自由度偏低,柯里化通用式具备更加强大的能力。因此我们需要知道如何去封装这样一个柯里化的通用式。

首先通过_add可以看出,柯里化函数的运行过程其实是一个参数的收集过程,我们将每一次传入的参数收集起来,并在最里层里面处理。在实现createCurry时,可以借助这个思路来进行封装。

封装如下:

function add() {
    var _args = [].slice.call(arguments);
    // 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
    var adder = function() {
      // [].push.apply(_args, [].slice.call(arguments));
        _args.push(...arguments);
        return adder;
    };
    // 利用隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
    adder.toString = function() {
        return _args.reduce(function(a, b) {
            return a + b;
        });
    }
    return adder;
}
var a = add(1)(2)(3)(4);   // f 10
var b = add(1, 2, 3, 4);   // f 10
var c = add(1, 2)(3, 4);   // f 10
var d = add(1, 2, 3)(4);   // f 10

// 可以利用隐式转换的特性参与计算
console.log(a + 10); // 20
console.log(b + 20); // 30
console.log(c + 30); // 40
console.log(d + 40); // 50

// 也可以继续传入参数,得到的结果再次利用隐式转换参与计算
console.log(a(10) + 100);  // 120
console.log(b(10) + 100);  // 120
console.log(c(10) + 100);  // 120
console.log(d(10) + 100);  // 120
4.4 函数合成(compose)

函数合成指的是将代表各个动作的多个函数合并成一个函数。 上面讲到,函数式编程是对过程的抽象,关注的是动作。以上面计算的例子为例,我们关注的是它的动作,先「加上 4」再「乘以 4」。那么我们的代码实现如下:

function add4(x) {
    return x + 4
}
function multiply4(x) {
    return x * 4
}

console.log(multiply4(add4(1)))  // 20

根据函数合成的定义,我们能够将上述代表两个动作的两个函数的合成一个函数。我们将合成的动作抽象为一个函数 compose,这里可以比较容易地知道,函数 compose 的代码如下:

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

所以我们可以通过如下的方式得到合成函数

var calculate=compose(multiply4,add4);  //执行动作的顺序是从右往左

console.log(calculate(1))  // 20

可以看到,只要往 compose 函数中传入代表各个动作的函数,我们便能得到最终的合成函数。但上述 compose 函数的局限性是只能够合成两个函数,如果需要合成的函数不止两个呢,所以我们需要一个通用的 compose 函数。 这里我直接给出通用 compose 函数的代码:

function compose(...args) {
    return function(x) {
        var composeFun = args.reduceRight(function(funLeft, funRight) {
            console.log(funLeft);
            return funRight(funLeft)
        }, x);
        return composeFun;
    }
}

让我们来实践下上述通用的 compose 函数~

function addHello(str){
    return 'hello '+str;
}
function toUpperCase(str) {
    return str.toUpperCase();
}
function reverse(str){
    return str.split('').reverse().join('');
}

var composeFn=compose(reverse,toUpperCase,addHello);

console.log(composeFn('ttsy'));  // YSTT OLLEH

上述过程有三个动作,「hello」、「转换大写」、「反转」,可以看到通过 compose 将上述三个动作代表的函数合并成了一个,最终输出了正确的结果。

结合使用

嗯,到了这里,已经初步了解了函数式编程的概念了,那么我们怎么使用函数式编程的方式写代码呢,举个例子: // 伪代码,思路 // 比如说,我们请求后台拿到了一个数据,然后我们需要筛选几次这个数据, 取出里面的一部分,并且排序

// 数据
const res = {
    status: 200,
    data: [
        {
            id: xxx,
            name: xxx,
            time: xxx,
            content: xxx,
            created: xxx
        },
        ...
    ]
}

// 封装的请求函数
const http = xxx;

// '传统写法是这样的'
http.post
    .then(res => 拿到数据)
    .then(res => 做出筛选)
    .then(res => 做出筛选)
    .then(res => 取出一部分)
    .then(res => 排序)
    
// '函数式编程是这样的'
// 声明一个筛选函数
const a = curry()
// 声明一个取出函数
const b = curry()
// 声明一个排序函数
const c = curry()
// 组合起来
const shout = compose(c, b, a)
// 使用
shout(http.post)

如何在项目中正式使用函数式编程 我觉得,想要在项目里面正式使用函数式编程有这样几个步骤:

  • 1、先尝试使用ES6自带的高阶函数
  • 2、熟悉了ES6自带的高阶函数后,可以自己尝试写几个高阶函数
  • 3、在这个过程中,尽量使用纯函数编写代码
  • 4、对函数式编程有所了解之后,尝试使用类似ramda的库来编写代码
  • 5、在使用ramda的过程中,可以尝试研究它的源代码
  • 6、尝试编写自己的库,柯里化函数,组合函数等

当然了,这个只是我自己的理解,我在实际项目中也没有完全的使用函数式编程开发,我的开发原则是:

不要为了函数式而选择函数式编程。如果函数式编程能够帮助你,能够提升项目的效率,质量,可以使用;如果不能,那么不用;如果对函数式编程还不太熟,比如我这样的,偶尔使用

扩展

函数式编程是在范畴论的基础上发展而来的,而关于函数式编程和范畴论的关系,阮一峰大佬给出了一个很好的说明,在这里复制粘贴下他的文章

本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行 列式是同一类东西,都是数学方法,只是碰巧它能用来写程序

所以,你明白了吗,为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是求值,不做其他事情,否则就无法满足函数运算法则了。