视频加载失败

前来领教一下函数式编程的魅力

4073 字
20 分钟
前来领教一下函数式编程的魅力

为什么我们需要函数式编程?#

可能大家已经听说函数式编程带来的各种好处,其中最吸引人的莫过于它可以降低代码的复杂度,这一点真的太重要了,实现同样的功能,面向对象的代码会更复杂,而使用函数式编程则可以轻松控制复杂度,那我们就没有理由不选择函数式编程啦! 注意,函数式编程不是一种解决方案,它是一种编程范式,如果你已经熟悉了面向对象编程这种思维习惯,那么你学习函数式编程则需要转变你的思维模式,初期可能会很困难,但是这都没关系,慢慢就会熟悉的.

在真正开始之前,我们首先要知道的几个概念#

我们先来理解在函数式编程中几个常见的概念吧

纯函数#

纯函数的具有以下特点

  • 相同的输入总是返回相同的输出,即函数的输出只依赖于它的输入参数。
  • 函数没有副作用,即它不会对除了它自己的外部环境产生任何影响。

副作用函数#

副作用函数具有包括但不限于以下特点

  • 修改全局变量或静态变量
  • 修改函数参数
  • 抛出异常
  • 修改文件系统
  • 发送网络请求
  • 调用其他副作用函数

柯里化#

柯里化(Currying)是指将一个多参数的函数转换成一系列单参数的函数的过程。换句话说,柯里化是一种将函数套娃的技术,它通过返回一个新函数来实现。新函数接受第一个参数并返回一个接受第二个参数的函数,以此类推,直到所有参数都被传递完毕,最终返回最终结果。

// 原始函数
function add(x, y, z) {
return x + y + z;
}
// 柯里化后的函数
function curriedAdd(x) {
return function(y) {
return function(z) {
return x + y + z;
}
}
}
// 使用柯里化后的函数
const result = curriedAdd(1)(2)(3); // 6

偏函数#

偏函数是指一个函数只接受部分参数,然后返回一个新函数来处理剩余的参数。换句话说,偏函数是一种通过部分应用程序来创建新函数的技术。

// 原始函数
function add(x, y, z) {
return x + y + z;
}
// 偏函数
const addPartial = x => (y, z) => add(x, y, z);
// 使用偏函数
const add5 = addPartial(5);
const result = add5(2, 3); // 10

引用透明性#

引用透明性是指一个表达式可以被它的值替换而不影响程序的行为。换句话说,引用透明性是指一个函数的输出仅取决于其输入,而与函数的内部状态、副作用或上下文无关。

例如,考虑以下函数:

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

这个函数是引用透明的,因为对于任何给定的x和y,它的输出总是x和y的和。我们可以将这个函数的调用替换为它的值,而不会影响程序的行为:

const result = add(2, 3);
const result2 = 5;

可置换性#

可置换性是指在程序中可以用一个表达式替换另一个表达式而不会影响程序的行为。

例如,考虑以下代码片段:

javascript
const x = 2;
const y = 3;
const z = x + y;

在这个代码片段中,我们可以将x + y替换为它的值5,而不会影响程序的行为:

const x = 2;
const y = 3;
const z = 5;

这是因为x + y是可置换的,它的值可以被它的计算结果替换,而不会影响程序的行为。

不可变数据#

不可变数据是指一旦创建就不能被修改的数据。这意味着一旦数据被创建,它的值就不能被改变,而任何对该数据的修改都会创建一个新的数据。

不可变数据是函数式编程的一个重要概念,因为它可以确保程序的正确性和可预测性。由于不可变数据不能被修改,所以它们可以被共享和缓存,从而提高程序的性能和效率。此外,不可变数据还可以避免出现副作用,从而使得程序更加易于理解和测试。

在js中,基础类型天生就具有不可变的特点,但是在引用类型上是可以被后期改变的,这会带来副作用,我们需要有意识的取避免这种情况发生,通常我们可以有一些手段来规避这些问题:

  • 闭包来隐藏变量,只向外界暴露get之类的操作,而没有set之类的方法,这样就可以做到不可变性了。虽然这样可以做到,但是由于js的动态性,这些暴露的方法也可以被重新覆写,唉,这也是没有办法的事情。
  • 还有一种保证引用类型不可变性的方式,就是完全拷贝一份对象的副本,这样即使你如何操作副本,源对象也不会受到影响。
  • 还可以通过代理源对象的行为来控制,比如使用Proxy,definedProperty来劫持set行为,使其触及不到源对象,比如使用Object.freeze()来冻结对象,它内部使用的也是definedProperty来重写属性的描述符达到不可更改的目的.
  • 社区中其实已经有一些专门的库来解决这些问题,比如ramda.js,它可以让你在做写入操作时,只会产生一个局部的副本,而不是创建完整的源对象本身,这样的性能无疑会比你每次都完整克隆一个源对象来得高得多

下面我们来展示一些实际的代码来提示函数式编程的好处#

函数式编程宏观上来说是鼓励任务的拆分,让子任务之间相互独立遵循单一原则,然后通过组合来完成最终的任务;组合和拆分,他们之间的相互作用就促成了函数式编程范式.

以下是一些使用函数式编程的代码示例,感受一下函数式编程带来的优点:

易于测试#

function add(x, y) {
return x + y;
}
// 测试
assert.equal(add(2, 3), 5);
assert.equal(add(5, 7), 12);

由于add函数是纯函数,它只依赖于输入参数,不会对外部环境造成影响。这使得测试变得容易,并且我们可以轻松地测试它的输出,而不需要考虑测试环境的状态。

可读性更好#

const numbers = [1, 2, 3, 4, 5];
// 命令式
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
// 函数式
const sum = numbers.reduce((acc, cur) => acc + cur, 0);

在这个例子中,我们可以看到函数式代码更简洁、更易读。reduce函数的目的很明显,它的实现描述了我们正在做什么。

可维护性更好#

// 命令式
let count = 0;
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
count++;
}
}
// 函数式
const count = numbers.filter(num => num % 2 === 0).length;

在这个例子中,函数式代码更简洁、更易读,更容易维护。我们不需要担心副作用,因为我们只是从数组中筛选出偶数,并计算它们的数量。

可并行处理#

const numbers = [1, 2, 3, 4, 5];
// 命令式
let squares = [];
for (let i = 0; i < numbers.length; i++) {
squares.push(numbers[i] * numbers[i]);
}
// 函数式
const squares = numbers.map(num => num * num);

在这个例子中,我们可以看到函数式代码更简洁、更易读,更容易并行处理。由于map函数是纯函数,它可以安全地在多个线程或进程中同时运行,而不会产生竞态条件和死锁。

函数式编程和面向对象编程的区别#

函数式编程和面向对象编程是两种不同的编程范式,它们有一些重要的区别:

    1. 数据和操作的处理方式不同:

在面向对象编程中,数据和操作是紧密耦合的。对象是数据和操作的集合,数据和操作都封装在对象中。对象之间通过方法调用来交互和通信。

而在函数式编程中,数据和操作是分离的。数据和操作被视为两个独立的实体,函数接受数据作为输入并返回输出。函数之间通过参数和返回值来交互和通信。

    1. 可变性不同:

在面向对象编程中,对象的状态可以被修改,因此对象是可变的。这意味着对象的状态可以在程序执行过程中发生变化。

而在函数式编程中,数据是不可变的。一旦数据被创建,它的值就不能被修改。这样可以避免副作用,并使程序更加可靠和容易理解。

    1. 控制流不同:

在面向对象编程中,程序的控制流通常是通过方法调用和对象之间的交互来实现的。这种方式可以使程序的控制流更加灵活和动态。

而在函数式编程中,程序的控制流通常是通过函数的调用链来实现的。这种方式可以使程序的控制流更加静态和预测。

    1. 并发性不同:

函数式编程更适合并发编程,因为函数不会修改状态,所以它们可以更容易地并行执行。

而面向对象编程更适合串行编程,因为对象之间的状态共享使得并发执行更加困难。

好的步入正题<函数式基础>#

接下来,正式步入函数式编程,一步一步走近函数式编程!

理解程序控制流#

控制流(Control Flow)可以理解你的程序为了实现业务需求而所行进的路径。

在面向对象编程里,我们通常需要暴露所有的的必要步骤才能很好的描述控制流,但是在函数式编程中,通过语义化的函数调用,并使用某些手段将其链接起来,可以很清晰的描述控制流.

链式调用与管道函数#

在我们将大模块分化成一个又一个函数的时候,如何将这些函数联系起来成了一个问题,主流的方法是链式调用或者使用管道函数,而且中链式调用的限制会让我们更倾向于使用管道函数

下边是一个lodash中函数式编程链式调用的示例

// lodash 链式调用示例
const _ = require('lodash')
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const newNums = _.chain(nums)
.map(n => n + 1)
.filter(n => n % 2 === 0)
.value()
console.log(newNums)

这里会有一个问题是,我们必须使用lodash提供给我们的对象,哪怕你自己实现一个自己的链式对象,你还是需要遵循指定的规则,而使用管道函数不会有这个问题,它只需要你满足两边两个条件即可使用:

  • 类型<管道中的函数返回值必须与接受函数的参数类型一致>
  • 元数(函数参数定义的个数)<接受函数必须声明至少一个参数来接受上一个函数的返回值>
// 管道函数示例
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x)
const newNums2 = pipe(
n => n + 1,
n => n * 2,
n => n / 3
)(1)
console.log(newNums2)

函数柯里化#

函数柯里化(Currying)是一种将接受多个参数的函数转换为一系列接受单个参数的函数的技术。 举个例子,对于一个接受两个参数的函数 f(x, y),使用柯里化后可以得到一个新的函数 g(x),它返回一个接受单个参数的函数 h(y),调用 g(x) 后再调用 h(y) 等价于直接调用 f(x, y)。 具体来说,柯里化是通过闭包实现的,每次调用柯里化函数时,都会返回一个新的函数,这个新函数会将之前传入的参数保存起来,并返回一个接受下一个参数的函数,直到所有参数都传入为止,最后再执行原始函数并返回结果。 函数柯里化的好处在于可以将一个接受多个参数的函数转化为多个只接受单个参数的函数,这样可以更加灵活地组合和复用函数,同时也可以更方便地进行函数的部分应用和参数绑定。 总之,函数柯里化是一种非常有用的函数式编程技术,可以帮助我们更好地进行函数组合和复用,提高代码的可读性和可维护性。

一下是一段函数柯里化的示例代码,我们想要获取三个数的求和

const creater = (fn) => {
return function (first) {
return function (second) {
return function (third) {
return fn(first, second, third)
}
}
}
}
const sum = creater((first, second, third) => {
return first + second + third
})
const res = sum(1)(2)(3)
console.log(res); //6

下边是一个有趣的案例,我们有时候可以使用柯里化来限制函数的参数个数

const arr = ['1', '2', '3', '4', '5', '6']
const nums = arr.map(parseInt)
// 使用柯里化来限制parseInt只接受第一个参数
const curry = (fn) => {
return (a) => {
return fn(a)
}
}
const nums2 = arr.map(curry(parseInt))
console.log(nums); // [ 1, NaN, NaN, NaN, NaN, NaN ]
console.log(nums2); // [ 1, 2, 3, 4, 5, 6 ]

部分应用#

部分应用(Partial Application)是函数式编程中的一个概念,它指的是将一个多参数函数转换为一个接受较少参数的函数的过程。在 TypeScript 中,您可以使用函数重载和箭头函数来实现部分应用。

以下是一个示例,演示如何使用箭头函数和部分应用来创建新的函数:

// 定义一个名为 "add" 的函数
function add(a: number, b: number, c: number): number {
return a + b + c;
}
// 使用箭头函数和部分应用来创建新的函数
const addOne = (a: number) => (b: number, c: number) => add(a, b, c);
// 调用新的函数
const result = addOne(1)(2, 3);
console.log(result); // 输出:6

在上面的示例中,我们首先定义了一个名为 add 的函数,它接受三个数字类型的参数,并返回它们的和。

然后,我们使用箭头函数和部分应用来创建一个新的函数 addOne。在箭头函数中,我们首先传递一个数字类型的参数 a,然后返回一个新的函数,该函数接受两个数字类型的参数 b 和 c,并将它们与 a 一起传递给原始函数 add。这样,我们就将一个多参数函数转换为了一个接受较少参数的函数。

最后,我们调用新的函数 addOne,并传递 1、2 和 3 三个数字作为参数。在函数调用中,我们可以使用箭头函数语法来将多个参数分开传递给函数,并使用部分应用来创建新的函数。

理解递归#

理解递归对函数式编程非常的重要,因为递归是可以理解为构建无限大应用的基础,理解递归可以让我们更好的理解函数以及函数式编程,

function restArguments(func) {
return function() {
// startIndex 表示使用哪个位置的参数用于储存剩余的参数
// 默认使用传人的函数的最后一个参数存储剩余的参数
var startIndex = func.length - 1;
var length = arguments.length - startIndex,
rest = Array(length),
index = 0;
// 使用一个数组储存剩余的参数
// 以上面例子为例,rest结果为[3, 4, 5]
for (; index < length; index++) {
rest[index] = arguments[index + startIndex];
}
var args = Array(startIndex + 1);
// args此时为[1, 2, undefined]
for (index = 0; index < startIndex; index++) {
args[index] = arguments[index];
}
// args此时为[1, 2, [3, 4, 5]]
args[startIndex] = rest;
return func.apply(this, args);
};
}

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前来领教一下函数式编程的魅力
https://slothvips.github.io/posts/qian-lai-ling-jiao-yi-xia-han-shu-shi-bian-cheng-de-mei-li/
作者
panda
发布于
2023-04-15
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
panda
写前端,也写字。源码、工程实践和一些生活。
公告
欢迎来到 PandaBox。这里记录前端、源码阅读和一些生活。
分类
标签
站点统计
文章
31
分类
7
标签
65
总字数
51,812
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.16.9
文章许可
CC BY-NC-SA 4.0
文章目录