快速入门函数式编程

lxf2023-04-10 10:24:02

大家好,今天我想跟大家介绍一下JavaScript函数式编程。函数式编程是一种编程范式,它强调的是函数的使用和组合,而不是像传统的面向对象编程那样,强调状态和可变性。在JavaScript中,函数式编程已经得到了广泛的应用,例如ReduxLodashRamda等库都是基于函数式编程思想实现的。如果你想学习JavaScript函数式编程,那么本次分享一定会对你有所帮助。

编程范式

函数式编程是一种编程范式。编程范式指的是一种编写代码方式方法,是一种思维方式,不同的编程范式有着不同的特点和优缺点,常见的编程范式有面向过程编程、面向对象编程、函数式编程等等。让我们来具体看几个例子

面向过程编程

// 定义一个计算两个数和的函数
function sum(a, b) {
  return a + b;
}
// 读取用户输入的两个数
let num1 = parseFloat(prompt("请输入第一个数:"));
let num2 = parseFloat(prompt("请输入第二个数:"));
// 调用sum函数计算两个数的和
let result = sum(num1, num2);
// 输出结果
console.log("两个数的和为:" + result);

这是一个面向过程编程的案例,我们定义了一个计算两个数和的函数sum,然后通过读取用户输入的两个数,调用sum函数计算它们的和,最后输出结果。这个案例中没有使用任何面向对象编程的概念,而是通过流程控制的方式完成了计算。

面向对象编程

接下来我们看一个面向对象编程的案例:

class Calculator {
  constructor(num1, num2) {
    this.num1 = num1;
    this.num2 = num2;
  }
  sum() {
    return this.num1 + this.num2;
  }
}
let calculator1 = new Calculator(5, 7);
console.log("两个数的和为:" + calculator1.sum());

在这个案例中,我们定义了一个名为Calculator的类,通过构造函数定义两个数的属性,然后定义了一个sum方法,用于计算两个数的和。最后,我们创建了一个Calculator对象calculator1,并调用其sum方法计算两个数的和,将结果输出到控制台中。这个案例中使用了面向对象编程的概念,使代码更加清晰、易于维护和扩展。

函数式编程

最后我们再来看一个函数式编程的案例:遍历一个数组,将每一个元素的数值翻倍

const arr = [1, 2, 3, 4, 5];
const doubledArr = arr.map(num => num * 2);
console.log(doubledArr);

在这个案例中,我们定义了一个名为arr的数组,并使用map方法,对数组中的每一个元素进行操作,在箭头函数中将每个元素乘以2,最终返回一个新的数组doubledArr。最后,我们将新的数组输出到控制台中。这个案例中使用了函数式编程的概念,通过使用高阶函数map,将操作封装成函数,并处理返回值,使代码更加清晰、易于维护和扩展。

注意:函数式编程中的函数不是JavaScript语言中的函数概念,而是我们初中数学里的函数,可以看做是一种映射关系,将输入值映射为输出值。

既然编程范式有那么多种,为什么我们在一些设计中更多会选择函数式编程,它究竟有什么好处呢?这个就要从副作用和纯函数说起了。

副作用和纯函数

函数是对过程的封装,但函数的实现本身可能依赖外部环境,而一旦依赖外部环境,就有可能产生副作用,那我们首先看下什么是副作用?

副作用

所谓函数的副作用,是指函数执行本身可能依赖了外部不可控的环境,具体可以分为3大类进行探讨:

第一类,函数中最常见的副作用,就是全局变量,来看一个案例:

let count = 0;
function increment() {
  count++;
  console.log(count);
}
increment(); // 输出1
increment(); // 输出2

在这个例子中,increment函数会对全局变量count的值造成改变,因此increment函数具有副作用。每次调用increment函数都会将count的值加1,并且将结果输出到控制台上。 这种全局变量对函数的副作用可能会导致代码变得难以理解和调试。因为我们无法确定哪些函数会对全局变量产生影响,从而导致程序的错误或不可预测的结果。

第二类函数中的副作用是 IO 影响,这里的 IO 说的不是函数里的参数和返回值,而是类似前端浏览器中的用户行为,比如鼠标和键盘的输入。

let clicked = 0;
function countClicks() {
  document.addEventListener('click', () => {  //和外部环境(文档的 DOM 结构)关联
    clicked++;
    console.log(clicked);
  });
}
countClicks();

在这个例子中,countClicks函数会对用户的鼠标行为造成影响,每次用户点击页面时,clicked的值都会加1,并且将结果输出到控制台上。这也是一种副作用,因为函数的执行不仅仅是输出结果,还会改变外部环境的状态。

第三类副作用是网络,比如我们要针对用户下单的动作发起一个网络请求,需要先获得用户 ID,再连着用户的 ID 一起发送。假如网络速度慢,如果还没获取到用户 ID,就发起下单请求,就会报错

那有没有办法减少以上这些副作用呢?答案是在函数式编程中有个核心概念叫做纯函数(pure function)

纯函数

通常把不依赖外部环境和没有副作用的函数叫做纯函数,依赖外部环境或有副作用的函数叫做非纯函数。具体我们来看几个案例:

function add(x, y) {
  return x + y;
}

function getEl(id) {
  return document.getElementById(id);
}

案例一add 是一个纯函数,它的返回结果只依赖于输入的参数,无论你调用多少次又或者任何时候调用,结果都是一样的。

案例二 getEl 是一个非纯函数,它的返回值除了依赖于参数 id,还和外部环境(文档的 DOM 结构)有关。

了解了什么是纯函数,那它和非纯函数相比有哪些优点呢?

纯函数优点

优点一:易于测试

纯函数不需要依赖外部环境,直接写测试 case 就可以了,例如:

test(t => {
  dosth...
  
  done!
});

非纯函数因为依赖外部环境,在测试的时候我们还需要构建外部环境

//开始准备工作
test.before(t => {
  //setup environments
});

//结束之后清空配置
test.after('cleanup', t => {
  //clean
});

test(t => {
  dosth...
  
  done!
});

优点二:纯函数可以并行计算

在浏览器中,我们可以利用 Worker 来并行执行多个纯函数,在 Node.js 中,我们也可以用 Cluster 来实现同样的并行执行。

优点三:纯函数有良好的 Bug 自限性

纯函数不会对外部环境产生任何影响,不会改变任何状态或变量,因为它们只能通过输入参数来计算输出结果,不会受到其他因素的影响。这样就使得纯函数的行为更加可预测和可控,从而减少了出现错误和异常的可能性。

既然纯函数有这么多好处,那我们就可以在自己的项目里尽量多的去使用它。接下来,我举几个在项目中常用到的纯函数案例:柯里化(curry)和函数复合(Compose)

柯里化(curry)

首先什么是柯里化呢?我们可以简单的理解成用少于期望数量的参数去调用一个函数,这个函数返回一个接受剩下参数的函数。这么说可能很抽象,我们来一个具体的案例:

我们先来看看一个简单的add 函数,这个函数接受一个参数并且返回一个函数。

//原始的加法函数add
function add(x, y, z) {
  return x + y + z;
}

// 改成柯里化函数
function curriedAdd(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    };
  };
}
curriedAdd(1)(2)(3) // 6

在这个例子中,我们定义了一个原始的加法函数add,它接受三个参数并返回它们的和。然后我们定义了一个柯里化的函数curriedAdd,它接受一个参数x,并返回一个新的函数,这个新函数接受参数y,并返回另一个新函数,这个新函数接受参数z,并返回x + y + z的结果。不过这样写太麻烦了,我们可以实现一个通用的 curry 方法来实现上面的效果。

function add(x, y, z) {  return x + y + z;}    function curry(fn) {  // 获得函数参数的数量  const arity = fn.length;  return function curried(...args) {    // 如果当前收集到的参数数量大于需要的数量,那么执行该函数    if (args.length >= arity) return fn(...args);    // 否者,将传入的参数收集起来    // 下面的写法类似于    // return (...args1) => curried(...args, ...args1);    return curried.bind(null, ...args);  };}let curryAdd = curry( add );console.log(curryAdd(1)(2)(3))  //6 可以每次只传一个参数console.log(curryAdd(1,2)(3))   //6 也可以根据需要传入多个参数,不影响结果

这段代码实现了一个函数柯里化的通用方法curry,它将任意一个函数转化为柯里化的函数,使得该函数可以接受单个参数,也可以接受多个参数并返回一个新的函数,直到收集到足够的参数后执行原始函数。

curry函数的实现方法比较简单,它首先获取原始函数的参数数量,然后返回一个接受任意数量参数的函数curried。在curried函数中,如果当前收集到的参数数量大于需要的数量,那么执行该函数,否则将传入的参数收集起来,返回一个绑定了这些参数的新函数,继续等待接收下一个参数。 通过使用函数柯里化,我们可以预先定义一些参数,生成一个新的函数,然后在需要的时候再传入剩余的参数,这样可以使代码更加灵活和可复用。使用这种方法,我们不需要在定义函数时考虑所有可能的参数组合,而是可以在运行时动态地定义和调用这些函数。

函数复合(Compose)

函数复合是函数式编程中的一个重要概念,它指的是将多个函数组合在一起,形成一个新的函数。这个新函数将会按照一定的顺序依次执行这些函数,每个函数的输出作为下一个函数的输入。这样的组合方式可以实现更为复杂的逻辑,同时也方便了代码的复用。我们还是来看一个具体的例子:

function addOne(x) {
  return x + 1;
}
function double(x) {
  return x * 2;
}
function square(x) {
  return x * x;
}
// 定义一个函数复合的函数
function compose(...fns) {
  return function(x) {
    return fns.reduceRight((acc, fn) => fn(acc), x);
  }
}
// 将三个函数组合成一个新函数
const addOneThenDoubleThenSquare = compose(square, double, addOne);
// 使用新函数计算结果
const result = addOneThenDoubleThenSquare(3); // ((3 + 1) * 2) ^ 2 = 64
console.log(result); // 输出 64

在这个例子中,我们定义了三个简单的函数:addOnedoublesquare。然后我们使用compose函数将它们组合成了一个新的函数addOneThenDoubleThenSquare。这个新函数的执行顺序是先执行addOne,然后将结果传递给double,再将double的结果传递给square。最后,我们使用这个新函数计算了输入值为3时的结果,并将结果输出到控制台上。 这个例子展示了如何使用函数复合来组合多个函数,实现更加复杂的逻辑。通过这种方式,我们可以将复杂的程序逻辑分解成简单的函数,并使用函数复合的方式将它们组合起来,使代码更加简洁、可读、易于维护。

要点总结

函数式编程的内容非常多,我这里只是举例说了一些基础的概念和代码,把大家带进了函数式编程的大门。里面还有很多知识点例如不可变,单子等等很多概念和应用,感兴趣推荐一本书

《Javascript函数式编程思想》

首先,我们了解了编程范式并且举例进行了说明,然后,我们知道函数式编程有一个非常大的优点,就是能够减少非纯函数的数量,这也是我们设计系统时要遵循的原则。因为相比于非纯函数,纯函数具有更好的可测试性、执行效率和可维护性。最后,我们还学会了函数式编程中最重要的两个应用柯里化和函数复合,也是函数式编程的