《少废话,直接看东西》之「函数式编程范式」

593 阅读27分钟

编辑摘要:本文是《少说废话,直接看东西》专栏的第一部分,主要介绍函数式编程范式,带你快速入门函数式编程的使用。

一、WHY?——为什么要学习和使用函数式编程?

首先,在介绍函数式编程是什么之前,我们需要简单的了解下,我们为什么需要学习和使用「函数式编程」。

原因总结如下:

  • 函数式编程随着 React 的流行而受到了越来越多的关注;
  • Vue3 也开始拥抱函数式编程了;
  • 函数式编程可以抛弃 this
  • 打包过程中可以更好的利用 tree shaking 过滤无用代码;
  • 方便测试,也方便并行处理;
  • 有很多第三方库可以帮助我们进行函数式开发,比如 Lodash 、 underscore 、 ramda 。

二、WHAT?—— 什么是函数式编程?

其实,函数式编程是一个非常古老的概念,其出现甚至早于第一台计算机的诞生。函数式编程的理论基础是由阿隆佐·邱奇在1930年代开发的Lambda演算。其本身是一种数学的抽象而不是编程语言。后来才被引入并应用到了编程语言中。

简言之,函数式编程(Function Programming,FP),是一种编程范式。
我们常听说的编程范式还有「面向过程编程」和「面向对象编程」:

  • (1)面向对象编程的思维方式:把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物之间的联系。
  • (2)函数式编程的思维方式:把现实世界的事物和事物之间的 联系 抽象成程序世界的函数(对运算过程进行抽象)
    • ① 程序的本质:根据输入通过某种运算获得对应的输出。程序开发过程中会涉及很多输入和输出的函数。
    • 函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数,即映射关系 ,例如:y=sin(x)、x 和 y 的关系。
    • ③ 函数式编程中一个很重要的概念是“纯函数”。纯函数最大的特征(或者说是它的定义)是:相同的输入始终要得到相同的输出。(关于纯函数,我们后面会介绍到)
    • ④ 简言之,函数式编程中的函数,就是用来描述数据之间的映射。
  • (3)面向过程编程由于我们不涉及,在此不做研究。

下面的代码可以帮助大家直观了解什么是函数式编程:

// 非函数式编程
let num1 = 2;
let num2 = 3;
let sum = num1 + num2;
console.log(sum);

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

三、推荐阅读

如果你想进一步了解函数式编程,推荐阅读阮一峰老师的《函数式编程初探》

在此不再赘述。

四、函数是一等公民

函数是一等公民, First-class Function ,MDN 中译为“头等函数”。其主要体现在:

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

在 JavaScript 中,函数就是一个普通的对象(可以通过 new Function() 的方式进行创建)。基于此,我们可以把函数存储到变量/数组中,还可以把函数作为另一个函数的参数和返回值,甚至我们也可以在函数运行的时候通过 new Function('alert(1)') 的方式来构造一个新的函数。

代码演示:

// 把函数赋值给变量
let fn = function () {
  console.log('Hello First-class Function');
} 
fn();

// 一个示例
const BlogController = {
  index (post) { return View.index(post) },
  show (post) { return View.show(post) },
  create (attrs) { return Db.create(attrs) },
  update (post, attrs) { return Db.update(post, attrs) },
  destroy (post) { return Db.destroy(post) },
};

// 实际上,类似于上述的 index、show 等函数就等同于 View.index、View.show 函数本身,所以可以将上述对象做下面的优化:
cosnt blogController = {
  index: View.index,
  show: View.show,
  create: Db.create,
  update: Db.update,
  destroy: Db.destroy
} 

// 函数作为另一个函数的参数
let fn = function () {
  console.log('Hello First-class Function');
};
let executeFn = function (fn) {
  fn();
};
executeFn();

// 函数作为返回值
let returnFn = function () {
    let fn = function () {
      console.log('Hello First-class Function');
    };
      return fn;
};
returnFn();

五、高阶函数

高阶函数,Higher-order function,即:

  • (1)可以把函数作为参数传递给另一个函数;
  • (2)可以把函数作为另一个函数的返回结果。

(一)函数可以作为参数

// 手写数组 forEach 方法
function forEach(array, func) {
    for (let i = 0; i < array.length; i++) {
        func(array[i]);
    }
};
// 测试
let arr = [1, 3, 4, 7, 8];
let fn = function (item) {
    console.log(item);
};
forEach(arr, fn); // 输出结果: 1 3 4 7 8
// 手写数组的 filter 方法
function filter(array, func) {
  let result = [];
  for (let i = 0; i < array.length; i++) {
    if (func(array[i])) {
      result.push(array[i]);
    }
  }
  return result;
}
// 测试
let arr = [1, 3, 4, 7, 8];
let fn = function (item) {
  return item % 2 === 0;
};
let result = filter(arr, fn);
console.log(result); // 输出结果: [4, 8]

(二)函数可以作为返回值

function makeFn () {
    let message = "Hello Function";
    return function () {
        console.log(message);
    };
};
const fn = makeFn(); // function () {console.log(message);};
fn(); //  "Hello Function"
// once 函数 —— 无论函数执行多少次,传入的函数只执行一次
// once 函数是函数作为返回值(高阶函数)的经典用法
function once(func) {
  let done = false;
  return function () {
    if (!done) {
      done = true;
      return func.apply(this, arguments); // apply 传递的参数是一个数组或类数组对象
    }
  };
}
// 使用 once 函数
let pay = once(function (money) {
  console.log(`you've payed ${money} RMB`);
});

// 测试
pay(5); // 输出:you've payed 5 RMB
pay(5); // 无输出
pay(5); // 无输出
pay(5); // 无输出
pay(5); // 无输出
// 执行结果:不论我们执行多少次 pay 函数,once 中传入的函数只会执行一次

(三)高阶函数的意义

使用高阶函数的意义在于:

  1. 高阶函数的抽象化可以帮助我们屏蔽细节,我们只需要关心我们的目标即可;
  2. 高阶函数是用来抽象通用问题的,通常具有普适性,并且可以重复使用;
  3. 高阶函数的使用可以使我们的函数变得很灵活,比如像 forEach 和 filter 函数。

(四)常用的高阶函数

高阶函数有很多,数组中常用的很多方法都是高阶函数,比如:forEach、map、filter、every、some、find、findIndex、reduce、sort ……

下面我们再来模拟几个高阶函数 —— map、every、some:

// 手写数组 map 方法
const map = (array, func) => {
  let result = [];
  for (const value of array) {
    result.push(func(value));
  }
  return result;
};

// 测试
let arr = [1, 2, 3, 4];
arr = map(arr, (value) => value ** 2); // 求幂
console.log(arr); // [ 1, 4, 9, 16 ]
// 手写数组 every 方法
const every = (array, func) => {
  let result = true;
  for (const value of array) {
    result = func(value);
    if (!result) {
      break;
    }
  }
  return result;
};

// 测试
const arr1 = [11, 12, 14];
const arr2 = [9, 12, 14];
const res1 = every(arr1, (value) => value > 10);
const res2 = every(arr2, (value) => value > 10);
console.log(res1, res2); // true false
// 手写数组 some 方法
const some = (array, func) => {
  let result = false;
  for (const value of array) {
    result = func(value);
    if (result) {
      break;
    }
  }
  return result;
};

// 测试
const arr1 = [1, 3, 4, 9];
const arr2 = [1, 3, 5, 9];
const res1 = some(arr1, (value) => value % 2 === 0);
const res2 = some(arr2, (value) => value % 2 === 0);
console.log(res1, res2); // true false

六、闭包

(一)闭包的概念

闭包(Closure):函数和其周围的状态(词法环境)的引用捆绑在一起就形成了闭包。
形象点讲就是,可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员。比如上面我们实现的 makeFnonce 函数。

(二)闭包的本质

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

(三)闭包的作用

闭包的作用在于延长了外部函数的内部变量的作用时长。

其带来的明显的好处是:

  1. 保护函数内部的变量不受外部作用域的影响;
  2. 将函数内部的变量保存了下来,而不至于在函数执行完成之后被移除掉(所占内存被释放掉)。

闭包所带来的坏处也同样的显而易见:如果闭包使用的过多,会导致大量的内存被长时间占有而无法释放,最终的结果就是使我们的代码运行起来会变得非常的卡顿。

所以,闭包虽好,可不要贪多吆~

(四)闭包的案例演示

在此通过两个案例来演示一下闭包的作用:

  1. 案例1——求一个数的幂运算结果:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>closure</title>
  </head>
  <body>
    <script>
      // 求一个数的幂运算的方法
      function makePower (power) {
          return function (number) {
              return Math.pow(number, power);
          };
      };
      
      // 当我们需要多次求固定次方的幂运算时,就可以利用闭包的机制先生存一个对应的函数
      const power2 = makePower(2); // 先生成一个专门用来求平方的方法
      const power3 = makePower(3); // 先生成一个专门用来求立方的方法
      
      // 执行(这样可以使代码变得简洁,并提高我们书写代码的效率)
      console.log(power2(4)); // 16(求4的平方)
      console.log(power2(5)); // 25(求5的平方)
      console.log(power3(2)); // 8(求2的立方)
      console.log(power3(3)); // 27(求3的立方)
    </script>
  </body>
</html>
  1. 案例2——写一个求工资的方法:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>closure</title>
  </head>

  <body>
    <script>
      function getSalary (base) {
          return function (permance) {
              return base + permance; // base表示基本工资,permanence表示绩效工资
          }
      }

      const salaryLevel1 = getSalary(12000); // 工资水平1:基本工资12000
      const salaryLevel2 = getSalary(15000); // 工资水平2:基本工资15000

      console.log(salaryLevel1(2000)); // 基本工资12000,绩效工资2000时的工资
      console.log(salaryLevel1(5000)); // 基本工资12000,绩效工资5000时的工资
      console.log(salaryLevel2(3000)); // 基本工资15000,绩效工资3000时的工资
      console.log(salaryLevel2(5000)); // 基本工资15000,绩效工资5000时的工资
    </script>
  </body>
</html> 

七、纯函数

注:
① 从本部分开始,我们会使用到高性能的 JavaScript 实用工具库 Lodash ,需要在本地安装 lodash 模块。
② 如果需要详细了解 Lodash ,请参阅官网文档 www.lodashjs.com/

(一)纯函数的概念

函数式编程中的函数,指的就是纯函数。
纯函数指的是,相同的输入永远会得到相同的输出,而且没有任何可观察的副作用的函数

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

如果你使用过一些 JS 的第三方库,其实它们中也提供了很多纯函数的功能。比如 lodash ,lodash 就是一个纯函数的功能库,它提供了对数组、数字、对象、字符串和函数等的一些操作方法,其中很多方法都是纯函数。

像日常使用的数组方法中,也有很多纯函数的方法,比如 slice
对应的,数组中的另一个方法 splice 就不是一个纯函数(或者称之为不纯的函数)。
下面我们来对比一下纯函数和不纯的函数:

// 纯函数 slice ,可以返回数组中的指定部分,返回被不会改变原数组。
// 不纯的函数 splice ,可以对数组进行删除、增加或者替换的操作,返回被修改的部分(以数组形式),会改变原数组。
let array = [1, 2, 3, 4, 5];

// 使用纯函数 slice 时,相同的输入对应相同的输出
console.log(array.slice(0,3)); // [1, 2, 3]
console.log(array.slice(0,3)); // [1, 2, 3]
console.log(array.slice(0,3)); // [1, 2, 3]

// 使用不纯的函数 splice 时,相同的输入却可以得到不同的输出
console.log(array.splice(0,3)); // [1, 2, 3]
console.log(array.splice(0,3)); // [4, 5]
console.log(array.splice(0,3)); // []

函数式编程通常不会保留计算中间的结果,所以变量是不可变的,或者说是无状态的(即函数式编程不会影响作为函数输入输出的变量的值,这些变量的值不会因为被函数使用而发生变化,所以也就不存在变化的过程)。
因此,我们可以把一个函数的执行结果交给另一个函数去处理(也就是可以进行链式调用,后面讲lodash的时候会涉及到)。

(二)纯函数的优势

纯函数的优势有:

  1. 可缓存;
    • 因为纯函数对相同的输入始终有相同的输出,所以可以把纯函数的结果缓存起来。例如 lodash 中的记忆函数 memoize 方法:
    // lodash 中的 memoize 方法
    const _ = require('lodash');
    
    // 求圆的面积
    function getArea (r) {
        console.log(r);
        return Math.PI * r * r;
    };
    
    let getAreaWithMemory = _.memoize(getArea);
    
    console.log(getAreaWithMemory(4)); // 4 50.26548245743669
    console.log(getAreaWithMemory(4)); // 50.26548245743669
    console.log(getAreaWithMemory(4)); // 50.26548245743669
    // lodash 中的 memoize 方法对 getArea 函数的返回结果在第一次执行后进行了缓存,所以后续再执行函数且输入相同的时候,不再重复执行函数 getArea ,而是直接返回了缓存的 gertArea 函数的返回值。
    
    • 模拟 lodash 中 memoize 方法的实现:
    // 模拟 memorize 方法的实现
    function memorize(func) {
      const cache = {};
      return function () {
        const key = JSON.stringify(arguments); // 把函数的参数(即函数执行时的输入)转为字符串,然后此字符串会作为上述缓存对象 cache 的属性。这里的 arguments 是匿名函数(当前所处的函数)的传入参数, arguments 是一个伪数组。
        cache[key] = cache[key] || func.apply(func, arguments); // 如果缓存中有对应的键值对,则取用缓存中的值;如果缓存中不存在,则将参数(arguments是类数组,所以用 apply 传参)传给函数,等函数执行时同时缓存函数的执行结果
        return cache[key];
      };
    }
    
    // 测试
    function getArea(r) {
      console.log(r);
      return Math.PI * r * r;
    }
    const getAreaWithMemory = memorize(getArea);
    console.log(getAreaWithMemory(4)); // 4 50.26548245743669
    console.log(getAreaWithMemory(4)); // 50.26548245743669
    console.log(getAreaWithMemory(4)); // 50.26548245743669
    
  2. 可测试
    由于纯函数始终有输入和输出,而单元测试就是在断言这个函数的结果,所以我们所有的纯函数都是可以测试的函数。因此,纯函数让测试更方便。
  3. 并行处理(仅做了解即可)
    在多线程环境下并行操作共享的内存数据很可能会出现意外情况(比如同时对一个数据进行操作时,由于不确定操作完成的先后顺序,所以不确定最后的数据会编程什么样)。
    而纯函数是一个封闭的空间。纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数。
    JavaScript 之前是完全单线程的,但是 ES6 之后新增了一个 Web Worker ,它可以开启新的线程,依次来提高程序运行的性能。所以现在 JavaScript 有时候也可能是多线程的了。
    但大多数时候 JavaScript 仍然是单线程的,因此并行处理这一点仅做了解即可。

(三)纯函数的副作用

  1. 什么是副作用 我们在定义纯函数的时候是这么定义的:“对于相同的输入永远会得到相同的输出,而且没有任何可观察的副作用”。
    这里可观察的副作用是指:函数如果依赖于外部的状态就无法保证相同的输入有相同的输出,此时就会带来副作用。
    副作用会让一个函数变得不纯。例如:
// 不纯的函数,如果依赖的外部状态发生变化,相同的输入会得到不同的输出。
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;
};
  1. 副作用的来源。 所有的外部交互都有可能导致副作用,例如:
  • 配置文件
  • 数据库
  • 获取用户的输入
  • ……
  1. 副作用的影响
  • 副作用使得方法通用性下降,不适合扩展,可复用性不高;
  • 同时副作用也会给程序带来安全隐患和不确定性。
  1. 副作用无法完全禁止,我们只能尽可能地把副作用控制在可控范围内。

八、柯里化(Haskell Brooks Curry)

(一)什么是柯里化?

函数的柯里化是指,当一个函数有多个参数的时候,先传递一部分参数去调用它(这部分参数以后永远不变),然后返回一个新的函数用于接受剩余的参数,然后执行新的函数返回结果。即:

  • 一个函数有多个参数时先传递一部分调用函数;
  • 调用函数时返回一个新的函数用于接受剩余参数;
  • 传递剩余参数执行返回的新函数返回最终结果。

(二)柯里化案例

举个例子,比如将上面的 checkAge 函数做柯里化处理:

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

// 柯里化处理后的纯函数
function checkAgeCurry (min) {
    return function (age) {
        return age >= min;
    };
};
// 使用 ES6 的箭头函数精简一下:
let checkAgeCurry = min => (age => age >= min);
// 柯里化后的纯函数的执行
let checkAgeCurry18 = checkAgeCurry(18);
let checkAgeCurry20 = checkAgeCurry(20);
console.log(checkAgeCurry18(20));
console.log(checkAgeCurry18(24));
console.log(checkAgeCurry20(18));
console.log(checkAgeCurry20(24));

再举个例子,将数组的 match 和 filter 方法做柯里化处理:

// 这里我们需要引入 lodash ,使用其提供的 curry 方法
const _ = require('lodash');

// 将 match 方法转换为纯函数,并柯里化
const match = _.curry(function (reg, str) {
    return str.match(reg);
});
const haveSpace = match(/\s+/g); // 匹配字符串中所有空白字符的方法
const haveNumber = match(/\d+/g); // 匹配字符串所有的数字字符的方法

// 将 filter 方法转换为纯函数,并柯里化
const filter = _.curry(function (func, array) {
    return array.filter(func);
});
const findSpace = filter(haveSpace);

// 测试
console.log(filter(haveSpace, ['John Conner', 'John_Donne'])); // [ 'John Conner' ]
console.log(findSpace(['John Conner', 'John_Donne'])); // [ 'John Conner' ]

(三)柯里化原理模拟

下面我们来模拟实现 lodash 中的 curry 方法:

// 模拟 curry 方法的实现
function curry(func) {
  return function curriedFunc(...args) {
    // 判断实惨和形参的个数
    if (args.length < func.length) {
      // 当传入的实参个数少于函数柯里化之前的形参个数时,返回一个新函数等待接受剩余参数
      return function () {
        return curriedFunc(...args.concat(Array.from(arguments))); // 将两次传入的实参拼接成一个新数组,然后解构,传给函数去执行
      };
    }
    return func(...args);
  };
}

// 测试
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

(四)对柯里化的总结

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

九、组合函数/函数组合

(一)函数组合的概念

  1. 为什么使用函数组合

纯函数和柯里化很容易写出 h(g(f(x))) 这样的洋葱代码来.
例如:使用 lodash 的相关方法获取数组的最后一个元素再转换为大写字母时,你会写出这样的代码来:

const _ = require('lodash');

const array = ['aaa', 'bbb', 'ccc', 'ddd'];
const result = _.toUpper(_first(_.reverse(array)));  // 这行代码就变成了洋葱代码,一层套一层

console.log(result);

这样的代码一方面写起来非常容易出错,另一方面维护起来也很麻烦,极易让人头大。所以我们引入了函数组合的概念来解决这种情况。

因此,我们首先明确的一点是,函数组合的目的或者说是作用,是可以让我们把细粒度的函数重新组合产生一个新的函数,以提高代码的可阅读性和可维护性。

  1. 管道

在正式介绍函数组合的概念之前,需要先引入另一个概念 —— 管道

“管道”并不是什么严谨的术语,只是我们暂时借助的一个概念。当我们使用函数处理数据的时候,我们可以把函数处理数据的过程,想像成一个管道。
当函数比较复杂的时候,我们可以把函数拆分成多个小函数,我们不需要关注中间的运算过程(包括运算过程中产生的临时变量之类的)。就像是把一个特别复杂的管道拆分成很多段简短的管道一样,虽然制作整个复杂的管道很难,但是制作很多个简单的管道却很简单,然后我们再把一段段简短的管道拼接起来就变成了一开始很复杂的整体管道。
这个拼接起来的过程,就是我们接下来要讲的“函数组合”的概念。

  1. 函数组合

函数组合(compose):如果一个函数需要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。
解释一下就是,函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。

需要注意的是,函数组合默认是从右往左执行的 。这是因为函数组合的内部执行最后还是会变成洋葱代码(只是我们不可见而已),默认情况下,函数执行时,靠前的在外层,靠后的在内层,而代码是自内而外的执行的。

// 模拟只能组合两个函数的函数组合方法,以做原理演示
function compose(f, g) {
    return function (value) {
        return f(g(value));
    };
};

// 测试
function reverse(array) {
    return array.reverse();
};
function first(array) {
    return array[0];
};
const last = compose(first, reverse); // 实际上我们可以使用 array[array.length-1] 直接取数组的最后一项,但是我们为了演示所以才会拆分为先将数组反转再取数组的第一项这两步操作。
console.log(last([1,2,3,4])); // 4

(二)Lodash 中的组合函数简介

Lodash 中的组合函数有两个,分别是:

  • flow ,组合的函数是从左往右被执行的;
  • flowRight ,组合的函数是从右往左被执行的,这个函数往往使用的更多一些。
// 这里我们仅演示 flowRight 方法, flow 的用法与其相同,只是参数的传递顺序相反。
const _ = require('lodash');

const reverse = arr => arr.reverse();
const first = arr => arr[0];
const toUpper = str => str.toUpperCase();

const func = _.flowRight(toUpper, first, reverse); // 取数组的最后一项,并将其字符变为大写
console.log(func(['one', 'two', 'three'])); // 'THREE'

(三)函数组合的原理模拟

下面我们来模拟实现 Lodash 中的 compose 方法:

// 模拟 compose 方法的实现
function compose(...args) {
  return function (value) {
    return args.reverse().reduce(function (accumulator, currentFunc) {
      return currentFunc(accumulator); // accumulator:累计器,累计回调的返回值,它是上一次调用回调时返回的累积值。currentFunc:数组中正在处理的元素,即当前要执行的函数。
    }, value);
  };
}

// 可以使用 ES6 的箭头函数进一步精简为:
const composeES6 =
  (...args) =>
  (value) =>
    args.reverse().reduce((acc, fn) => fn(acc), value);

// 测试
const reverse = (arr) => arr.reverse();
const first = (arr) => arr[0];
const toUpper = (s) => s.toUpperCase();
const f = composeES6(toUpper, first, reverse);
console.log(f(["one", "two", "three"])); // 'THREE'

(四)函数组合的结合律

函数的组合要满足结合律(associativity),即我们可以把任意相邻的函数先组合,然后再与其他函数或其他组合后的函数继续组合,最终得到的结果都是一样的。

// 结合律演示:
let f = compose(f, g, h);
let assciativity1 = compose(compose(f, g), h);
let associativity2 = compose(f, compose(g, h));
console.log(assciativity1 == f); // true(此处的相等仅表示两个函数是等效的,并不代表两个函数的内存地址相等)
console.log(assciativity2 == f); // true(此处的相等仅表示两个函数是等效的,并不代表两个函数的内存地址相等)
// lodash 中函数组合的结合律
const _ = require('lodash');

const arr = ['one', 'two', 'three'];
const f1 = _.flowRight(_.toUpper, _.first, _.reverse); // 此处的 toUpper 、 first 、 reverse 是 Lodash 中提供的方法,功能和前面我们自己写的同名函数相同。
const f2 = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse));
const f3 = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse);

console.log(f1); // 'THREE'
console.log(f2); // 'THREE'
console.log(f3); // 'THREE'

(五)函数组合的调试

使用函数组合的时候还存在一个问题,那就是如果程序出现问题,我们应该怎么调试程序,找出问题所在。
其实操作也很简单,我们只需要写一个跟踪函数,并将其放在函数组合的适当位置即可。

下面我们用代码进行演示:

// 需求:将大写的 "NEVER SAY DIE" 转换为小写的 "never-say-die"
const _ = require('lodash);

// 我们先来封装需求实现的功能函数
// 字符串分割函数
const split = _.curry((separator, string) => _.split(string, separator));
// 字符转小写用 lodash 中的 toLower 函数
// 字符串拼接函数
const join = _.curry((connector, array) => _.join(array, connector));
// 遍历数组并对数组每一项都进行处理的函数
const map = _.curry((func, array) => _.map(array, fn));

// ⭐️ 下面我们来写跟踪函数(函数调试的重点) ⭐️
const trace = _.curry((tag, value) => {
    console.log(tag, value); // tag 是标识符,用于标记跟踪函数所在的位置,value 是接受的上个函数的返回值
    return value;
});

// 函数组合的时候,将跟踪函数也组合进去,并加上对应的标识,这样就可以对函数出问题的位置进行跟踪了。
const fn = _.flowRight(join('-'), trace('join函数之前'), map(_.toLower), trace('map函数之前'), split(' ')); // 也可以将标识文字改为“在xxx之后"

console.log(fn("NEVER SAY DIE")); // 加入跟踪函数之后,我们就可以对函数组合后的每一步执行结果都做到一目了然了。如果中间某个小函数出现了问题,那也可以很快的定位出问题所在

十、Lodash 中的 FP 模块

lodash 中还有个 fp 模块,这个模块为我们提供了实用的对函数式编程友好的方法。所谓的函数式编程友好是指 lodash/fp 中提供了“自动柯里化、优先迭代/函数、数据最后/置后”(auto-curried iteratee-first data-last)的方法。

  • 自动柯里化:fp 模块中的函数都是已经经过柯里化处理的,可以直接使用无需再进行柯里化处理。
  • 优先迭代:如果 fp 模块中的函数的参数中有函数(迭代器),函数参数会被要求放在参数的最前面。
  • 数据最后:fp 模块中的函数的参数中,要处理的数据被要求放在参数的最末尾。

fp 模块带给我们的最大的方便就是所有的方法都不需要考虑去做柯里化处理,而且他使我们关注的重点不需要放在如何处理所调用的方法和函数上,这使得我们可以更专心的处理数据。

演示代码:

// 需求:将数组中的每一项(英文字符串)转换为大写
const arr = ['a', 'b', 'c'];

// ① 使用 lodash 模块中的方法时:
const _ = require('lodash');
const result = _.map(arr, _.toUpper); // 数据优先,迭代最后
console.log(result);

// ② 使用 lodash/fp 模块中的方法时:
const fp = require('lodash/fp');
const resultFp = fp.map(fp.toUpper, arr); // 迭代优先,数据置后
// 或者
const resultFpCurry = fp.map(fp.toUpper)(arr); // fp 模块中的方法都是经过柯里化处理过的
console.log(resultFp);
console.log(resultCurry);

再比如上一节中演示代码中的需求,如果使用 fp 模块中的方法进行处理的话将变得非常简单:

// "NEVER SAY DIE" --> "never-say-die"
const fp = require('lodash/fp');

const fn = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '));
console.log(fn("NEVER SAY DIE"));

十一、Lodash 中 map 方法的小问题

由于 lodash 和 lodash/fp 中的 map 方法是存在不同的,所以这就导致了使用 lodash 的 map 方法搭配 parseInt 内置方法时可能会出现问题,而 fp 模块中的 map 方法则不会出现问题。

下面我们使用代码演示这个问题;

  1. 当使用 lodash 中的 map 方法时
// lodash 中的 map 方法搭配 parseInt 使用时
const _ = require('lodash');

const result = _.map(['23', '8', '10'], parseInt);
console.log(result); // 输出结果:[23, NaN, 2]

得出的结果与我们预期的结果大相径庭。
这是因为,lodash 中的 map 方法的第二个参数 —— 迭代函数接收的参数有3个,按照先后顺序分别是(如下图,截取自官方文档):

  • ① 要处理的数组中的每一个元素;
  • ② 每一个元素的索引;
  • ③ 原数组

图片来源自Lodash官方文档.png

而 parseInt 方法最多只能接受2个参数,分别是(如下图,截取自MDN):

  • ① 要被解析的值;
  • ② 指定被解析值的进制数,有效取值为 2 到 36。可选参数。

图片来源自MDN.png

由此,导致了 lodash 中的 map 方法将第一个参数数组中的每一项的索引传递给了 parseInt 方法的第二个参数,使得 parseInt 方法以数组索引为数组每一项的进制数,对数组每一项进行了解析。才得到了上述代码中的有问题的答案(进制的问题请参考MDN 中 parseInt 方法的描述部分的后半段:

  • 将‘23’按照十进制进行解析(0时 parseInt 会根据数据的实际情况推断对应的进制数,以非0开头时会被推断为十进制),解析结果即为原数字23;
  • 将‘8’按照一进制进行解析(由于1不符合参数的有效取值范围,即不存在一进制的数,因此解析结果为不是一个数字,即NaN);
  • 将‘10’按照二进制进行解析,解析结果即为2。
  1. 当使用 lodash/fp 中的 map 方法时,则不会出现上述问题:
// lodash/fp 中的 map 方法搭配 parseInt 使用时
const fp = require('lodash/fp');

const resultFp = fp.map(parseInt, ['23', '8', '10']);
console.log(resultFp); // 输出结果:[23, 8, 10]

这是因为,lodash/fp 模块中的 map 方法,第一个参数为迭代器函数,第二个参数为数组。而且 fp 模块的 map 方法的迭代器只会传递一个参数,即第二个参数数组的每一项的值。

如果使用 lodash 的 map 方法,请注意这一点。

十二、Point Free

如想进一步了解 Point Free,推荐阅读:阮一峰老师的《Pointfree 编程风格指南》。地址:www.ruanyifeng.com/blog/2017/0…

Point Free:我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只是把一些简单的运算步骤合成到一起(就像是数学家使用简单的逻辑运算推导出可以解决复杂问题的公式,而不用关心要计算的数据具体是什么一样)。

当然,在使用这个模式之前,我们需要定义一些没有被提供的基本的运算函数。
当然,合成运算的过程肯定要用到我们前面提到的函数组合

阮一峰老师将 Point Free 译为无值风格。

实际上,我们在第十部分《Lodash 中的 FP 模块》的使用 fp 模块时的演示代码(点击跳转查看代码)中,已经使用了 Point Free 的思想。
接下来,我们再用一个案例来演示一下 Point Free :

// 需求:把一个字符串中的首字母提取,并转换为大写,然后使用“. ”进行分割,最终拼接卫字符串。
// "world wild web" => "W. W. W"
const fp = require('lodash/fp');

const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' ')); // 按照需求来合成运算,不需要考虑数据

console.log(firstLetterToUpper("world wild web)); // 'W. W. W'

十三、Functor —— 函子

如想更详细的了解函子的相关知识,可以参考阮一峰老师的函数式编程入门教程一文,地址:www.ruanyifeng.com/blog/2017/0…

(一)函子的概念

容器:包含了值和值的变形关系的对象。 函子是一个特殊的容器。
一般约定,函子的标志就是容器具有 map 方法。 map 方法可以将容器里面的每一个值,映射到另一个容器。(map 方法是从外部接受数据和向外部返回数据的接口)

下面来演示一下函子的简单用法:

// Functor 函子
class Container1 {
    constructor(value) { // value:值
        this._value = value;
    }
    
    map(func) { // func:变形关系
        return new Container1(func(this._value)); 
    }
}

// 测试
let r1 = new Container195).map(x => x + 1).map(x => x ** 2);
console.log(r1); // Container1 {_value: 36}

// 每一次执行类的时候都需要 new 来处理一下,书写起来不太方便,所以我们可以把 new 这一步给封装一下:
class Container2 {
    static of(value) {
        return new Container2(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return Container2.of(func(this._value));
    }
};

// 测试
// 我们在前面的操作就可以变得像下面的这样简洁:
let r2 = Container2.of(5).map(x => x + 1).map(x => Math.pow(x, 2));
console.log(r2); // Container2 {_value: 36}

// 上述的 Container1 和 Container2 就是函子

(二)函子的要点

  1. 函数式编程的运算不直接操作值,而是由函子完成。
  2. 函子是一个实现了 map 契约的对象。
  3. 我们可以把函子想象成一个盒子,这个盒子里封装了一个值。
  4. 需要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数),由这个函数对值进行处理。
  5. 最终 map 方法返回一个包含新值的函子。
  6. 函子的副作用:当我们传入的值为 null 、 undefined 等时,函子执行可能会报错。
// 函子的副作用
class Container {
    static of(value) {
        return new Container(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return Container.of(func(this._value));
    }
}

// 测试
let result = Container.of(null).map(x => x.toUpperCase());
console.log(result); // 报错:Uncaught TypeError: Cannot read property 'toUpperCase' of null。因为在 null 上没有 toUpperCase 方法。

(三)MayBe 函子

从本小节开始,我们介绍比较常用且比较重要的几个函子。

像上节中,我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理。
MayBe 函子的作用就是可以对外部的空值情况做处理(将空值副作用控制在允许的范围内)。

对于上节中的代码,我们进一步完善,解决控制时的副作用,如下:

// MayBe 函子
class MayBe {
    static of(value) {
        return new MayBe(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return this.isNothing ? MayBe.of(null) : MayBe.of(func(this._value));
    }
    
    isNothing() {
        return this._value === null || undefined;
        // 或者 return this._value ? false : true;
    }
}

// 测试
let r1 = MayBe.of('Hello World').map(x => x.toUpperCase());
console.log(r1); // MayBe {_value: 'HELLO WORLD'}

let r2 = MayBe.of(null).map(x => x.toUpperCase());
console.log(r2); // MayBe {_value: null}

let r3 = MayBe.of('Hello World')
  .map(x => x.toUpperCase())
  .map(x => null)
  .map(x => x.split(' '));
console.log(r3); // MayBe { _value: null }

上述代码中的 r3 中,虽然输出结果是正确的,但是我们无法确定是哪一步输出了 null 。因此我们需要借助于下一节中的 Either 函子。

(四)Either 函子

Either 意为两者中的任何一个。在函数式编程中,Either 函子类似于 if...else... 的处理。
由于异常会让函数变得不纯,因此 Either 函子也可以用来做异常处理。

Either 函子的常见用途:

  • 提供默认值(如果右值有值,就使用右值,否则使用左值。通过这种方式,Either 函子表达了条件运算)
  • 代替try...catch,使用左值表示错误。

Either 函子可以这样写:

// Left 和 Right 类就是 Either 函子
class Left {
    static of(value) {
        return new Left(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return this;
    }
}

class Right {
    static of(value) {
        return new Right(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return Right.of(func(this._value));
    }
}

// 测试
let r1 = Right.of(12).map(x => x + 2);
let r2 = Left.of(12).map(x => x + 2);
console.log(r1); // Right {_value: 14}
console.log(r2); // Left {_value: 12}

function parseJSON(str) {
    try {
        return Right.of(JSON.parse(str));
    } catch(e) {
        return Left.of({error: e.message});
    }
}

let r = parseJSON('{name: zs}');
console.log(r); // Left {_value: {error: 'Unexpected token n in JSON at position 1'}}

也可以这样写:

class Either {
    static of(left, right) {
    return new Either(left, right);
    }
    
    constructor(left, right) {
        this._left = left;
        this._right = right;
    }
    
    map(func) {
        return this._right ? 
            Either.of(this._left, func(this._right)) : 
            Either.of(func(this._left), this._right);
    }
}

// 测试
let r1 = Either.of(5, 6).map((x) => x + 1);
let r2 = Either.of(1, null).map((x) => x + 1);
console.log(r1); // Either {_left: 5, _right: 7}
console.log(r2); // Either {_left: 2, _right: null}

function parseJSON(str) {
  try {
    return Either.of(null, JSON.parse(str));
  } catch (e) {
    return Either.of(e.message, null);
  }
}
let r = parseJSON("{name: zs}");
console.log(r); // Either { _left: 'Unexpected token n in JSON at position 1', _right: null }

(五)IO 函子

IO 函子中的 _value 是一个函数,这里是把函数作为值来处理。
IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不存的操作(惰性执行)。
不纯的操作最后交给调用者来处理。

代码示例:

// IO 函子
const fp = require("lodash/fp");

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

// 测试
// 当我们在控制台使用 node 命令执行当前文件时,由于是 node 环境,所以我们可以调用 node 的方法来获取一下 node 的文件的路径
let r = IO.of(process).map((p) => p.execPath);
console.log(r); // IO { _value: [Function (anonymous)] }  <= 这个匿名函数是 map 方法中用到的 flowRight 方法
// 我们手动调用 _value 中存储的不纯的函数
console.log(r._value()); // Mac下输出:/usr/local/bin/node ,Window下输出:C:\Program Files\nodejs\node.exe

(六)Folktale 中的 Task 函子

函子也可以用于处理异步任务,由于异步容易出现回调地狱,所以我们可以使用 task 函子来进行处理。
由于异步任务的实现过于复杂,所以我们在此使用 folktale 中的 Task 函子来进行演示。

  1. folktale
    folktale 是一个标准的函数式编程库,其特点是:
  • 和 lodash 、 ramda 不同的是,他没有提供很多功能函数;
  • 他只提供了一些函数式处理的操作,例如: compose 、 curry 等,以及一些函子 Task 、Either 、 MayBe 等。

代码示例:

// 在此先演示 folktale 中的 compose 、 curry 函数
const { compose, curry } = require("folktale/core/lambda");
const { toUpper, first } = require("lodash/fp");

// folktale 中 curry 函数的第一个参数是第二个参数——函数的参数个数(arity)
let f = curry(2, (x, y) => {
  return x + y;
});
console.log(f(1, 2)); // 3
console.log(f(1)(2)); // 3

let g = compose(toUpper, first);
console.log(g(["one", "two"])); // 'ONE
  1. Task 函子
    这里我们以 folktale 2.3.2 版本中的 Task 函子来进行演示:
// Task 处理异步任务(需要提前安装好 node 、 folktale 和 lodash)
const fs = require('fs');
const {task} = require('folktale/concurrency/task');
const {split, find} = require('lodash/fp');

// 使用 Task 来处理异步的读取文件的操作
function readFile(filename) {
    return task(
        resolver => {
            fs.readFile(filename, 'utf-8', (err, data) => {
                if(err) resolver.reject(err);
                
                resolver.resolve(data);
            })
        }
    );
}

// 假设统计目录下存在一个 package.json 文件
// 执行上述防范返回的是一个 Task 函子,可以调用 Task 的一些内置方法(如 run 、 listen)
readFile('package,json')
    .run()
    .listen({
        onRejected: err => {
            console.log(err);
        },
        onResolved: value => {
            console.log(value);
        }
    })
// 执行上述代码输出如下结果(假设 package.json 文件中的内容如下):
/* {
    "name": "juejin
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "ColdCoder"
    "license": "ISC",
    "dependencies": {
      "folktale": "^2.3.2",
      "lodash": "^4.17.21"
} */

// 需求:将读取文件的结果按行进行分割为数组,然后找到有 version 的项
readFile('package,json')
    .map(split('\n'))
    .map(find( x => x.includes('version')))
    .run()
    .listen({
        onRejected: err => {
            console.log(err);
        },
        onResolved: value => {
            console.log(value);
        }
    })
// 代码执行后输出结果为: "version": "1.0.0",

(七)Pointed 函子

所谓 Pointed 函子是指实现了 of 静态方法的函子。

of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文 Context 中(即:把值放到容器中,以方便使用 map 方法来处理值)

代码示例:

class Container {
    static of(value) {
        return new Container(value);
    }
    
    constructor(value) {
        this._value = value;
    }
    
    map(func) {
        return Container.of(func(this._value));
    }
}

Container.of(2).map(x => x + 5);

(八)Monad 函子

  1. IO 函子问题
    如果我们在使用 IO 函子的时候,由于函数的嵌套,在输出结果时可能需要多次执行实例中的函数。这样的链式调用使用起来很不爽。
// IO 函子的问题
const fs = require("fs");
const fp = require("lodash/fp");

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

// readFile 函是是个嵌套函数
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");
console.log(r); // IO{_value: [Function]}

r = cat("package.json")._value();
console.log(r); // IO{_value: [Function]} IO{_value: [Function]}

// 我们在拿到结果前需要多次调用,这很麻烦,于是需要使用 Monad 函子来解决这个问题。
r = cat("package.json")._value()._value();
console.log(r); // package.json 的内容

因此我们需要使用 Monad 函子来解决这个问题。

  1. Monad(单子)函子
    Monad 函子是可以变扁(扁平化处理)的 Pointed 函子。
    一个函子如果有 join 和 of 两个方法,并遵循一些定律,就是一个 Monad 函子。

像上面的 IO 函子的需要多次调用的问题,可以通过下面的 join 方法来解决

// IO Monad
const fs = require("fs");
const fp = require("lodash/fp");

class 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();
  }
  
  flatMap(fn) {
      return this.map(fn).join();
  }
}

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 r = readFile('package.json').flatMap(print).join(); // 输出文件信息
console.log(r);

// 输出大写的文件内容
let rUpper = readFile('package.json')
    .map(x => x.toUpperCase())
    .flatMap(print)
    .join();
console.log(rUpper);
// 如果使用 lodash 来实现的话,代码如下:
// let rUpper = readFile('package.json').map(fp.toUpper).flatMap(print).join();

函数式编程范式的简单介绍到此结束。如有错误,烦请不吝指正。