Js中函数式编程的理解

函数式编程的理解

函数式编程是一种编程范式,可以理解为是利用函数把运算过程封装起来,通过组合各种函数来计算结果。函数式编程与命令式编程最大的不同其实在于,函数式编程关心数据的映射,命令式编程关心解决问题的步骤。

描述

到近些年,函数式以其优雅,简单的特点开始重新风靡整个编程界,主流语言在设计的时候无一例外都会更多的参考函数式特性Lambda表达式、原生支持mapreduce...Java8开始支持函数式编程等等。
在前端领域,我们同样能看到很多函数式编程的影子,ES6中加入了箭头函数,Redux引入Elm思路降低Flux的复杂性,React16.6开始推出React.memo(),使得pure functional components成为可能,16.8开始主推Hook,建议使用pure function进行组件编写等等。

实例

实际上这个概念还是比较抽象的,直接来举一个例子说明,假设我们有一个需求,对数据结构进行更改。

["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, n = arr.length; i < n ; i++) {
  let name = arr[i];
  let names = name.split("-");
  let newName = [];
  for (let j = 0, neamLen = names.length; j < neamLen; j++) {
    let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
    newName.push(nameItem);
  }
  newArr.push({ name : newName.join(" ") });
}
console.log(newArr);

/*
[
  { name: 'John Reese' },
  { name: 'Harold Finch' },
  { name: 'Sameen Shaw' }
]
*/

这样当然能完成任务,但是产生了比较多的中间变量,另外变量名比较多,有比较复杂的逻辑,假如作为一个函数并返回值来处理的话就需要从头到尾读完才能知道整体的逻辑,而且一旦出问题很难定位。
如果我们换一个思路,采用函数式编程的思想来做,我们可以先忽略其中的currycompose以及map这些函数,之后当我们实现这两个函数后会重现这个示例,当我们只是看这个编程思路,可以清晰看出,函数式编程的思维过程是完全不同的,它的着眼点是函数,而不是过程,它强调的是如何通过函数的组合变换去解决问题,而不是我通过写什么样的语句去解决问题,当你的代码越来越多的时候,这种函数的拆分和组合就会产生出强大的力量。当然下面的例子可以直接在Ramda环境跑,需要将未定义的方法都加上R.作为R对象的方法调用。

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"]);

函数式编程

根据学术上函数的定义,函数即是一种描述集合和集合之间的转换关系,输入通过函数都会返回有且只有一个输出值。 所以,函数实际上是一个关系,或者说是一种映射,而这种映射关系是可以组合的,一旦我们知道一个函数的输出类型可以匹配另一个函数的输入,那他们就可以进行组合,就例如上边的compose函数,它实际上就完成了映射关系的组合,把一个数据从String转换成了另一个String然后再转换成Object,实际上类似于数学上的复合运算g°f = g(f(x))

const convert2Obj = compose(genObj("name"), capitalizeName);

在编程世界中,我们需要处理的其实也只有数据和关系,而关系就是函数,我们所谓的编程工作也不过就是在找一种映射关系,一旦关系找到了,问题就解决了,剩下的事情,就是让数据流过这种关系,然后转换成另一个数据罢了。这其实就是一种类似于流水线的工作,把输入当做原料,把输出当做产品,数据可以不断的从一个函数的输出可以流入另一个函数输入,最后再输出结果。 所以通过这里就可以理解函数式编程其实就是强调在编程过程中把更多的关注点放在如何去构建关系,通过构建一条高效的建流水线,一次解决所有问题,而不是把精力分散在不同的加工厂中来回奔波传递数据。

相关特性

函数是一等公民

函数是一等公民First-Class Functions,这是函数式编程得以实现的前提,因为我们基本的操作都是在操作函数。这个特性意味着函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

声明式编程

声明式编程Declarative Programming,函数式编程大多时候都是在声明我需要做什么,而非怎么去做,这种编程风格称为 声明式编程,这样有个好处是代码的可读性特别高,因为声明式代码大多都是接近自然语言的,同时它解放了大量的人力,因为它不关心具体的实现,因此它可以把优化能力交给具体的实现,这也方便我们进行分工协作。SQL语句就是声明式的,你无需关心Select语句是如何实现的,不同的数据库会去实现它自己的方法并且优化。React也是声明式的,你只要描述你的UI,接下来状态变化后UI如何更新,是React在运行时帮你处理的,而不是靠你自己去渲染和优化diff算法。

无状态和数据不可变

无状态和数据不可变Statelessness and Immutable data,是函数式编程的核心概念,为了实现这个目标,函数式编程提出函数应该具备的特性,没有副作用和纯函数。

  • 数据不可变: 它要求你所有的数据都是不可变的,这意味着如果你想修改一个对象,那你应该创建一个新的对象用来修改,而不是修改已有的对象。
  • 无状态: 主要是强调对于一个函数,不管你何时运行,它都应该像第一次运行一样,给定相同的输入,给出相同的输出,完全不依赖外部状态的变化。

没有副作用

没有副作用No Side Effects,是指在完成函数主要功能之外完成的其他副要功能,在我们函数中最主要的功能当然是根据输入返回结果,而在函数中我们最常见的副作用就是随意操纵外部变量。由于Js中对象传递的是引用地址,哪怕我们用const关键词声明对象,它依旧是可以变的。保证函数没有副作用,一来能保证数据的不可变性,二来能避免很多因为共享状态带来的问题。当你一个人维护代码时候可能还不明显,但随着项目的迭代,项目参与人数增加,大家对同一变量的依赖和引用越来越多,这种问题会越来越严重,最终可能连维护者自己都不清楚变量到底是在哪里被改变而产生Bug。传递引用一时爽,代码重构火葬场。

纯函数

纯函数pure functions,纯函数算是在没有副作用的要求上再进一步了。在Redux的三大原则中,我们看到它要求所有的修改必须使用纯函数,纯函数才是真正意义上的函数,它意味着相同的输入,永远会得到相同的输出,其实纯函数的概念很简单就是两点:

  • 不依赖外部状态(无状态):函数的的运行结果不依赖全局变量,this指针、IO操作等。
  • 没有副作用(数据不变):不修改全局变量,不修改入参。

流水线的构建

如果说函数式编程中有两种操作是必不可少的那无疑就是柯里化Currying和函数组合Compose,柯里化其实就是流水线上的加工站,函数组合就是我们的流水线,它由多个加工站组成。

柯里化

对于柯里化Currying,简单来说就是将一个多元函数,转换成一个依次调用的单元函数,也就是把一个多参数的函数转化为单参数函数的方法,函数的柯里化是用于将一个操作分成多步进行,并且可以改变函数的行为,在我的理解中柯里化实际就是实现了一个状态机,当达到指定参数时就从继续接收参数的状态转换到执行函数的状态。
简单来说,通过柯里化可以把函数调用的形式改变。

f(a,b,c) → f(a)(b)(c)

与柯里化非常相似的概念有部分函数应用Partial Function Application,这两者不是相同的,部分函数应用强调的是固定一定的参数,返回一个更小元的函数。

// 柯里化
f(a,b,c) → f(a)(b)(c)
// 部分函数调用
f(a,b,c) → f(a)(b,c) / f(a,b)(c)

柯里化强调的是生成单元函数,部分函数应用的强调的固定任意元参数,而我们平时生活中常用的其实是部分函数应用,这样的好处是可以固定参数,降低函数通用性,提高函数的适合用性,在很多库函数中curry函数都做了很多优化,已经不是纯粹的柯里化函数了,可以将其称作高级柯里化,这些版本实现可以根据你输入的参数个数,返回一个柯里化函数/结果值,即如果你给的参数个数满足了函数条件,则返回值。
实现一个简单的柯里化的函数,可以通过闭包来实现。

var add = function(x) {
  return function(y) {
    return x + y;
  }; 
};
console.log(add(1)(2)); // 3

当有多个参数时,这样显然不够优雅,于是封装一个将普通函数转变为柯里化函数的函数。

function convertToCurry(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return convertToCurry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

var funct = (x, y, z) => x + y + z;
var addCurry = convertToCurry(funct);
var result = addCurry(1)(2)(3);
console.log(result); // 6

举一个需要正则匹配验证手机与邮箱的例子来展示柯里化的应用。

function convertToCurry(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return convertToCurry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

var check = (regex, str) =>  regex.test(str);
var checkPhone = convertToCurry(check, /^1[34578]\d{9}$/);
var checkEmail = convertToCurry(check, /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);
console.log(checkPhone("13300000000")); // true
console.log(checkPhone("13311111111")); // true
console.log(checkPhone("13322222222")); // true
console.log(checkEmail("[email protected]")); // true
console.log(checkEmail("[email protected]")); // true
console.log(checkEmail("[email protected]")); // true

高级柯里化有一个应用方面在于Thunk函数,Thunk函数是应用于编译器的传名调用实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体,这个临时函数就叫做Thunk 函数。Thunk函数将参数替换成单参数的版本,且只接受回调函数作为参数。

// 假设一个延时函数需要传递一些参数
// 通常使用的版本如下
var delayAsync = function(time, callback, ...args){
    setTimeout(() => callback(...args), time);
}

var callback = function(x, y, z){
    console.log(x, y, z);
}

delayAsync(1000, callback, 1, 2, 3);

// 使用Thunk函数

var thunk = function(time, ...args){
    return function(callback){
        setTimeout(() => callback(...args), time);
    }
}

var callback = function(x, y, z){
    console.log(x, y, z);
}

var delayAsyncThunk = thunk(1000, 1, 2, 3);
delayAsyncThunk(callback);

实现一个简单的Thunk函数转换器,对于任何函数,只要参数有回调函数,就能写成Thunk函数的形式。

const convertToThunk = (funct) => (...args) => (callback) => funct.apply(null, args);

const callback = function(x, y, z){
    console.log(x, y, z);
}

const delayAsyncThunk = convertToThunk(function(time, ...args){
    setTimeout(() => callback(...args), time);
});

thunkFunct = delayAsyncThunk(1000, 1, 2, 3);
thunkFunct(callback);

Thunk函数在ES6之前可能应用比较少,但是在ES6之后,出现了Generator函数,通过使用Thunk函数就可以可以用于Generator函数的自动流程管理。首先是关于Generator函数的基本使用,调用一个生成器函数并不会马上执行它里面的语句,而是返回一个这个生成器的迭代器iterator 对象,他是一个指向内部状态对象的指针。当这个迭代器的next()方法被首次(后续)调用时,其内的语句会执行到第一个(后续)出现yield的位置为止,yield后紧跟迭代器要返回的值,也就是指针就会从函数头部或者上一次停下来的地方开始执行到下一个yield。或者如果用的是yield*,则表示将执行权移交给另一个生成器函数(当前生成器暂停执行)。

function* f(x) {
    yield x + 10;
    yield x + 20;
    return x + 30;
}
var g = f(1);
console.log(g); // f {<suspended>}
console.log(g.next()); // {value: 11, done: false}
console.log(g.next()); // {value: 21, done: false}
console.log(g.next()); // {value: 31, done: true}
console.log(g.next()); // {value: undefined, done: true} // 可以无限next(),但是value总为undefined,done总为true

由于Generator函数能够将函数的执行暂时挂起,那么他就完全可以操作一个异步任务,当上一个任务完成之后再继续下一个任务,下面这个例子就是将一个异步任务同步化表达,当上一个延时定时器完成之后才会进行下一个定时器任务,可以通过这种方式解决一个异步嵌套的问题,例如利用回调的方式需要在一个网络请求之后加入一次回调进行下一次请求,很容易造成回调地狱,而通过Generator函数就可以解决这个问题,事实上async/await就是利用的Generator函数以及Promise实现的异步解决方案。

var it = null;

function f(){
    var rand = Math.random() * 2;
    setTimeout(function(){
        if(it) it.next(rand);
    },1000)
}

function* g(){ 
    var r1 = yield f();
    console.log(r1);
    var r2 = yield f();
    console.log(r2);
    var r3 = yield f();
    console.log(r3);
}

it = g();
it.next();

虽然上边的例子能够自动执行,但是不够方便,现在实现一个Thunk函数的自动流程管理,其自动帮我们进行回调函数的处理,只需要在Thunk函数中传递一些函数执行所需要的参数比如例子中的index,然后就可以编写Generator函数的函数体,通过左边的变量接收Thunk函数中funct执行的参数,在使用Thunk函数进行自动流程管理时,必须保证yield后是一个Thunk函数。
关于自动流程管理run函数,首先需要知道在调用next()方法时,如果传入了参数,那么这个参数会传给上一条执行的yield语句左边的变量,在这个函数中,第一次执行next时并未传递参数,而且在第一个yield上边也并不存在接收变量的语句,无需传递参数,接下来就是判断是否执行完这个生成器函数,在这里并没有执行完,那么将自定义的next函数传入res.value中,这里需要注意res.value是一个函数,可以在下边的例子中将注释的那一行执行,然后就可以看到这个值是f(funct){...},此时我们将自定义的next函数传递后,就将next的执行权限交予了f这个函数,在这个函数执行完异步任务后,会执行回调函数,在这个回调函数中会触发生成器的下一个next方法,并且这个next方法是传递了参数的,上文提到传入参数后会将其传递给上一条执行的yield语句左边的变量,那么在这一次执行中会将这个参数值传递给r1,然后在继续执行next,不断往复,直到生成器函数结束运行,这样就实现了流程的自动管理。

function thunkFunct(index){
    return function f(funct){
        var rand = Math.random() * 2;
        setTimeout(() => funct({rand:rand, index: index}), 1000)
    }
}

function* g(){ 
    var r1 = yield thunkFunct(1);
    console.log(r1.index, r1.rand);
    var r2 = yield thunkFunct(2);
    console.log(r2.index, r2.rand);
    var r3 = yield thunkFunct(3);
    console.log(r3.index, r3.rand);
}

function run(generator){
    var g = generator();

    var next = function(data){
        var res = g.next(data);
        if(res.done) return ;
        // console.log(res.value);
        res.value(next);
    }

    next();
}

run(g);

函数组合

函数组合的目的是将多个函数组合成一个函数,写一个简单的示例。

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,支持多个函数组合。

const compose = (...fns) => (...args) => fns.reduceRight((params, fn) => [fn.apply(null, [].concat(params))], args).pop();

const f = x => x + 1;
const g = x => x * 2;
const t = (x, y) => x + y;

let fgt = compose(f, g, t);
console.log(fgt(1, 2)); // 7 // 3 -> 6 -> 7

现在我们考虑一个小需求,将数组最后一个元素大写,假设logheadreversetoUpperCase函数存在,之后以命令式的写法是:

log(toUpperCase(head(reverse(arr))))

面向对象的写法:

arr.reverse()
  .head()
  .toUpperCase()
  .log()

链式调用看起来顺眼多了,通过函数组合组合的写法:

const upperLastItem = compose(log, toUpperCase, head, reverse);

这其实就是类似于所谓管道pipe的概念,在Linux命令中常会用到,类似ps grep的组合,只是管道的执行方向和compose的(从右往左组合好像刚好相反,因此很多函数库LodashRamda等中也提供了另一种组合方式pipe

const upperLastItem = R.pipe(reverse, head, toUppderCase, log);

那么最终,我们回到一开始的那个例子,将其完成为一个能跑通的示例。

const compose = (...fns) => (...args) => fns.reduceRight((params, fn) => [fn.apply(null, [].concat(params))], args).pop();

const curry = function(funct, ...args) {
    const argsLength = funct.length;
    return function(..._args) {
        _args.unshift(...args);
        if (_args.length < argsLength) return curry.call(this, funct, ..._args);
        return funct.apply(this, _args);
    }
}

const join = curry((str, arr) => arr.join(str));

const map = curry((callback, arr) => arr.map(callback));

const split = curry((gap, str) => str.split(gap));

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

const result = convertName(["john-reese", "harold-finch", "sameen-shaw"]);

console.log(result);
/*
[
  { name: 'John Reese' },
  { name: 'Harold Finch' },
  { name: 'Sameen Shaw' }
]
*/

每日一题

//github.com/WindrunnerMax/EveryDay

参考

//zhuanlan.zhihu.com/p/67624686
//juejin.cn/post/6844903936378273799
//www.ruanyifeng.com/blog/2017/02/fp-tutorial.html
//gist.github.com/riskers/637e23baeaa92c497efd52616ca83bdc
//llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch1.html
//blog.fundebug.com/2019/08/09/learn-javascript-functional-programming/