高阶函数与闭包
高阶函数与闭包
2023年7月29日
摘要
高阶函数是深入研究计算机编程的路上绕不过的一个门槛,另外在任何但凡是包含一定函数式编程范式的语言中,对于闭包的支持也都是不可或缺的一部分,简单来说闭包就是一种匿名函数,但在匿名之上多了一种捕获上下文变量的能力。本文将对高阶函数和闭包进行一定的阐述。
函数做参数
首先,对于函数和参数的关系,对于大多数情况下来讲,参数就是传入函数的数据,而实际情况是,函数和参数并无什么太大的区别,也就是说,函数不仅可以接收参数,也可以作为参数传入其他函数。能接收函数作为输入的函数叫做高阶函数。
C 语言:函数作为输入参数
其实这样的操作并不需要多么先进的编程语言才能支持,在高级语言中最基础的 C 语言里,函数作为参数这一操作已经通过函数指针进行了实现,例如:
int calculate((int* func)(int, int), int a, int b)
{
return func(a, b);
}
此段代码就是定义了一个名为 calculate
的函数,其接收一个函数指针和两个需要计算的数,而这个函数指针代表着一个接收两个 int
参数返回一个 int
值的任意函数。在 calculate
内部,我们再调用传入的 func
函数指针完成计算。在这里,calculate
函数并没有任何实际功能,他只是承担了一个类似接口的功能,只要按照其要求定义传入函数,就能随时更改 calculate
的功能。例如,再定义一个加法函数:
int add(int x, int y)
{
return x + y
}
将 add
函数传入,就可以将 calculate
变成一个加法计算器:
int result = 0;
result = calculate(add, 2, 3);
这样的操作看似没什么意义,因为加法计算完全可以直接通过调用 add
函数实现,但函数作为参数这件事实际给我们提供了一种非常强大的能力,可以让我们在定义函数时预留一个更改部分函数内部行为的接口,让我们的函数有更大的可复用性。这在更复杂的场景中会极为有用,因为许多函数其实大体是一致的,只有少部分略有区别,我们可以将其相同部分定义成一个高阶函数,不同的部分使用使用别的函数作为参数传入。
在数学上,就类似我们定义了一个函数:f(g, x, y)=g(x, y),仅此而已
C 语言这样最为基础的高级语言已经提供了高阶函数的功能,我们可以按照规定的类型将函数作为参数输入,然而却无法实现创建一个新的函数并作为结果进行输出。这是因为 C 语言的函数只是汇编语言的稍高一层的抽象,其编译器会严格的将函数编译好后等待调用,函数作为输入参数时只是会在运行时进行额外的一次跳转而已,但是目标函数也是已经编译好的。如果想要根据运行中的情况,在运行时创建一个函数,那显然是无法实现的,因为运行时不会有编译器介入,无法动态的编译新的函数。
函数式语言:函数作为输出参数
万物皆函数
和 C 语言这样面向过程的语言不同,函数式语言作为截然不同的另一种范式,函数在他们之中就是至高无上的一等公民。如果说面向过程的 C 语言中,不同的东西有不同的属性,在面向对象的语言中,万物皆对象,那么,在函数式语言中,万物皆函数。在这样的大前提下,函数既能作为传入参数也能作为输出参数就显得无比正常了,因为其将会遵循一套截然不同的底层逻辑。
对于万物皆函数这一思维,可能需要稍作解释才能更好理解。函数本身作为函数不必解释,因为其本是如此。除了函数以外,运算符也是函数,这一点稍作思考也不难理解,因为运算符是无法离开操作数的,例如 +
就是一个需要两个操作数的运算符,而 ++
就是只需要一个一个操作数的运算符。我们完全可以把运算符当作一个调用语法稍有不同的函数而已。然而,在函数式语言里,数据本身,也是函数。
数据也是函数,这一点是最难理解的,但其实只要摒弃一些固有思维,也会变的非常直白,例如,2
这个数,也可以被当作一个函数,一个接收 0 个参数,返回一个 int
值的函数。直接解释可能不够直观,直接用代码来描述或许会更为直观,例如,我们定义一个名为 two
的函数:
int two()
{
return 2;
}
那么,在任何需要用的 2
这个数的地方,我们都可以直接使用 two()
代替,二者在结果上没有任何区别,也就是说,数据本身,在逻辑上,完全也可以被描述为一个函数。在数学上,也就是一个 f(None)=2 的常数函数而已。
返回函数
在数学中,一个函数的结果是另一个函数这一情况十分普遍,例如 f(x, y) = 2x + 3y 这个最为常见的二元函数,如果没有规定必须一次性提供所有参数,那此时传入参数 x=1,我们就可以得到 f(y)=2+3y 这样一个一元函数。这就是一个最简单的输出也是函数的例子,如果从数学的角度来看,这也许比前文的函数作为输入参数的 f(g, x, y)=g(x, y) 还要好理解。
在多个输入参数的函数中,如果允许一次只接收一个参数,并返回一个“接收剩下所有参数的函数“的函数叫做柯里化(Currying)函数。
为了更清楚的描述,我们将使用函数式语言的代表 Haskell 进行举例,例如我们定义一个加法函数:
add :: Int -> Int -> Int
add x y = x + y
在 Haskell 的语法中,第一行是函数签名,代表着函数的类型信息,第二行是函数定义,代表着函数具体要做的事。根据签名,我们可以知道 add
函数接收两个 Int
输入并得到一个 Int
输出,但是,巧妙的是,Haskell 的函数签名很直白的表述出了一件事,第一个箭头清晰的告诉了我们当我们只输入了一个参数时,得到的东西的类型,那就是 Int -> Int
,是另一个函数,这个函数依然需要再接收一个 Int
参数才能得到最后一个箭头指向的 Int
类型,也就是最终输出。也就是说,如果我们调用 add 2
,就能得到另外一个函数,我们可以把它存起来,给他一个名字绑定,后续再接着调用:
-- add_two y :: Int -> Int = 2 + y
let add_two = add 2 -- add_two is still a function
-- result :: Int = 2 + 3 = 5
let result = add_two 3
函数做为输出结果这件事看起来好像也没什么意义,但实际上他也提供了一种能力:灵活的创造函数。我们可以以一个函数为基准,只输入一部分参数,根据这部分参数的不同我们可以得到不同的变种,也是一种优雅的提高可复用性的方式。
在提高函数可复用性上:函数作为输入参数可以看做给了开发者一定的权限,深入函数内部去自定义部分功能,总结来讲就是一个函数,多个版本;而函数作为输出结果可以看做是给开发者提供了一个工厂,生产出各种某一模版的衍生函数,总结来讲就是一个模版,多个函数。
闭包
只是单纯的传入或是传出一个函数,看似已经很够用了,但在某些场景下却依然有很大的局限。当我们定义一个函数的时候,往往可以在函数定义内使用当前文件内的全局变量,在非全局的作用域下,如函数内部,再定义局部函数时(如语言支持),则同样可以在函数定义内访问当前作用域内的局部变量。对局部变量的访问,则是闭包的雏形,也是闭包最重要的性质。
如果要传入一个函数作为输入参数,并且需要访问当前内部作用域内的上下文参数,例如当前所处的循环迭代次数,我们可以直接在当前局部作用域定义一个局部函数,并传入,不过,有时这样的操作会显得有些多余,虽然功能上完全没有任何问题。
再思考一下作为输入参数传入的函数,很多时候都有一大特点,就是只会根据当前的上下文语境使用一次,因此,似乎完全没有必要专门为这个临时用一次的函数创建一个函数名,就像我们给一个函数传入一个字符串时,直接使用字面量例如 "abc"
传入即可,完全没必要给他单独起个变量名再传入。因此,对于只用一次的函数,我们也可以使用就地定义的方式传入,这样的操作也叫匿名函数,也就是闭包。
虽然说,作用域的上下文变量完全可以通过增加函数目标函数的输入参数个数来进行吸收,但这样的更改无疑会破坏函数的复用性,因为不同的情况下可能会需要不同的上下文,因此,闭包和局部函数提供了一种不破坏复用性的情况下,传入不限数量的上下文变量的可能性,因为其本质即是隐式的将当前的上下文参数全部打包和要传入的函数一起传入进了目标函数。
对于打包上下文的必要性,或许还需要多做一点说明。表面上,既然局部函数已经能访问当前作用域下的局部变量了,为何还需要再打包一次?其实原因很简单,函数作为参数传入后,其将会被在另一处,也就是另一个函数的内部进行调用,而此处并不处于局部函数定义时的那个作用域,自然也无法访问原位的局部变量,因此,才需要将上下文进行打包,以实现在另一处还能获得曾经定义时所能获知的上下文环境。
由于闭包的定义语法会根据语言的不同天差地别,因此在这里不作展示,本文的重点是讲解清楚闭包背后的逻辑和其能解决的问题。但是可以提到的是,Lambda 表达式是最通用的闭包实现方式,各大拥有一定的函数式特性的语言都有支持,只是语法不太一样。某些语言例如 Apple 的 Swift 语言还对较为复杂的闭包提供了额外的语法以方便代码的编写,具体的可以查阅各个语言的官方文档。
如果是需要多次使用的,要在多个函数中作为传入参数使用的函数,则当然可以不使用匿名函数,而使用常规的局部函数进行复用。
总结
高阶函数和闭包是函数式编程范式中极为优雅的一种思想,灵活的应用这样的技术可以使得项目的开发拥有更高的封装性和可复用性。另外,函数式编程范式真的是个好东西,强推 Haskell 语言。