当前位置: 首页 > Web前端 > JavaScript

深入理解函数式编程(上)

时间:2023-03-27 15:12:35 JavaScript

函数式编程是一种历史悠久的编程范式。作为一种算法,它的历史可以追溯到现代计算机诞生之前的λ演算。本文希望能带你快速了解函数式编程的历史、基本技术、重要特性和实用规则。在内容层面,主要使用JavaScript语言描述函数式编程的特性,通过大量的演示实例以演算规则、语言特性、范式特性、副作用处理为切入点来讲解编程范式.同时在文末列出并比较了该范式的一些优缺点,以供读者参考。由于文章涵盖了一些范畴论知识,可能需要其他参考资料来辅助阅读。前言本文分为两部分。第一部分介绍了函数式编程的基本概念和特点,第二部分介绍了函数式编程的高级概念、应用、优缺点。函数式编程既不是简单的函数堆,也不是最终的语言范式。我们将深入浅出地探讨其特点,以便在日常工作中灵活应用到相应的场景中。1.预览:代码组合与复用在前端代码中,我们有一些可行的模块复用方法,例如:除了上面提到的组件和功能层面的代码复用,我们还可以在软件架构层面,通过选择一些合理的架构设计来减少重复开发的工作量,比如很多公司在中后端场景大量使用的低代码平台。可以说,在大多数软件项目中,我们都要探索代码的组合和复用。函数式编程曾经有过黄金时代,后来由于面向对象范式的兴起逐渐成为小众范式。然而,函数式编程在不同的语言中再次流行起来。Java8、JS、Rust等语言都支持函数式编程。今天我们将探索JavaScript函数,并进一步探索JavaScript中的函数式编程(关于函数式编程风格软件的组织、组合和重用)。2.什么是函数式编程?2.1定义函数式编程是一种没有标准教条定义的风格范式。我们来看一下来自维基百科的定义:函数式编程是一种将计算机操作视为函数式操作并避免使用程序状态和可变对象的编程范式。其中,λ演算是该语言最重要的基础。而且,lambda演算的函数可以接受函数作为输入参数和输出返回值。我们可以直接阅读以下资料:避免状态变化函数作为输入和输出与λ-演算有关那么什么是λ-演算?2.2函数式编程的起源:LambdaCalculuslambdacalculus(发音为lambdacalculus)最早由数学家AlonzoChurch于1930年代发表,从数理逻辑发展而来,使用变量绑定(binding)和代换规则(substitution)来研究形式化系统抽象了函数如何定义(define)、函数如何应用(apply)和递归(recursion)。Lambda演算相当于图灵机(图灵完备,作为研究语言很方便)。这种定义形式通常用于表示lambda演算。所以lambda演算有3个要点:绑定关系。变量是任意的,x、y、z都可以,它只是具体数据的一个代理。递归定义lambda项是递归定义的,M可以是lambda项。置换减量。可以应用λ项,以空格分隔以将N应用于M,N可以是λ项。例如微积分:通过变量代换(substitution)和归约(reduction),我们可以把我们的微积分当作一个简化的方程。进行lambda演算的方法有很多种,数学家也总结了很多与之相关的规律和规律(见维基百科)。例如,我们小时候学习整数是通过学习几个数字,然后使用加法/减法推导出其他数字。在函数式编程中,我们可以使用函数来定义自然数。有很多方法可以定义它们。这里说说一种实现方法:上面的计算公式就是说有一个函数f,一个参数x。设0为x,1为fx,2为ffx……什么意思?这是不是很像我们数学中的幂:a^x(a的x次方就是a自乘x次)。相应地,我们理解上面的计算公式就是数n是f作用于x的次数。有了这个数的定义,我们就可以在这个基础上定义操作了。其中SUCC代表后继函数(+1运算),PLUS代表加法。现在我们推导出这个正确性。这样,进行λ演算就如同方程的代入化简,在已知前提下(公理,如0/1、加法)进行规则推导。2.2.1微积分:变量的含义在lambda演算中,我们的表达式只有一个参数,那么如何实现两个数的二进制运算呢?例如,加法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缩减意味着如果你有一个函数应用程序(函数调用),那么你可以通过使用参数(可能是另一个演算)来替换标识符对应的函数体部分来替换标识符。听起来有点开玩笑,但实际上是函数调用的参数替换。例如:可以用1代替m,用3代替n,那么整个表达式就可以简化为4。这也是函数式编程中引用透明性的由来。需要注意的是,这里的1和3代表的是表达式运算值,可以用其他表达式代替。例如将1替换为(λm.λn.m+n13),这里需要做两次归约,最终结果如下:2.3JavaScript中的Lambda表达式:箭头函数ECMAScript2015规范引入了箭头函数,它有没有这个,没有争论。它只能用作表达式(expression)而不能用作语句(statement)。表达式生成一个箭头函数引用,它仍然有name和length属性,分别代表箭头函数的名称和参数。长度。箭头函数是一个简单的计算公式。箭头函数也可以称为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。Lambda演算是一种抽象的数学表达式。我们不关心真正的操作,我们只关心这个操作形式。所以上一节的计算可以用JavaScript来模拟。让我们实现lambda演算的JavaScript表示。我们将lambda演算中的f和x分别作为countTime和x,代入运算得到我们的自然数。这也说明无论你用符号系统还是JavaScript语言,你要表达的自然数都是等价的。这也说明λ演算是形式抽象(与具体语言表达式无关的抽象表达式)。2.4函数式编程基础:函数的meta、currying、Point-Free回到JavaScript本身,我们想探讨函数本身是否能给我们带来更多的东西?我们在JavaScript中创建函数的方式有很多种:虽然函数有这么多的定义,但是用function关键字声明的函数有arguments和this关键字,这使得它们看起来更像对象方法(methods)而不是函数(function)。而且,大部分function定义的函数也是可以构造的(比如newArray)。我们将只关注箭头函数的其余部分,因为它们更像是数学意义上的函数(仅执行计算)。没有争论和这个。不能新建。2.4.1函数的要素:完全调用和不完全调用无论用什么方法构造一个函数,这个函数都有两个固定的信息可以获取:name表示当前标识符指向的函数的名称。length表示定义当前标识符指向的函数时参数列表的长度。在数学上,我们定义f(x)=x是一元函数,f(x,y)=x+y是二元函数。在JavaScript中我们可以使用定义函数时的长度来定义它的元素。定义函数的元素的意义在于我们可以对函数进行分类并指定函数所需参数的确切数量。函数元素在编译时(类型检查、重载)和运行时(异常处理、动态代码生成)都扮演着重要的角色。如果我给你一个二元函数,你知道你需要传递两个参数。例如,+可以看作是一个二元函数,接受左边一个参数和右边一个参数,返回它们的和(或字符串拼接)。在其他一些语言中,+确实是由抽象类来定义和实现的,比如Rust语言的traitAdd。但是在我们上面看到的lambda演算中,每个函数只有一个元素。为什么?只有一个元素的函数方便我们进行代数运算。λ演算的参数列表按λx.λy.λz的格式划分,返回值一般为函数。如果只用一个参数调用二元函数,它将返回一个“不完整的调用函数”。下面用三个例子来解释“不完整的调用”。第一个部分调用在参数替换后生成部分调用函数λz.3+z。第二个,Haskell代码,调用一个函数add(类型为a->a->a),它得到另一个函数add1(类型为a->a)。第三,前面例子的JavaScript版本。“不完整的调用”在JavaScript中也是如此。其实就是JavaScript中的闭包(Closure,我们上面已经提到)的原因。一个函数还没有被销毁(调用还没有完全完成),可以在子环境中使用父环境的变量。请注意,我们上面使用的是一元函数。如果我们用一个三元函数来表示addThree,那么这个函数会被一次性调用,不会出现调用不完整的情况。函数的元素还有一个著名的例子(面试题):出现上面结果的原因是Number是一元的,接受map的第一个参数后对返回值进行转换;而parseInt是二进制的,第二个参数取base为1时(map函数是三元函数,第二个参数返回元素索引),不能输出正确的转换,只能返回NaN。此示例涉及一元、二元和三元函数。如果多了一个元素,函数体就会多一种状态。如果世界上只有一元函数就好了!我们可以通过一元函数和不完全调用生成新函数来处理新问题。认识到函数有元素是函数式编程的重要一步,每一个额外的元素都是一个额外的复杂度。2.4.2Curry函数:函数元素降维技术Curry(咖喱)函数是一种对函数元素进行降维的技术。这个名词是为了纪念我们上面提到的数学家AlonzoQiu。奇怪的。首先,函数的几种写法是等价的(最终计算结果一致)。这里有一个简单的方法可以把一个普通的函数变成一个curry函数(CurryfunctionCurry)。柯里化函数帮我们把一个多元函数变成了一个不完整的调用,利用闭包的魔力把一个函数调用变成了延迟的偏函数(不完整调用的函数)调用。这在功能组合和重用等场景中非常有用。例如:虽然你可以使用其他的闭包方法来实现函数的延迟调用,但是Curry函数绝对是最漂亮的方法之一。2.4.3Point-Free|无参风格:函数的高阶组合函数式编程中有一种point-free风格。point在中文语境下大概可以看成是参数点,对应lambda演算中的函数应用(FunctionApply),或者JavaScript中的函数调用(FunctionCall),所以可以理解为Point-Free指的是到没有参数的调用。让我们看一个日常示例,将二进制数据转换为八进制:这段代码工作正常,但我们需要知道函数parseInt(x,2)和toString(8)才能处理转换(为什么有幻数2和幻数8),并关心每个节点的数据形状(函数类型a->b)(关心数据流)。有没有办法让我们只关心输入输出参数,而不关心数据流转过程呢?上面的方法假设我们已经有了toBinary(语义化,消除幻数2)和toStringOx(语义化,消除幻数8)的一些基本功能,并且有一种方式(pipe)将这些基本功能组合起来(Composition)。如果我们用Typescript来表示我们的数据流,那么用Haskell更简洁,然后用Haskell类型表示作为我们的符号语言。值得注意的是,我们在这里不需要关心x->[int]->y,因为pipe(..)函数为我们处理了它们。管道就像一个盒子。BOX内容不需要我们看懂。为了实现这个目标,我们需要做这些事情:utils一些特定的实用函数。curryCurry和make函数可以重复使用。composition像胶水一样将所有操作绑定在一起的组合函数。name为每个函数定义一个众所周知的名称。总结一下,Point-Free风格就是把一些基本的功能粘合在一起,最终让我们的数据操作不再关心中间状态。这就是函数式编程,或者说我们在函数式编程语言中一直遇到的风格,说明我们的基本函数是值得信赖的,我们只关心数据转换的形式,而不关心过程。JavaScript中有很多库实现了这个基本的功能工具,最著名的是Lodash。可以说,函数式编程范式就是在不断组合函数。2.5函数式编程的特点我们讲了这么久的函数,那么到底什么是函数式编程呢?网上可以看到很多定义,但大部分都离不开这些特性。第一类函数:函数可以作为数据应用和处理。纯函数,无副作用:在任何时候用相同的参数多次调用该函数将得到相同的结果。ReferentialTransparency指的是透明度:可以用表达式代替。Expression基于表达式:可以计算表达式,方便数据流动,state语句就像暂停一样,好像数据会在这里停一会儿。Immutable不可变性:参数不可修改,变量不可修改---宁可牺牲性能,也要产生新的数据(Rust内存模型除外)。HighOrderFunction大量使用高阶函数:变量的存储,闭包的应用,函数是高度可组合的。Curry:函数降维方便组合。Composition函数组合:将多个单一函数组合起来,像流水线一样工作。除此之外,还有一些特性,有的会提到,有的会提到,但其实就是一个特性(以Haskell为例)。TypeInference类型推导:如果无法确定数据的类型,如何组合函数?(常见,但不是必需的)LazyEvaluation惰性求值:一个函数自然是一个执行环境,惰性求值是一种自然的选择。SideEffectIO:一种处理副作用的类型。一个不能进行打印文本、修改文件等操作的程序是没有意义的。必须始终有一个地方来处理副作用。(边缘)在数学上,我们将函数定义为从集合A到集合B的映射。在函数式编程中,我们以相同的方式思考。函数是数据从一种形式到另一种形式的映射。注意理解“映射”,后面再说。2.5.1第一类:函数也是一种数据。函数本身也是一种数据,可以是参数也可以是返回值。这样既可以让函数作为一个可以保存状态的值来流动,也可以充分利用偏调用函数进行函数组合。将函数用作数据实际上使我们能够访问函数内部的状态,这也创建了闭包。但是函数式编程并不强调状态。在大多数情况下,我们的“状态”是函数的元素(我们从元素中获取外部状态)。2.5.2纯函数:一个无状态的世界通常我们将输入输出(IO)定义为不纯的,因为IO操作不仅操作数据,还操作数据范畴之外的世界,如打印、播放声音、修改变量状态等、网络请求等这些操作并不代表它们会对程序造成损害,相反,一个完整的程序必须需要它们,否则我们所有的计算都将毫无意义。但是纯函数是可预测的并且引用透明的。我们希望代码中多一些纯函数式的代码。这样的代码可以被预测和替换为表达式,更多的IO操作可以放在一个统一的位置进行处理。add函数是不纯的,但是我们把所有的副作用都放到了里面。凡是用到这个add函数的地方都会变得不纯(就像async/await的传递性一样)。需要注意的是,不管实际应用如何,只谈功能的纯粹性是没有意义的。我们的程序需要和终端、网络等设备进行交互,否则一个计算的结果是没有意义的。但是对于函数的元素来说,这种纯粹是很有意义的。例如:当函数的元素是像上面这样的引用值时,如果一个函数调用没有控制好自己的纯度,对于其他人来说,可能会是毁灭性的。所以我们需要非常小心参考值。上述解构赋值的方法只是解决了第一层的参考值。在很多其他情况下,我们需要处理一个引用树或者返回一个引用树,这就需要更深层次的解引用操作。小心你的引用。函数的纯粹性要求我们不断提醒自己减少状态的数量,将变化保持在函数之外。2.5.3引用透明性:代入通过表达式代入(即lambda演算中的归约)可以得到最终的数据形式。也就是说,在调用函数的地方,我们可以用函数调用的结果来代替函数调用,结果不变。引用透明的函数调用链始终是可合并的。2.5.4不变性:对自己保持简单。一个函数不应改变原有的引用值,以免影响其他部分的运行。充满变化的世界是混乱的。在函数式编程世界中,我们需要强调参数和值的不变性。很多时候,为了不改变原有的参考值,我们甚至可以牺牲性能来生成新的计算对象。牺牲部分性能来保证我们程序的每一部分都是可预测的,任何对象从创建到消失,它的值都应该是固定的。如果元数据是参考值,则使用副本(克隆、复制、替换等)来获取状态更改。2.5.5高阶:函数抽象与组合JS中最常用的是Array相关的高阶函数。实际上,Array是Monad的一种(稍后解释)。通过高阶函数传递和修改变量:高阶函数实际上为我们提供了注入环境变量(或绑定环境变量)的更多可能性。React的高阶组件借鉴了这个想法。高阶函数是使用或产生另一个函数的函数。高阶函数是函数组合的一种方式。高阶函数使我们能够更好地组合业务。常见的高阶函数有:mapcomposefoldpipecurry....2.5.6惰性计算:减少运行时开销惰性计算的意义是真正调用的时候才执行,中间的步骤并不真正执行程序。这让我们可以在运行时创建很多基础功能,但不影响实际业务运行速度。只有真正调用业务代码的时候才会产生开销。map(addOne)并不真正执行+1,只有在实际调用exec时才执行。当然这只是一个简单的例子,在函数式编程语言中还有很多强大的延迟计算的例子。例如:“无限”只是一个字面定义。我们知道,计算机无法定义无穷大的数据,所以数据实际上是在取的时候产生的。惰性求值让我们可以无限地使用函数组合,编写这些函数组合的过程不会产生调用。但是这种形式可能会有一个严重的问题,就是会产生很长的调用栈,而虚拟机或者解释器的函数调用栈一般都有一个上限,比如2000,如果超过这个限制,函数call会溢出栈。虽然函数调用栈过长导致了这个严重的问题,但是这个问题其实并不是函数式语言设计的逻辑问题,因为调用栈溢出的问题在任何设计不好的程序中都可能出现,而惰性计算只是利用了函数的一个特性调用堆栈,不是错误。记住,任何时候我们都可以重构代码,通过良好的设计来解决栈溢出的问题。2.5.7类型推导现在的JS有TypeScript的支持,所以可以认为是有类型推导。没有类型推断的函数式编程使用起来非常不方便。所有工具功能都需要查表和例子。开发效率会比较低,容易出错。但这并不意味着功能语言必须具有类型推导,它不是强制性的。像Lisp这样的东西没有强制类型推导,JavaScript也没有强制类型推导,这不影响他们的成功。只不过,有了类型推导,我们的编译器可以在编译的时候及早发现错误,甚至在编译之前,写代码的时候就可以发现错误。类型推导是一些语言强调的一个优秀特性,它确实可以帮助我们提前发现更多的代码问题。比如Rust,Haskell等等。2.5.8其他你现在也可以总结一些其他的风格或者定义。比如强调功能的组合,强调Point-Free风格等等。3.总结函数式编程有很多基本特征。熟练地使用这些特性,巧妙地组合它们,就形成了我们的“函数式编程范式”。这些基本特征使我们能够将函数更多地视为函数而不是方法。在很多场景下,使用这种范式进行编程就像是在做数学推导(或类型推导)。它可以让我们把复杂的问题一个一个地简化,然后积累/积累,就像学习数学一样。从而得到结果。同时,函数式编程还有一个很大的领域需要进入,那就是副作用处理。不处理副作用的程序是没有意义的(只能在内存中计算)。在下一篇文章中,我们将深入函数式编程的应用,为我们的工程应用发掘更多的特性。4.作者简介俊杰,美团到家研发平台/医疗技术部前端工程师。阅读更多美团技术团队的技术文章。前端|算法|后端|资料|安全|运维|iOS|2019货、【2018货】、【2017货】等关键词,可以查看美团技术团队历年技术文章合集。|本文由美团技术团队制作,版权归美团所有。欢迎转载或将本文内容用于分享、交流等非商业用途,转载请注明“内容由美团技术团队转载”。本文未经许可不得转载或用于商业用途。任何商业活动,请发邮件至tech@meituan.com申请授权。