乐趣区

关于前端:深入理解函数式编程上

函数式编程是一种历史悠久的编程范式。作为演算法,它的历史能够追溯到古代计算机诞生之前的 λ 演算,本文心愿带大家疾速理解函数式编程的历史、根底技术、重要个性和实际法令。在内容层面,次要应用 JavaScript 语言来形容函数式编程的个性,并以演算规定、语言个性、范式个性、副作用解决等方面作为切入点,通过大量演示示例来解说这种编程范式。同时,文末列举比拟一些此范式的优缺点,供读者参考。因为文章涵盖一些领域论常识,可能须要其余参考资料一起辅助浏览。

前言

本文分为高低两篇,上篇讲述函数式编程的根底概念和个性,下篇讲述函数式编程的进阶概念、利用及优缺点。函数式编程既不是简略的堆砌函数,也不是语言范式的终极之道。咱们将深入浅出地探讨它的个性,以期在日常工作中能在对应场景中进行灵便利用。

1. 先览:代码组合和复用

在前端代码中,咱们现有一些可行的模块复用形式,比方:

除了下面提到的组件和性能级别的代码复用,咱们也能够在软件架构层面上,通过抉择一些正当的架构设计来缩小反复开发的工作量,比如说很多公司在中后盾场景中大量应用的 低代码平台

能够说,在大部分软件我的项目中,咱们都要去摸索 代码组合和复用

函数式编程,已经有过一段黄金时代,起初又因面向对象范式的崛起而逐渐变为小众范式。然而,函数式编程目前又开始在不同的语言中流行起来了,像 Java 8、JS、Rust 等语言都有对函数式编程的反对。

明天咱们就来探讨 JavaScript 的 函数 ,并进一步探讨JavaScript 中的函数式编程(对于函数式编程格调软件的 组织、组合和复用)。

2. 什么是函数式编程?

2.1 定义

函数式编程是一种格调范式,没有一个规范的教条式定义。咱们来看一下维基百科的定义:

函数式编程是一种编程范式,它将电脑运算视为函数运算,并且防止应用程序状态以及易变对象。其中,λ 演算是该语言最重要的根底。而且 λ 演算 的函数能够承受函数作为输出的参数和输入的返回值。

咱们能够间接读出以下信息:

  1. 防止状态变更
  2. 函数作为输入输出
  3. λ 演算 无关

那什么是 λ 演算 呢?

2.2 函数式编程起源:λ 演算

λ 演算(读作 lambda 演算)由数学家 阿隆佐·邱奇 在 20 世纪 30 年代首次发表,它从 数理逻辑(Mathematical logic)中倒退而来,应用变量绑定(binding)和代换规定(substitution)来钻研函数如何抽象化定义(define)、函数如何被利用(apply)以及递归(recursion)的形式系统。

λ 演算 和图灵机等价(图灵齐备,作为一种钻研语言又很不便)。

通常用这个定义模式来示意一个 λ 演算

所以 λ 演算式 就三个要点:

  1. 绑定关系。变量任意性,x、y 和 z 都行,它仅仅是具体数据的代称。
  2. 递归定义 。λ 项递归定义,M 能够是一个 λ 项。
  3. 替换归约 。λ 项可利用,空格分隔示意 对 M 利用 N N能够是一个 λ 项。

比方这样的演算式:

通过变量 代换(substitution) 归约(reduction),咱们能够像化简方程一样解决咱们的演算。

λ 演算 有很多形式进行,数学家们也总结了许多和它相干的法则和定律(可查看维基百科)。

举个例子,小时候咱们学习整数就是学会几个数字,而后用加法 / 减法来推演其余数字。在函数式编程中,咱们能够用 函数 来定义自然数,有很多定义形式,这里咱们讲一种实现形式:

下面的演算式示意有一个函数 f 和一个参数 x。令0x1f x2f f x

什么意思呢?这是不是很像咱们数学中的幂:a^x(a 的 x 次幂示意 a 对本身乘 x 次)。相应的,咱们了解下面的演算式就是 数字 n 就是 f 对 x 作用的次数。有了这个数字的定义之后,咱们就能够在这个根底上定义运算。

其中 SUCC 示意后继函数(+ 1 操作 ),PLUS 示意加法。当初咱们来推导这个正确性。

这样,进行 λ 演算 就像是方程的代换和化简,在一个已知前提(公理,比方 0 /1,加法)下,进行规定推演。

2.2.1 演算:变量的含意

λ 演算 中咱们的表达式只有一个参数,那它怎么实现两个数字的二元操作呢?比方加法 a + b,须要两个参数。

这时,咱们要把函数自身也视为值,能够通过把一个变量绑定到上下文,而后返回一个新的函数,来实现数据(或者说是状态)的保留和传递,被绑定的变量能够在须要理论应用的时候从上下文中援用到。

比方上面这个简略的演算式:

第一次函数调用传入 m=5,返回一个新函数,这个新函数接管一个参数n,并返回m + n 的后果。像这种状况产生的上下文,就是 Closure(闭包,函数式编程罕用的状态保留和援用伎俩),咱们称变量m 是被 绑定(binding)到了第二个函数的上下文。

除了绑定的变量, λ 演算 也反对自在的变量,比方上面这个y

这里的 y 是一个没有绑定到参数地位的变量,被称为一个 自在变量

绑定变量 自在变量 是函数的两种状态起源,一个能够被代换,另一个不能。理论程序中,通常把绑定变量实现为局部变量或者参数,自在变量实现为全局变量或者环境变量。

2.2.2 演算:代换和归约

演算分为 Alpha 代换和 Beta 归约。后面章节咱们实际上曾经波及这两个概念,上面来介绍一下它们。

Alpha 代换 指的是变量的名称是不重要的,你能够写 λm.λn.m + n,也能够写λx.λy.x + y,在演算过程中它们示意同一个函数。也就是说咱们只 关怀计算的模式,而不关怀细节用什么变量去实现。这不便咱们不扭转运算后果的前提上来批改变量命名,以不便在函数比较复杂的状况下进行化简操作。实际上,连整个 lambda 演算式的名字也是不重要的,咱们只须要这种模式的计算,而不在乎这个模式的命名。

Beta 归约 指的是如果你有一个 函数利用(函数调用),那么你能够对这个函数体中与标识符对应的局部做 代换(substitution),形式为应用参数(可能是另一个演算式)去替换标识符。听起来有点绕口,然而它实际上就是 函数调用的参数替换。比方:

能够应用 1 替换 m3 替换 n,那么整个表达式能够化简为4。这也是函数式编程外面的援用透明性的由来。须要留神的是,这里的13示意表达式运算值,能够替换为其余表达式。比方把 1 替换为(λm.λn.m + n 1 3),这里就须要做两次归约来失去上面的最终后果:

2.3 JavaScript 中的 λ 表达式:箭头函数

ECMAScript 2015 标准引入了箭头函数,它没有 this,没有arguments。只能作为一个 表达式(expression)而不能作为一个 申明式(statement),表达式产生一个箭头函数援用,该箭头函数援用依然有 namelength属性,别离示意箭头函数的名字、形参(parameters)长度。一个箭头函数就是一个单纯的运算式,箭头函数咱们也能够称为lambda 函数,它在书写模式上就像是一个 λ 演算式

能够利用箭头函数做一些简略的运算,下例比拟了四种箭头函数的应用形式:

这是间接针对数字(根本数据类型)的状况,如果是针对函数做运算(援用数据类型),事件就变得乏味起来了。咱们看一下上面的示例:

fn_x类型,表明咱们能够利用函数内的函数,当函数被当作数据传递的时候,就能够对函数进行利用(apply),生成更高阶的操作。并且 x => y => x(y) 能够有两种了解,一种是 x => y 函数传入 X => x(y),另一种是x 传入y => x(y)

add_x类型表明,一个运算式能够有很多不同的门路来实现。

下面的 add_1/add_2/add_3 咱们用到了 JavaScript 的立刻运算表达式IIFE

λ 演算 是一种形象的数学表达方式,咱们不关怀实在的运算状况,咱们只关怀这种 运算模式。因而上一节的演算能够用 JavaScript 来模仿。上面咱们来实现 λ 演算的 JavaScript 示意

咱们把 λ 演算中fx 别离取为 countTimex,代入运算就失去了咱们的自然数。

这也阐明了不论你应用 符号零碎 还是 JavaScript 语言,你想要表白的 自然数 是等价的。这也侧面阐明 λ 演算 是一种 模式上的形象(和具体语言表述无关的形象表白)

2.4 函数式编程根底:函数的元、柯里化和 Point-Free

回到 JavaScript 自身,咱们要探索 函数 自身能不能带给咱们更多的货色?咱们在 JavaScript 中有很多创立函数的形式:

尽管函数有这么多定义,但 function 关键字申明的函数带有 arguments 和 this 关键字,这让他们看起来更像是对象办法(method),而不是函数(function)。

况且 function 定义的函数大多数还能被结构(比方 new Array)。

接下来咱们将只钻研箭头函数,因为它更像是数学意义上的函数(仅执行计算过程)。

  • 没有 arguments 和 this。
  • 不能够被结构 new。

2.4.1 函数的元:齐全调用和不齐全调用

不管应用何种形式去结构一个函数,这个函数都有两个固定的信息能够获取:

  • name 示意以后标识符指向的函数的名字。
  • length 示意以后标识符指向的函数定义时的参数列表长度。

在数学上,咱们定义 f(x) = x 是一个一元函数,而 f(x, y) = x + y 是一个二元函数。在 JavaScript 中咱们能够应用函数定义时的 length 来定义它的元。

定义 函数的元 的意义在于,咱们能够对函数进行归类,并且能够明确一个函数须要的确切参数个数。函数的元在编译期(类型查看、重载)和运行时(异样解决、动静生成代码)都有重要作用。

如果我给你一个 二元函数,你就晓得须要传递两个参数。比方 + 就能够看成是一个二元函数,它的右边承受一个参数,左边承受一个参数,返回它们的和(或字符串连贯)。

在一些其余语言中,+ 的确也是由抽象类来定义实现的,比方 Rust 语言的trait Add<A, B>

但咱们下面看到的 λ 演算,每个函数都只有一个元。为什么呢?

只有一个元的函数不便咱们进行代数运算。λ 演算的参数列表应用 λx.λy.λz 的格局进行宰割,返回值个别都是函数,如果一个二元函数,调用时只应用了一个参数,则返回一个「不齐全调用函数」。这里用三个例子解释“不齐全调用”。

第一个,不齐全调用,代换参数后产生了一个 不齐全调用函数 λz.3 + z

第二个,Haskell 代码,调用一个函数add(类型为a -> a -> a),失去另一个函数add 1(类型为a -> a)。

第三个,上一个例子的 JavaScript 版本。

“不齐全调用”在 JavaScript 中也成立。实际上它就是 JavaScript 中闭包(Closure,下面咱们曾经提到过)产生的起因,一个函数还没有被销毁(调用没有齐全完结),你能够在子环境内应用父环境的变量。

留神,下面咱们应用到的是一元函数,如果应用三元函数来示意 addThree,那么函数一次性就调用结束了,不会产生不齐全调用。

函数的元还有一个驰名的例子(面试题):

造成上述后果的起因就是,Number是一元的,承受 map 第一个参数就转换失去返回值;而 parseInt 是二元的,第二个参数拿到进制为 1map 函数为三元函数,第二个参数返回元素索引),无奈输入正确的转换,只能返回NaN。这个例子外面波及到了一元、二元、三元函数,多一个元,函数体就多一个状态。如果世界上只有一元函数就好了!咱们能够全通过一元函数和不齐全调用来生成新的函数解决新的问题。

意识到函数是有元的,这是函数式编程的重要一步,多一个元就多一种复杂度。

2.4.2 柯里化函数:函数元降维技术

柯里化(curry)函数是一种把函数的元降维的技术,这个名词是为了留念咱们上文提到的数学家 阿隆佐·邱奇

首先,函数的几种写法是等价的(最终计算结果统一)。

这里列一个简略的把一般函数变为柯里化函数的形式(柯里化函数 Curry)。

柯里化函数帮忙咱们把一个多元函数变成一个不齐全调用,利用 Closure 的魔法,把函数调用变成提早的偏函数 (不齐全调用函数) 调用。这在函数 组合、复用 等场景十分有用。比方:

尽管你能够用其余闭包形式来实现函数的提早调用,但 Curry 函数相对是其中模式最美的几种形式之一。

2.4.3 Point-Free|无参格调:函数的高阶组合

函数式编程中有一种 Point-Free 格调,中文语境大略能够把 point 认为是参数点,对应 λ 演算 中的函数利用(Function Apply),或者 JavaScript 中的函数调用(Function Call),所以能够了解Point-Free 就指的是无参调用

来看一个日常的例子,把二进制数据转换为八进制数据:

这段代码运行起来没有问题,但咱们为了解决这个转换,须要理解 parseInt(x, 2)toString(8) 这两个函数(为什么有魔法数字 2 和魔法数字 8),并且要关怀数据(函数类型a -> b)在每个节点的状态(关怀数据的流向)。有没有一种形式,能够让咱们只关怀入参和出参,不关怀数据流动过程呢?

下面的办法假如咱们曾经有了一些根底函数 toBinary(语义化,打消魔法数字 2)和toStringOx(语义化,打消魔法数字 8),并且有一种形式(pipe)把根底函数组合(Composition)起来。如果用Typescript 示意咱们的数据流动就是:

用 Haskell 示意更简洁,前面都用 Haskell 类型示意形式,作为咱们的符号语言。

值得注意的是,这里的 x-> [int] ->y 咱们不必关怀,因为 pipe(..) 函数帮咱们解决了它们。pipe 就像一个盒子。

BOX内容不须要咱们了解。而为了达成这个目标,咱们须要做这些事:

  • utils 一些特定的工具函数。
  • curry 柯里化并使得函数能够被复用。
  • composition 一个组合函数,像胶水一样粘合所有的操作。
  • name 给每个函数定义一个见名知意的名字。

综上,Point-Free 格调是粘合一些根底函数,最终让咱们的数据操作不再关怀两头态的形式。这是函数式编程,或者说函数式编程语言中咱们会始终遇到的格调,表明咱们的根底函数是值得信赖的,咱们仅关怀数据转换这种模式,而不是过程。JavaScript 中有很多实现这种根底函数工具的库,最闻名的是 Lodash。

能够说函数式编程范式就是在不停地组合函数。

2.5 函数式编程个性

说了这么久,都是在讲函数,那么到底什么是函数式编程呢?在网上你能够看到很多定义,但大都离不开这些个性。

  • First Class 函数:函数能够被利用,也能够被当作数据。
  • Pure 纯函数,无副作用:任意时刻以雷同参数调用函数任意次数失去的后果都一样。
  • Referential Transparency 援用通明:能够被表达式代替。
  • Expression 基于表达式:表达式能够被计算,促成数据流动,状态申明就像是一个暂停,如同数据到这里就会停滞了一下。
  • Immutable 不可变性:参数不可被批改、变量不可被批改 — 宁肯就义性能,也要产生新的数据(Rust 内存模型例外)。
  • High Order Function 大量应用高阶函数:变量存储、闭包利用、函数高度可组合。
  • Curry 柯里化:对函数进行降维,不便进行组合。
  • Composition 函数组合:将多个单函数进行组合,像流水线一样工作。

另外还有一些个性,有的会提到,有的一笔带过,但理论也是一个个性(以 Haskell 为例)。

  • Type Inference 类型推导:如果无奈确定数据的类型,那函数怎么去组合?(常见,但非必须)
  • Lazy Evaluation 惰性求值:函数人造就是一个执行环境,惰性求值是很天然的抉择。
  • Side Effect IO:一种类型,用于解决副作用。一个不能执行打印文字、批改文件等操作的程序,是没有意义的,总要有地位解决副作用。(边缘)

数学上,咱们定义函数为汇合 A 到汇合 B 的映射。在函数式编程中,咱们也是这么认为的。函数就是把数据从某种状态映射到另一种状态。留神了解“映射”,前面咱们还会讲到。

2.5.1 First Class:函数也是一种数据

函数自身也是数据的一种,能够是参数,也能够是返回值。

通过这种形式,咱们能够让函数作为一种能够保留状态的值进行流动,也能够充分利用不齐全调用函数来进行函数组合。把函数作为数据,实际上就让咱们有能力获取函数外部的状态,这样也产生了闭包。但函数式编程不强调状态,大部分状况下,咱们的“状态”就是一个函数的元(咱们从元获取内部状态)。

2.5.2 纯函数:无状态的世界

通常咱们定义输入输出(IO)是不纯的,因为 IO 操作不仅操作了数据,还操作了这个数据领域内部的世界,比方打印、播放声音、批改变量状态、网络申请等。这些操作并不是说对程序造成了毁坏,相同,一个残缺的程序肯定是须要它们的,不然咱们的所有计算都将毫无意义。

但纯函数是可预测的,援用通明的,咱们心愿代码中更多地呈现纯函数式的代码,这样的代码能够被预测,能够被表达式替换,而更多地把 IO 操作放到一个对立的地位做解决。

这个 add 函数是不纯的,但咱们把副作用都放到它外面了。任意应用这个 add 函数的地位,都将变成不纯的(就像是 async/await 的传递性一样)。须要阐明的是抛开理论利用来议论函数的纯正性是毫无意义的,咱们的程序须要和终端、网络等设施进行交互,不然一个计算的运行后果将毫无意义。但对于函数的元来说,这种纯正性就很有意义,比方:

当函数的元像下面那样是一个援用值,如果一个函数的调用不去管制本人的纯正性,对他人来说,可能会造成毁灭性打击。因而咱们须要对援用值特地小心。

下面这种解构赋值的形式仅解决了第一层的援用值,很多其余状况下,咱们要解决一个援用树、或者返回一个援用树,这须要更深层次的解援用操作。请小心看待你的援用。

函数的纯正性要求咱们时刻揭示本人升高状态数量,把变动留在函数内部。

2.5.3 援用通明:代换

通过表达式代替(也就是 λ 演算中讲的归约),能够失去最终数据状态。

也就是说,调用一个函数的地位,咱们能够应用函数的调用后果来代替此函数调用,产生的后果不变。

一个援用通明的函数调用链永远是能够被合并式代换的。

2.5.4 不可变性:把简略留给本人

一个函数不应该去扭转原有的援用值,防止对运算的其余局部造成影响。

一个充斥变动的世界是混沌的,在函数式编程世界,咱们须要强调参数和值的不可变性,甚至在很多时候咱们能够为了不扭转原来的援用值,就义性能以产生新的对象来进行运算。就义一部分性能来保障咱们程序的每个局部都是可预测的,任意一个对象从创立到隐没,它的值应该是固定的。

一个元如果是援用值,请应用一个正本(克隆、复制、代替等形式)来失去状态变更。

2.5.5 高阶:函数形象和组合

JS 中用的最多的就是 Array 相干的高阶函数。实际上 Array 是一种 Monad(前面解说)。

通过高阶函数传递和批改变量:

高阶函数实际上为咱们提供了注入环境变量(或者说绑定环境变量)提供了更多可能。React 的高阶组件就从这个思维上借用而来。一个高阶函数就是应用或者产生另一个函数的函数。高阶函数是函数组合(Composition)的一种形式。

高阶函数让咱们能够更好地组合业务。常见的高阶函数有:

  • map
  • compose
  • fold
  • pipe
  • curry
  • ….

2.5.6 惰性计算:升高运行时开销

惰性计算的含意就是在真正调用到的时候才执行,两头步骤不实在执行程序。这样能够让咱们在运行时创立很多根底函数,但并不影响理论业务运行速度,唯有业务代码实在调用时才产生开销。

map(addOne)并不会实在执行 +1,只有实在调用 exec 才执行。当然这个只是一个简略的例子,弱小的惰性计算在函数式编程语言中还有很多其余例子。比方:

“无穷”只是一个字面定义,咱们晓得计算机是无奈定义无穷的数据的,因而数据在 take 的时候才实在产生。

惰性计算让咱们能够有限应用函数组合,在写这些函数组合的过程中并不产生调用。但这种模式,可能会有一个重大的问题,那就是产生一个十分长的调用栈,而虚拟机或者解释器的函数调用栈个别都是有下限的,比方 2000 个,超过这个限度,函数调用就会栈溢出。尽管函数调用栈过长会引起这个重大的问题,但这个问题其实不是函数式语言设计的逻辑问题,因为调用栈溢出的问题在任何设计不良的程序中都有可能呈现,惰性计算只是利用了函数调用栈的个性,而不是一种缺点。

记住,任何时候咱们都能够重构代码,通过良好的设计来解决栈溢出的问题。

2.5.7 类型推导

以后的 JS 有 TypeScript 的加持,也能够算是有类型推导了。

没有类型推导的函数式编程,在应用的时候会很不不便,所有的工具函数都要查表查示例,开发中效率会比拟低下,也容易造成谬误。

但并不是说一门函数式语言必须要类型推导,这不是强制的。像 Lisp 就没有强制类型推导,JavaScript 也没有强制的类型推导,这不影响他们的胜利。只是说,有了类型推导,咱们的编译器能够在编译器期间提前捕捉谬误,甚至在编译之前,写代码的时候就能够发现错误。类型推导是一些语言强调的优良个性,它的确能够帮忙咱们提前发现更多的代码问题。像 Rust、Haskell 等。

2.5.8 其余

你当初也能够总结一些其余的格调或者定义。比方强调函数的组合、强调 Point-Free 的格调等等。

3. 小结

函数式有很多根底的个性,熟练地应用这些个性,并加以奇妙地组合,就造成了咱们的“函数式编程范式”。这些根底个性让咱们看待一个 function,更多地看作 函数 ,而不是一个 办法。在许多场景下,应用这种范式进行编程,就像是在做数学推导(或者说是类型推导),它让咱们像学习数学一样,把一个个简单的问题简单化,再进行累加 / 累积,从而失去后果。

同时,函数式编程还有一块大的畛域须要进入,即副作用解决。不解决副作用的程序是毫无意义的(仅在内存中进行计算),下篇咱们将深刻函数式编程的利用,为咱们的工程利用发掘出更多的个性。

4. 作者简介

豪杰,美团到家研发平台 / 医药技术部前端工程师。

浏览美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2021 年货】、【2020 年货】、【2019 年货】、【2018 年货】、【2017 年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至 [email protected] 申请受权。

退出移动版