JavaScript函数式编程第一弹—高阶函数、纯函数、柯里化

782 阅读14分钟

话不多说,直接进入正题。

为什么要学函数式编程?

函数式编程是一个非常古老的概念:

  • 函数式编程随着React的流行受到越来越多的关注(React的高阶组件使用了高阶函数来实现,高阶函数就是函数式编程的一个特性。Redux也使用了函数式编程的思想。)
  • Vue3也开始拥抱函数式编程
  • 函数式编程可以抛弃this,从而抵消this指向问题的困扰
  • 打包过程中可以更好的利用tree shaking过滤无用代码
  • 方便测试、方便并行处理
  • 有很多库可以帮助我们进行函数式编程开发:lodash、underscore、ramda等

什么是函数式编程?

函数式编程,缩写FP,是一种编程范式,也是一种编程风格,和面向对象是并列的关系。函数式编程我们可以认为是一种思维模式,加上实现方法。其思维方式就是把现实世界事物和事物之间的联系抽象到程序世界(是对运算过程进行抽象)

常听说的编程范式还有面向过程编程(按照步骤来实现)、面向对象编程(把现实中的事物抽象成类和对象,通过封装、继承和多态来演示不同事物之间的联系)。

函数式编程和面向对象编程的不同

  • 从思维方式上来说 面向对象编程是对事物的抽象,将行为以对象方法的方式封装到数据实体内部,从而降低系统的耦合度。而函数式编程是对运算过程的抽象,将数据以输入输出流的方式封装进过程内部,从而也降低系统的耦合度。

对于函数式编程思维方式的理解:

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

// 函数式
function add(a, b) {
    return a + b
}
let sum = add(2, 3)
console.log(sum)

函数式编程的预备知识

函数是一等公民

在JS中函数就是一个普通的对象,我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过new Function('alert(1)')来构造一个新的函数。

  • 函数可以存储在变量中
// 把函数赋值给变量(函数表达式)
let fn = function () {
    console.log("hi")
}

fn() 

下面两个特性在高阶函数中会有详细说明

  • 函数作为参数
  • 函数作为返回值

高阶函数

什么是高阶函数?

高阶函数(Higher-order function)

  • 函数作为参数
// 模拟forEach
function forEach(arr, fn) {
    for(let i = 0; i < arr.length; i++) {
      	fn(arr[i]);
    }
}

const arr1 = [1, 2, 3, 4];
forEach(arr1, item => {
    item = item * item;
    console.log(item);
})
  • 函数作为返回值
function createFn() {
    let name = 'ming';
    return function() {
      	console.log(`my name is ${name}`);
    }
}

let getName = createFn();
// 调用方式1
getName(); // my name is ming
// 调用方式2
createFn()(); // my name is ming
// 模拟once函数,只执行一次
function once(fn) {
    let flag = false;
    return function() {
        if(!flag) {
            flag = true;
            fn.apply(this, arguments);
        }
    }
}
let commitOrder = once((time) => {
    console.log(`订单提交了${time}次`);
})
commitOrder(1); // 订单提交了1次
commitOrder(2);
commitOrder(3);

使用高阶函数的意义

  • 高阶函数可以用来抽象通用的问题(只需要知道我们的目标和定义解决这类问题的函数,而不需要关心实现的细节)

常用的高阶函数

有一个通用的特点,就是需要一个函数作为参数。

  • forEach
function forEach(arr, fn) {
    for(let i = 0; i < arr.length; i++) {
      	fn(arr[i]);
    }
}

const arr1 = [1, 2, 3, 4];
forEach(arr1, item => {
    item = item * item;
    console.log(item);
})
  • map 对数组中的每个元素进行遍历,并处理,处理的结果放在一个新数组中返回
function map(arr, fn) {
    const result = [];
    for(let i = 0; i < arr.length; i++) {
      	result.push(fn(arr[i]));
    }
    return result;
}

const arr2 = [1, 2, 3, 4];
const newArr2 = map(arr2, item => item * item)
console.log(newArr2);
  • filter 创建一个新的数组,新数组中的元素是通过检查指定数组中符合条件的所有元素。
function filter(arr, fn) {
    const result = [];
    for(let i = 0; i < arr.length; i++) {
        if(fn(arr[i])) {
          	result.push(arr[i]);
        }
    }
    return result;
}

const arr3 = [1, 3, 4, 5, 8, 9];
const newArr3 = filter(arr3, item => item % 2 === 0);
console.log(newArr3);
  • every 数组中的每一个元素是否都匹配我们指定的一个条件,如果都满足返回true,如果不满足返回false
function every(arr, fn) {
    let flag = true;
    for (let item of arr) {
        flag = fn(item);
        if (!flag) {
            break;
        }
    }
    return flag;
}

const arr5 = [2, 4, 6, 8, 9];
const q = every(arr5, item => item % 2 === 0);
console.log(q);
  • some 判断数组中是否有一个元素满足我们指定的条件,满足是true,都不满足为false
function some(arr, fn) {
    let flag = false;
    for(let item of arr) {
        flag = fn(item);
        if(flag) {
          	break;
        }
    }
    return flag;
}

const arr4 = [2, 3, 6, 8, 9];
const result = some(arr4, item => item > 7);
console.log(result);

  • find/findIndex
  • reduce
  • sort

闭包

闭包的概念

闭包:函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包(闭包让你可以在一个内层函数中访问到其外层函数的作用域)

  • 可以在另一个作用域中调用一个函数的内部函数并访问到该函数作用域中的成员

在上面函数作为返回值的过程中,其实我们就用到了闭包,下面进行语法演示:

function createFn () {
    let name = 'ming';
}
// 正常情况下,执行完createFn,里面的变量name会释放掉
// 但是下面的情况

function createFn () {
    let name = 'ming';
    return function() {
      	console.log(`my name is ${name}`);
    }
}
// 在上面函数中,返回了一个函数,而且在函数中还访问了原来函数内部的成员,就可以称为闭包

let getName = createFn();
getName(); // my name is ming
// getName为外部函数,当外部函数对内部成员有引用的时候,那么内部的成员name就不能被释放。当我们调用getName的时候,我们就会访问到name。

闭包的核心作用

把函数内部成员的作用范围扩大

闭包的本质

函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除。但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

/解读:函数执行的时候在执行栈上,执行完毕之后从执行栈上移除,内部成员的内存被释放。但是在函数执行完毕移除之后,释放内存的时候,如果外部有引用,则内部成员的内存不能被释放。/

闭包的案例

案例一

计算一个数平方和立方的运算

Math.pow(4, 2)
Math.pow(5, 2)
// 后面的二次方三次方很多次重复,下面要写一个二次方三次方的函数
function makePower (power) {
  return function (number) {
    return Math.pow(number, power)
  }
}

// 求平方
let power2 = makePower(2)
let power3 = makePower(3)

console.log(power2(4)) // 16
console.log(power2(5)) // 25
console.log(power3(4)) // 64
案例二

计算不同级别的员工工资

// 假设计算员工工资的函数第一个函数传基本工资,第二个参数传绩效工资
// getSalary(12000, 2000)
// getSalary(15000, 3000)
// getSalary(15000, 4000)

// 不同级别的员工基本工资是一样的,所以我们将基本工资提取出来,之后只需要加上绩效工资
function cretateSalary (base) { 
    return function (performance) { 
        return base + performance 
    }
}
let salaryLevel1 = cretateSalary(12000)
let salaryLevel2 = cretateSalary(15000)

console.log(salaryLevel1(2000)) // 14000
console.log(salaryLevel2(3000)) // 18000
console.log(salaryLevel2(4000)) // 19000

纯函数

纯函数的概念

相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。

纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)

let numbers = [1, 2, 3, 4, 5];
// 纯函数 
// 对于相同的输入,输出总是一样的

// slice方法,截取的时候返回截取的函数,不影响原数组
numbers.slice(0, 3); // [1, 2, 3] 
numbers.slice(0, 3); // [1, 2, 3] 
numbers.slice(0, 3); // [1, 2, 3] 

// 不纯的函数 
// 对于相同的输入,输出是不一样的

// splice方法,返回原数组,改变原数组
numbers.splice(0, 3); // [1, 2, 3] 
numbers.splice(0, 3); // [4, 5] 
numbers.splice(0, 3); // []

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

Lodash——纯函数的代表

  • lodash 是一个纯函数的功能库,提供了模块化、高性能以及一些附加功能。提供了对数组、数字、对象、字符串、函数等操作的一些方法

浅试Lodash

  • 安装

npm init -y -> npm i lodash

  • 体验
const _ = require('lodash');

const array = ['a', 'b', 'c', 'd'];

// head的别名是first  _.head(array)也可以
console.log(_.first(array)); // a
console.log(_.last(array)); // d

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

console.log(_.reverse(array));  // [ 'd', 'c', 'b', 'a' ]
// 数组的翻转不是纯函数,因为会改变原数组。这里的reserve是使用了数组的reverse,所以也不是纯函数

const r = _.each(array, (item, index) => {
  console.log(item, index)
  // d 0
  // c 1
  // b 2
  // a 3
})
console.log(r) // [ 'd', 'c', 'b', 'a' ]

纯函数的好处

可缓存

因为对于相同的输入始终有相同的结果,那么可以把纯函数的结果缓存起来,可以提高性能。

const _ = require('lodash')

function getArea(r) {
  console.log(r)
  return Math.PI * r * r
}

let getAreaWithMemory = _.memoize(getArea)
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
// 4
// 50.26548245743669
// 50.26548245743669
// 50.26548245743669

接下来模拟一个记忆函数memoize

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

function getSum(a, b) {
    console.log('getSum被调用');
    return a + b;
}

let getSumWithMemory = memoize(getSum);
console.log(getSumWithMemory(2, 3)); // getSum被调用 5
console.log(getSumWithMemory(2, 3)); // 5
console.log(getSumWithMemory(2, 3)); // 5

// getSum被调用
// 5
// 5
// 5

可测试

纯函数让测试更加的方便

并行处理

  • 多线程环境下并行操作共享的内存数据很可能会出现意外情况。纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数
  • 虽然JS是单线程,但是ES6以后新出现的Web Worker,可以开启一个新线程

副作用

副作用就是让一个函数变得不纯,纯函数的根据是相同的输入总是返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用,如下面的例子:

// 不纯的函数,因为它依赖于外部的变量
let min = 18;
function checkAge (age) { 
    return age >= min;
}

副作用来源:

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

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

柯里化

解决硬编码的问题

// 下面这段代码是解决了不纯的函数的问题,但是里面出现了硬编码
function checkAge (age) { 
    let min = 18;
    return age >= min;
}

// 普通的纯函数
function checkAge (min, age) {
    return age >= min;
}
console.log(checkAge(18, 20));  // true
console.log(checkAge(18, 24));  // true
console.log(checkAge(20, 24));  // true

// 经常和18进行比较,参数18是重复的。可以使用闭包进行改写
// ES5
function checkAge (min) {
    return function (age) {
        return age >= min;
    }
}

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

let checkAge18 = checkAge(18);
let checkAge20 = checkAge(20);

console.log(checkAge18(20)); // true
console.log(checkAge18(24)); // true

柯里化:当函数有多个参数的时候,我们可以对函数进行改造。我们可以调用一个函数,只传递部分的参数(这部分参数以后永远不变),然后让这个函数返回一个新的函数。新的函数传递剩余的参数,并且返回相应的结果。

Lodash中的柯里化 —— curry()

_.curry(fn)

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

// 几元函数对应几个参数,参数是一个的为一元函数,两个的是二元函数
// 柯里化可以把一个多元函数转化成一元函数
function getSum (a, b, c) {
  return a + b + c;
}

// 定义一个柯里化函数
const curried = _.curry(getSum);

// 如果输入了全部的参数,则立即返回结果
console.log(curried(1, 2, 3)); // 6

//如果传入了部分的参数,此时它会返回当前函数,并且等待接收getSum中的剩余参数
console.log(curried(1)(2)(3)); // 6
console.log(curried(1)(2, 3)); // 6
console.log(curried(1, 2)(3)); // 6

案例

判断字符串中有没有空白字符,或者提取字符串中所有空白字符,可以使用字符串的match方法:

''.match(/\s+/g)

但是我们要是写一个数组的去处空白字符的方法,上面的代码就无法重用。那我们如何用函数式方法去写

function match(reg, str) {
  return str.match(reg)
}

reg的表达式是重复的,上面的函数如何柯里化,思路是这样的:

//柯里化处理
const _ = require('lodash')

//利用lodash的curry函数,第一个参数是匹配规则,第二个参数是字符串,生成一个match函数
const match = _.curry(function (reg, str) {
  return str.match(reg)
})

// 根据规则haveSpace是一个匹配空格的函数
const haveSpace = match(/\s+/g)

console.log(haveSpace("hello world")) // [ ' ' ]
console.log(haveSpace("helloworld")) // null
// 由此可以判断字符串里面有没有空格

// 那如果是数字的话怎么办呢?
// 根据规则haveNumber是一个匹配数字的函数
const haveNumber = match(/\d+/g)
console.log(haveNumber('abc')) // null

// 对于数组怎么匹配元素中有没有空格
const filter = _.curry(function(func, array) {
  return array.filter(func)
})

// filter函数,第一个参数传递匹配元素中有没有空格
//第二个参数是指定的数组
console.log(filter(haveSpace, ['John Connor','John_Donne'])) // [ 'John Connor' ]

// 如果上述写还是比较麻烦,那么可以再封装一个函数出来
// filter可以传一个参数,然后返回一个函数
// 这个findSpace就是匹配数组元素中有没有空格的函数
const findSpace = filter(haveSpace)
console.log(findSpace(['John Connor','John_Donne'])) // [ 'John Connor' ]

下面对上面的思路做一个小的总结,柯里化的好处就是可以最大程度的重用函数

const _ = require('lodash')

// match函数是根据正则,匹配字符串,返回匹配结果
const match = _.curry(function (reg, str) {
  return str.match(reg)
})

// haveSpace函数是一个匹配空格的函数
const haveSpace = match(/\s+/g)

// haveNumber函数是一个匹配数字的函数
const haveNumber = match(/\d+/g)

// filter函数是定义一个数组和过滤规则,返回符合匹配规则的数组
const filter = _.curry(function(func, array) {
  return array.filter(func)
})

// findSpace函数是匹配数组元素中有空格并返回符合情况的数组的函数
const findSpace = filter(haveSpace)

柯里化原理模拟

我们找一个之前做过的例子分析一下

const _ = require('lodash')

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

const curried = _.curry(getSum)

console.log(curried(1, 2, 3))  // 6
console.log(curried(1)(2, 3))  // 6
console.log(curried(1, 2)(3))  // 6

实现一个柯里化转换函数要进行分析

  1. 入参出参:调用函数传入一个纯函数的参数,完成之后返回一个柯里化函数
  2. 入参情况分析:
  • 如果curried调用传递的参数和getSum函数参数个数相同,那么立即执行并返回调用结果
  • 如果curried调用传递的参数是getSum函数的部分参数,那么需要返回一个新的函数,并且等待接收getSum的其他参数
  1. 重点关注:
  • 获取调用的参数
  • 判断个数是否相同
// 模拟柯里化函数
function curry (fn) {
  return function curriedFn(...args) {
    if(args.length < fn.length) {
      return function() {
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    return fn(...args)
  }
}

const curriedTest = curry(getSum)

console.log(curriedTest(1, 2, 3))  // 6
console.log(curriedTest(1)(2, 3))  // 6
console.log(curriedTest(1, 2)(3))  // 6
console.log(curriedTest(1)(2)(3))  // 6

柯里化总结

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

参考