高阶函数到函数式编程需要掌握的知识点

428 阅读10分钟

函数式编程

1. 什么是函数式编程

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

  • 面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物事件的联系

  • 函数式编程的思维方式:把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)

    • 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如:y = sin(x),x和y的关系,用来描述数据(函数)之间的映射。
    • 程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数。
// 非函数式
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)

// 函数式
function add (n1, n2) {  
    return n1 + n2
}
let sum1 = add(2, 3)
console.log(sum1)

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

  • 函数式编程是随着 React 的流行受到越来越多的关注
  • Vue 3也开始拥抱函数式编程(扶起来也要学啊)
  • 函数式编程可以抛弃 this
  • 打包过程中可以更好的利用 tree shaking 过滤无用代码
  • 方便测试、方便并行处理
  • 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda

3. 函数式编程的前置知识

  • 前置基础知识

  • 3.1 函数是一等公民

    • 函数可以存储在变量中
    • 函数作为参数
    • 函数作为返回值
// 把函数赋值给变量
let fn = function () {  
    console.log('Hello First-class Function')
}
fn()


// 一个示例
const BlogController = {
    index (posts) {
        return Views.index(posts)
    },
    create (attrs) {
        return Db.create(attrs)
    }
}

// 优化

const BlogController = {
    index: Views.index,
    create: Db.create
}

/*
上面示例中BlogController对象内部的index方法和Views.index方法做了同样是事情,
所以直接把Views.index这个方法赋值给BlogController.index,而不是方法的调用。
*/
  • 3.2 高阶函数
    • 什么是高阶函数
      • 可以把函数作为参数传递给另一个函数
      • 可以把函数作为另一个函数的返回结果
    • 使用高阶函数的意义

什么是高阶函数?

// 高级函数 - 函数作为参数
function array(array, fn) {
    for (let i = 0, len = array.length; i < len; i++) {
        fn(array[i],i);
    }
}

/**
 * 测试
*/

array([1, 2, 3, 4, 5], function(item, index) {
    console.log(`索引是: ${index}, 值是: ${item}`);
    // 索引是: 0, 值是: 1
    // 索引是: 1, 值是: 2
    // 索引是: 2, 值是: 3
    // 索引是: 3, 值是: 4
    // 索引是: 4, 值是: 5
});


function filter(array, fn) {
    const result = [];
    for (let i = 0, len = array.length; i < len; i++) {
        if(fn(array[i])) {
            result.push(array[i]);
        }
    }
    return result;
}

/**
 * 测试
*/
const result = filter([1,2,3,4], function (item) {
    return item % 2 === 0
})
console.log(result); // [2, 4]
// ## 高阶函数-函数作为返回值

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

使用高阶函数的意义

// 面向过程的方式
const arr = [1, 2, 3]
for (let i = 0, len = arr.length; i < len; i++) {
    console.log(arr[i])
}
// 高阶函数
function forEach(arr, fn) {
    for (let i = 0, len = arr.length; i < len; i++) {
        fn(arr[i])
    }
}

// 调用
forEach([1,2,3], function (item) {
    console.log(item)
})

  • 3.3 闭包
    • 闭包 (Closure):函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。
    • 可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员
// once函数
function once(fn) {
    let done = false;
    return function(...args) {
        if(done === false) {
            done = true;
            // 把this和参数传递给fn
            fn.apply(this, args);
        }
    }
}

const pay = once(function (money) {
    console.log('支付: ' + money + 'RMB');
});

pay(5);
pay(5);
pay(5);
// 支付: 5RMB 只会支付一次
  • 闭包的本质: 函数在执行的时候会放到一个执行栈上当函数执行完毕之后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。缺点也明显:过度使用闭包会造成内存泄漏;
// 闭包案例一:获取基数的指数次幂
function raisePower(power) {
    return function (base) {
        // 该函数被调用取闭包空间中的power
        return Math.pow(base, power)
    }
};

// exponent指数为2
const power2 = raisePower(2);

// exponent指数为3
const power3 = raisePower(3);

console.log(power2(2)); // 4
console.log(power3(2)); // 8



function makeSalary(makeSalary) {
    return function (performance) {
        return makeSalary + performance;
    }
}

const level1 = makeSalary(10000);
const level2 = makeSalary(15000);

console.log(level1(2000));
console.log(level2(4000));

4. 函数式编程的特性

纯函数概念

  • 纯函数: 相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。
  • 纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)

chunhanshu.png

  • lodash 是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法
  • 数组的 slice 和 splice 分别是:纯函数和不纯的函数
    • slice 返回数组中的指定部分,不会改变原数组
    • splice 对数组进行操作返回该数组,会改变原数组
// 相同的输入返回相同的输出, slice就是一个纯函数
// 纯函数
const arr = [1, 2, 3, 4, 5, 6];
console.log(arr.slice(0,3)); // [1,2,3]
console.log(arr.slice(0,1)); // [1]

// 不纯函数
const arr1 = [1, 2, 3, 4, 5, 6];
console.log(arr1.splice(0,4)); // [1,2,3,4]
console.log(arr1.splice(0,6)); // [5,6]
console.log(arr1.splice(0,2)); // []


// Lodash介绍 Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库。lodash 是一个纯函数的功能库

const _ = require('lodash');

const array = ['jack', 'tom', 'lucy', 'kate'];

console.log(_.first(array)) // jack

console.log(_.last(array)); // kate

console.log(_.toUpper(_.first(array))); // JACK

console.log(_.reverse(array)); // [ 'kate', 'lucy', 'tom', 'jack' ]


const newArr = [1, 2, 3, 4, 5]
const result = _.each(newArr, (item, index) => {
    console.log(`索引是: ${index}, 值是: ${item}`);
})
console.log(result);

  • 函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的)
  • 我们可以把一个函数的执行结果交给另一个函数去处理

纯函数的好处

  • 可缓存
    • 因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来。
// 记忆函数
// 获取圆的面积,实现的效果是,调用getAreaWidthMemory时,如果圆半径相同,就返回内存中的圆面积。
// memoize,创建一个会缓存 func 结果的函数。
function getArea(r) {
    console.log(r);
    return Math.PI * r * r;
}

const getAreaWidthMemory = _.memoize(getArea);
console.log(getAreaWidthMemory(4));
console.log(getAreaWidthMemory(4));
console.log(getAreaWidthMemory(5));
// getArea只执行了一下


// 自己动手手写一个模拟memoize函数

function memoize(fn) {
    const cache = {};
    return function (...args) {
        const key = JSON.stringify(args);
        return cache[key] = cache[key] || fn.apply(this, args);
    }
}

const getMyAreaWidthMemory = memoize(getArea);
console.log(getMyAreaWidthMemory(4));
console.log(getMyAreaWidthMemory(4));
console.log(getMyAreaWidthMemory(5));

// --- 记忆函数 lodash的memoize输出 ---
// 4
// 50.26548245743669
// 50.26548245743669
// 5
// 78.53981633974483

// --- 模拟memoize 手写的输出 ---
// 4
// 50.26548245743669
// 50.26548245743669
// 5
// 78.53981633974483
// 相同圆的半径只调用了一次getArea方法

副作用

  • 纯函数:对于相同的输入永远会得到相同的输出,而且没有任何可观察的副作用
// 无副作用的理解 没有修改外部状态
// 多次调用结果一样
function test2(a) {
  return a + 1;
}
// 不纯的
let mini = 18
function checkAge (age) {
  return age >= mini
}
// 纯的(有硬编码,后续可以通过柯里化解决)
function checkAge (age) {
    let mini = 18
    return age >= mini
}

副作用让一个函数变的不纯(如上例),纯函数的根据相同的输入返回相同的输出,如果函数依赖于外部 的状态就无法保证输出相同,就会带来副作用。 副作用来源:

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

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

柯里化 (Haskell Brooks Curry)

  • 使用柯里化解决上一个案例中硬编码的问题
const lodash = require('lodash');

// 示例一 使用硬编码实现的柯里化

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

console.log(checkAge(20));

// 示例二 函数的柯里化

function checkAge1(min) {
    return function (age) {
        return age > min
    }
}

const checkAge18 = checkAge1(18);
const checkAge20 = checkAge1(20);

console.log(checkAge18(20));
console.log(checkAge20(20))

柯里化 (Currying):

  • 当一个函数有多个参数的时候先传递一部分参数调用它(这部分参数以后永远不变)
  • 然后返回一个新的函数接收剩余的参数,返回结果

lodash 中的柯里化函数

  • _.curry(func)
    • 功能:创建一个函数,该函数接收一个或多个 func 的参数,如果 func 所需要的参数都被提 供则执行 func 并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
    • 参数:需要柯里化的函数
    • 返回值:柯里化后的函数
const _ = require('lodash')
// 要柯里化的函数
function getSum (a, b, c) {
  return a + b + c
}
// 柯里化后的函数
let curried = _.curry(getSum)

// 测试
curried(1, 2, 3)
curried(1)(2)(3)
curried(1, 2)(3)

// 柯里化案例
// 使用lodash的curry之前先回顾一下柯里化

const _ = require('lodash')

const match = _.curry(function (reg, str) {
  return str.match(reg)
})

const filter = _.curry(function (func, array) {
    return array.filter(func)
  })

const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)

const findSpace = filter(haveSpace)

const findNumber = filter(haveNumber)


// 字符串匹配正则表达式的结果
console.log(haveSpace('hello world'))
console.log(haveNumber('Lucy is 20 years old today'))
console.log(findSpace(['John Connor', 'John_Donne']))
console.log(findNumber(['Lucy is 20 years old today', 'John_Donne 30']))
// 注册事件
function nodeListen(node, eventName) {
    return function(fn) {
        node.addEventListener(eventName, function() {
                fn.apply(this, arguments);
            }, false);
    };
}

const btn = document.querySelector('#btn');
var bodyClickListen = nodeListen(document, "click");
var btnClickListen = nodeListen(btn, "click");

bodyClickListen(function(e) {
    alert('点击了document');
});

btnClickListen(function () {
    alert('点击了btn');
})
// 模拟_.curry函数的实现

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

function myCurried (func) {
    return function next(...args) {
      // 判断实参和形参的个数
      if (args.length < func.length) {
          return function () {
              return next(...(args.concat(Array.from(arguments))))
          }
      }
      // 实参和形参个数相同,调用func,返回结果
      return func(...args);
    }
  }


const curried1 = myCurried(getSum);

console.log(curried1(1, 2, 3));
console.log(curried1(1, 2)(3));
console.log(curried1(1)(2, 3));

总结:

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

函数组合

  • 纯函数和柯里化很容易写出洋葱代码 h(g(f(x)));
    • 获取数组的最后一个元素再转换成大写字母, .toUpper(.first(_.reverse(array)))

compose.png

管道

  • 下面这张图表示程序中使用函数处理数据的过程,给 fn 函数输入参数 a,返回结果 b。可以想想 a 数据 通过一个管道得到了 b 数据。

guandao.png

  • 当 fn 函数比较复杂的时候,我们可以把函数 fn 拆分成多个小函数,此时多了中间运算过程产生的 m 和 n。
    • 下面这张图中可以想象成把 fn 这个管道拆分成了3个管道 f1, f2, f3,数据 a 通过管道 f3 得到结果 m,m 再通过管道 f2 得到结果 n,n 通过管道 f1 得到最终结果 b

guandao2.png

// 伪代码
const fn = compose(f1, f2, f3);
const result = fn(a);

手写函数组合

  • 函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间 过程的函数合并成一个函数
    • 函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
    • 函数组合默认是从右到左执行
// 组合函数 硬编码实现compose
function compose (f, g) {
  return function (x) {
    return f(g(x))
} }
function first (arr) {
  return arr[0]
}
function reverse (arr) {
  return arr.reverse()
}
// 从右到左运行
let last = compose(first, reverse) 
console.log(last([1, 2, 3, 4]))

lodash 中的组合函数

  • lodash 中组合函数 flow() 或者 flowRight(),他们都可以组合多个函数

  • flow() 是从左到右运行

  • flowRight() 是从右到左运行,使用的更多一些

// 示例一、
const _ = require('lodash')
const toUpper = s => s.toUpperCase() 
const reverse = arr => arr.reverse() 
const first = arr => arr[0]
const f = _.flowRight(toUpper, first, reverse) 
console.log(f(['one', 'two', 'three']))
// 示例二
const _ = require('lodash');

const reverse = function (array) {
    return _.reverse(array)
}

const first = function (array) {
    return _.first(array)
}

const toUpper = function (array) {
    return _.toUpper(array)
}

const f = _.flowRight(toUpper, first, reverse);
console.log(f(['andy', 'tom', 'jack']));

函数组合总结

  • 两个参数的函数不能在函数组合中直接使用

    • 使用lodahs.curry转化为一个参数的函数(返回一个柯里化的函数)
    • 使用 Lodash中的FP模块
  • 模拟实现 lodash 的 flowRight 方法

// 函数组合(把这些管道连接起来)
function compose(...fns) {
    // fns:用于处理value的函数集合(数据的管道)
    // value:管道的初始输入数据
    // 反转数组:从右到左计算
    return function (value) {
        return fns.reverse().reduce( function (acc, fn) {
            return fn(acc)
        }, value)
    }
}
// 调用伪代码 多个函数组合为一个函数fn
const fn = compose(f3, f2, f1)
const result = fn(value)

// ES6
const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)

函数组合的结合律

  • 函数的组合要满足结合律 (associativity): 字母表示:a + b + c = a + (b + c)
// 结合律(associativity)
const _ = require('lodash')
const f = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))
console.log(f(['one', 'two', 'three'])) // => THREE

如何调试组合函数

使用log方式

// NEVER SAY DIE -> never-say-die

/*
1 split(' ') -> [ NEVER, SAY, DIE]
2 让每一项转换为小写
3 arr.join('')
*/

const _ = require('lodash');

// 两个参数的函数不能在函数组合中直接使用!!。
// _.split(str, sepa)  string: string, separator 我们需要最后一个参数是str,无法直接使用
// 使用函数柯里化转换为一个参数的函数,调用lodash的curry方法

// 重新包装split
const split = _.curry(function (step, str) {
    return _.split(str, step)
})

// _.map() collection: any[], iteratee: _.ArrayIterator<any, any>
// iteratee回调函数,决定我们如何返回新数组

// 重新包装map
const map = _.curry( function (fn, array) {
    return _.map(array, fn)
})

// join
// _.join() array: _.List<any>, separator?: string

// 重新包装join
const join = _.curry( function (step, array) {
    return _.join(array, step)
})

// 直接使用
// _.toLower() string?: string


const log = function(v) {
  console.log(v)
  //   [ 'NEVER', 'SAY', 'DIE' ]
  //   [ 'never', 'say', 'die' ]
  //   never-say-die
  return v
}

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

console.log(f('NEVER SAY DIE'))

使用trace方式


const _ = require('lodash');

const split = _.curry(function (step, str) {
    return _.split(str, step)
})

// _.map() collection: any[], iteratee: _.ArrayIterator<any, any>
// iteratee回调函数,决定我们如何返回新数组

const map = _.curry( function (fn, array) {
    return _.map(array, fn)
})

// _.toLower() string?: string

// join
// _.join() array: _.List<any>, separator?: string

const join = _.curry( function (step, array) {
    return _.join(array, step)
})

// const log = function (v) {
//     console.log(v)
//     return v
// }

const trace = _.curry( function (log, v) {
    console.log(log, v)
    // split之后 [ 'NEVER', 'SAY', 'DIE' ]
    // map之后 [ 'never', 'say', 'die' ]
    // never-say-die
    return v
})


const f = _.flowRight(join('-'), trace('map之后'), map(_.toLower), trace('split之后'), split(' '));

console.log(f('NEVER SAY DIE'))

总结

  • Lodash的map(),split()方法在函数组合中无法直接使用,使用lodahs.curry转化为一个参数的函数后可以正常使用。
  • 两个参数的函数不能在函数组合中直接使用
    • 使用lodahs.curry转化为一个参数的函数(返回一个柯里化的函数)
    • 使用 Lodash中的FP模块(函数式编程模块)

使用Lodash的fp模块,在使用函数组合中不需要重写split,和map方法了

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

// NEVER SAY DIE -> never-say-die

const f = _.flowRight( _.join('-'), _.map(_.toLower), _.split(' '));
console.log(f('NEVER SAY DIE'));

🆙 回到顶部