JavaScript 函数式编程入门

关键词: 函数式编程 纯函数 函数柯里化 函数组合

〇. 概述

函数式编程是一个古老的概念, 他的历史可以追溯到计算机诞生之初的 λ 演算,

在 1950 年代, 随着 Lisp 语言的创建,函数式编程 ( Functional Programming, 简称 FP ) 就已经开始出现在大家视野

直到近些年函数式以其优雅, 简单的特点开始重新风靡整个编程界, 主流语言在设计的时候无一例外都会更多的参考函数式特性

在前端领域, 我们同样能看到很多函数式编程的影子: ES6 中的新增的箭头函数, react 中大量使用函数式编程, Vue3 也开始拥抱函数式编程...

下面用一个例子直观的展示函数式编程:

假设现在有这样一个需求点:

用户输入一些产品 id 列表, "114514,114515,114516" ,( 因为输入时不严谨, 所以实际存在两种分隔符: 全角逗号 , 和半角逗号 ,) 为了便于之后扩展使用, 需要将"id 列表" 转换成如下格式的框对象数组

js 复制代码
[{ id: "114514" }, { id: "114515" }, { id: "114516" }]

如果是面向过程的编程思路, replace 替换, split 分割, forEach 遍历...一步一步的实现就可以了:

js 复制代码
const id_input = "114514,114515,114516";
const handleFuncPOP = str => {
  // 全角逗号替换成半角逗号
  const ids = str.replace(/,/g, ",");
  // 分割成数组
  const idsList = ids.split(",");
  const result = [];
  // 遍历数组, 转换成对象
  idsList.forEach(item => {
    result.push({ id: item });
  });
  return result;
};
console.log(handleFuncPOP(id_input));

但如果是一个 FPer, 他会这样思考:

1, 我要实现 原始字符串 => 规范的字符串 => 字符串数组 => 对象数组的转换;

2, 替换字符串和字符串转数组可以用 Lodash 提供的 replace 和 split;

3, 字符串数组转对象数组需遍历数组, 然后将数组的每一项转换成目标格式的对象;

4, 数组遍历可以使用 Lodash 提供的 map , 转换对象需要自己实现一个抽象;

5, 最后将这些函数组合起来, 就可以供业务逻辑调用了

改用函数式编程的思维编写代码:

ps: 这段代码里面涉及的 柯里化 curry, 函数组合 flowRight 等概念接下来会深入介绍, 此处只需简单体会下函数式编程的代码即可~

js 复制代码
const fp = require("lodash/fp");
const id_input = "114514,114515,114516";
const createObj = fp.curry((key, arr) => new Object({ [key]: arr }));
const handleFuncFP = fp.flowRight(fp.map(createObj("id")), fp.split(","), fp.replace(",", ","));
console.log(handleFuncFP(id_input));

对比两种编程思想, 面向过程的代码中有很多中间变量 (往往是实际项目中很多 bug 的来源), 复用性低 , 代码不够简洁, 且没有函数式编程线性逻辑可读性

ps: 这里指的函数式代码可读性好 指的是对于一个熟悉函数式编程的开发人员, 对比其他编程范式代码发散的逻辑来说, 函数式编程的代码更趋近于线性逻辑且更接近自然语言, 所以可读性好

一. 什么是函数式编程

函数式编程没有一个标准的定义, 你可以通过以下介绍对它有一个自己的理解

  • 函数式编程(Functional Programming, 简称 FP)是一种"编程范式(programming paradigm)"

  • 函数式编程中的"函数"指的不是代码中的函数 Function, 而是数学中的函数, 即映射关系 y = f(x)

  • 函数式编程避免使用程序状态以及易变对象

  • 函数是编程抽象出的函数都是细粒度的函数, 这些函数可以无限次复用, 通过组合可以将这些细粒度的函数变成功能强大的函数

  • 如果说面向对象编程是抽象现实当中的事物, 那么函数式编程就是对运算过程的抽象

函数式编程有几个前提概念, 其中最重要的概念就是"函数是一等公民"

二. 如何理解"函数是一等公民"

所谓"一等公民(first class)"指的是在 JavaScript 和很多其他编程语言中, 函数与其他数据类型一样, 可以去任何"值"可以去的地方, 很少有限制

譬如我们可以将函数赋值给其他变量, 也可以将函数作为参数传入另一个函数, 亦或者将函数作为别的函数的返回值返回(引出下一个概念, 高阶函数)

三. 使用高阶函数的意义

高阶函数定义: 将函数作为参数或作为返回结果的函数

JavaScript 中也提供了很多好用的高阶函数: forEach, map, filter...

抽象可以帮助我们屏蔽细节, 只需关注于我们的目标, 高阶函数可以让我们更好的去抽象通用的问题:

js 复制代码
// 打印数组每个元素
const array = [1, 2, 3, 4];
// 普通方法
for (let i = 0; i < array.length; i++) {
  console.log(array[i]);
}
// 高阶函数的方法
// 可以将需求抽象成两个函数: 遍历数组 + 打印元素
// 遍历数组过程的抽象, 可以使用ES6提供的遍历数组函数 forEach
// 打印元素: item => console.log(item)
array.forEach(item => console.log(item));

四. 回顾闭包

函数式编程另一个重要的前提概念, 回顾一下我们的老朋友"闭包"

闭包的概念:

  • 函数和周围状态(词法环境)的引用捆绑在一起形成闭包

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

闭包可以"缓存"外层函数的变量:

js 复制代码
// 生成一个只会执行一次的函数
function once(fn) {
  let done = false;
  return function () {
    if (!done) {
      // 这里访问的就是闭包中的done
      done = true;
      return fn.apply(this, arguments);
    }
  };
}
let pay = once(money => console.log(`支付${money}元`));
pay(5); // => 打印"支付5元"
pay(5); // => 不会打印
pay(5); // => 不会打印

闭包也可以"缓存"入参:

js 复制代码
// 生成求不同次幂的函数
function makePower(power) {
  return function (number) {
    return MAth.pow(number, power);
  };
}
let power2 = makePower(2);
let power3 = makePower(3);
power2(2); // => 4
power3(2); // => 8

五. 纯函数

纯函数定义: 相同的输入永远会得到相同的输出 , 而且没有任何可察觉的副作用

举个例子: slice 和 splice 同样可以实现数组截取, 但是 slice 每次输出相同的结果, 而 splice 每次输出的结果不同; slice 不会改变原数组, 但 splice 会改变原数组, 也就是说 slice 是纯函数, 而 splice 是不纯的函数:

js 复制代码
let numberList = [1, 2, 3, 4, 5];
// 纯函数 slice
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
// 不纯的函数 splice
console.log(numberList.splice(0, 3)); // => [1, 2, 3]
console.log(numberList.splice(0, 3)); // => [4, 5]
console.log(numberList.splice(0, 3)); // => []

关于副作用:

  • 如果函数依赖于外部的状态就无法保证相同输出, 就会带来副作用

  • 副作用使得方法通用性下降不适合扩展和可重用性

  • 副作用会给程序带来安全隐患和不确定性

  • 副作用不可能完全禁止, 尽可能控制它们在可控范围内发生

  • 副作用来源

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

函数式编程的一个特点是不会保留中间的结果, 变量是无状态 的, 所以函数式编程所用的函数基本都是纯函数, 这样就可以放心的把一个函数的结果交给另一个函数去执行, 且不用考虑会产生副作用

因为纯函数相对相同的输入始终有相同的输出, 所以需要的时候可以把纯函数的结果缓存起来

六. 函数柯里化

函数柯里化的概念: 当一个函数有多个参数的时候先传递一部分参数, 然后返回一个新的函数接收剩余的参数, 直到收集全部所需的参数, 返回最终结果

举一个简单的例子体验下柯里化:

js 复制代码
const _ = require("lodash");
const getSum = (a, b, c) => a + b + c;
const curredGetSum = _.curry(getSum);
console.log(curredGetSum(1, 2, 3)); // => 6
console.log(curredGetSum(1)(2)(3)); // => 6

手动实现一个简单的柯里化

js 复制代码
function curry(func) {
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if (args.length < func.length) {
      return function () {
        return curriedFn(...args.concat(Array.form(arguments)));
      };
    }
    return func(...args);
  };
}
// 可以将柯里化形象的理解为: 每次收集一颗龙珠(入参)放兜(闭包)里, 直到集齐全部七颗龙珠(全部入参)才能召唤神龙(返回结果)

总结柯里化:

  • 柯里化可以让我们给一个记住了某些参数的新函数 (可以理解为对函数的配置)

  • 柯里化是一种对函数参数的"缓存" (闭包的应用)

  • 柯里化让函数变得灵活, 让函数的粒度更小

  • 柯里化可以将多元函数转换成一元函数, 利用函数组合可以产生更强大的函数

七. 函数组合

函数组合的概念:

  • 如果一个函数要经过多个函数处理才能得到最终值, 这个时候可以把中间过程的函数组合并成一个函数

  • 函数就像是数据的管道 pipe, 函数组合就是把这些管道连接起来, 让数据穿过多个管道, 最终形成结果

ps: 关于管道可以回想下 vue 被废弃的一个功能: 过滤器 filter, filter 中就有管道的概念

  • 函数组合通常是从右向左执行

  • 函数组合要满足结合律

js 复制代码
const fun1 = compose(f, g, h);
const fun2 = compose(compose(f, g), h);
const fun3 = compose(f, compose(g, h));
// fun1, fun2, fun3 等价

手动实现一个简单的函数组合

js 复制代码
function compose(...args) {
  return function (value) {
    return args.reverse().reduce(function (acc, fn) {
      return fn(acc);
    }, value);
  };
}
// 改写成箭头函数
const compose =
  (...args) =>
  value =>
    args.reverse().reduce((acc, fn) => fn(acc), value);

八. Lodash

Lodash 是一个纯函数的功能库, 提供了对数组, 数字, 字符串, 函数等操作的一些方法

Lodash 专门为函数式编程提供了 fp 模块, Lodash/fp 的函数遵循 "自动柯里化, 函数优先, 数据置后"的理念, 方便函数式编程使用, 同样类型的库还有 Ramda 等

可以回顾下开头的例子, 体验 Lodash/fp

九. 总结

总结下函数式编程的优点

  • 更少的状态量, 降低复杂度, 减少出错概率

  • 引用透明, 不会牵一发而动全身

  • 高度可组合, 代码简洁, 流程清晰, 更接近自然语言

  • 打包过程中可以更好的利用 tree shaking 来过滤无用代码

  • 有很多优秀的库可以帮助我们进行函数式开发, 譬如 Lodash, Ramda

相对的, 它也有一些缺点

  • 学习路径陡峭, 新概念多且抽象

  • 大量使用闭包, 递归, 且为了实现状态不变的特性往往需要创建新对象, 导致额外消耗空间, GC 压力大, 影响性能

  • 复杂功能需要大量抽象函数, 抽象的粒度不好掌握

函数式编程的核心思想:

  • 多使用纯函数减少副作用的影响
  • 抽象函数提高复用率
  • 使用柯里化增加函数适用率
  • 使用函数函数组合减少中间变量的使用

学习函数式编程的意义在于: 让你意识到在指令式编程, 面向对象编程之外, 还有一种全新的思路, 一种利用函数去抽象问题的思路, 学习函数式编程可以丰富你的武器库, 不然你的手里只有一把锤子, 看什么都是钉子

日常开发中, 往往我们要取长补短, 不同的场景下用合适的方法, 我们学习不同编程范式的最终目的都是为了让自己的编码更加高效, 易懂, 稳定, 软件开发没有"银弹"

参考资料

相关推荐
安冬的码畜日常1 个月前
【玩转 JS 函数式编程_016】DIY 实战:巧用延续传递风格(CPS)重构倒计时特效逻辑
开发语言·前端·javascript·重构·函数式编程·cps风格·延续传递风格
zidea2 个月前
Rust 闭包:捕获环境的魔法函数
rust·ai编程·函数式编程
独泪了无痕2 个月前
Optional 使用指南:彻底告别 NPE
后端·函数式编程
doodlewind3 个月前
通过 TypeScript 类型体操学习日语语法
typescript·编程语言·函数式编程
谦谦橘子3 个月前
rxjs原理解析
前端·javascript·函数式编程
桦说编程3 个月前
【硬核总结】如何轻松实现只计算一次、惰性求值?良性竞争条件的广泛使用可能超过你的想象!String实际上是可变的?
后端·函数式编程
Oberon4 个月前
从零开始的函数式编程(2) —— Church Boolean 编码
数学·函数式编程·λ演算
桦说编程4 个月前
CompletableFuture 超时功能有大坑!使用不当直接生产事故!
java·性能优化·函数式编程·并发编程
桦说编程4 个月前
如何安全发布 CompletableFuture ?Java9新增方法分析
java·性能优化·函数式编程·并发编程
桦说编程4 个月前
【异步编程实战】如何实现超时功能(以CompletableFuture为例)
java·性能优化·函数式编程·并发编程