函数式编程是一种历史悠久的编程范式。作为演算法,它的历史可以追溯到现代计算机诞生之前的λ演算,本文希望带大家快速了解函数式编程的历史、基础技术、重要特性和实践法则。
在内容层面,主要使用JavaScript语言来描述函数式编程的特性,并以演算规则、语言特性、范式特性、副作用处理等方面作为切入点,通过大量演示示例来讲解这种编程范式。同时,文末列举比较一些此范式的优缺点,供读者参考。
前言
1. 先览:代码组合和复用
2. 什么是函数式编程?
2.1 定义
2.2 函数式编程起源:λ演算
2.2.2 演算:代换和归约
2.3 JavaScript中的λ表达式:箭头函数
2.4 函数式编程基础:函数的元、柯里化和Point-Free
2.5 函数式编程特性
3. 小结
本文分为上下两篇,上篇讲述函数式编程的基础概念和特性,下篇讲述函数式编程的进阶概念、应用及优缺点。函数式编程既不是简单的堆砌函数,也不是语言范式的终极之道。我们将深入浅出地讨论它的特性,以期在日常工作中能在对应场景中进行灵活应用。
在前端代码中,我们现有一些可行的模块复用方式,比如:
图 1
除了上面提到的组件和功能级别的代码复用,我们也可以在软件架构层面上,通过选择一些合理的架构设计来减少重复开发的工作量,比如说很多公司在中后台场景中大量使用的低代码平台。
可以说,在大部分软件项目中,我们都要去探索代码组合和复用。
函数式编程,曾经有过一段黄金时代,后来又因面向对象范式的崛起而逐步变为小众范式。但是,函数式编程目前又开始在不同的语言中流行起来了,像Java 8、JS、Rust等语言都有对函数式编程的支持。
今天我们就来探讨JavaScript的函数,并进一步探讨JavaScript中的函数式编程(关于函数式编程风格软件的组织、组合和复用)。
图 2
函数式编程是一种风格范式,没有一个标准的教条式定义。我们来看一下维基百科的定义:
函数式编程是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算是该语言最重要的基础。而且λ演算的函数可以接受函数作为输入的参数和输出的返回值。
我们可以直接读出以下信息:
那什么是λ演算呢?
λ演算(读作lambda演算)由数学家阿隆佐·邱奇在20世纪30年代首次发表,它从数理逻辑(Mathematical logic)中发展而来,使用变量绑定(binding)和代换规则(substitution)来研究函数如何抽象化定义(define)、函数如何被应用(apply)以及递归(recursion)的形式系统。
λ演算和图灵机等价(图灵完备,作为一种研究语言又很方便)。
通常用这个定义形式来表示一个λ演算。
图 3
所以λ演算式就三个要点:
比如这样的演算式:
图 4
通过变量代换(substitution)和归约(reduction),我们可以像化简方程一样处理我们的演算。
λ演算有很多方式进行,数学家们也总结了许多和它相关的规律和定律(可查看维基百科)。
举个例子,小时候我们学习整数就是学会几个数字,然后用加法/减法来推演其他数字。在函数式编程中,我们可以用函数来定义自然数,有很多定义方式,这里我们讲一种实现方式:
图 5
上面的演算式表示有一个函数f和一个参数x。令0为x,1为f x,2为f f x...
什么意思呢?这是不是很像我们数学中的幂:a^x(a的x次幂表示a对自身乘x次)。相应的,我们理解上面的演算式就是数字n就是f对x作用的次数。有了这个数字的定义之后,我们就可以在这个基础上定义运算。
图 6
其中SUCC表示后继函数(+1操作),PLUS表示加法。现在我们来推导这个正确性。
图 7
这样,进行λ演算就像是方程的代换和化简,在一个已知前提(公理,比如0/1,加法)下,进行规则推演。
在λ演算中我们的表达式只有一个参数,那它怎么实现两个数字的二元操作呢?比如加法a + b,需要两个参数。
这时,我们要把函数本身也视为值,可以通过把一个变量绑定到上下文,然后返回一个新的函数,来实现数据(或者说是状态)的保存和传递,被绑定的变量可以在需要实际使用的时候从上下文中引用到。
比如下面这个简单的演算式:
图 8
第一次函数调用传入m=5,返回一个新函数,这个新函数接收一个参数n,并返回m + n的结果。像这种情况产生的上下文,就是Closure(闭包,函数式编程常用的状态保存和引用手段),我们称变量m是被绑定(binding)到了第二个函数的上下文。
除了绑定的变量,λ演算也支持自由的变量,比如下面这个y:
图 9
这里的y是一个没有绑定到参数位置的变量,被称为一个自由变量。
绑定变量和自由变量是函数的两种状态来源,一个可以被代换,另一个不能。实际程序中,通常把绑定变量实现为局部变量或者参数,自由变量实现为全局变量或者环境变量。
演算分为Alpha代换和Beta归约。前面章节我们实际上已经涉及这两个概念,下面来介绍一下它们。
Alpha代换指的是变量的名称是不重要的,你可以写λm.λn.m + n,也可以写λx.λy.x + y,在演算过程中它们表示同一个函数。也就是说我们只关心计算的形式,而不关心细节用什么变量去实现。这方便我们不改变运算结果的前提下去修改变量命名,以方便在函数比较复杂的情况下进行化简操作。实际上,连整个lambda演算式的名字也是不重要的,我们只需要这种形式的计算,而不在乎这个形式的命名。
Beta归约指的是如果你有一个函数应用(函数调用),那么你可以对这个函数体中与标识符对应的部分做代换(substitution),方式为使用参数(可能是另一个演算式)去替换标识符。听起来有点绕口,但是它实际上就是函数调用的参数替换。比如:
图 10
可以使用1替换m,3替换n,那么整个表达式可以化简为4。这也是函数式编程里面的引用透明性的由来。需要注意的是,这里的1和3表示表达式运算值,可以替换为其他表达式。比如把1替换为(λm.λn.m + n 1 3),这里就需要做两次归约来得到下面的最终结果:
图 11
ECMAScript 2015规范引入了箭头函数,它没有this,没有arguments。只能作为一个表达式(expression)而不能作为一个声明式(statement),表达式产生一个箭头函数引用,该箭头函数引用仍然有name和length属性,分别表示箭头函数的名字、形参(parameters)长度。一个箭头函数就是一个单纯的运算式,箭头函数我们也可以称为lambda函数,它在书写形式上就像是一个λ演算式。
图 12
可以利用箭头函数做一些简单的运算,下例比较了四种箭头函数的使用方式:
图 13
这是直接针对数字(基本数据类型)的情况,如果是针对函数做运算(引用数据类型),事情就变得有趣起来了。我们看一下下面的示例:
图 14
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表示。
图 15
我们把λ演算中的f和x分别取为countTime和x,代入运算就得到了我们的自然数。
这也说明了不管你使用符号系统还是JavaScript语言,你想要表达的自然数是等价的。这也侧面说明λ演算是一种形式上的抽象(和具体语言表述无关的抽象表达)。
回到JavaScript本身,我们要探究函数本身能不能带给我们更多的东西?我们在JavaScript中有很多创建函数的方式:
图 16
虽然函数有这么多定义,但function关键字声明的函数带有arguments和this关键字,这让他们看起来更像是对象方法(method),而不是函数(function) 。
况且function定义的函数大多数还能被构造(比如new Array)。
接下来我们将只研究箭头函数,因为它更像是数学意义上的函数(仅执行计算过程)。- 没有arguments和this。
不论使用何种方式去构造一个函数,这个函数都有两个固定的信息可以获取:
在数学上,我们定义f(x) = x是一个一元函数,而f(x, y) = x + y是一个二元函数。在JavaScript中我们可以使用函数定义时的length来定义它的元。
图 17
定义函数的元的意义在于,我们可以对函数进行归类,并且可以明确一个函数需要的确切参数个数。函数的元在编译期(类型检查、重载)和运行时(异常处理、动态生成代码)都有重要作用。
如果我给你一个二元函数,你就知道需要传递两个参数。比如+就可以看成是一个二元函数,它的左边接受一个参数,右边接受一个参数,返回它们的和(或字符串连接)。
在一些其他语言中,+确实也是由抽象类来定义实现的,比如Rust语言的trait Add<A, B>。
但我们上面看到的λ演算,每个函数都只有一个元。为什么呢?
只有一个元的函数方便我们进行代数运算。λ演算的参数列表使用λx.λy.λz的格式进行分割,返回值一般都是函数,如果一个二元函数,调用时只使用了一个参数,则返回一个“不完全调用函数”。这里用三个例子解释“不完全调用”。
第一个,不完全调用,代换参数后产生了一个不完全调用函数 λz.3 + z。
图 18
第二个,Haskell代码,调用一个函数add(类型为a -> a -> a),得到另一个函数add 1(类型为a -> a)。
图 19
第三个,上一个例子的JavaScript版本。
图 20
“不完全调用”在JavaScript中也成立。实际上它就是JavaScript中闭包(Closure,上面我们已经提到过)产生的原因,一个函数还没有被销毁(调用没有完全结束),你可以在子环境内使用父环境的变量。
注意,上面我们使用到的是一元函数,如果使用三元函数来表示addThree,那么函数一次性就调用完毕了,不会产生不完全调用。
函数的元还有一个著名的例子(面试题):
图 21
造成上述结果的原因就是,Number是一元的,接受map第一个参数就转换得到返回值;而parseInt是二元的,第二个参数拿到进制为1(map函数为三元函数,第二个参数返回元素索引),无法输出正确的转换,只能返回NaN。这个例子里面涉及到了一元、二元、三元函数,多一个元,函数体就多一个状态。如果世界上只有一元函数就好了!我们可以全通过一元函数和不完全调用来生成新的函数处理新的问题。
认识到函数是有元的,这是函数式编程的重要一步,多一个元就多一种复杂度。
柯里化(curry)函数是一种把函数的元降维的技术,这个名词是为了纪念我们上文提到的数学家阿隆佐·邱奇。
首先,函数的几种写法是等价的(最终计算结果一致)。
图 22
这里列一个简单的把普通函数变为柯里化函数的方式(柯里化函数Curry)。
图 23
柯里化函数帮助我们把一个多元函数变成一个不完全调用,利用Closure的魔法,把函数调用变成延迟的偏函数(不完全调用函数)调用。这在函数组合、复用等场景非常有用。比如:
图 24
虽然你可以用其他闭包方式来实现函数的延迟调用,但Curry函数绝对是其中形式最美的几种方式之一。
函数式编程中有一种Point-Free风格,中文语境大概可以把point认为是参数点,对应λ演算中的函数应用(Function Apply),或者JavaScript中的函数调用(Function Call),所以可以理解Point-Free就指的是无参调用。
来看一个日常的例子,把二进制数据转换为八进制数据: 图 25
这段代码运行起来没有问题,但我们为了处理这个转换,需要了解 parseInt(x, 2) 和 toString(8) 这两个函数(为什么有魔法数字2和魔法数字8),并且要关心数据(函数类型a -> b)在每个节点的形态(关心数据的流向)。有没有一种方式,可以让我们只关心入参和出参,不关心数据流动过程呢?
图 26
上面的方法假设我们已经有了一些基础函数toBinary(语义化,消除魔法数字2)和toStringOx(语义化,消除魔法数字8),并且有一种方式(pipe)把基础函数组合(Composition)起来。如果用Typescript表示我们的数据流动就是:
图 27
用Haskell表示更简洁,后面都用Haskell类型表示方式,作为我们的符号语言。
图 28
值得注意的是,这里的x-> [int] ->y我们不用关心,因为pipe(..)函数帮我们处理了它们。pipe就像一个盒子。
图 29
BOX内容不需要我们理解。而为了达成这个目的,我们需要做这些事:
综上,Point-Free风格是粘合一些基础函数,最终让我们的数据操作不再关心中间态的方式。这是函数式编程,或者说函数式编程语言中我们会一直遇到的风格,表明我们的基础函数是值得信赖的,我们仅关心数据转换这种形式,而不是过程。JavaScript中有很多实现这种基础函数工具的库,最出名的是Lodash。
可以说函数式编程范式就是在不停地组合函数。
说了这么久,都是在讲函数,那么究竟什么是函数式编程呢?在网上你可以看到很多定义,但大都离不开这些特性。
另外还有一些特性,有的会提到,有的一笔带过,但实际也是一个特性(以Haskell为例)。
数学上,我们定义函数为集合A到集合B的映射。在函数式编程中,我们也是这么认为的。函数就是把数据从某种形态映射到另一种形态。注意理解“映射”,后面我们还会讲到。
图 30
函数本身也是数据的一种,可以是参数,也可以是返回值。
图 31
通过这种方式,我们可以让函数作为一种可以保存状态的值进行流动,也可以充分利用不完全调用函数来进行函数组合。把函数作为数据,实际上就让我们有能力获取函数内部的状态,这样也产生了闭包。但函数式编程不强调状态,大部分情况下,我们的“状态”就是一个函数的元(我们从元获取外部状态)。
通常我们定义输入输出(IO)是不纯的,因为IO操作不仅操作了数据,还操作了这个数据范畴外部的世界,比如打印、播放声音、修改变量状态、网络请求等。这些操作并不是说对程序造成了破坏,相反,一个完整的程序一定是需要它们的,不然我们的所有计算都将毫无意义。
但纯函数是可预测的,引用透明的,我们希望代码中更多地出现纯函数式的代码,这样的代码可以被预测,可以被表达式替换,而更多地把IO操作放到一个统一的位置做处理。
图 32
这个add函数是不纯的,但我们把副作用都放到它里面了。任意使用这个add函数的位置,都将变成不纯的(就像是async/await的传递性一样)。需要说明的是抛开实际应用来谈论函数的纯粹性是毫无意义的,我们的程序需要和终端、网络等设备进行交互,不然一个计算的运行结果将毫无意义。但对于函数的元来说,这种纯粹性就很有意义,比如:
图 33
当函数的元像上面那样是一个引用值,如果一个函数的调用不去控制自己的纯粹性,对别人来说,可能会造成毁灭性打击。因此我们需要对引用值特别小心。
图 34
上面这种解构赋值的方式仅解决了第一层的引用值,很多其他情况下,我们要处理一个引用树、或者返回一个引用树,这需要更深层次的解引用操作。请小心对待你的引用。
函数的纯粹性要求我们时刻提醒自己降低状态数量,把变化留在函数外部。
通过表达式替代(也就是λ演算中讲的归约),可以得到最终数据形态。
图 35
也就是说,调用一个函数的位置,我们可以使用函数的调用结果来替代此函数调用,产生的结果不变。
一个引用透明的函数调用链永远是可以被合并式代换的。
一个函数不应该去改变原有的引用值,避免对运算的其他部分造成影响。
图 36
一个充满变化的世界是混沌的,在函数式编程世界,我们需要强调参数和值的不可变性,甚至在很多时候我们可以为了不改变原来的引用值,牺牲性能以产生新的对象来进行运算。牺牲一部分性能来保证我们程序的每个部分都是可预测的,任意一个对象从创建到消失,它的值应该是固定的。
一个元如果是引用值,请使用一个副本(克隆、复制、替代等方式)来得到状态变更。
JS中用的最多的就是Array相关的高阶函数。实际上Array是一种Monad(后面讲解)。
图 37
通过高阶函数传递和修改变量:
图 38
高阶函数实际上为我们提供了注入环境变量(或者说绑定环境变量)提供了更多可能。React的高阶组件就从这个思想上借用而来。一个高阶函数就是使用或者产生另一个函数的函数。高阶函数是函数组合(Composition)的一种方式。
高阶函数让我们可以更好地组合业务。常见的高阶函数有:
惰性计算的含义就是在真正调用到的时候才执行,中间步骤不真实执行程序。这样可以让我们在运行时创建很多基础函数,但并不影响实际业务运行速度,唯有业务代码真实调用时才产生开销。
图 39
map(addOne)并不会真实执行+1,只有真实调用exec才执行。当然这个只是一个简单的例子,强大的惰性计算在函数式编程语言中还有很多其他例子。比如:
图 40
“无穷”只是一个字面定义,我们知道计算机是无法定义无穷的数据的,因此数据在take的时候才真实产生。
惰性计算让我们可以无限使用函数组合,在写这些函数组合的过程中并不产生调用。但这种形式,可能会有一个严重的问题,那就是产生一个非常长的调用栈,而虚拟机或者解释器的函数调用栈一般都是有上限的,比如2000个,超过这个限制,函数调用就会栈溢出。虽然函数调用栈过长会引起这个严重的问题,但这个问题其实不是函数式语言设计的逻辑问题,因为调用栈溢出的问题在任何设计不良的程序中都有可能出现,惰性计算只是利用了函数调用栈的特性,而不是一种缺陷。
记住,任何时候我们都可以重构代码,通过良好的设计来解决栈溢出的问题。
当前的JS有TypeScript的加持,也可以算是有类型推导了。
图 41
没有类型推导的函数式编程,在使用的时候会很不方便,所有的工具函数都要查表查示例,开发中效率会比较低下,也容易造成错误。
但并不是说一门函数式语言必须要类型推导,这不是强制的。像Lisp就没有强制类型推导,JavaScript也没有强制的类型推导,这不影响他们的成功。只是说,有了类型推导,我们的编译器可以在编译器期间提前捕获错误,甚至在编译之前,写代码的时候就可以发现错误。类型推导是一些语言强调的优秀特性,它确实可以帮助我们提前发现更多的代码问题。像Rust、Haskell等。
你现在也可以总结一些其他的风格或者定义。比如强调函数的组合、强调Point-Free的风格等等。
图 42
函数式有很多基础的特性,熟练地使用这些特性,并加以巧妙地组合,就形成了我们的“函数式编程范式”。这些基础特性让我们对待一个function,更多地看作函数,而不是一个方法。在许多场景下,使用这种范式进行编程,就像是在做数学推导(或者说是类型推导),它让我们像学习数学一样,把一个个复杂的问题简单化,再进行累加/累积,从而得到结果。
同时,函数式编程还有一块大的领域需要进入,即副作用处理。不处理副作用的程序是毫无意义的(仅在内存中进行计算),下篇我们将深入函数式编程的应用,为我们的工程应用发掘出更多的特性。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/XTXnTPAbK1ipaHIsUD2kBQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。