学习笔记:函数式编程基础

176 阅读10分钟

前言:该内容为自学笔记,如有错误及不妥地方请及时指出

函数式编程

一、为什么要学习函数式编程

  • React
  • VUE3
  • 可以抛弃this
  • 用很多库可以帮助我们进行函数式开发: lodash、underscore、remda

二、什么是函数式编程

函数式编程是一种编程范式**(Functional Programming ),我们常听的还有面向过程编程**、面向对象编程

  • 面向对象的思维方式:把现实世界中的事物抽象成程序员世界中的类和对象,通过封装,继承和多态来演式事物事件的联系
  • 函数式编程思维方式:把事件的事物和事物之间的联系抽象到程序世界中(对运算过程进行抽象)
    • 程序的本质:根据输入通过某种运算获得相应的输出,程序的开发过程中会涉及很多输入和输出函数
    • 函数是指的数学中的函数映射关系,如三角函数,非编程中的函数
    • 相同的输入结果的得到相同的输出(纯函数),可以上级封装
    • 函数式编程用来描述数据(函数)之间的联系
//过程式编程
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(num);
// 函数式
function add(n1, n2) {
    return n1 + n2
}
let sum2 = add(n1,n2)
console.log(sum2)

三、前置知识

  • 函数是一等公民
  • 高阶函数
  • 闭包

函数是一等公民

MDN First-class Function

  • 函数可以存储在变量中
  • 函数可以作为参数
  • 函数可以作为返回值

在JavaScript中函数是一个普通对象(可以通过 new function()创建),我们可以把函数存储到变量和数值中,他还可以作为另一个参数的返回值,甚至可以在函数运行的时候创建一个新的函数

  • 把函数赋值给变量
function test(val){
window.alert('您输入的是:'+val);
}
//var myVal=test;//将函数赋值给了变量
//var myVal=test('aa');将函数返回的值赋值给变量,如果函数test()没有返回值,但是你又接收了,则myVal会返回undefined
var myVal=test;
myVal('化红翠');
  • 函数是一等公民是后面学习高阶函数、柯里化函数等的基础

四、高阶函数

1.什么是高阶函数

  • 高阶函数(hight-order functi)
    • 可以把函数作为参数传递给另外一个函数
    • 可以把函数作为另一个函数的返回结果
  • 函数作为参数传入
//高阶函数-函数作参数
// 自己写的forEach 
function forEach(array, fn) {
    for (let i = 0; i < array.length; i++) {
        fn(array[i])
    }
}

//测试
let arr = [1, 4, 5, 7, 8]

forEach(arr,function(item){
    console.log(item);
})

// 自己写的filter  //这里fn是可以不同的判断条件
// 最终产物是可以忽略实现过程专注与结果
function filter(array, fn) {
    let results = [];
    for (let i = 0; i < array.length; i++) {
        if (fn(array[i])) {
            results.push(array[i])
        }
    }
    return results
}

let r = filter(arr, function (item) {
    return item % 2 == 0
})

console.log(r);
  • 函数作为值返回
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(5)
pay(6)

2.高阶函数的意义

  • 抽象可以帮我们屏蔽细节,只需要关注与我们的目标
  • 高阶函数就是用来抽象通用的问

五、闭包

闭包(Closure):函数和其周围的状态(环境语法)的引用捆绑在一起形成的闭包。

  • 可以在另外一个作用域中调用一个函数的内部,并访问到该函数的作用域中的成员
  • 闭包的本质:函数在执行的时候会放到一个执行栈上当函数执行完毕后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问到外部函数的成员。
// 工资计算
function makeSalary (base){
    return function (performance) {
        return base + performance
    }
}

let level1 = makeSalary(7000) //等级一的基本工资
let level2 = makeSalary(10000) //等级二的基本工资

console.log(level1(1000));
console.log(level2(3000));

六、纯函数

1.纯函数的概念

  • 纯函数:相同的输入永远的得到相同的输出:而且没有任何可观察的副作用
    • 纯函数就类似与数学中的函数(用来描述输入和输出之间的关系),y=f(x)
    • lodash是一个纯函数的数学功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法
    • 数组中的slice和splice分别是:纯函数和不纯的函
      • slice返回数组中的指定部分,不会改变原数组
      • splice对数组进行操作返回该数组,会改变数组
let arr = [1, 2, 3, 4, 5]

//纯函数
console.log(arr.slice(0, 3));
console.log(arr.slice(0, 3));

//不纯函数
console.log(arr.splice(0, 3));
console.log(arr.splice(0, 3));

//手写纯函数
function getSum(n1, n2) {
    return n1 + n2
}
console.log( getSum(1, 2) );
console.log( getSum(1, 2) );
  • 函数式编程式不会保留计算的中间结果,所以变量是不可变的(无状态)
  • 我们可以把一个函数的执行结果交给另外一个函数去处理

2.存函数的好处

  • 可缓存

因为纯函数对相同的输入始终有相同的结果,所以可以把函数的结果缓存起来(利用loadeh的memoize方法)

const _ = require('loadsh')
//求圆面积的函数
function getArea(r){
    console.log(r);
    return Math.PI*r*r
}

let geiAreaWithMenory = _.memoize(getArea)
console.log(geiAreaWithMenory(3));
console.log(geiAreaWithMenory(3));
console.log(geiAreaWithMenory(3));

自己模拟一个memoize

function memoize(fn){
    let cache = {}
    return function () {
        let key = JSON.stringify(arguments)
        //看看数组内有没有这个参数,没有有的话就执行函数
        cache[key] = cache[key] || fn.apply(fn,arguments)
        return  cache[key]
    }
}

let geiAreaWithMenory = memoize(getArea)
console.log(geiAreaWithMenory(3));
console.log(geiAreaWithMenory(3));
console.log(geiAreaWithMenory(3));
console.log(geiAreaWithMenory(3));
  • 可测试
    • 纯函数让测试更方便
  • 并行处理(了解即可,因为es6后可以多开一个线程)
    • 在多线程环境下并行操作共享内存数据的时候可能出现的意外
    • 纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数

3.副作用

  • 纯函数:对于相同的输入的永远得到相同的输出,而且没有任何可观察的副作用
//不纯的
let min = 18 
function checkAge(age){
    return age > min
} //因为受外部的min影响可能输入的相同数据得不到相同结果

//纯
function checkAge2(age){
    let min = 18
    return age > min
}

副作用让一个函数变得不纯(上例),如果函数依赖于外部状态就无法保证输出相同,带来副作用。

副作用的来源

  • 配置文件
  • 数据库
  • 获取用户输入
  • .....

所有的外部交互都有可能带来副作用,副作用也使得方法得通用性下降不适合扩展和可重用性,同时副作用会给程序带来安全隐患给程序带来不确定性,但是副作用式不可能完全禁止,尽可能控制他们在可控范围内发生

七、柯里化

1.概念

  • 当一个函数有多个参数得时候先传一定一部分参数调用它(这部分数据以后式永远不变)
  • 然后返回一个新的函数接收剩余参数,返回结构
//柯里化
function checkAge(min) {
  return function (age) {
        return age >= min
    }
}

//ES6
let checkAge = min => (age => age > min)

let checkAge18  = checkAge(18)
console.log(checkAge18(20));

2.lodash中得柯里化函数

  • _.curry(func)
    • 功能:创建一个函数,该函数接收一个或多个func的参数,如果func所需的参数都是被提供则执行func并返回执行结构,否则继续返回该函数并等待剩余的参数。
    • 参数:需要柯里化的函数
    • 返回值:柯里化后的函数
//lodash中的柯里化使用
const _ = require('lodash');

function getsum(a, b, c) {
    return a + b + c
}

const curried = _.curry(getsum)
//当我们调用式两个的时候会返回一个一元函数
console.log(curried(1,2,3));
console.log(curried(1)(2,3)); 
console.log(curried(4,2)(1)); 
  • demo过滤数组
//柯里化案例
const _ = require('lodash')

//创建一个能匹配正则的柯里化函数
const match = _.curry((reg, str) => str.match(reg))

const haveSpace = match(/\s+/g) //匹配字符串内有没有空格
const haveNum = match(/\d+/g) //匹配字符串中有没有数字

console.log(haveSpace('hell'));
console.log(haveNum('123abc'));

//创建一个过滤数组的方法
const filter = _.curry((func, arr) => arr.filter(func))

const findSpace = filter(haveSpace) //创建一个过滤数组中的空格的函数
console.log(findSpace(['hello 51c', 'hello_51c']));

3.柯里化实现原理

function curry (func){
    return function curriedFn (...args){  //这里是使用es6的剩余参数...args就是一个实参数组
        console.log(args,'这里卡看');
        //判断实参和形参的个数
        if(args.length < func.length){
            return function (){
                console.log(arguments,'我要看看');
                return  curriedFn(...args.concat(Array.from(arguments))) //arguments是下一次调用传递参数的伪数组
            }
        }
        // 实参大于形参的时候就直接调用
        return func(...args)
    }
}

4.总结

  • 柯里化可以让一个我们给一个函数传递较小的参数的到一个已经记住了某些固定值参数的新函数(闭包)
  • 这个一种对函数参数的缓存
  • 然函数变得更加灵活,然函数的粒度变得更少
  • 可以把多元函数转换成一元函数,可以祝贺使用函数产生强大的功能

八、函数的组合

纯函数和柯里化很容易出现洋葱代码h(g(f(x)))

例如获取数组中最后一个元素在再转换成大写字母_.toUpper(first(_.reverse(array)))

函数组合可以让我们把细粒度的函数重新组合成一个新的函数

1.管道

就是把一个大的fn拆分成函数,此时多了中间运算的m值,最后可以快速地位,也可以避免代码过多嵌套

2.函数组合

  • 函数组合(compose): 如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数
    • 函数就像是数据的管道,函数的组合就是把这些管道链接起来,让数据穿过多个管道形成最终结果。
    • 函数组合的默认值是从右到左
    • 函数组合是能传递一个从参数

3.lodash中的组合函数

  • lodash中的组合函数flow()或者flow Right(),他们都可以组合多喝函数
  • flow()是从左到右运行
  • flowRight()是从右到左运行,使用的更多一些
const _ = require('lodash')


//反转函数
function reverse(array){
    return array.reverse()
}

// 获取函数第一元素
function first(array){
    return array[0]
}

//转成大写
const toUpper = s => s.toUpperCase()
const f = _.flowRight(toUpper,first,reverse)

console.log(f(['one','two','three'])); //"THREE"

4.函数组合的原理

//反转函数
function reverse(array){
    return array.reverse()
}
// 获取函数第一元素
function first(array){
    return array[0]
}
//转成大写
const toUpper = s => s.toUpperCase()
//手写flowRight
const  compose  = (...args) => value => args.reverse().reduce( (acc,item) => item(acc) ,value)
const f = compose(toUpper,first,reverse)
console.log(f(['one','two','three']));

5.函数组合要满足结合律

const { flatMap } = require('lodash')
const _ = require('lodash')
const f = _.flowRight(_.flowRight(_.toUpper,_.first),_.reverse)

console.log(f(['one','two','three']));

6.调试

//函数组合调试
// NEVER SAY DIE => never-sat-di
const _ = require('lodash')

// 这里是一个调试时候查看函数 
const log  = v => {
    console.log(v)
    return v
}

//这里是优化的跟踪函数
const trace = _.curry((tap,value) => { 
    console.log(tap,value)
    return value
})

// _.split() _.join() _.map 因为这个方法需要传递两个参数,所有需要柯里化(形成个只有一个参数的函数)
const  mySplit = _.curry((sep,str) => _.split(str,sep))  //这里重要点是把需要两个参数的位置调换(柯里化机制)
const myJoin = _.curry((sep,arr) => _.join(arr,sep))
const myMap = _.curry( (fn,arr) => _.map(arr,fn))

// 注意的的是这里的myJoin和mySplit是会产生中间的函数,需要等待相关的值传过来
const f = _.flowRight(myJoin('-'),log, myMap(_.toLower) ,log,mySplit(' '))
const f1 = _.flowRight(myJoin('-'),trace('第二次打印'), myMap(_.toLower) ,trace('第一次打印'),mySplit(' '))
console.log(f1('NEVER SAY DIE'));

7.FP模块

  • lodash/fp
    • lodash中的fp模块提供了函数式编程的友好方法
    • 提供了不可变的auto_currried iterate-first data-last的方法
    • lodash原生式数据优先,函数滞后,而fp是函数有限数据滞后(其实就上面方法的封装)
//fp就是柯里化的,和柯里化处理过的
// NEVER SAY DIE => never-sat-di
const fp = require('lodash/fp')
const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower) ,fp.split(' '))
console.log(f('NEVER SAY DIE'));

8.Point Free

point Free:我们可以把数据处理的过程定义与数据无关的合成运算,不需要用到代表数据的哪个参数,只要把简单的运算步骤合成到一起,在使用这种模式之气那我们需要定义一些辅助的基本运算函数。(例如上面组合一个方法)

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 需要定义一些辅助函数
const fp = require('lodash/fp')
const f = fp.flowRight(fp.replace(/\s+/g,'_'),fp.toLower)
console.log(f('Hello      World'))
//把一个字符串中首字母提取并转换成大写,使用.分割符
// world wild web  => W.W.W.

const fp = require('lodash/fp')
// 第一步先切割成数组,然后遍历数组转换成大写,然后去第一个值,然后合并
// const firstLetterToUpper = fp.flowRight( fp.join('.'),fp.map(fp.first),fp.map(fp.toUpper) ,fp.split(' '))
//这里循环了两个次不合理。可以在用再用flowRight将两个循环合并成一个
const firstLetterToUpper = fp.flowRight( fp.join('.'),fp.map(fp.flowRight(fp.first,fp.toUpper)) ,fp.split(' '))

console.log(firstLetterToUpper('world wild web'));

九、Functor函子

1.概念

  • 为什么要学习函子

函数式编程中如何把副作用控制在可控范围内、异步处理、异步操作等。

  • 什么是Functor

    • 容器:包含值和值的变形关系(变形关系就是函数)
    • 函子:是一个特殊的容器,通过一个普通对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)
  • 实现过程

//Functor 函子
class Container {
    constructor(value) {
        this._value = value  //私有值
    }
    //map对象返回一个新的函子并把数值处理后的数值传递过去。而且这数值对外不公布
    //如果我们需要对这个中间值处理我们就直接调用map方法就可以了
    map(fn) {
        return  Container(fn(this._value))
    }
}
let r =new Container(5)
    .map(x => x + 1)
    .map(x => x*x)   
console.log(r); //36
  • 优化
class Container {
    // 优化不用看着像面向对象编程不用使用new去创建
    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)
    .map(x => x + 2)
    .map(x => x*x)
    
console.log(r); //49
  • 总结
    • 函数式编程的运算不直接操作数据,而是由函子完成
    • 函子就是一个实现了map契约的对象(返回新函子并处理传递值)
    • 我们可以把函子想像成一个盒子,这个盒子里面封装了一个值
    • 想要处理盒子中的值,我们需要给盒子的map传递一个处理值的函数(纯函数)
    • 最终map方法返回一个包含新值的盒子(函子)

2.Maybe函子

  • 我在编程的过程中会遇到很多错误,需要对这些错误相应处理。
  • Maybe函子的作用就是对外部空值的情况做处理(控制副作用在允许范围内)
//MayBe函子
class Maybe {
    static of(value){
        return new Maybe(value)
    }

    constructor(value){
        this._value = value
    }

    //判断中间是否存在,不存在返回一个值为null的函子
    map(fn){
        return  this.isNothing() ?  Maybe.of(null): Maybe.of(fn(this._value))
    }
    
    isNothing() {
        return this._value === undefined || this._value === null
    }
}

let r = Maybe.of('Hello World')
    .map(x => x.toUpperCase())
console.log(r);

let aa = Maybe.of(null)
    .map(x => x.toUpperCase()) 
console.log(aa);
//但是会看不到那里返回bull的位置

3.Either函子

  • Either两者中的任何一个,类似于if.esle的处理
  • 异常会让函数变得不纯,Either函子可以用来做异常处理
// Either函子
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))
    }
}

function PareseJson(str){
    try {
        return Right.of(JSON.parse(str))
    }
    catch(e){
        return Left.of({err:e.message})
    }
}

let r = PareseJson(`{name:25}`)
console.log(r);

let r1 = PareseJson(`{"name":"2s"}`)
        .map(x => x.name.toUpperCase())
console.log(r1);

4.IO函子

  • IO函子中的_value是一个函数,这里把函数作为值来处理
  • IO函子可以把不纯的动作存储到_value中,延迟执行这个不纯的操作(惰性执行),包装当前的操作纯函数
  • 把不纯的操作交给调用者来处理
// IO函子
const fp = require('lodash/fp')
class IO  {
    //这IO是需要传入一个函数
    static of(value){
        return new IO(function() {
            return value
        })
    }

    constructor(fn){
        this._value = fn
    }

    //这里使用组合函数生成一个中间函数
    map(fn){
        return new IO(fp.flowRight(fn,this._value))
    }
}
 
let r = IO.of(process).map(p => p.execPath)
console.log(r); //这里返回的是组合后的函数就是map的函数都组合起来。
console.log(r._value());

5.folktale

Task异步任务

  • 异步任务的实现过于复杂,我们使用folktale中的Task来演示
  • folktale一个标准的函数式编程库
    • 和lodash、remda不同的是,他没有提供很多功能函数
    • 知识提供了一些函数处理的操作,ex:compose、curry等,一些函子Task、Either、Maybe等
//Task处理异步任务
const {task} = require('folktale/concurrency/task')
const fs = require('fs')
const {split,find} = require('lodash/fp')

//这里返回一个处理异步的函子
function readFile(name){
    return task(resolver => {
        fs.readFile(name,'utf-8',(err,data) =>{
            if(err){
                resolver.reject(err)
            } 
            resolver.resolve(data)
        })
    })
}

readFile('package.json')
.map(split('\n'))   //将字符串空格分成数组
.map(find( x => x.includes('version'))) //找到字段
.run()   //执行
.listen({ //监听task运行成功与否
    onRejected : err => {
        console.log(err);
    },
    onResolved: value => {
        console.log(value);
    }   
})

6.pointed函子

  • Pointed函子是实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用map来处理)

7.Monad(单子)

  • Monad函子是可以变扁的Pointed,IO(IO(x))
  • 一个函子如果具有join和of的两个方法,并遵守一些定律就是一个Monad
// IO函子
const fp = require('lodash/fp')
const fs = require('fs')
class IO  {
    //这IO是需要传入一个函数
    static of(value){
        return new IO(function() {
            return value
        })
    }

    constructor(fn){
        this._value = fn
    }

    //这里使用组合函数生成一个中间函数
    map(fn){
        return new IO(fp.flowRight(fn,this._value))
    }

    join(){
        return this._value()
    }
    // this.map
    flatMap(fn){
        return this.map(fn).join()
    }
}


// 因为fs去读文件事不存的操作所以需要用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
    })
}

let cat = fp.flowRight(print,readFile)
//IO(IO(x))
let r =cat('package.json')._value()._value()
console.log(r);

let r1 = readFile('package.json')
            .flatMap(print)  //这里会合并读取文件和print函数并执行同时返回了一个新的IO函子         
			.join()//调用join就是执_value()