从 js 入门 fp

(已编辑)
1 0
摘要
从 js 入门 fp,记录一些函数式编程的重要概念和体会

参考

入门:

https://www.bilibili.com/video/BV1rC4y1C7z2/?spm_id_from=333.337.search-card.all.click&vd_source=133a4c6b8765759be3947374e6336df7

深入:

https://juejin.cn/book/7173591403639865377?utm_source=course_list

https://github.com/llh911001/mostly-adequate-guide-chinese

作用

复用,复用,还是复用。

前提

需要目标语言中的函数是一等公民。

即:可以作为函数参数传递、作为函数返回值返回、能赋值给变量

核心

声明式 vs 命令式

声明式:关注「输入」和「输出」,以「流水线」的形式度过整个流程。

命令式:走一步算一步,关注每一步的进行(过程)。自然语言描述能够与代码产生一一对应的关系

一般来说,声明式代码的性能 < 命令式,毕竟无论如何都要多出解析的消耗。然而声明式代码的可维护性 >> 命令式,在考虑心智负担和维护的前提下,很多时候声明式代码由于更易完成一些优化,性能表现反而会更优。例如,某些情况下使用声明式数据流编程模型可以更好地进行并行处理和优化。

纯函数与副作用

纯函数:有且仅有「显式数据流」

即:输入只能以参数形式传入,输出只能以返回值形式传递。除了入参和返回值,不以任何其他形式和外界进行数据交换。

副作用:如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的「副作用」。

我们提倡纯函数,是为了保证内部运行的稳定。

那全写纯函数不好吗?如果一个程序全是纯函数,那么它什么也做不到————所有的内容,跑完一遍都丢失了。就比如页面的渲染,显然就是副作用。

我们提倡的是将副作用限制在可控范围内(比如通过单一入口点控制所有 I/O 操作),通过使用纯函数和不可变数据结构尽可能地减少副作用的影响

实践纯函数的目的并不是消灭副作用,而是将计算逻辑与副作用做合理的分层解耦,从而提升我们的编码质量和执行效率。

不可变数据

「不可变」不是要消灭变化,而是要控制变化。

针对 js 而言,由于对引用类型赋值的时候传递的是引用,产生了可变数据,导致了一系列问题。

需要采用深/浅拷贝的方式,确保外部数据的只读性。反正拿到的是一个副本,怎么折腾也不会影响到原来的数据。

针对拷贝的性能消耗,有更优的解法:Immutable.js 通过快照机制(git 同理)维护索引,用持久化数据结构( 底层为 Trie(字典树))实现数据共享。

另解:Immer.js。基于代理/手动克隆+结构共享实现。

高阶函数

高阶函数的表现很有意思,大概可以分成两种类型:加强/模板。

  • 加强类型的高阶函数:吃函数,吐出「更强」的函数。如 lodash.debounce,把函数给他,返回出来的函数就具有了防抖的额外效果。通过对函数的加强,我们可以很轻松的实现对防抖这一逻辑的复用。
  • 模板类型的高阶函数:吃函数,吐出「加工」后的结果。加工过程由模板提供,具体逻辑由调用者提供。如 map 函数,我们给他传一个回调函数,map 会在加工过程中执行我们给他的逻辑,并返回对宿主(array)处理之后的结果。map 通过为我们提供模板,实现了在不同上下文对模板的复用。

另解:装饰者模式

curry:原料准备

第一次遇见 curry 操作的时候,我完全不能理解:这样做的意义在哪里?

实际上这是为了后续函数组合的处理:流水线上的函数,只能接受一个参数。因此需要一种方法来让多参函数变成单参函数。

而处理的过程也比较巧妙,有一个偏应用/部分应用(partial application)的概念,类似于高数里的偏导,通过固定一个参数从而消参。

例如,针对一个三数和的函数 add(a,b,c),在接收第一个参数后(假设为 2),则把它转化为 add2(b,c),把第一个接收的 2 通过闭包保存,然后再接收一个参数,就可以转化为单参函数了。最后再接收一个,返回结果。

这样的行为就是柯里化的过程。

function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

一方面,部分应用也可以作为代码的复用(如针对细致的组件,固定通用组件的一些参数)

另一方面,curry 提供了正统的「原料」,用以在下述的流水线中组合

pipe/compose:流水线的魅力

reducer:函数组合的核心。

回顾 reduce,我们可以给他传递一个回调,然后 reduce 作为一个模板类型的高阶函数,帮我们把 array 里的数据「累加」起来。(当然不一定是+操作,只是有这么一个累计的感觉)

如果我们的数组,是一个函数数组呢?

把待组合的函数放进一个数组里,调用 reduce,reduce 不就会帮我们实现一个工作流吗?initialValue 进入 reduce,reduce 为他「累加」一个又一个函数,最后把经过一系列函数执行后的结果返回出来。

整个流程就好比一个流水线:货物经过不同工人的加工,最后组合好,生产出来。这里的工人们是 function 数组,流水线则是 reduce。

function pipe(...functions) {
  return function (input) {
    return functions.reduce((pre, cur) => cur(pre), input);
  };
}

然而 reduce 并不是万能的,有一个很关键的限制,就是在回调函数的参数中,只能这样接收:(pre,cur,curIdx,array)。参数里真正有用的,只有 cur——当前的函数,pre——「累加」的结果。不难发现,我们只能给 cur 传递一个参数,也就是之前处理的结果 pre。

因此,我们给 reduce 的函数,必须满足只有一个参数的条件,满足这个条件的函数就真不多了。然而,得益于 curry,我们可以把任意的函数都改造成符合流水线上的形式,以此形成函数的组合。

正向的整个流程是 pipe。反过来则是 compose。(利用 reduceRight)

为什么需要反序的?

在数学的范畴中,(g·f)(x) = g(f(x))是函数的复合运算。

转化为 js => compose(g,f)。而在数学的复合中,函数的书写顺序和执行顺序是相反的,因此需要 compose。

函子

没研究明白。

oop vs fp

修言大佬的这一段话写的非常精髓

遗憾的是,在我们所生存的现实世界中,OOP 往往主宰了很多开发者的思维。当你手里只有一把锤子的时候,你看什么都像钉子。这就好像一个人一生只见过一种世界观、也只能理解这一种世界观,由于他不听、不看、不思考任何其它的世界观,于是只能被迫地狂热痴迷这唯一的一种世界观,这就谈不上信仰与否,而是被世界观所奴役了。

对于大多数后端入门的程序员,或许会倾向于优先使用已知的 oop 来组织代码。当然,es6 提供了 class,ts 也提供了 abstract 和装饰器,使用 oop 是完全可行的。

而对于前端入门的程序员,或许会对 fp 产生更多好感。毕竟无论是高阶函数的使用:js 原生:map、reduce,lodash:_debounce,还是框架层面上的,如 react 的 hooks,视图公式:UI = f(data),redux 与 reducer,废弃类组件等,都体现出对 fp 的推崇。

在思考某个业务场景下到底采用哪种范式的时候,进行的是一种抽象的过程 => 对数据结构的抽象/行为的抽象。

有说法认为:

oop 更适用于数据结构复杂的情景,而 fp 更适用于行为复杂的情景。

继承实现的复用,孩子对父亲数据紧耦合是一方面,孩子无法得知父亲的数据改变则是另一方面。一旦父亲发生了变化,对孩子的影响是灾难性的,另一方面,孩子会继承到父亲里也许自己并不需要的特性。

因此,采用 extends 的复用并不是想象中的那么完美,如果可以保证以上的情况不容易出现,也就是上层的基类比较稳定的情况下,采用 class 的维护就非常合理。不行的话,fp 自由组合的优势就在这里体现的淋漓尽致。

扩展

有兴趣可以继续了解

  1. 数组的另外几个方法,都是可以用 reduce 实现的。

  2. promise 是 monad 吗?

~~promise 是 monad 思想 的一种体现,monad 是自函子范畴上的幺半群~~

  1. js 已经有提案:加入 pipe operator(也就是本文提到的这个 pipe)、record 和元组(不可变数据)

  2. react 设计哲学

加载中...
© 2024~2025 sayoriqwq.

Command Palette

Search for a command to run...