1. 概述

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

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

需要注意的是函数式编程并不是用函数来编程,也不是传统的面向过程编程,目的是将复杂的函数复合成简单的函数,运算过程尽量写成一系列嵌套的函数调用。将复杂的函数复合成简单的函数就是将一个复杂的函数按功能拆分成多个简单的函数,然后进行组合完成原本的功能。

React的高阶组件就是使用了高阶函数来实现的,而高阶函数正是函数式编程的一个特性。当然了虽然React当中使用了一些函数式编程的特性,但它并不是纯函数式的。另外React的一些生态,比如Redux也使用了函数式编程的一些思想,所以想要更好的学习ReactRedux就需要了解函数式编程。

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

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

用函数式编程有很多的好处,比如说打包的时候可以更好的利用tree-shaking来过滤无用的代码。使用函数式编程还可以方便测试,方便并行处理,这些都是函数式编程的特性决定的。

2. 函数式编程的概念

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

只要彼此之间存在某种关系都构成范畴,任何事物只要找出他们之间的关系,就可以被定义。比如说教室中上课的人,可以彼此都不认识,但是大家的关系是同学,就是一个范畴。关系一般用箭头来表示,正式的名称叫做态射 。范畴论认为,同一个范畴的所有成员,就是不同状态的变形。通过态射一个成员就可以变形成另一个成员。简单来说就是每个成员之间都是有关系的。

函数式编程英文的叫法是Functional Programming 缩写是FP。他是一种编程范式,可以认为是一种编程的风格,和面向对象是并列的关系。函数式编程还可以认为是一种思维的模式。函数式编程的思维方式是把现实世界中的事物和事物之间的联系抽象到程序世界中。

首先来解释一下程序的本质,就是根据输入然后根据某种运算获得相应的输出,程序在开发的过程中会涉及到很多输入和输出的函数,函数式编程就是对这些运算过程进行抽象。假设这里有个输入x,可以通过映射关系变成y,映射关系就是函数式编程中的函数。

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

在函数式编程中要求相同的输入始终要得到相同的输出,这也是纯函数的概念。函数式编程就是对运算过程的抽象,下面用一段代码来体会一下函数式编程。

比如要计算两个数字的和,并且打印这个结果,一般会定义两个变量num1num2,然后将这个两个变量相加,最后打印结果。

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

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

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

使用函数式编程的时候一定会有一些函数,这些函数后续可以无数次的重用,函数式编程的好处就是可以让代码进行重用,而且在编程的过程中抽象出来的函数都是细粒度的,将来可以重新组合成功能更强大的函数。再次强调函数式编程不是写几个函数,他是用数学的思维方式借助javascript的语法进行一些代码的开发,所以他是一套数学的规律。

函数式编程的时候是不可以用if的也没有else,因为数学中不存在ifelse,也没有变量while,一切都是数学的思维,通过javascript的语法来承接。当然他可以使用递归,因为递归本就是数学的概念。

3. 函数是一等公民

所谓一等公民,指的是函数与其它数据类型一样处于平等地位,可以赋值给其它变量,可以当做参数,也可以作为返回值使用。在函数式编程中,变量是不能被修改的,所有的变量只能被赋值一次,所有的值全都靠传参来解决。

简单来说就是,函数是一等公民,可以赋值给变量,可以当做参数传递,可以作为返回值。在函数式编程中,只能使用表达式,不能使用语句,因为数学里面没有语句。因为变量只能被赋值一次,不能修改变量的值,所以不存在副作用,也不能修改状态。函数之间运行全靠参数传递,而且参数是不会被修改的,这样的好处是引用比较透明。

4. 高阶函数

如果函数A可以接收另一个函数B作为参数,那么函数A就称之为高阶函数。说简单一点就是参数列表中包含函数。函数式编程的思想是对运算过程进行抽象,也就是把运算过程抽象成函数,然后在任何地方都可以去重用这些函数。

抽象可以屏蔽实现的细节,在调用这些函数的时候只需要关注目标,高阶函数就是抽象通用的问题。举个例子,比如说想遍历打印数组中的每个元素,如果使用面向过程编程代码如下。

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

可以发现要写一个循环来做这样一件事,要关注数组的长度,要控制变量不能大于数组长度,要关心很多额外的东西,但是根本的目的只是循环打印数组元素。拿Array.prototype.forEach来说,在使用的时候完全不需要关注循环的具体实现,也不需要控制变量,只需要知道forEach函数可以完成循环就行了。

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

forEach就是对通用问题的一个抽象,可以看到使用forEach要比for循环简洁很多,所以使用函数式编程的一个好处就是使代码更简洁。在javascript中,数组的forEachmapfiltereverysomefindfindIndexreducesort等都是高阶函数,因为他们都可以接收一个函数为参数。

5. 闭包

闭包的概念并不复杂,但是他的定义比较绕(函数和其周围的状态的引用捆绑在一起,可以在另一个作用域中调用这个函数内部的函数并访问到该函数作用域中的成员),通过一段代码来体会闭包的概念。

首先定义一个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函数只会执行一次,这就需要有一个标记来记录这个函数是否被执行了,这里定义一个局部变量done,默认情况下是false,也就是fn并没有被执行。

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

当调用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);

6. 纯函数的概念

函数式编程中的函数指的是纯函数,纯函数的概念就是对函数来说,使用相同的输入始终会得到相同的输出,而且没有可观察到的副作用。关于副作用我们后面在解释。这里我们只讨论相同的输入始终得到相同的输出。纯函数其实就是数学中函数的概念,他是用来描述输入和输出的映射关系y=f(x);

这里通过数组的两个方法slicesplice演示一下纯函数和不纯的函数。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));

在函数是编程中,不会保留中间计算的结果,所以就认为他的变量是不可变的,也就是无状态的。基于函数式编程的过程中经常需要一些细粒度的纯函数,可以把函数的执行结果传递给另一个函数进行处理,这也就是函数组合。

7. 纯函数的优点

纯函数的第一个好处是可缓存,因为纯函数对相同的输入始终会有相同的输出,所以可以把纯函数的结果进行缓存,从而来提高程序的性能。

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];
    }
}

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

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

另外纯函数还方便并行处理,因为在多线程环境下并行操作共享的内存数据很可能会出现意外情况,假设多个线程同时修改一个全局变量,并且每个线程修改后的值都不同,那这个变量的值最终是没办法确定的。纯函数就不会有这样的问题,因为他只依赖参数,他不能访问共享的内存数据,也就是自己作用域外的数据,所以在并行环境下可以任意运行纯函数。在以前这和javascript基本上是没关系的,因为javascript是单线程的,但是在ES6之后,javascript新增了Web Worker, 可以开启多线程,这就需要考虑了。

8. 副作用

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

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

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

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

副作用让一个函数变得不纯,这里mini变量带来了副作用,除了全局变量,副作用的来源还有配置文件,很多时候都有可能会从配置文件中获取信息。还有数据库和获取用户输入等等,这些都会带来副作用。

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

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

9. 柯里化

将上面代码中不纯的函数变成纯函数。就是将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记录到了函数中。

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

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

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

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

上面的代码并不够通用,这里来一下lodash中提供的通用柯里化方法curry,参数是一个函数,返回值是柯里化之后的函数。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);
curried(1)(2)(3);

通过柯里化后的函数使用起来非常方便,可以传递一个参数,也可以传入多个参数。可以来模拟一下lodash中柯里化的实现,加深一下了解。

首先调用curry方法的时候,需要传入一个纯函数参数,调用完成之后会返回一个新的函数,这个函数就是柯里化之后的函数。返回的柯里化函数在执行的时候,可以传递全部参数,也可以传递部分参数,当传递全部参数的时候,函数就要立即执行,当传递是部分参数的时候,会返回一个新的函数,然后等待接收剩余的参数。

首先这里传递的参数是不固定的,所以在函数的内部要判断传入的参数和形参的个数是否相同。可以通过ES6reset剩余参数来实现。然后把形参个数和实参个数进行对比,判断是否相同。

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

            }
        }
    }
}

当传入部分参数的时候,需要将当前传入的参数和之前传入的参数合并到一起,然后与原函数的参数进行对比。新传入的参数用...newArgs获取,以前传入的参数在...args中。可以将argsnewArgs进行合并,然后手动调用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));
            }
        }
    }
}

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

柯里化的内部使用了闭包对参数进行了缓存。柯里化可以让函数变得更灵活,可以生成一些粒度更小的函数。使用柯里化可以把多元函数转化成一元的函数,在后面通过组合的方式可以把一元函数组合成功能更强大的函数。

10. 函数组合概念

使用纯函数和柯里化很容易写出洋葱代码h(g(f(x))),也就是一层包一层的代码,比如要获取数组的最后一个元素,然后转换成大写字母。可以先调用数组对象的reverse方法反转数组,然后调用first方法获取数组第一个元素,再调用toUpper方法将获取的第一个元素转为大写。可以发现这些方法的调用就是一层包一层的,这就是洋葱代码。

const _ from 'lodash';

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

函数组合可以避免这样的代码出现,他可以把细粒度的函数重新组合生成一个新的函数,也就是将多个函数组合成一个新的函数。

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

函数组合的概念是如果一个函数要经过多个函数处理才能得到最终的值,这个时候可以把中间这些过程函数合并成一个新的函数。函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。

函数组合默认情况是从右到左执行的,比如下面的代码,将f1f2f3组合,当调用fn的时候,会先执行f3,再执行f2,最后执行f1

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

首先组合函数需要接收多个函数作为参数,组合函数还要返回一个新的函数,并且返回的这个函数要能接收一个参数。

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是数组方法他接收一个函数作为参数会去遍历数组,传入的函数会接收两个参数,第一个参数是前一次循环中的返回值,第二个参数是当前遍历到的数组中的值。

当第一个函数执行的时候,给函数传入value作为参数,然后将执行结果返回,第二个函数执行的时候,可以拿到第一个函数的执行结果acc,然后当做第二个函数的参数传入进去,以此类推。

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

对代码进行改造,用剪头函数从新整理一下。

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

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

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

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

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

通过案例来演示一下, 使用lodashflowRight组合函数,将toUpperfirstreverse进行组合。功能是获取数组最后一个元素,并且大写。

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

可以先去组合前两个,然后再去组合第三个函数。

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

结合后两个函数,在于第一个函数进行组合。

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

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

12. 函数组合的调试

使用函数组合的时候,如果执行的结果跟我们预期的不一致,比如说下面的代码,当想知道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

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

13. PointFree

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

整个这句话比较绕口,可以把这句话提炼成三点,首先不需要指明处理的数据,第二点只需要合成运算的过程,最后在合成运算的时候需要一些辅助的基本运算函数。

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

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

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

通过案例演示一下非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; // 字符串替换的方法

导入lodashfp模块,先定义一个f等于fp中的flowRight组合函数。在函数组合的时候,首先要处理的是转换小写的运算,传入fp.toLower

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

使用fp.replace替换,因为flowRight是从右向左执行的,所以要写在fp.toLower前面。fp.replace方法有三个参数, 第一个是被替换的值,可以是正则表达式,第二个是替换成的内容,第三个是要处理的字符串。

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

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

f('Hello World'); // hello_world

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

14. Functor

Functor翻译过来称为函子,在了解函子之前先了解一下容器,容器包含值和值的变形关系也就是函数,所以容器是包含值和处理值的函数。

函子是一个特殊的容器,可以把函子想象成一个盒子,在这个盒子里面有一个值,并且这个盒子对外要公布一个方法,这个方法叫做mapmap方法会接收一个参数,这个参数是一个对值进行处理的函数。可以想象一下Array.prototype.map方法。

[1].map(v => { return v;})

因为函数式编程是建立在数学思想上的,纯函数其实就是数学中的函数,所以函子也是建立在数学的基础上。他是建立在数学的范畴论基础上。在学习函数式编程的过程中还没有学习如何去控制副作用,因为副作用可以让函数变得不纯,虽然副作用不好,但是又没有办法完全避免,所以应该尽可能的把副作用控制在可控的范围内。可以通过函子来控制副作用,当然除了这个之外还可以通过函子控制异常,进行异步操作等,这就是函子的具体作用。

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

class Container {
    map () {
    }
}

创建函子的时候函子内部要有一个值,在构造函数中把值传递进来叫做value,函子内部需要将这个值存储起来,注意这个值是函子内部维护的,只有他自己知道并不对外公布。可以约定所有以下划线开始的成员都是私有成员。

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

还要对外公布一个map方法作用是接收处理值的函数,这个函数需要是一个纯函数,因为把值传递给这个函数。map返回一个新的容器,也就是一个新的函子new Container。在返回新的函子的时候,要把处理的值传递给Container

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

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

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

可以创建一个Container函子,传入一个5, 接着要处理函子内部的值可以调用map方法传入一个函数,这个函数要接收一个参数,假设值加1

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

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

console.log(r);

这里的r是一个Container对象,对象里面的_value6map方法返回的最终不是值,而是一个新的函子对象,在新的函子对象里面去保存新的值,始终不把值对外公布,想要处理值的话,就给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方法执行完成之后,他要返回一个包含新值的盒子,也就是一个新的函子,可以通过.map进行链式调用。

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

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

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

15. MayBe函子

MayBe意思为可能,可能会是…的情况,可以通过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。可以写一个辅助的函数用来判断当前的值是否为空。

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的函子。

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方法的时候,哪一次出现了空值是不太明确的。

16. Either函子

Either相当于if else的处理过程。之前在使用MayBe函子的时候,当传入null的时候,不会去处理外部的函数fn,仅仅返回一个值为null的函子,但是不会给出任何有效的信息,并不会显示哪里出了问题,出了什么问题。Either函子可以解决这个问题,当出现问题的时候Either会给出有效的提示信息。

Either函子是二选一,需要定义两种类型LeftRight,在两个类中分别定义静态的of方法、构造函数和map方法。Leftmap方法比较特殊,直接返回thisRightmap方法与之前保持一致。

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方法,constructormap。这样就可以继承之前的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返回的是传入的值。Left当中的map方法直接返回this,并没有调用传入的fn。可以在Left中嵌入一个错误消息,我们把JSON字符串转换成JSON对象。

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

可以在tryreturn一个函子,把转换后的结果交给这个函子,将来在这个函子内部去处理Right.ofRight.of创建的函子,调用map方法的时候传入的函数会去处理传入的值。如果出现错误就在catch中返回一个值,因为纯函数需要有输出,这个时候也是要返回一个函子,Either中的Left用于处理异常。

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

17. IO函子

IO函子也就是输入输出的函子,和之前函子不同的地方在于他内部的value始终是一个函数。IO函子就是把不纯的操作都存储在value中,在函子内部并没有调用这个函数,IO函子实际是延迟执行了这些不纯的操作,也就相当于惰性执行。因为IO函子中存储的函数有可能是不纯的,但是通过IO函子包装起来的话,当前的操作就是一个纯的操作。把不纯的操作延迟到调用的时候。

有了IO函子就可以把各种不纯的操作装进笼子里,只是这些不纯的操作最终都要执行的,可以把操作交给调用者来处理。IO函子使用的时候,会先创建一个IO的类,构造函数接收一个函数,这个函数中返回数据,这和之前是不一样的,of方法中也和之前不一样接收的是数据,在of方法里面返回IO函子。

IO函子最终还是想要返回的数据,只不过通过函数把值包起来了,IO函子的value保存的就是这个函数。这个函数返回的是一个值,他把求值的过程做了延迟处理,当想要这个值的时候调用IO函子的value函数。

map方法里面通过调用IO的构造函数创建IO函子,参数里面调用flowRightfnvalue组合起来,最终得到新的函数传递给构造函数得到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));
    }
}

调用IOof方法返回一个函子,of方法接收一个值可以传入一个process。调用map方法来获取process中的exePath,就是当前node环境中进程的执行路径。

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

console.log(r);

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

可以发现返回的是一个IO函子,这个IO函子中的value保存的是函数function,这个function就是当前函子的value也就是组合之后的函数。

想要获取执行结果,需要调用IO函子中的函数r._value()

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

console.log(r._value());

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

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

18. Monad函子

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的函子,所以这里拿到的是嵌套函子。

console.log(r._value());

当执行_value的时候得到的是readFile函数返回的IO函子, 因为readFile返回值会传递给print函数。想要拿到文件的结果还需要再调用一次_value方法,这个方法才是readFile中的_value。虽然获取到了文件内容但是问题是在调用嵌套函子的时候非常的不方便,需要._value()._value()看起来很怪异。

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

Monad是可以变扁的Pointed函子,就是解决函子嵌套的问题。之前学过,如果函数嵌套的话,可以使用函数组合来解决这个问题,如果函子嵌套就可以使用Monad。如果一个函子同时具有joinof两个方法,并且遵守一些定律的话,就是一个Monad

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();
    }
}

map在组合这个函数的时候最终会返回一个函子,需要调用join把他变扁。flatMap的作用就是同时调用mapjoinflapMap执行完成之后,调用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();
    }
}
let r = readFile('package.json');

调用readFile的时候会生成一个函子,这个函子包裹了读文件的操作,然后将读文件的操作和打印的操作合并起来。调用map还是flatMap取决于要合并的函数返回的是值还是函子,如果是值就调用map,函子就调用flatMap

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

调用readFile会返回IO函子,里面封装了一个读取文件的函数,接下来调用flatMap传入printflatMap里面调用了this.map,将printvalue合并返回新的函子。这个函子包裹的函数最终返回的还是一个函子,接着调用join也就是调用返回这个函子的value

flatMap返回的是print的函子,想要获取print的文件内容,再调用join就可以了因为join就是在调用内部的value

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

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

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

19. Task函子

Task函子可以控制副作用进行异常处理,还可以处理异步任务,使用Task函子可以避免出现回调的嵌套,因为异步任务的实现过于复杂,使用folktale库中提供的Task函子来进行演示。

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

先来演示一下folktale中的composecurry如何使用。

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

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

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

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

compose是函数组合的意思,这里使用lodash的函数,把数组中的第一个元素取出来,并且转换成大写。compose函数和lodash中的flowRight用法是一样的。

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

let f = compose(toUpper, first)

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

folktale2.x中的Taskfolktale1.x中的Task使用方式区别很大,1.x中的用法更接近现在使用的函子,这里以2.x来演示。无非就是api的不同,可以通过查阅文档来了解使用。通过读取文件来演示异步任务。

task是一个函数形式,这个函数会返回一个函子对象,在1.x中提供的是一个类。接着写一个读取文件的函数readFile, 这个函数接收一个文件路径参数,返回一个task函子。task这个函数本身需要接收一个函数,而这个函数的参数是固定的,叫做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);
    }
})

20. Pointed函子

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

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

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

21. 总结

  1. 认识函数式编程

函数式编程是一种编程范式,或者说编程思想,他和面向对象编程是同一级别的,不需要把所有东西都用函数式来写,因为这看起来太困难了。函数式编程的核心思想是把运算过程进行抽象成函数,在编程的过程中是面向函数进行编程的。

  1. 函数相关复习

函数是一等公民指的是,函数也是对象所以可以把函数像值一样去处理,函数也可以作为另一个函数的参数,或者返回值。高阶函数其实就是把函数作为参数或者把函数作为返回值,在使用柯里化或者函数组合的时候其实就是基于高阶函数的,至于闭包他是无处不在的。

  1. 函数式编程基础

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

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

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

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

  1. 函子

函子可以帮助控制副作用,进行异常处理或者异步操作等等,函子的概念非常简单,可以把函子想象成一个盒子。这个盒子里面包裹着一个值,想要对这个值进行处理需要调用这个盒子提供的map方法接收一个函数类型的参数,传递的这个函数,就是去处理值的这个函数。

MayBe函子的作用是处理空值的异常,想要对异常进行处理的话,创建了Either函子这些函子内部的value都是保存一个值。

IO函子的value里面存储的是一个函数,使用IO函子可以延迟执行一个函数控制副作用。

转载须知

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

转自:【致前端 - zhiqianduan.com】 函数式编程范式  "隐冬"
请输入评论...