1. 概述

函数式编程比较复杂比较枯燥,但是为了了解ReactRedux,如果没有函数式编程的理论铺垫,很难学好他们。

函数式编程在js中是一个比较抽象的概念,和面向对象编程一样,是一套编程范式,你可以根据函数式编程的理论为你的代码设计这个范式。只是函数式编程要求相对较高。

2. 为什么学习函数式编程

函数式编程相对于计算机的历史来说是一个更加古老的概念,他的出现甚至早已第一台计算机的诞生。他并非设计在计算机上执行,而是在20世纪30年代引入的一套用于研究函数定义,函数应用和递归的形式系统

也就是说函数式编程已经是一个很老的概念了,那为什么我们还要学习他,其实函数式编程在以前和前端没有任何关系,也并不流行。只是因为ReactRedux将它带火了。React中出现高阶函数,高阶函数正是函数式编程的一部分。

注意:函数式编程不是用函数来编程,也不是传统的面向过程编程,主旨在于将复杂的函数复合成简单的函数,运算过程尽量写成一系列嵌套的函数调用。大家注意区分用函数编程和函数式编程是不同的。将复杂的函数复合成简单的函数就是将一个复杂的函数按功能拆分成多个简单的函数,然后进行组合完成原本的功能。

React的高阶组件就是使用了高阶函数来实现,高阶函数是函数式编程的一个特性,后面会讲到。虽然React当中使用了一些函数式编程的特性,但它并不是纯函数式的。

另外React的一些生态,比如Redux,它使用了函数式编程的一些思想,所以我们想要更好的学习ReactRedux的话,就需要了解函数式编程。

Vue3Vue2做了很大的重构,而且越来越偏向函数式,在使用Vue3的一些api的时候可以感受到。当然在Vue2的源码中也大量的使用到了高阶函数,这些流行框架都在趋向于函数式编程,甚至可以说你可以不学习这些框架,但是你不能不了解函数式编程。因为这些才是永远不变的内容。

很多同学在学习js之前可能都了解过面向对象的语言,比如说JavaC#C++等等,所以在学习js的时候也都是从面向对象开始学习的,会通过学习原型,原型链以及模拟实现继承的机制来实现面向对象的一些特性。在学习的过程中还会遇到this的各种各样问题,如果你是从函数式编程入手的完全可以抛弃掉this

用函数式编程有很多的好处,比如说打包的时候可以更好的利用tree-shaking来过滤无用的代码。

使用函数式编程还可以方便测试,方便并行处理,这些都是由函数式编程的特性来决定的。

还有很多库可以帮助进行函数式开发,比如说lodashunderscoreramda

以上就是为什么要学习函数式编程。

3. 函数式编程的概念

函数式编程是范畴轮的数学分支,是一门很复杂的数学,认为世界上所有的概念体系都可以抽象出一个范畴。范畴可以理解为群体的概念,比如一个班级中的同学,就可以理解为一个范畴。

只要彼此之间存在某种关系概念,事物,对象等等,都构成范畴,任何事物只要找出他们之间的关系,就可以被定义。比如说教室中上课的人,可以彼此都不认识,但是大家的关系是同学,所以就是一个范畴。

关系一般用箭头来表示,正式的名称叫做 态射 。范畴轮认为,同一个范畴的所有成员,就是不同状态的变形。通过态射一个成员就可以变形成另一个成员。简单来说就是每个成员之间都是有关系的。

函数式编程英文的叫法是Functional Programming 缩写是FP。函数式编程是一种编程范式,我们可以认为他是一种编程的风格,他和面向对象是并列的关系。函数式编程我们可以认为是一种思维的模式,我们常听说的编程范式,还有面向过程变成和面向对象编程。

函数式编程的思维方式,是把现实世界中的事物,和事物之间的联系,抽象到程序世界中。

我们首先来解释一下程序的本质,就是根据输入然后根据某种运算获得相应的输出,程序在开发的过程中会涉及到很多说如和输出的函数,函数式编程就是对这些运算过程进行抽象。

假设我们有个输入x,可以通过一个映射关系变成y,那这个映射关系就是函数式编程中的函数。

关于函数式编程我们要注意的是,函数式编程中的函数,不是程序中的函数或者方法,不是说我们在编程过程中使用到了函数或者方法就是函数式编程,函数式编程中的函数,值得其实是数学中的函数,数学中的函数是用来描述映射关系的,例如 y = sin(x) 这个函数,sin是用来描述x和y的关系。当x=1时y的值也就确定了,也就是说当x的值确定了y的值一定也是确定的。

在函数式编程中我们要求相同的输入始终要得到相同的输出,这是纯函数的概念。

函数式编程就是对运算过程的抽象,下面我们用一段代码来体会一下函数式编程。

比如我们要计算两个数的和,并且打印这个结果,我们一般会定义两个变量num1和num2,然后将这个两个变量想加,最后打印想加的结果。

let num1 = 2;
let num2 = 3;
let num = num1 + num2;
console.log(sum)

那这是非函数式的,如果使用函数式的思想应该像下面这样,首先我们要对运算过程抽象add函数,这个函数接收两个参数n1和n2,当这个函数执行之后会把结果返回。也就是说,函数式编程中的函数一定要有输入和输出。

function add (n1, n2) {
    return n1 + n2;
}
let sum = add(2, 3);
console.log(sum);

可以看到,当我们使用函数式编程的时候一定会有一些函数,这些函数后续可以无数次的重用,所以函数式编程的一个好处就是,可以让代码进行重用,而且在函数式编程的过程中,抽象出来的函数都是细粒度的,那这些函数将来可以重新去组合成功能更强大的函数。

函数式编程不是说写几个函数就是函数式开发,他是用数学的思维方式借助js的语法来进行一些代码的开发,所以说函数式他是一套数学的规律。

那这样他跟我们平常写代码有什么区别呢?用函数式编程的时候我们是不可以用if的,也没有else,因为数学中不存在if和else,也没有变量和while,整个都是数学的思维,然后用js的语法来承接。可以使用递归,因为递归是数学的概念。

4. 函数是一等公民

所谓一等公民,指的是函数与其它数据类型一样,处于平等地位,可以赋值给其它变量,可以作为参数,也可以作为返回值。

在函数式编程中,变量是不能被修改的,所有的变量只能被赋值一次,所有的值全都靠传参来解决。

所以简单来说就是,函数是一等公民,可以赋值给变量,可以当做参数传递,可以作为返回值。

在函数式编程中,只能用表达式,不能用语句,因为数学里面没有语句。

因为变量只能被赋值一次,不能修改变量的值,所以不存在副作用,也不能修改状态。

函数之间运行全靠参数传递,而且这个参数是不会被修改的,所以引用比较透明。

5. 高阶函数

高阶函数的定义其实很简单,就是如果一个函数A可以接收另一个函数B作为参数,那么这种函数A就称之为高阶函数。说简单一点就是参数列表中包含函数。

函数式编程的思想是对运算过程进行抽象,也就是把运算过程抽象成函数,然后在任何地方都可以去重用这些函数。

抽象可以帮我们屏蔽实现的细节,我们以后在调用这些函数的时候只需要关注我们的目标就可以了,那高阶函数就是帮我们抽象这些通用的问题。

我们举一个简单的例子,比如说我们想遍历打印数组中的每一个元素,如果我们使用面向过程编程代码如下。

可以发现我们要写一个循环来做这样一件事,我们要关注数组的长度,要控制变量不能大于数组长度,要关心很多东西。

// 面向过程方式
let array = [1, 2, 3, 4];
for (let i = 0; i < array.length; i++) {
    console.log(array[i]);
}

我们这里Array的forEach函数实现,我们在使用的时候不需要关注循环的具体实现,也就是不需要关注循环的细节,也不需要变量去控制,我们只需要知道forEach函数可以帮我们完成循环就ok了。

// 高阶函数
let array = [1, 2, 3, 4];
array.forEach(item => {
    console.log(item);
})

这里的forEach就是对通用问题的一个抽象,我们可以看到使用forEach要比for循环简洁很多,所以我们使用函数式编程还有一个好处就是使代码更简洁。

在js中,数组的forEach,map,filter,every,some,find, findIndex, reduce, sort等都是高阶函数,因为他们都可以接收一个函数为参数。

6. 闭包

函数和其周围的状态的引用捆绑在一起,可以在另一个作用域中调用这个函数内部的函数并访问到该函数作用域中的成员。

闭包的概念并不复杂,但是他的定义比较绕,我们通过一段代码来体会闭包的概念。

首先我们定义一个makeFn的函数,在这个函数中定义一个变量msg,当这个函数调用之后,msg就会被释放掉。

function makeFn () {
    let msg = 'Hello';
}

maknFn();

如果我们在makeFn中返回一个函数,在这个函数中又访问了msg,那这就是闭包。

和刚刚不一样的是,当我们调用完makeFn之后他会返回一个函数,接收的fn其实就是接收makeFn返回的函数,也就意味着外部的fn对函数内部的msg存在引用。

所以当我们调用fn的时候,也就是调用了内部函数,会访问到msg,也就是makeFn中的变量。

function makeFn () {
    let msg = 'Hello';
    return function() {
        console.log(msg);
    }
}

const fn = maknFn();

fn();

所以闭包就是在另一个作用域(这里是全局),可以调用到一个函数内部的函数(makeFn内部返回的函数),在这个函数中可以访问到这个函数(makeFn)作用域中的成员。

根据上面的描述,闭包的核心作用就是把我们makeFn中内部成员的作用范围延长了,正常情况下makeFn执行完毕之后msg会被释放掉,但是这里因为外部还在继续引用msg,所以并没有被释放。

我们接下来看下下面这个例子, 介绍一下闭包的作用。

这里有一个once函数,他的作用就是控制fn函数只会执行一次,那如何控制fn只能执行一次呢?这里就需要有一个标记来记录,这个函数是否被执行了,我们这里定义一个局部变量done,默认情况下是false,也就是fn并没有被执行。

在once函数内部返回了一个函数,在这个新返回的函数内部先去判断done,如果done为false,就把他标记为true,并且返回fn的调用。

当once被执行的时候,我们创建一个done,并且返回一个函数。这个函数我们赋值给pay。

当我们调用pay的时候,会访问到外部的done,判断done是否为false,如果是将done修改为true,并且执行fn。这样在下一次次调用pay的时候,由于done已经为true了,所以就不会再次执行了。

function once(fn) {
    let done = false;
    return function() {
        if (!done) {
            done = true;
            return fn.apply(this, arguments);
        }
    }
}

let pay = once(function(money) {
    console.log(`${money}`);
});

// 只会执行一次。
pay(1);
pay(2);

闭包的本质就是,函数在执行的时候会放到一个执行栈上执行,当函数执行完毕之后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

7. 纯函数的概念

函数式编程中的函数,指的就是纯函数,纯函数的概念就是对于一个函数来说,使用相同的输入始终会得到相同的输出,而且没有可观察到的副作用。关于副作用我们后面在解释。这里我们只讨论相同的输入始终会得到相同的输出。

纯函数其实就是数学中函数的概念,他是用来描述输入和输出的映射关系。y=f(x);

我们这里通过数组的两个方法slice和splice演示一下纯函数和不纯的函数。slice是返回数组中的指定部分,不会改变原数组,splice是对数组进行操作,会改变原数组。

我们这里调用了三次slice,注意纯函数的定义,相同的输出始终会得到相同的输出。

let array = [1, 2, 3, 4, 5, 6];

console.log(array.slice(0, 2));
console.log(array.slice(0, 2));
console.log(array.slice(0, 2));

测试发现三次打印的结果都是一样的,所以slice就是一个纯函数。接下来我们再来演示一下splice。

let array = [1, 2, 3, 4, 5, 6];

console.log(array.splice(0, 2));
console.log(array.splice(0, 2));
console.log(array.splice(0, 2));

我们发现每一次打印的结果都是不同的,因为每一次调用的时候都会修改原数组,每一次都会移除掉数组中的两个元素。这里相同的输入得到的输出是不一样的所以splice这个方法是不纯的函数。

接下啦我们自己来写一个纯函数,比如我们写一个计算两个数的和的函数。

对于纯函数来说,比如要有输入,也要有输出,我们这里多次调用,得到的结果都是相同的。

function getSum (n1, n2) {
    return n1 + n2;
}

console.log(getSum(1, 2));
console.log(getSum(1, 2));
console.log(getSum(1, 2));

在函数是编程中,不会保留中间计算的结果,所以我们就认为他的变量是不可变的,也就是无状态的。

我们在基于函数式编程的过程中我们会经常需要一些细粒度的纯函数,我们可以把一个函数的执行结果传递给另一个函数去处理,这就是函数组合。

8. 纯函数的优点

纯函数的第一个好处是可缓存,因为纯函数对相同的输入始终会有相同的输出,所以可以把纯函数的结果进行缓存。

为什么要缓存函数呢,比如说我们有个函数,执行起来特别耗时,但是这个函数需要多次调用,那每次调用这个函数的时候都需要去等一段时间,才能获取到这个结果,所以他对性能来说是有影响的,使用缓存可以很好的解决这个问题,提高程序的性能。

lodash存在一个带记忆功能的函数memoize,我们定义一个球圆面积的纯函数getArea。我们想要把这个计算结果缓存下来,就要用到memoize。这个方法会返回一个带有记忆功能的函数。

为了演示这个函数被缓存,我们可以在getArea中打印一句话,然后调用两次getAreaWithMemory。

const _ from 'lodash';

function getArea (r) {
    console.log(`getArea 执行了`);
    return Math.PI * r * r;
}

const getAreaWithMemory = _.memoize(getArea);

console.log(getAreaWithMemory(3)));
console.log(getAreaWithMemory(3))); 

可以发现,当我们第一次调用getAreaWithMemory的时候,打印了getArea中的console, 第二次调用getAreaWithMemory的时候并没有打印getArea中的console。但是两次调用getAreaWithMemory都返回了相同的结果。

这就说明函数getArea被缓存了,这里我们来模拟一下memoize内部是如何实现纯函数的缓存的。

根据memoize我们知道,这个函数执行的时候要传入一个函数f作为参数,这个f就是真实的函数,也就是上面例子中的getArea,并且返回值也是一个函数。函数的内部要存在一个对象缓存函数f执行的结果,我们可以用f函数传入的参数作为对象的键,因为用户实际调用的是返回的这个参数,所以形参应该在返回的函数中,f的执行结果作为对象的值。

在返回的函数中我们需要存储传入的参数作为键,然后判断cache中是否存在该键对应的值,如果存在,直接返回该值,如果不存在,则调用f函数,并且将执行结果存入cache再返回执行结果。

这里我们通过apply来调用函数f,因为我们并不知道有多少个参数,所以我们使用arguments参数集合,apply第二个参数可以接收一个参数集合。第一个参数是函数调用的this,这里不是主要的,我们可以写成f它自身。

function memoize (f) {
    let cache = {};
    return function () {
        let key = JSON.stringify(arguments);
        cache[key] = cache[key] || f.apply(f, arguments);
        return cache[key];
    }
}

这里其实还有一点问题的,假设缓存的值是false,0,null, undefined或者空字符串等仍然会执行原函数,不过这些暂时不在我们讨论之列,这里就不再赘述了。

到这里关于纯函数的第一个好处,可缓存,我们这里就演示完了,将来我们在写程序的时候就可以通过这种方式来提高程序的性能。

纯函数的第二个好处就是可测试,因为纯函数始终有输入和输出,而单元测试就是在断言函数的结果,所以我们所有的纯函数都是可测试的函数。

另外纯函数还方便并行处理,因为在多线程环境下并行操作共享的内存数据很可能会出现意外情况,假设多个线程同时修改一个全局变量,并且每个线程修改后的值都不同,那这个变量的值最终是没办法确定的。纯函数就不会有这样的问题,因为他只依赖参数,他不能访问共享的内存数据,也就是自己作用域外的数据,所以在并行环境下可以任意运行纯函数。

在以前这和js基本上是没关系的,因为js是单线程的,但是在ES6之后,js新增了Web Worker, 可以开启多线程,但是大多数我们使用js还是单线程的。

9. 副作用

纯函数的另一个特性是没有任何可观察的副作用,我们通过一段代码来演示什么是副作用

let mini = 18;
function checkAge (age) {
    return age >= mini;
}

checkAge(20); // true
mini = 28;
checkAge(20); // false

上面这个函数就是不纯的,因为我们知道,对于一个纯函数来说,相同的输入永远得到想用的输出,而checkAge这个函数,依赖了外部变量mini,这个变量是可能发生变化的。所以并不能保证相同的输入始终返回相同的输出,所以他是不纯的,也就是存在副作用。

副作用让一个函数变得不纯,纯函数的根据是相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用,也就是我们这个mini变量带来了副作用,让函数变得不纯。

除了全局变量,副作用的来源还有配置文件,我们有可能会从配置文件中获取信息。还有数据库和获取用户输入等等,这些都会带来副作用。

总结就是所有的外部交互都会产生副作用,副作用也会使得方法通用性下降不适合以后的扩展和重用。同时副作用也会给程序中带来一些安全隐患,比如说用户的输入可以带来攻击。

虽然副作用存在这么多问题,但是副作用是不可能完全禁止的,因为我们不可能将用户名密码等一些信息记录到代码中,这些信息还是需要放在数据库中的,我们应该尽可能的控制副作用在可控的范围内发生。

10. 柯里化

这里我们来谈论下函数式编程中另一个重要的概念,柯里化

首先,我们先通过下面的方式将上节代码中不纯的函数变成纯函数。就是将mini拿到函数内部去。

function checkAge (age) {
    let mini = 18;
    return age >= mini;
}

但是当我们把这个mini拿到函数内部的时候还有一个问题,因为这个变量的值等于一个具体的数字,就出现了硬编码,我们都知道,在写程序的时候要尽量避免硬编码。我们要解决硬编码也比较简单,只是需要把18提取到参数位置就可以了。

function checkAge (min, age) {
    return age >= min;
}

checkAge(18, 20);
checkAge(18, 21);
checkAge(18, 22);

这里我们就改造完了,我们根据输入始终会得到相同的输出,因为他不在依赖于外部的变量,并且它里面也没有硬编码。

可以发现,当我们经常使用18这个基准值的时候,这个18就会经常重复,我们想要避免这个18重复,我们可以使用闭包来解决这个问题。比如我们重新定义chekAge函数,他接收一个基准值min,返回一个函数。

返回的函数中接收一个age参数, 在函数体中我们返回age大于等于min,定义完之后,我们可以通过checkAge返回一个新的函数checkAge18,checkAge调用的时候就可以传入18。这个18就记录到了函数中。

function checkAge (min) {
    return function (age) {
        return age >= min;
    }
}
let checkAge18 = checkAge(18);

checkAge18(20);
checkAge18(21);
checkAge18(22);

这里可以发现我们在调用的时候不会让基准值重复,因为我们在第一个函数中已经确定下来了。

以上函数调用的方式就是柯里化,那我们这里简单说明一下什么是柯里化。

当我们的函数有多个参数的时候我们可以对这个函数进行改造,我们可以调用一个函数,只传递部分参数,并且让这个函数返回一个新的函数,这个新的函数去接收剩余的参数,并且返回相应的结果,这就是函数的柯里化。

上面的代码不够通用,我们这里介绍一下lodash中提供的通用柯里化方法,lodash中柯里化的方法叫做curry,这个方法的参数是一个函数,返回值是柯里化之后的函数。curry本身是一个纯函数,如果我们传入的参数是个纯函数的话,返回的函数也会是一个纯函数。

我们这里演示一下lodash中curry方法的使用。

我们这里定义一个求三个数和的函数, 柯里化可以将一个多元(参数个数)函数转换为一元函数。我们使用curried来接收柯里化之后的getSum方法。这个新得到的curried被调用时,当他判断传入的参数个数已经是需要的个数了,就会执行。我们可以一次性全部传入,也可以从前到后部分传入。当传入部分参数时,他会返回一个新的函数。

const _ = require('lodash');

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

const curried = _curry(getSum);

// curried(1, 2, 3);
// curried(1)(2, 3);
curried(1, 2)(3);

所以我们这里发现,我们通过柯里化过后的函数使用起来非常方便,他可以传递一个参数,可以传入多个参数。

下面我们来模拟一下lodash中柯里化的实现,在模拟之前我们先来回顾一下curry方法是如何调用的,当我们调用这个方法的时候,我们需要给他传入一个参数,这个参数是一个纯函数,当调用完成之后他会返回一个函数,那这个函数是柯里化之后的函数。

返回的柯里化函数在执行的时候,可以传递全部参数,也可以传递部分参数,当传递全部参数的时候,这个函数就要立即执行,当传递是部分参数的时候,会返回一个新的函数,然后等待接收剩余的参数。

这里我们知道,传递的参数是不固定的,所以我们在函数的内部就要判断一下传入的参数和形参的个数是否相同。我们可以通过ES6的reset剩余参数(..args)来实现。

然后我们需要把形参个数和实参个数进行对比,判断是否相同。实参就是args,形参可以通过函数名的长度获取,也就是func.length。

function curry (func) {
    return function curriedFn(...args) {
        if (args.length >= func.length) {
           return func(...args);
        } else {
            return function () {

            }
        }
    }
}

当传入部分参数的时候,我们需要将当前传入的参数和之前传入的参数合并到一起,然后与原函数的参数进行对比。

新传入的参数我们用…newArgs获取,以前传入的参数在…args中。这里我们可以将args和newArgs进行合并,然后手动调用curriedFn,让curriedFn帮我们判断参数是否相等的逻辑。

function curry (func) {
    return function curriedFn(...args) {
        if (args.length >= func.length) {
           return func(...args);
        } else {
            return function (...newArgs) {
                return curriedFn(...args.cancat(newArgs));
            }
        }
    }
}

这里我们就写完了,最后我们来总结一下函数的柯里化。

函数的柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数。也就是柯里化可以实现函数的参数分步传递,如果传递的参数不满足函数的参数要求,就会返回一个新的函数,可以在新的函数中继续传递后面的参数。前面传递的参数已经被记录在新函数里面了。

柯里化的内部使用了闭包对函数的参数进行了缓存,柯里化可以让函数变得更灵活,因为可以生成一些粒度更小的函数。我们这么做的目的是为了后续学习组合的时候使用。

使用柯里化可以把多元的函数转化成一元的函数,可以把这些一元函数组合成功能更强大的函数。

11. 函数组合概念

我们在使用纯函数和柯里化时很容易写出洋葱代码,h(g(f(x))),也就是一层包一层的代码,比如我们要获取数组的最后一个元素,然后在转换成大写字母。

我们可以先去调用数组对象的reverse方法反转数组,然后调用first方法获取数组第一个元素,再调用toUpper方法将获取的第一个元素转为大写。

const _ from 'lodash';

const array = ['a', 'b', 'c', 'd'];
_.toUpper(_.first(_.reverse(array)));

可以发现这些方法的调用就是一层包一层的,这就是洋葱代码,我们使用函数的组合可以避免这样的代码出现。

使用函数的组合可以把细粒度的函数重新组合生成一个新的函数。也就是将多个函数组合成一个新的函数。

比如上面的例子需要调用reverse,first,toUpper三个函数,我们可以通过组合,将这三个函数合并成一个,调用的时候仍旧传入array数组,处理的结果是不变的。函数组合其实就相当于隐藏掉了多个函数调用的中间结果,比如reverse传递给first,first传递给toUpper。

我们来看下函数组合的概念: 如果一个函数要经过多个函数处理才能得到最终的值,这个时候我们可以把中间这些过程函数合并成一个新的函数。

函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。

函数组合默认情况是从右到左执行的,比如下面的代码,我们将f1,f2,f3组合,当调用fn的时候,会先执行f3,再执行f2,最后执行f1,也就是把f3的执行结果交给f2,再把f2的执行结果交给f1。

const fn = compose(f1, f2, f3);
const b = fn(a);

接下来我们来演示一下函数的组合如何去使用,我们想要函数的组合的话,首先我们要有一个可以把多个函数组合成一个函数的函数。我们来定义一下这个函数。

首先这个函数需要接收多个函数类型的参数,我们可以通过剩余参数来写,也就是ES6的reset(…args),这个函数还要返回一个新的函数,并且我们返回的这个函数要能接收一个参数, 这个参数就是输入参数,我们叫做value。

function compose (...args) {
    return function (value) {
    }
}

注意当我们调用这个返回的函数时,会获取到我们最终的结果。所以这个函数内部应该是依次调用我们传递进来的函数,并且是从右向左执行的。

args中就是传递进来的函数,我们要对它进行一个反转,反转之后我们要依次调用里面的函数,并且前一个函数的返回值需要是下一个函数的参数。

这里我们选用数组的reduce方法, 这个方法接收一个函数作为参数,在函数中会接收两个参数,一个是前一次执行的返回值我们叫做acc,第二个数组当前的遍历值我们叫做fn。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {

        })
    }
}

这里我们acc接收的是前一次执行的返回值,那第一次执行的时候这个值是不存在的,我们可以在reduce的第二个参数位置设置这个初始值,我们这里设置为传入的value。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {

        }, value)
    }
}

如果不了解reduce的用法这里可能会有点绕,我们来简单介绍一下,reduce也是数组方法,他接收一个函数作为参数,类似于forEach的写法,他也会去遍历数组,与forEach不同的是,传入的函数会接收两个参数,第一个参数是前一次循环中的返回值,第二个参数是当前遍历到的数组中的值。

因为我们这里的函数组合正好是前一个函数的执行结果传递给后一个函数,所以选用reduce,当第一个函数执行的时候,我们给函数传入value作为参数,然后将执行结果返回,第二个函数执行的时候,我们可以拿到第一个函数的执行结果acc,然后当做第二个函数的参数传入进去,以此类推。

function compose (...args) {
    return function (value) {
        return args.reverse().reduce(function (acc, fn) => {
            return fn(acc);
        }, value)
    }
}

到这我们的组合函数就写完了,我们对这个代码进行一个改造,因为他看起来太乱了,有三个return搭在一起,我们用剪头函数从新整理一下。

const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

这样看起来就简洁多了。

12. 函数组合要满足的条件

函数组合要满足结合律,这个结合律就是数学中的结合律。

假设我们要把三个函数组合成一个函数,我们可以先去组合后两个函数,也可以先去组合前两个函数,他的结果都是一样的。这就是结合律。

比如我们在组合f,g,h这三个函数的时候,我们可以先把f和g组合成一个函数,然后再和h去组合,我们也可以把g和h组合成一个函数,然后再和f进行组合。下面这3种方式都是等效的。

let t = compose(f, g, h);
compose(compose(f, g), h) === compose(f, compose(g, h)); // true

我们通过一个案例来演示一下, 我们使用lodash的flowRight组合函数,将toUpper,first和reverse进行组合。功能是获取数组最后一个元素,并且大写。

const _ = require('lodash');
const f = _.flowRight(_.toUpper, _.first, _.reverse);
console.log(f(['a', 'b', 'c'])); // C

当我们把这三个函数组合的时候,我们可以先去组合前两个,然后再去组合第三个函数。通过flowRight

const _ = require('lodash');
const f = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse);
console.log(f(['a', 'b', 'c'])); // C

我们再去结合后两个函数,同样通过flowRight。

const _ = require('lodash');
const f = _.flowRight(_.toUpper,_.flowRight(_.first, _.reverse));
console.log(f(['a', 'b', 'c'])); // C

可以发现我们无论先结合前两个还是先结合后两个,得到的结果都是相同的,这就是结合律,和数学中的结合律是一样的。

13. 函数组合的调试

当我们使用函数组合的时候,如果我们执行的结果跟我们预期的不一致,这个时候我们应该如何调试呢?

比如说下面的代码,当我们想知道reverse执行的结果是什么时候。我们可以在reverse函数前面追加一个log函数,把他打印出来看一下。

const _ = require('lodash');

const log = (v) => { // debug函数,该函数不做任何处理,直接返回
    console.log(v); // 打印v
    return;
}
const f = _.flowRight(_.toUpper, _.first, log _.reverse);
console.log(f(['a', 'b', 'c'])); // C

我们在调试的时候可以写一个辅助函数,我们通过这个辅助函数来观察每一个中间函数的执行结果。

以上就是函数组合。

14. PointFree

PointFree是一种编程风格,他的具体实现是函数的组合,他更抽象一些。

PointFree的概念是,我们可以把数据处理的过程定义成与数据无关的合成作用,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

整个这句话比较绕口,我们可以把这句话提炼成三点。

1.第一点: 不需要指明处理的数据

2.第二点: 只需要合成运算的过程

3.第三点: 在合成运算的时候需要一些辅助的基本运算函数。

使用函数组合在处理问题的时候,其实就是一种PointFree模式,比如下面的这个案例,在这个案例中我们先把一些基本的运算合成为一个函数,而在这个过程中是没有指明要处理的数据的,这就是PointFree模式。

const _ = require('lodash');
const fp = require('lodash/fp');

const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(' '))

接下来我们通过案例来演示一下,非PointFree模式和PointFree模式。

我们先来看一下非PointFree模式,假设我们要把Hello World转换为hello_world这样的形式。

按照我们传统的思维方式,我们会先定义一个函数,来接收一个我们要处理的数据,接着我们在这个函数里面对我们的数据进行处理,得到我们想要的结果,这是非PointFree模式。

function f (word) {
    return word.toLowerCase().replace(/\s+/, '_');
}
f('Hello World')

而我们如果使用PointFree模式来解决这个问题的话,我们首先会定义一些基本的运算函数,然后把他们何成为一个新的函数,而在合成的过程中我们不需要指明我们需要处理的数据。

那我们来回顾一下函数式编程的核心,其实就是把运算过程抽象成函数。PointFree模式就是把我们抽象出来的函数再合成为一个新的函数,而这个合成的过程其实又是一个抽象的过程。在这个抽象的过程中我们依然是不需要关心数据的。

下面我们使用PointFree模式来实现一下上面的案例。

我们首先来分析一下,我们可以把这个字符串先转换成小写,然后再把空格替换成下划线,那如果中间的空格比较多,我们应该使用正则来匹配。所以在这个过程中我们要用到两个方法,一个是转换成小写的方法,一个是字符串替换的方法。

const fp = require('lodash/fp');
fp.toLower; // 转换为小写的方法
fp.replace; // 字符串替换的方法

因为我们要使用PointFree的方式来处理,所以我们可以把这两个过程合并成一个新的函数,而在这个过程中我们是不需要指明我们所需要的数据的。

我们先来导入lodash的fp模块,接着我们就要去合成函数,我们先定义一个f等于fp中的flowRight组合函数。

那在函数组合的时候,首先我们要处理的是转换小写的运算,我们传入fp.toLower

const fp = require('lodash/fp');
const f = fp.flowRight(fp.toLower);

然后我们再使用fp.replace替换,因为flowRight是从右向左执行的,所以我们要写在fp.toLower前面。

我们知道fp.replace这个方法是有三个参数的, 第一个参数是匹配的模式,也就是被替换的值,可以是正则表达式,第二个参数是替换成的内容,第三个参数是要处理的字符串。

fp中提供的方法都是已经被柯里化的,所以我们可以只传部分参数他会返回一个新的函数。

那这里我们调用fp.replace的时候就传入两个参数,第一个是匹配空白的正则表达式/\s+/g, 第二个参数是下划线_, 当我们只传两个参数的时候他会返回一个新的函数,新函数会接收要处理的数据,所以这里函数组合就写完了。

const fp = require('lodash/fp');
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower);

f('Hello World'); // hello_world

我们可以发现,当我们在函数组合的过程中,我们是不需要去指明我们要处理的数据的。

至此,PointFree的模式我们就演示完了。

15. Functor

函子的英文叫做Functor,在了解函子之前我们先来说一下容器,容器包含值和值的变形关系,变性关系指的就是函数。所以容器是包含值和处理值的函数。

其实函子就是一个特殊的容器,我们可以把函子想象成一个盒子,那这个盒子里面有一个值,并且这个盒子对外要公布一个方法,这个方法我们叫做map,map方法会去接收一个参数,这个参数是一个对值进行处理的函数。这就是函子的基本概念。

首先说一下为什么要学习函子,我们知道,函数式编程是建立在数学思想上的,比如说我们的纯函数其实就是数学中的函数,那我们要学习的函子也是建立在数学的基础上。

他是建立在数学的范畴论基础上,我们这里就不讲解什么是范畴轮了,他比较复杂一些。

那我们在学习函数式编程的过程中还是没有学习如何去控制副作用,因为副作用可以让我们函数变得不纯,虽然副作用不好,但是我们又没有办法完全避免,所以我们应该尽可能的把副作用控制在可控的范围内。

我们可以通过函子来控制副作用,当然除了这个之外我们还可以通过函子去控制异常,来进行异步操作等等。

我们这里通过代码来演示一下函子,函子是一个普通的对象,这个对象里面维护一个值,并且对外公布一个map方法,所以我们可以通过一个类来描述函子,因为函子是一个容器,我们这里类的名字叫做Container。

class Container {
    map () {
    }
}

当我们创建函子的时候函子内部要有一个值,所以在构造函数中我们要把这个值传递进来叫做value,函子内部要把这个值存储起来,注意这个值是函子内部维护的,只有他自己知道,这个值是包含在一个盒子里面,不对外公布的。

我们约定所有以下划线开始的成员都是私有成员,所以我们这里用this._value接收。

class Container {
    constructor(value) {
        this._value = value;
    }

    map () {
    }
}

我们这个盒子还要对外公布一个map方法,map方法的作用是一个接收处理值的函数,那这个函数也是一个纯函数,因为我们要把这个函数去传递给这个函数,由这个函数来处理这个值。

所以我们map接收的参数叫fn,在map方法中我们要处理这个值,并且返回一个新的容器盒子,也就是一个新的函子new Container。

那在返回新的函子的时候,我们要把处理的值传递给Container,所以是fn(this._value)

class Container {
    constructor(value) {
        this._value = value;
    }

    map (fn) {
        return new Container(fn(this._value));
    }
}

那这就是一个基本的函子, 函子里面要维护一个基本的值,这个值不对外公布,另外要对外提供一个map方法,map方法要接收一个处理值的函数,并且返回一个新的函子,新函子中的值就是处理函数处理过后的值。

接下来我们创建一个Container函子,传入一个5, 接着我们想要处理函子内部的值,我们要调用map方法,调用map方法的时候需要传入一个函数,这个函数要接收一个参数,因为他要去处理Container内部的值,假设我们要让函子内部的值加1。

map执行完返回了一个新的函子,新的函子我们仍旧可以调用他的map方法,我们可以继续处理新的函子中的值,初始的时候我们给的是5,map之后得到的值是6,我们可以继续对这个值进行map处理。

const r = new Container(5).map(x => x + 1);

console.log(r);

这里的r是一个Container对象,对象里面的_value是6。我们map方法返回的最终不是值,而是一个新的函子对象,在新的函子对象里面去保存新的值,我们始终不把值对外公布,我们想要处理值的话,就给map对象传递一个处理值的函数。

那我们每次要创建一个函子的时候,我们都要调用一个new来处理,有点不太方便,我们可以把new Container这个操作封装一下。

为了和面向对象区别开来我们不使用new来创建函子,我们可以在Container中创建一个静态的方法of,这个方法的作用就是返回一个函子对象,创建函子对象的时候需要传递一个value,所以of方法接收一个value传递给对象。

其实of方法里面就封装了new关键字,这只是为了区别面向对象,所以我们不能使用new创建对象,要通过调用of创建。

这里map方法里面我们也要把new Container替换为of,因为他是静态方法,所以直接可以通过类名调用。

class Container {
    static of (value) {
        return new Container(value);
    }

    constructor(value) {
        this._value = value;
    }

    map (fn) {
        return Container.of(fn(this._value));
    }
}

let r = Container.of(5);

注意我们r拿到的是函子对象,并不是函子里面的值,我们永远也不会去取函子里面的值,如果想要对这个值处理的话,我们就会调用map方法,如果想要打印这个值,就可以在map方法传递的函数里面打印。

函子是一个具有map方法的对象,在函子里面要维护一个值,这个值永远不对外公布,就像这个值包裹在一个盒子里面,我们想要对这个值进行处理的话,我们会调用map方法。map方法执行完毕之后会返回一个新的函子。

16. Functor总结

函数式编程的运算不直接操作值,而是由函子来完成。函子就是一个实现了map契约的对象,也就是所有函子都有一个map对象。

我们可以把函子想象成一个盒子,这个盒子里面封装了一个值,如果我们想要处理盒子中的值,那么我们就需要给盒子的map方法传递一个处理值的函数,这个函数是纯函数,他需要一个参数并且返回一个值,吧处理值的过程交给这个函数来完成。

map方法执行完成之后,他要返回一个包含新值的盒子,也就是一个新的函子,所以我们可以通过.map进行链式调用。

因为map方法始终返回的是一个函子,所有的函子都有map方法,因为我们可以把不同运算方法封装到函子中,所以我们可以引申出很多不同类型的函子,有多少运算,就有多少函子,最终可以使用不同的函子,来解决实际的问题。

上面我们写的函子存在一个问题,如果我们创建函子的时候传入了null,比如说网络请求时没有获取到数据,当我们执行map方法时,可能就会报错,这就会让我们的函数变得不纯。

因为纯函数需要有输入和输出,而当传入null的时候,函数没有输出,这个时候传入的null其实就是副作用,接下来我们要想办法去解决这个问题,也就是控制副作用。

17. MayBe函子

MayBe是可能会是,可能会是空值的情况,我们可以通过MayBe来处理,在上一小结我们使用Functor的时候,如果出现了空值,这个时候会出现异常,而MayBe函子可以帮我们去处理空值的这种情况。

我们在编程的过程中可能会遇到很多的错误,我们需要对这些错误做处理,MayBe函子的作用就是对外部空值的情况做处理。

外部传递空值我们可以认为是一种副作用,而MayBe函子可以控制这种副作用发生。下面我们来演示一下MayBe函子。

我们首先创建一个MayBe的类,

class MayBe {
    static of (value) {
        return new MayBe(value)
    }
    constructor (value) {
        this._value = value;
    }
    map (fn) {
        return MayBe.of(fn(this._value)); 
    }
}

MayBe函子要去结局传入的值可能为null的情况,我们在map中处理这个值之前需要判断一下这个值是否为null或者undefined。

我们写一个辅助的函数用来判断当前的值是否为空,我们写一个isNothing方法判断this._value是否有值。

class MayBe {
    static of (value) {
        return new MayBe(value)
    }
    constructor (value) {
        this._value = value;
    }
    map (fn) {
        return MayBe.of(fn(this._value)); 
    }
    isNothing () {
        return ths._value === null || this._value === undefined;
    }
}

然后我们在map方法里面,在执行fn之前我们需要判断一下this._value是否为空,如果当前的值是空的话,我们不能去调用fn,我们应该返回一个值为null的函子,如果有值我们再调用fn,这里我们使用三元表达式。

class MayBe {
    static of (value) {
        return new MayBe(value)
    }
    constructor (value) {
        this._value = value;
    }
    map (fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value)); 
    }
    isNothing () {
        return ths._value === null || this._value === undefined;
    }
}

此时如果我们传入的是null,我们代码不会报错,而是会返回一个值为null的新的MayBe函子。

接下来我们再来看MayBe函子的一个问题,虽然我们可以处理空值的问题,但是如果多次调用map方法的时候,哪一次出现了空值,我们是不太明确的。

18. Either函子

Either单词的意思是两者中的任何一个,我们在使用Eight处理问题的时候,就相当于if else 的处理过程。

我们之前在使用MayBe函子的时候,当我们在传入null的时候,我们不会去处理外部的函数fn,仅仅返回一个值为null的函子,但是不会给出任何有效的信息,他不会告诉我们是哪里出了问题,出了什么问题。

我们可以使用Either这个函子来解决这个问题,当出现问题的时候Either会给出我们有效的提示信息。

我们一个函数中如果出现异常,会让这个函数变得不纯,那我们Either函子也可以用来处理异常,下面我们来看一下,Either函子如何实现。

我们在使用Either函子的时候,因为他是二选一,所以我们需要定义两种类型,一个是Left一个是Right,在这两个类中我们分别要去定义静态的of方法去放回当前这个对象,还有构造函数和map方法。

在Left的map方法中,这里比较特殊,直接返回了this,Right的map方法和之前保持一致。

class Left {
    static of (value) {
        return new Left(value);
    }
    constructor (value) {
        this._value = value;
    }
    map (fn) {
        return this;
    }
}

class Right {
    static of (value) {
        return new Right(value);
    }
    constructor (value) {
        this._value = value;
    }
    map (fn) {
        return Right.of(fn(this._value));
    }
}

我们观察这两个函子可以发现,他们和我们之前的函子基本上是一样的,都有of方法,都有constructor和map,其实我们在写的时候都可以继承之前的Container的,我们这里就不去继承了,方便演示。我们分别创建一个函子,打印看一下。

let r1 = Right.of(12).map( x => x + 2);
let r12 = Left.of(12).map( x => x + 2);

console.log(r1); // ...14
console.log(r2); // ...12

这里我们听过打印Left和Right创建的函子可以发现Left返回的是我们直接传入的值,没有做任何的处理。Left当中的map方法是直接返回的当前对象this,并没有调用当前传入的fn。

为什么要这么做呢,我们可以在Left中嵌入一个错误消息,下面我们演示一个可能会发生错误的函数,比如我们要去把一个JSON形式的字符串,转换成一个JSON对象。

因为调用JSON.parse的时候可能出现异常,所以我们使用try…catch。如果发生异常我们不去处理的话他不是一个纯函数,现在我们希望用函数式的方式来处理,所以我们需要些一个纯函数。

这里我们在try里需要return一个函子,我们会把我们转换后的 结果交给这个函子,将来在这个函子内部去处理,我们直接返回一个正确的值,Right.of。

我们通过Right.of创建的函子,当我们调用map方法的时候,map方法传入的这个函数会去处理我们传的这个值。这里我们传递把字符串转换成对象的值JSON.parse(str)。

如果出现错误我们也要在catch中返回一个值,因为纯函数需要有输出,这个时候我们也是要返回一个函子,Either中的Left用于处理异常。

function parseJSON (str) {
    try {
        return Right.of(JSON.parse(str));
    } cache (e) {
        return Left.of({
            error: e.message
        })
    }
}

这样我们的parseJSON就写完了,这就是Either对异常的处理。

19. IO函子

我们已经对函子有一个简单的认识,我们可以把函子想象成一个盒子,盒子里保存一个值,通过调用盒子的map方法可以传入一个函数,通过这个函数对盒子里面的值进行处理。

接下来我们来学习一下IO函子,也就是输入输出的函子,他和之前函子不同的地方在于,他内部的value始终是一个函数。

IO函子就是把不纯的操作都存储在value中,value中存储的是函数,在函子内部并没有调用这个函数,通过IO函子是延迟执行了这些不纯的操作,也就相当于惰性执行。

通过IO函子先包装一些函数,当我们需要的时候,再来执行这些函数,因为IO函子中存储的函数有可能是不纯的,但是通过IO函子包装起来的话,我们当前的操作就是一个纯的操作。把不纯的操作延迟到调用的时候。

有了IO函子就可以把各种不纯的操作装进笼子里,但是这些不纯的操作,最终都要执行的,我们可以把这些不纯的操作交给调用者来处理。

使用IO函子的时候,先创建一个IO的类,构造函数接收一个函数,这和之间是不一样的,我们这里把这个函数存起来。在of方法中也和之前不一样,of方法接收的是一个数据,在of方法里面返回一个IO函子,在构造函数中传入一个函数,这个函数中返回数据。

通过of方法我们可以看出,IO函子最终还是想要返回的数据,只不过这里通过一个函数把这个值包裹起来了,IO函子的value保存的是这个函数。这个函数返回的是一个值,他把求值的过程做了延迟处理,当我们想要这个值的时候再调用IO函子的value函数。

这里的map方法和之前也是不同的,map韩式接收一个fn函数,在map方法里面通过调用IO的构造函数来创建一个IO的函子,参数里面调用了fp的flowRight将fn和value组合起来,最终得到新的函数传递给IO的构造函数,得到一个IO函子,并且返回。

const  fp = require('lodash/fp');

class IO {
    static of (x) {
        return new IO(function() {
            return x;
        })
    }
    consturctor (fn) {
        this._value = fn;
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
}

我们首先调用IO的of方法返回一个函子,of方法接收一个值,我们可以传入一个process(Node环境才可以的)。

接下来我们调用map方法,来获取process中的exePath,就是当前node环境中进程的执行路径。

let r = IO.of(process).map(p => p.execPath);

console.log(r);

可以发现我们这里返回的是一个IO函子,这个IO函子中的value保存的是函数function,我们来分析一下这个函数是谁。

当我们调用IO.of的时候我们传入了process对象,在of我们返回一个函子,并且把process包装到函数中,接着调用map方法,在map方法中调用flowRight把of中包裹process的函数组合上map传入的函数。返回一个IO函子。

这个function就是当前函子的value也就是组合之后的函数。

那接下来我们想要获取这个执行结果,想要调用IO函子中的函数,我们看到IO中的value就是一个函数,所以我们可以r._value()直接调用。

let r = IO.of(process).map(p => p.execPath);

console.log(r._value());

这里总结一下,IO函子内部包装了一些函数,我们在传递函数的时候有可能这个函数是一个不纯的操作,我们不关心这个函数是否纯净,IO函子在执行的过程中返回的结果始终是一个纯的操作。

IO中有可能包裹了一些不纯的操作,但是当前的执行始终是一个纯的操作,调用map方法的时候始终会返回一个IO函子,但是IO函子的value属性里面保存的一些函数,因为他里面最终要去合并很多函数,所以他可能是不纯的。我们将不纯的操作延迟到了调用的时候,也就是通过IO函子控制了副作用在可控的范围内发生。

20. Monad函子

Monad单词的意思是单细胞动物的意思,我们经常把他翻译成单子。

在学习Monad之前我们先来说下IO函子的一个问题,在linux系统中有个cat命令,是读取文件内容并且把他打印出来,我们写一个函数来模拟这个命令。

class IO {
    static of (x) {
        return new IO(function() {
            return x;
        })
    }
    consturctor (fn) {
        this._value = fn;
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
}

我们先来写一个读取文件的函数,再来写一个打印的函数,然后把他们组合成一个函数。

因为读取文件存在副作用,会让函数变得不纯,所以我们这里使用IO函子, 也就是我们把读取文件的过程延迟执行。

在打印函数中我们也返回IO函子,延迟执行

let readFile = function (filename) {
    return new IO(function () {
        return fs.readFileSync(filename, 'utf-8');
    })
}

let print = function (x) {
    return new IO(function () {
        console.log(x);
        return x;
    })
}

下面我们将这两个函数合并成cat。

let cat = fp.flowRight(print, readFile);

let r = cat('package.json');

console.log(r);

这里的调用之后,我们readFile返回一个IO函子,IO函子传入print函数之后,这个函数返回了一个函子,函子中的value就是readFile的函子,所以这里拿到的是嵌套函子。

下面我们去执行函子里面的函数,之前我们介绍过可以通过调用_value()执行。

console.log(r._value());

当我们执行_value的时候得到的是readFile函数返回的IO函子, 因为readFile返回值会传递给print函数。

我们现在想要拿到文件的结果,我们还需要再调用一次_value方法,这个方法才是readFile中的_value

console.log(r._value()._value());

至此我们就获取到了文件内容,但是问题是我们在调用嵌套函子的时候非常的不方便,我们需要._value()._value(),这看起来很怪异。

下面我们来介绍一下Monad来解决一下上面的问题。

Monad是可以变扁的Pointed函子,那什么是变扁呢,上面我们出现了一个问题,就是函子嵌套的话,我们调用起来会很不方便,变扁就是解决函子嵌套的问题。

之前学过,如果函数嵌套的话,可以使用函数组合来解决这个问题,如果函子嵌套就可以使用Monad。

如果一个函子同时具有join和of两个方法,并且遵守一些定律的话,就是一个Monad。

of我们很熟悉,join也不复杂,他直接就返回了我们对_value的调用。

我们将IO类改造成Monad,添加一个join方法,join方法不需要任何参数,这里只是返回_value的调用。

class IO {
    static of (x) {
        return new IO(function() {
            return x;
        })
    }
    consturctor (fn) {
        this._value = fn;
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
    join () {
        return this._value();
    }
}

接着我们再写一个flatMap方法,我们在使用Monad的时候经常会把map和join联合起来去使用,因为map的作用是把当前的函数和函子内部的value组合起来,返回一个新的函子,map在组合这个函数的时候,这个函数最终也会返回一个函子,所以我们需要调用join把他变扁,把他拍平。

flatMap的作用就是同时调用map和join,flatMap要调用map方法,map方法需要一个fn参数,所以flatMap也需要一个参数,在flapMap执行完成之后,我们要去调用join,并且把join执行的结果,也就是这个函子返回。

当我们调用map的时候,我们就把value和fn进行合并,合并之后返回一个新的函子,在这个函子包裹的函数最终也会返回一个函子,所以我们再去调用join()。

class IO {
    static of (x) {
        return new IO(function() {
            return x;
        })
    }
    consturctor (fn) {
        this._value = fn;
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value));
    }
    join () {
        return this._value();
    }
    flatMap(fn) {
        return this.map(fn).join();
    }
}

至此Monad就写完了,下面我们看下如何去使用。

let r = readFile('package.json');

当我们调用readFile的时候他会生成一个函子,这个函子包裹了我们读文件的操作,然后我们将读文件的操作和打印的操作合并起来。

我们要调用map还是flatMap取决于我们要合并的函数返回的是值还是函子,如果是指就调用map,函子就调用flatMap。

let r = readFile('package.json').flatMap(print);

我们调用完readFile会返回一个IO函子,它里面封装了一个读取文件的函数,接下来调用flatMap我们传入print,我们看下flatMap执行,当我们调用flatMap的时候传入了print,在flatMap里面调用了this.map, 我们将print和当前函子内部的value进行合并,合并之后返回了一个新的函子。

当我们调用完map之后,我们得到一个函子,并且这个函子中报国的函数最终返回的还是一个函子,接着我们调用join,他就是调用返回这个函子的value。

所以flatMap返回的就是print的函子,最后我们想要获取print的文件内容,我们再调用一下join就可以了, 因为join就是在调用内部的value。

let r = readFile('package.json').flatMap(print).join();

这里可能看起来比较麻烦,不过在实际运用中是不需要关心函子内部实现的,只需要调用函子的api实现想要的功能就可以了。

假设我们读取完文件内容,我们想把文件的字符串全部转换成大写,我们直接在readFile后面调用map方法就可以了,因为map方法作用是处理函子内部value的值。

21. Task函子

Task函子可以帮我们控制副作用进行异常处理,还可以处理异步任务,因为异步任务会带来回调地狱问题。

使用Task函子可以避免出现回调的嵌套,因为异步任务的实现过于复杂,所以这里我们使用folktale库中提供的Task函子来进行演示。

folktale是一个标准的函数式编程库, 他和lodash,ramda不同的是,它里面没有提供很多功能性的函数,他只提供了和函数式处理相关的操作,例如compose,curry等,他还提供了一些函子,例如Task,Eight,MayBe等。

我们先来演示一下folktale中的compose和curry如何使用。

const { curry } = require('folktale/core/lambda');

let f = curry(2, (x, y) => {
    return x + y;
});

console.log(f(1, 2));

这里面的curry和lodash有所不同,这里面接收两个参数,第一个参数用来指明函数参数有几个参数。文档上说这里传递第一个参数的目的是为了避免一些错误。

下面演示下compose,这是函数组合的意思,我们这里不自己写函数了,直接使用lodash的函数,我们把数组中的第一个元素取出来,并且转换成大写。

const { compose } = require('folktale/core/lambda');
const { toUpper, first } = require('lodash/fp');

let f = compose(toUpper, first)

console.log(f(['a', 'b']));

这里的compose函数和lodash中的flowRight用法是一样的。

接下来我们介绍下folktale中提供的Task函子处理异步任务。folktale2.x中的Task和folktale1.x中的Task使用方式区别很大,1.x中的用法更接近现在使用的函子,我们这里以2.x来演示。无非就是api的不同,我们可以通过查阅文档来了解使用。

我们通过读取一个文件,来演示下异步任务。具体在folktale的什么位置我们需要自己翻阅文档去了解。

这里提供的task是一个函数形式,这个函数会返回一个函子对象,在1.x中提供的是一个类。

接着我们写一个读取文件的函数readFile, 这个函数接收一个文件路径参数,返回一个task函子。

task这个函数本身需要接收一个函数,而这个函数的参数是固定的,叫做resolver,resolver是一个对象,它里面有两个方法,一个是resolve,执行成功之后调用的方法,还一个reject,执行失败之后执行的方法,他使用起来非常像Promise。

我们在这个函数中读取文件。

const { task } = require('folktale/concurrency/task');
const fs = require('fs');

function readFile(filename) {
    return task(resolver => {
        fs.readFile(filename, 'utf-8', (err, data) => {
            if (err) {
                resolver.reject(err);
            } else {
                resolver.resolve(data)
            }
        })
    })
}

当我们调用这个readFile函数的时候,他会返回一个Task函子,当我们想要读取文件的话,我们需要调用Task函子提供的run方法。

readFile('package.json').run();

我们可以通过listen方法监听文件读取状态,这里传入一个对象,对象中包括onRejected回调和onResolved回调。

readFile('package.json').run().listen({
    onRejected: err => {
        console.log(err);
    },
    onResolved: value => {
        console.log(value);
    }
})

此时我们再去执行代码,就会发现这个文件已经读取到了,我们如果想要处理拿到的值,我们可以在run之前调用一下Task函子的map方法,在map方法里面可以处理拿到的结果。这样更符合函数式编程。

在map方法里我们会去处理我们拿到这个文件的返回结果,所以我们在使用函子的时候,我们就没有必要去想它里面的实现机制了,之前是自己写函子,我们了解内部实现机制,而我们实际开发的过程中我们就直接使用。

readFile('package.json').map(value => {
    console.log(value); // 处理文件
    retrun value;
}).run().listen({
    onRejected: err => {
        console.log(err);
    },
    onResolved: value => {
        console.log(value);
    }
})

22. Pointed函子

Pointed函子指的是实现了of静态方法的函子,那我们之前所写的函子都是实现了of方法的,所以他们都是Pointed函子。

之前说of方法是为了避免使用new啦创建对象,避免我们的代码看起来很面向对象,但是of方法更深层的含义是,他是用来把值放到一个上下文中,然后在上下文中处理我们的值。(把值放到容器中,使用map来处理值)。

假设我们的值是2,我们通过of方法可以把这个值放到一个盒子里,那这个盒子我们就叫做上下文,其实就是我们的函子。

假设我们有一个Container函子,这个函子有一个of方法,他就是一个Pointed函子,of方法的作用是帮我们把值包裹到一个新的函子里面,并且返回。那我们称这个返回的结果就是上下文。

当我们调用of方法时候我们获得一个上下文,将来我们在这个上下文里面去处理这个数据。

这就是Pointed函子,他比较简单,就是一个概念而已,我们早已经在使用了。

23. 总结

到这里函数式编程我们就讲完了,下面我们来总结一下,整个函数式编程我们分为四个部分。

也就是函数式编程的概念。

函数式编程是一种编程范式,或者说编程思想,他和面向对象编程是同一级别的,我们想要掌握一门编程思想是需要花费很长时间的,我们可以把我们掌握的直接在工作中运用,不需要把所有东西都用函数式来写,因为这看起来太困难了。

函数式编程的核心思想是,把运算过程进行抽象成函数,在编程的过程中是面向函数进行编程的。

现在我们要学习函数式编程是因为像vue或者React,他们内部都已经使用了部分的函数式编程的思想,所以学习函数式编程有助于我们去使用vue或者React

函数是一等公民指的是,函数也是对象,所以我们可以把函数像值一样去处理,函数也可以作为另一个函数的参数,或者返回值。

高阶函数其实就是把函数作为参数或者把函数作为返回值,我们在使用柯里化或者函数组合的时候其实就是基于高阶函数的,至于闭包他是无处不在的。

lodash是一个函数式编程的库,它里面提供了很多函数式编程的方法,可以辅助我们开发。

纯函数指的是给一个函数输入相同的参数,总能得到相同的输出,并且没有任何的副作用,纯函数其实就是数学中的函数,可以把一个值映射成另一个值,纯函数可缓存,可测试并且方便并行的处理。

柯里化可以对函数进行降维处理,也就是我们可以把多元函数转化成一个一元函数,我们把多元函数转换成一元函数的目的是我们在函数组合的时候要去使用。

我们理解了管道之后对于我们学习函数组合是有帮助的,我们可以把一个函数想象成一个处理数据的管道,我们给这个管道输入一个数据,当这个数据经过这个管道之后会得到一个相应的结果,函数组合其实就是这样来处理的。函数组合可以把多个一元的函数组合成一个新的函数,组合成一个功能更强大的函数。

函子可以帮助我们控制副作用,进行异常处理或者异步操作等等,函子的概念非常简单,我们可以把函子想象成一个盒子。这个盒子里面包裹着一个值,我们想要对这个值进行处理的话,我们需要调用这个盒子给我们提供的map方法。

map方法接收一个函数类型的参数,我们传递的这个函数,就是去处理值的这个函数。

我们通过Functor演示了函子的基本使用,后面我们又学习了MayBe函子,MayBe函子的作用是帮我们处理空值的异常,我们想要对异常进行处理的话,我们创建了Either函子,这些函子内部的value都是保存一个值。

后面我们创建了IO函子,他的value里面存储的是一个函数,使用IO函子可以延迟执行一个函数,使用IO函子可以控制副作用。

再后面我们学习了Task,在学习Task的时候,介绍了一个函数式编程的库folktale,这个库没有提供功能性的方法,他提供的方法都是方便函数式处理的,这个库还提供了一些函子,比如说Task,Task的作用是进行异步处理,帮助处理异步任务。

Monad函子的作用是解决函子嵌套的问题,如果一个函子具有静态的of方法,并且还有一个join方法,那他就是一个Monad。

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - https://madaozhijian.com】 函数式编程范式  "隐冬"