11.纯函数及柯里化

881 阅读22分钟

JS高级-纯函数及柯里化

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列45-52集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 本章节中,我们会深入纯函数的概念以及柯里化思想当中,并从Vue3源码以及Redux源码中,去追寻其中存在的痕迹
    • 从纯函数引申出副作用的理念
    • 从柯里化引申出单一职责的思想模式
  • 通过代码操作,让我们来感受这两者的思想魅力

一、理解JavaScript纯函数(Pure Function )

  • 函数式编程中有一个非常重要的概念叫纯函数,而JavaScript符合函数式编程的范式,所以也有纯函数的概念
    • 在React开发中,纯函数会被频繁的使用,如果后续想要学习该框架,这个概念是必须要掌握的
    • 比如React中组件就被要求像是一个纯函数(为什么是像,因为还有Class组件),redux中有一个reducer的概念,也是要求必须是纯函数,如图11-1的Redux技术文档
    • 虽然Class组件不是函数,但也被要求像纯函数一样的效果。不过在如今的前端开发中,函数式编程已经是越来越主流了,和函数息息相关的纯函数概念也会越发重要
    • 所以掌握纯函数对理解很多框架的设计是非常有帮助的,这个概念会成为构建我们知识体系的一部分,在学习上面,体系越完善,就越容易从一点内容中察觉出更多信息,也更容易掌握

React中的数据不可变性

图11-1 React中的数据不可变性

  • 纯函数的维基百科定义:
    • 在程序设计中,若一个函数符合以下条件,那么这个函数就被称为纯函数
      1. 此函数在相同的输入值时,需产生相同的输出
      2. 函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关
      3. 该函数不能有语义上可观察的函数副作用,诸如"触发事件",使输出设备输出,或更改输出值以外物件的内容等
  • 当然上面的定义会过于的晦涩,所以我们可以简单总结一下:
    • 确定的输入,一定会产生确定的输出。因为不确定的输出未知性是不可控的,在编程的大部分情况当中是很难容许这种不稳定性的
    • 函数在执行过程中,不能产生副作用
    • 纯函数的纯在这里,所表达的是纯粹的意思

1.1. 副作用的理解

  • 我们刚才讨论到函数在执行中的一个概念,叫做副作用,什么叫做副作用呢?
    • 副作用(side effect)其实本身是医学的一个概念,比如我们经常说吃什么药本来是为了治病,但可能会产生一些其他的副作用
    • 在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值以外,还对调用函数产生了附加的影响,比如修改了全局变量,修改参数或者改变外部的存储
  • 纯函数在执行的过程中就是不能产生这样的副作用
    • 副作用是产生bug的温床
    • 在编程当中,在利用一些数据的时候,最好不要对这些原有数据进行变动。这种做法往往会被称为"数据的不可变性"

1.2. 纯函数的案例

我们来看一个对数组操作的两个函数:

  • slice:slice截取数组时不会对原数组进行任何操作,而是生成一个新的数组
  • splice:splice截取数组,会返回一个新的数组,也会对原数组进行修改
  • slice就是一个纯函数,不会修改传入的参数
    • 在执行的时候,也不会产生副作用(没有修改外部的变量,也没有修改传入的参数)
    • 只要是确定的输入,就会产生确定的输出
var names = ["小余",'coderwhy','JS高级']

//slice只要给它传入一个start/end,那么对于同一个数组来说,它会给我们返回确定的值
//slice函数本身是不会修改原来的数组
//slice -> this
var newNames1 = names.slice(0,2)
console.log("newNames1",newNames1);
console.log("names",names);

//splice是会修改原来的数组对象本身的,所以它不是纯函数
var newNames2 = names.splice(2)
console.log("newNames2",newNames2);
console.log("names",names);
  • 纯函数练习
    • 纯函数单从使用练习的角度来说,非常简单
    • 难点在于,理解为什么要"纯",从效益来看,是要避免"副作用"的产生,为了程序的稳定程度
    • 在简单的程序中我们可以轻松避免,但越是深入,所谓的"副作用"就越难以判断,很多方面,往往是理念的问题,功能的权衡,而非真的缺陷,这其中的体会需要我们反复咀嚼
//非纯函数,传入的值被修改了
function baz(info){
    info.age = 100
}

var obj = {name:"小余",age:23}
baz(obj)
console.log(obj)
//{name: '小余', age: 100}

//test是否是一个纯函数?是
function test(info){
    return{
        ...info,
        age:100
    }
}
test(obj )

//React的函数组件(或者类组件)
function HelloWorld(props){

}

<HelloWorld info="{}"/>

1.3. 纯函数的优势

  • 为什么纯函数在函数式编程中非常重要呢?
    • 因为我们可以安心的编写和安心的使用
    • 写的时候保证函数的纯度,只是单纯实现自己的业务逻辑即可,不需要关心传入的内容是如何获得的或者依赖其他的外部变量是否已经发生了修改
    • 用的时候,确定我们的输入内容不会被任意篡改,并且自己确定的输入,一定有确定的输出
  • React中就要求我们无论是函数还是class声明一个组件,这个组件都必须像纯函数一样,保护它们的props不被修改

React的严格规则

图11-2 React的严格规则

二、JavaScript柯里化

  • 柯里化也是属于函数式编程里面一个非常重要的概念
  • 维基百科解释:
    • 在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化
    • 是把接收多个参数的函数,变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数,而且返回结果的新函数的技术
    • 柯里化声称"如果你固定某些参数,你将得到接受余下参数的一个函数"
  • 柯里化总结
    • 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩下的参数
    • 这个过程称为柯里化
//假设我们有一个需要填入4个参数的 函数
function foo(m,n,x,y){
    
}
foo(10,20,30,40)
//柯里化的过程
//我们对其进行转化,变得只需要传入一个参数,但这里面需要返回一个函数继续处理剩下的参数
function bar(m){
    return function(n){
        return function(x,y){
            //你也可以将y参数继续return
            m+n+x+y
        }
    }
}
bar(10)(20)(30,40)
  • 我们抽取出核心的对比
    • 这个其实就像风扇调节风速档位一样,对于较为复杂的需求,我们可以分档次来进行调动
    • 这个档次的每一次调用,都是在满足前者档位之后才能往后进行。从这点来看,每个档位之间的联系是非常紧密的,有着一定的顺序
foo(10,20,30,40)//正常调用
bar(10)(20)(30,40)//柯里化调用

2.1. 柯里化的结构

  • 从这代码中,我们可以看出柯里化的基本思想是将一个多参数的函数转换成一个嵌套的单参数函数的形式。每一个函数接收一个参数并返回一个新的函数,直到所有参数都被处理完。这可以通过闭包来实现,闭包允许函数保持对它的词法作用域的访问,即使这个函数已经执行结束
2.1.1. 正常结构的多参数函数
function add(x, y, z) {
    return x + y + z;
}

var result = add(10, 20, 30);
console.log(result);
2.1.2. 柯里化的函数
  • sum 函数通过柯里化实现,它接受第一个参数 x 并返回一个新的函数,该新函数接受第二个参数 y,再返回另一个函数,这个最内层的函数接受第三个参数 z,最后返回这三个参数的和
  • 每个函数都通过闭包访问上一个函数的参数,从而做到当最终函数被调用时,它可以访问所有先前提供的参数
function sum(x) {
    return function(y) {
        return function(z) {
            return x + y + z;
        }
    }
}

var result1 = sum(10)(20)(30);
console.log(result1);
2.1.3. 使用箭头函数简化柯里化代码
  • 使用箭头函数可以大幅简化柯里化函数的写法。箭头函数隐式返回表达式的结果,省略了花括号和 return 关键字(如果直接返回一个表达式)
    • 相较于基础函数的柯里化使用,这种方式所省略的代码会更加明显
//方式1
var sum2 = x => y => z => {
    return x + y + z;
}
//方式2
var sum2 = x => y => z => x + y + z;
var result2 = sum2(20)(30)(40);
console.log(result2, "使用箭头函数简化柯里化的方式");

2.2. 柯里化的作用

那么为什么需要有柯里化呢?

  • 在函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理
  • 那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完后在下一个函数中再使用处理后的结果
  • 这个做法体现的是一个原则:单一职责
单一职责原则(SRP)
面向对象 -> 类 -> 尽量只完成一件单一的事情
2.2.1. 柯里化 - 单一职责的原则

单一职责原则(Single Responsibility Principle, SRP)是面向对象设计的五个基本原则之一,通常简称为SOLID原则中的"S"。这个原则由罗伯特·C·马丁(Robert C. Martin,也被称为“Uncle Bob”)提出的。单一职责原则指的是一个类应该仅有一个引起它变化的原因,或者更加通俗地说,一个类应该只负责一件事

  • 单一职责原则的核心在于促使软件开发者将复杂类分解成更小、更管理得当的组件
  • 当一个类承担的职责过多时,它在应对变化时会更加脆弱,修改一部分可能会影响到其他功能,而分解的这个操作通常我们会称为解耦,程序的耦合度往往也是评判代码是否优秀的重要指标
//全部挤在一起处理
function add(x,y,z){
    x = x + 2
    y = y * 2
    z = z * z
    return x + y +z
}

console.log(add(10,20,30));
//柯里化处理
function sum(x){
    x = x + 2
    return function(y){
        y = y * 2
        return function(z){
            z = z * z
                return x + y + z
        }
    }
}
console.log(sum(10)(20)(30));
  • 对于各种原则,往往是灵活进行使用的,一个类只负责一件事情,而这一件事情的范围究竟有多广(界限),则是根据整体结构来进行判定
    • 抽取过头则容易造成过渡封装和复杂度提升的效果,而每一个人对于这点的理解都不尽相同
    • 因此,真正的学习则需要放到项目中去决定,在这里,我们虽然学习了单一职责,但也不能够迷信于它,需要多进行思考
2.2.2. 柯里化 - 逻辑的复用
  • 在我们手写bind函数的时候,有着重讲解其用法和call函数的区别
    • 而在柯里化的用法之中,也是如此的模式。换句话说,bind函数正是使用了柯里化思想的一种体现
    • 通常来说,我们并不会嵌套返回太多层函数,2-3层是最普遍的情况,更多层的嵌套同样会给人带来一定的心智负担,需要更彻底的解耦
  • 而下面,我们就来实现一个柯里化函数的复用效果,这和所学的bind函数优势是相同的
function foo(m,n){
    return m + n
}
console.log(foo(5,1))
console.log(foo(5,2))
console.log(foo(5,3))
console.log(foo(5,4))
console.log(foo(5,5))//第一个数一直都是不变的,但是我们每次都是需要重复输入,使用柯里化就能实现逻辑上的复用了


function makeAdder(count){
    return function(num){
        return count + num
    }
}

//柯里化复用
var adder5 = makeAdder(5)
console.log(adder5(1));//重复的逻辑就直接复用了
console.log(adder5(2));
console.log(adder5(3));
console.log(adder5(4));
console.log(adder5(5));
  • 接下来让我们来实现一个打印日志的效果
    • 在正常的情况下,我们可能会使用普通函数进行实现
//打印日志时间
function log(date,type,message){
    console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`)
}
log(new Date(),'DEBUG','查找到轮播图的bug')//[22:24][DEBUG]:[查找到轮播图的bug]
log(new Date(),'DEBUG','查询菜单的bug')//[22:24][DEBUG]:[查询菜单的bug]
log(new Date(),'DEBUG','查询数据的bug')//[22:24][DEBUG]:[查询数据的bug]
  • 但聪明的大家一定可以发现,我们每一次在调用log函数的时候,都有new Date()输入是重复的,而这显然是没有必要的事情
    • 我们使用了柯里化的写法,逻辑复用的new Date()传入第一层函数,返回了第二层及后续内容用来处理个性化内容
    • 通过这种方式,我们可以更高效的利用函数。在写各种工具函数的时候,也可以更加通用和灵活
//柯里化优化
var log = date => type => message =>{
    console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`)
}
//如果我打印的都是当前的时间,我们就可以将时间复用
var nowLog = log(new Date());
nowLog("DEBUG")("查找小余去哪了")//[22:32][DEBUG]:[查找小余去哪了]
//或者时间+类型都全部复用
var nowLog1 = log(new Date())("JS高级系列查找");
nowLog1("查找信息1")//[22:34][JS高级系列查找]:[查找信息1]
nowLog1("查找信息2")//[22:34][JS高级系列查找]:[查找信息2]
nowLog1("查找信息3")//[22:34][JS高级系列查找]:[查找信息3]
2.2.3. 柯里化函数的实现
  • 除了编写柯里化函数之外,我们还可能对正常的函数进行优化处理,但我如果是想要做到自动化的去转化,而不是一个个去修改,需要如何实现?

    • 需求:传入一个普通函数,返回一个柯里化函数

    1. 传入一个函数,返回一个function
    2. 想要获取参数的个数方式
function foo(x,y,z,q){
    //获取总共参数的数量
    console.log(foo.length)//4
}
foo()
  • 第一点需求很好理解,返回一个function,返回的就是我们的柯里化函数。那第二点呢?我们为什么需要获取到参数的个数,这是我们需求的关键
    • 我们知道柯里化本质上就是通过嵌套函数进行逻辑分层,而参数的总数量,决定了逻辑分层的数量
    • 通过哪一种方式来返回函数取决于大家的偏好
//方式1
function hyCurrying(fn){
  function curried(...args){
      
  }
  return curried
}

//方式2
function hyCurrying(fn){
  return function(...args){
      
  }
}
  • 在做到了返回柯里化函数和获取参数个数的方法之后,我们就需要开始编写正式的逻辑了
    • 在返回函数的时候,我们从一开始就是使用剩余参数来接收。但我们明明能够从fn当中拿到用户传递进来的参数数量了,为什么在第二层函数中获取参数?
    • 这是因为两个参数所代表的性质是不一样的
      1. fn参数:传递进来函数的形参,这是固定的上限
      2. ...args参数:这是用户传递进来的实参,不固定的方式
//通过柯里化调用的方式有很多种
curryAdd(10,20,30)
curryAdd(10,20)(30)
curryAdd(10)(20)(30)
  • 当用户传递进来足够的参数的时候,我们就应该进行调用了
    • 而参数不够应有数量的时候,则递归返回函数,让其继续传入参数,对参数进行拼接调用,直到参数足够后才进行最终调用
    • 并且在返回函数的时候,this指向可能会因为函数的嵌套及调用方式而产生偏移,所以需要显式的去绑定一下
function add1(x, y, z) {
    return x + y + z
}

function hyCurrying(fn){
    function curried(...args){
        //1.当已经传入的参数 大于等于 需要的参数时,就执行函数
        if(args.length >= fn.length){
            //不使用fn(...args)这种方式,因为可能会发生this指向问题
            return fn.apply(this,args)//如果使用call的话,args就需要加上...
            //原因是apply第二个参数本身就是数组,所以直接args输出,但call函数第二个参数是一个一个的,需要扩展出来
        }else{
            //当出现hyCurrying(10)(20)(30)这种极端情况的时候,我们就需要再返回新的函数来接收参数
            function curried2(...args2){//由于我们不知道要接收多少参数,这里还是需要...
                //接收到参数后,需要递归调用curried来检查函数的个数是否达到
                //将第一个curried参数跟curried2的参数进行拼接
                return curried.apply(this,args.concat(args2))
            }
            return curried2
        }
    }
    return curried
}
var curryAdd = hyCurrying(add1)
console.log(curryAdd(10,20,30));
console.log(curryAdd(10,20)(30));
console.log(curryAdd(10)(20)(30));
//可能在一个里面将参数全部接收hyCurrying(10,20,30)
//也可能分开接收hyCurrying(10,20)(30)
//也可能全部分开hyCurrying(10)(20)(30)
2.2.4. 柯里化-源码体现
  • 在正常编写代码的时候,我们其实是很少使用到柯里化的,但在编写工具函数的时候,就能够派上很好的用场
    • 而这些运用方式,在各类源码中也能够看到其身影,学习柯里化带给我们的好处更多时候是潜移默化的
    • 接下来我们来看下Vue3源码中对柯里化的体现,如图11-3

Vue3源码中的科里化

图11-3 Vue3源码中的科里化

  • 在源码中,柯里化的运用方式和我们又有所不同,返回的函数是来自createAppAPI,而我们是通过createApp来进行调用
    • 这种方式和我们正常柯里化直接返回函数是有所不同的,如图11-4

Vue3源码createAppAPI的科里化运用

图11-4 Vue3源码createAppAPI的科里化运用

  • 所以形成的代码是这样的
    • 最终形成了和柯里化一样的嵌套格式,二层调用所传递进去的就是根组件以及对于的根参数
    • 这种写法对于封装来说可以进一步的扩大其优势,但同时抽象程度也会提升
return {
  render,
  hydrate,
  createApp:createAppAPI(render,hydrate)(rootComponent,rootProps)
}
  • 而由于返回的是一个对象,且createAppAPI所返回的函数名就是createApp,在ES6语法之后,有对象简写形式
    • 所以createApp:createAppAPI(render,hydrate)(rootComponent,rootProps)的表达形式其实是createApp:createApp
    • 然后通过对象的简写形式就会变成createApp,不过在变成这个之前,前面的createAppAPI是已经执行结束返回createApp才有的事情
return {
  createApp
}

Redux柯里化调用

图11-5 Redux柯里化调用

三、理解组合函数

组合(Compose)函数是在JavaScript开发过程中一种对函数的使用技巧、模式

  • 比如我们现在需要对某一个数据进行函数的调用,执行两个函数fn1和fn2,这两个函数是依次执行的
  • 那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复
  • 那么是否可以将这两个函数组合起来,自动依次调用呢?
  • 这个过程就是对函数的组合,我们称之为 组合函数(Compose Function)
//乘2
function double(num){
    return num*2
}
//二次方
function square(num){
    return num ** 2//平方
}

var count = 10
var result = square(double(count))
console.log(result);

//如何将double和square结合起来,实现简单的组合函数
function composeFn(m,n){
    return function(count){
        return n(m(count))
    }
}

var newFn = composeFn(double,square)
console.log(newFn(10));
  • 我们通过柯里化的形式将两个函数组合了起来,两次的调用可以简化为一次调用
    • 这种方式适合两个函数的关联性强,需要连续调用的情况。而且可以保持两个函数自身功能的独立性,是一种很好的方式
    • 我们第一层函数用来接收需要组合的函数,返回第二层函数(组合函数)用来接收需要的数据,返回结合后的结果
  • 两层函数的思想模式就是我们非常熟悉的bind函数形式,所有的操作都放在了第二层函数中,也就是组合形式的内容
    • 我们既可以先平方,再乘2,也可以反过来,取决于我们在第二层函数中返回的操作形式
    • 说到底,这只是函数的一种运用技巧,对于我们的开发来说属于锦上添花的事情

四、通用组合函数的实现

刚才我们实现的compose函数比较简单,我们需要考虑更加复杂的情况:比如传入了更多的函数,在调用 compose函数时,传入了更多的参数:

  • 在操作的适合,传入了更多函数,我们就需要做出一定的校验判断,比如传入的是否都是函数类型,并因此返回对应的错误提示。这个错误提示还没学过,我们可以先用着,后面章节会进行讲解
    • 前面简单案例中,我们已经说明了两层函数所对应的作用分别是什么,第一层函数进行判断传入的是否为函数类型
    • 第二层传入的则是数据,我们需要在第二层中对数据进行处理,并且对函数进行结合
    • 我们在第二层的边界处理就是绑定this,防止在使用数据的时候出现this指向偏差问题和对传入数据参数是否为空的判断处理
    • 最后,我们通过while对初步调用的函数,进行多次函数调用执行。因为传入的函数都会返回对应的内容,我们拿到上一个函数内容后会在接下来的函数中继续调用执行,形成一个接力,而这每一次接力通过call函数来绑定this正确指向以及参数接力传递执行问题。最终形成组合函数的效果
function hyCompose(...fns){
    var length = fns.length
    for(var i = 0;i < length;i++){
        if(typeof fns[i] !== 'function'){
            throw new TypeError('要求都是函数类型')//new出一个异常的错误,抛出异常
        }
    }

    function compose(...args){
        var index = 0
        //fns[index].apply(this,args):取出来fns第一个函数进行apply调用,并将args参数都传递进去。注意,我们是直接使用fns而不是...fns哦
        var result = length ? fns[index].apply(this,args) : args
        while(++index < length){
            result = fns[index].call(this,result)
        }
        return result
    }
    return compose
}
function double(m){
    return m*2
}
function square(n){
    return n ** 2
}
var newFn = hyCompose(double,square)
console.log(newFn(30));
  • 而这个实现方式,是先进行平方操作还是乘法操作,则取决于我们在第一层函数传递进去的顺序了,先传入的先执行,然后返回的结果作为下一个函数的调用参数

后续预告

  • 通过本章节的学习,我们掌握了什么是纯函数、副作用以及柯里化
    • 这些思想都是非常灵活的内容,使用所带来的效益往往取决于使用者的编程水平,需要我们不断的去探索强化,而非是学了就结束的内容
  • 那在接下来我们会对JS对一定的边角料补充,比如严格模式到底是什么?带来的影响都有哪些?在之前学习this的时候,我们曾说过在严格模式下,this指向全局会变成undefined,但没说原因,在下一章节中都可以得到揭晓
  • 以及我们会补充with语句和eval函数的概念
    • 这两样内容其实不在我们必学的范围内,也很少遇到,学习这两个的目的在于补充我们JS体系的完整度,可以更好的形成体系的思考模式
    • 在接下来的很多章节中,我们都会额外讲解一些碎片化的内容,有的会常用到,有的不经常用,但目的都是一样的