高阶函数
函数的高级应用:用作参数、函数生成的函数和匿名函数
Jonathan Bartlett (johnnyb@eskimo.com), 技术总监, New Media Worx
Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书简要介绍了使用 Linux 汇编语言进行编程的知识。他是 New Media Worx 的首席开发人员,负责为客户端开发 Web、视频、信息站和桌面应用程序。您可以通过 johnnyb@eskimo.com 与 Jonathan 联系。
简介: 函数是计算机程序功能强大的构建块。使用函数可以将代码划分为更简单、更容易管理的代码段,还可以将程序划分为可重用的单元--这些单元既可以在本程序中使用,也可以在其他程序中使用。在本文中,我们将介绍如何基于一些模板在运行时创建新函数,如何创建一些在运行时可以使用参数配置的函数,以及在函数中 Scheme 语言如何成为一个有价值的工具。
标记本文!
发布日期: 2005 年 4 月 18 日
级别: 初级
访问情况 : 1890 次浏览
评论: 0 (查看 | 添加评论 - 登录)
在计算机程序中,函数构成了功能强大的代码段,这让开发人员可以将代码划分成更简单、更容易管理的代码段,同时还让程序员可以将自己的程序划分成可重用的单元。作为对函数强大功能的首肯,在本文中我将向您展示如何在运行时创建一些基于模板的函数,并介绍如何构建一些在运行时可以使用参数配置的函数。
本文中的例子使用了 Scheme 和 C 编程语言。对于 Scheme 编程语言的简介可以在 “Better programming through effective list handling” 一文中找到(developerWorks,2005 年 1 月)(请参阅 参考资料 中给出的其他 Scheme 简介和 Scheme 函数的参考资料)。
现在让我们立即开始创建一些函数。
创建匿名函数
在 Scheme 中,函数创建时缺省情况下是无名的。lambda 这个特殊的表达式会创建一个无名函数,然后将该值返回给 enclosure 表达式或函数调用。enclosure 表达式可以:
- 设置一个符号来引用该值(这样就给它了一个名字)。
- 将该值保存在一个数据结构中供以后使用。
- 将该值作为参数传递给某个函数。
在大部分编程语言中,函数的定义和命名的操作是同时进行的。而在 Scheme 中,这两个操作是独立的,这会对 Scheme 新手产生很多误解。Scheme 方法实际上非常简单,因为 Scheme 只不过是将函数与其他值同样对待。
函数是使用 lambda
创建的。就像其他值一样,这些函数可以:
- 作为参数传递
- 保存到变量中
- 保存到一个大型数据结构的一部分中
为了让使用 lambda
创建的 Scheme 函数能与其他语言的函数一样使用,需要将其保存到一个全局变量中--这样可以让该函数对于其他函数都是可见的,并且可以使用这个名字进行访问。清单 1 显示了 Scheme 中的一个匿名函数的例子,它的功能是计算给定数字的平方。
清单 1. 无名函数这段代码定义了一个函数,它有一个形参 x
;该函数会计算这个参数的平方,并返回结果。记住,Scheme 并不需要显式地返回一个值。Scheme 简单地返回函数中最后一个表达式的结果。
现在让我们继续,为该函数赋予一个名字,如清单 2 所示。
清单 2. 对函数进行命名 (define square (lambda (x) (* x x))) (display (square 3)) (newline) (display (square 4)) (newline)
|
这是使用函数的一种最简单也是最普通的方法--为函数赋予一个名字,然后在计算中使用这个函数的名字。然而,在 Scheme 中并没有什么规则会强制您在使用函数之前必须为其赋予一个名字。
在 Scheme 中,程序中每个列表的头都必须返回一个函数或一个特殊的表达式,不过这并不意味着它必须是一个函数的 名字。因为 lambda
这个特殊的表达式会返回函数,所以您实际上可以在函数调用中直接使用 lambda
函数定义。
例如,我们可以不使用 (square 4)
,而是使用 ( (lambda (x) (* x x)) 4)
。这是一个有两个元素的列表,其中第一个元素是函数的定义,第二个元素是一个数字。这个列表的头是函数定义本身。当它返回时,首先会计算列表中的值。lambda
表达式会分析函数的定义,因为这是列表的头,它是对第二个元素进行调用的。这个表达式的返回值是 4 的平方。
定义并立即调用函数,而不是先命名或将其作为值传递,这是一个有趣的概念,但是其用处并不太大。然而,这可以说明将函数作为值的概念,并显示匿名函数是如何工作的。
现在让我们来了解一下将函数作为函数参数的用法。
回页首
使用函数作为函数参数
无名函数的一个实际用法是构建一些函数用来作为参数传递给其他函数。其最好的一个例子是 Scheme 内置的 map
函数。
map
函数有两个参数:一个函数变量和一个列表。然后它会将列表中的每个元素都作为这个函数的参数进行调用,并返回一个由计算结果所构成的新列表。例如,使用 square 函数,您可以使用一个数字列表,并计算它们的平方(如清单 3 所示)。
清单 3. 在 Scheme 中将函数作为参数传递给其他函数 ;Our function (define square (lambda (x) (* x x))) ;Our data (define my-test-data '(1 2 3 4 5 6)) ;Create a new list as the squares of my-test-data (define list-of-squares (map square my-test-data)) ;Display results (display list-of-squares) (newline)
|
与其他语言相比,这个程序非常简明。例如,在 C 语言中,等价的代码如清单 4 所示。
清单 4. 在 C 语言中,将函数作为参数传递给其他函数 int square(int x) { return x * x; } int main() { int my_test_data[6] = {1, 2, 3, 4, 5, 6};; int list_of_squares[6]; int i; /* Note that this would only work if C had a function * called map which worked with integer functions. * It doesn't, so this is largely theoretical. Also remember * that C doesn't know the size of the arrays, so you have to * pass it explicitly. Also, list_of_squares has to be passed * as a parameter in C. */ map(square, my_test_data, list_of_squares, 6); printf("("); for(i=0; i<6; i++) { printf("%d ", list_of_squares[i]); } printf(")\n"); }
|
当然,在 C 语言中,除非您使用在“Better programming through effective list handling” 一文中介绍的类型标记机制,否则就需要为在 map
中使用的每种数据类型都定义一个不同的函数。
使用 Scheme 编写的这段代码除了比 C 语言代码更加简明之外,还有其他一些优点--您无需对 square
函数进行命名。由于函数可以与 Scheme 中的其他值一样创建并传递,因此代码的核心可以进行修改,这样就不需要对这个计算平方的函数进行命名了。您可以仅仅定义一个函数,并将其传递给 map
函数。在变量名 square
处,我们可以使用一个用 lambda
表达式编写的函数定义。清单 5 显示了这样一段代码。
清单 5. 将匿名函数作为值传递 (define list-of-squares (map (lambda (x) (* x x)) '(1 2 3 4 5 6))) (display list-of-squares) (newline)
|
那么,我们为什么要这样做呢?与有名函数相比,当函数只使用一次时,匿名函数有几个优点:
- 程序的名称空间不会受到那些只使用一次的函数的污染。
- 函数的代码就位于使用它的地方,这样程序员就不用到处寻找这些只会使用一次的小函数代码了。
- 这种函数显然是与它所传递到的函数相关的,因此它显然与使用这个上下文的其他程序员也是有关的。如果这些只使用一次的函数都是与调用函数单独编写的,而后来调用函数被删除了,那么显然这些只使用一次的函数也就需要删除了。
简而言之,使用匿名函数可以让代码更加简明,并且易于跟踪,当一个函数专用于一个实例或特殊用途时,使其对于其他程序员来说是显而易见的。
而且,正如您可以从 map
函数中可以看到的一样,将函数作为参数传递可以让您更好地控制数据流所发生的操作。在这个例子中,map
函数会遍历列表中的每个值,然后 使用您的函数 对这个列表值进一步进行处理。这样就为函数的编写者和用户提供了很大的灵活性--您可以创建一个函数,它允许用户通过使用一个函数作为参数传递来实现其他功能。
除了 map
函数之外,还有其他几个常见的列表处理函数,它们都使用函数作为参数。这些函数,或者与此类似的函数,是数据处理应用程序的骨架。使用这些常见的函数,可以节省很多反复编程的工作;否则您就只能编写一个又一个的代码循环,即使它们的工作模式都相同也是如此。这不但可以节省您的时间,而且还可以减少引入 bug 的可能。
下面是最常见的一些列表处理函数:
map
接受一个列表参数,并对这个列表的每个元素应用用户指定的函数,并返回一个结果的新列表。 filter
接受一个列表参数,并生成一个新列表,其元素为旧列表中满足用户指定条件的元素。 reduce
接受一个列表,并将其中的值合并为一个值。这个合并过程可以是求和、计算最小值/最大值等等。为了正确使用这些函数,您需要使用 closures(这会在 在运行时构建函数 一节中讨论)。
还有其他一些常见的列表处理函数,不过这些是最基本的,也是最容易学习而又最广泛使用的。对这些函数的详细介绍可以在 参考资料 一节的链接中找到。
您已经知道了如何将函数作为参数传递给其他函数了,现在让我们来开始了解一下如何编写一些自己使用函数作为参数的函数。
回页首
使用函数作为参数
新入门的程序员通常都会疑惑将函数作为参数传递在哪些地方才有用呢?在很多程序中,都有一两个这种实例:有一些基本的算法会被编写多次,但是可能每次稍有不同。这就是发挥使用函数作为参数所长的地方。
您可以做的事情是将算法的基本结构编写到一个函数中。然后,对少许部分进行修改,并将其作为函数参数补充回来。这样可以在一个函数中增加很多可定制的因素。您还可以添加其他函数参数为将来进行扩展。这种组合函数可以减少编程的冗余,通过扩展,还可以减少在一个程序中反复编写相同的算法所引起的错误。
假设您有一个订单处理的算法,它由以下几部分组成:
- 处理订单的每一行记录,并计算总价。
- 计算订单的送货费用。
- 验证购买者的信用记录。
- 如果成功,就对该订单进行收费,发送订单确认信息,并将其记录到数据库中。
现在让我们假设不同的顾客需要计算不同的送货方式,他们要计算的信用记录也都不同,对每个订单的收费方式也都不同。例如,送货是通过不同的依赖于客户端的服务提供者进行计算的。信用记录的验证对某些顾客可能是通过您自己业务进行的,对其他顾客则可能是通过信用卡公司进行的。订单收费可能会根据客户端是否是正常付款、通过信用卡收费或执行自动撤消而有所不同。不同的顾客对于每个步骤可能都有不同的处理需求。
编写这种函数的一种方法是在订单处理算法中直接简单地硬编码各种可能的方式。然后对于这个函数的调用将包括一组标记来说明所请求的是哪种处理类型。然而,随着可能性数目越大,这个订单处理算法可能会变得非常难用。
编写这种函数的另外一种方法是将这些步骤分别由于传递给该算法的一些独立函数进行处理。这样,这个订单处理算法就只需要编写一个简明的通用算法流程。每个主要步骤的细节都是由所传递进来的函数进行处理的。这种算法需要有几个参数:送货计算函数、信用记录验证函数以及订单收费函数。
清单 6 展示了使用 Scheme 编写的这样一个算法。
清单 6. 在订单处理中使用函数作为参数 (define process-order ;;The function has one data record for the order and three functions as parameters (lambda (order ship-calc-func credit-validate-func charge-order-func) (let ( ;;Here you execute functions to get the fields ;;from the order that you will need for later ;;processing (order-lines (get-order-lines order)) (origin (get-origin-address order)) (destination (get-destination-address order)) (delivery-time (get-delivery-time order)) (customer-email (get-customer-email order)) ;;These values will be calculated when you ;;process the order (weight 0.0) (subtotal 0.0) (total 0.0) (shipping 0.0) (tax 0.0)) ;;Iterate through each order line, running a function on each line ;;to add the weight and price to the weight and price of the order (for-each ;;Note that anonymous functions created within a function ;;have full access to the enclosing function's variables (lambda (order-line) (set! weight (+ weight (get-weight order-line))) (set! subtotal (+ total (get-price order-line)))) order-lines) ;;This uses your shipping calculation function that was passed as a parameter. ;;Remember, this could be any number of things, such as calculating from ;;a shipping provider's remote database, calculating based on static ;;customer-specific tables, or any other method you wish. (set! shipping (ship-calc-func weight origin destination delivery-time)) ;;For this exercise, tax will be considered fairly uniform, so you didn't pass ;;a tax-calculating function (set! tax (calculate-tax destination subtotal)) ;;Now record the total (set! total (+ subtotal shipping tax)) ;;Validate the user's credit line, according to the validation parameter (if (credit-validate-func total) (begin ;;Charge the order according to the validation parameter (charge-order-func total) (send-confirmation customer-email order-lines shipping tax total) (mark-as-charged order)) (begin ;;Cancel the order (send-failure-message customer-email) (mark-as-failed order))))))
|
这些函数参数的传递方式与其他参数类似,唯一的区别是它们在程序中的用法不同。这种技术让您可以在算法中编写通用的控制流程,而让处理的细节可以得到定制。很多不了解这种技术的程序员通常都使用很多标记来控制处理流程,如果在处理过程中要验证的信息很简单又很少,这并不是什么坏主意。但是当差异增多时,这个问题就变得麻烦了。
很难判断在一个函数中何时传递函数作为参数比使用特殊的情形/标记来控制处理流程更加有效。然而,这的确存在一些指导方针:
- 当选项不多而又非常具体时,使用特殊的情形标记更好。
- 当选项与算法绑定得非常紧密,而且有多个函数会使用该算法的本地变量来生成所需要的选项时,使用特殊的情形标记更好。
- 当每个选项都需要完全不同的变量集来判断问题的答案时,使用特殊的情形标记更好。
- 当选项有很多,或者可能会出现新选项时,使用函数作为参数通常会更好。
- 当选项非常清晰,而且逻辑上是与代码独立的,那么使用函数作为参数通常会更好。
C 语言也可以使用函数作为参数,但是在 C 语言中为一个函数编写参数有点容易引起误解。C 语言的类型是静态的,也就是说,类型是在编译时进行检查的;而 Scheme 则不是。因此,声明一个变量或参数来存放一个函数需要同时指定这些函数所使用的参数类型。
例如,让我们来看一下如何编写前面介绍的 map
函数。这个函数需要使用一个函数作为参数,后者使用一个整型的参数,返回值也是整型。square
函数如下所示: int square(int x)
。因此,要声明一个保存函数支持的变量,您需要编写如下的代码:
清单 7. 在 C 语言中为函数指针声明一个变量 /* This creates a variable called "the_function" which you can store * pointers to functions */ int (*the_function)(int);
|
第一个 int
表示返回值是一个整数。然后在括号中,我们使用了一个指针符号,以及该变量的名字。函数名后面的括号中是使用逗号分隔开的该函数的参数列表。
函数的赋值和使用实际上非常简单,这是因为在 C 语言中 所有的 函数实际上都是函数指针。
清单 8. 在 C 语言中对函数指针的操作 int main() { /* Declare function pointer */ int (*the_function)(int); /* assign function pointer */ the_function = square; /* call function */ printf("The square of 2 is %d\n", the_function(2)); return 0; }
|
使用 C 语言中有关函数指针的这些知识,您可以非常容易地使用 C 语言实现一个 Scheme 版本的 map
函数。然而,您的版本的用处可能会非常有限,因为它只能处理整型和整型的数组,而不是像 Scheme 中一样可以处理 任何 类型的数据和链表。
清单 9. 使用 C 语言实现 map 函数 void map(int (*mapping_function)(int), int src[], int dst[], int count) { int i; for(i = 0; i < count; i++) { dst[i] = mapping_function(src[i]); } }
|
另外一种更加通用的方法是可以使用在“Better programming through effective list handling”一文中介绍的类型标记技术,并将映射函数、源数组、目的数组都当作 data
类型使用。
当使用第三方的库时,作为参数传递给该库的函数通常都称为 回调函数(callback function)。这是因为它们都是用来从库中“回调”程序员定制的代码。这可以让编写库函数的开发人员编写更加通用的函数,它们可以回调程序员的代码进行更具体的处理。
使用回调函数,程序员可以对库进行扩充,而不用直接修改库函数的源代码。例如,大部分 GUI 工具包的 API 都使用回调函数进行事件的处理。回调函数被传递给 GUI API,然后进行保存,直到用户使用一些时间触发它们,例如鼠标点击事件或键盘按下事件。GUI API 当然不知道为了响应这些事件应该执行什么操作,因此它只是简单地使用调用程序所提供的回调函数来引导适当的函数执行操作。
现在我们已经了解了函数可以作为参数进行传递,接下来将进一步讨论如何根据程序员定义的模板来 创建 函数。
回页首
在运行时构建函数
在 Scheme 中,lambda
可以创建一个函数。然而,Scheme 中函数的概念比很多其他语言中的函数概念都“大”。
要理解它们之间的区别,首先需要理解 Scheme 是如何看待变量的。在 Scheme 中,变量都成组位于 环境(environment) 中。环境有两类:局部环境和全局环境。全局环境包括一个函数的所有全局变量;Scheme 对全局环境的处理基本上与大部分其他编程语言都是相同的。
除了全局环境之外,Scheme 还有无数的局部环境。局部环境是在程序某个给定的地方可以看到的局部变量和形参的集合。但是局部环境并不仅仅是变量名:它还包括这些变量所引用的存储/ 值。与其他编程语言中的局部环境不同,Scheme 中的局部环境可以保存下来供以后使用。
因此您该如何保存一个局部环境呢?又应该对函数作些什么呢?在 Scheme 中,当定义函数时,如果是在一个局部环境中定义的,那么这个局部变量就会永久地将自己附加到这个函数定义上。这样,这个函数就可以访问在创建该函数时所有处于活动状态且在此范围之内的变量,即使创建这些变量的函数已经返回之后仍然可以访问。下面让我们来看一下几个附加了局部环境的函数的例子。
清单 10. 在局部环境中声明的函数例子 ;Environment test is a function taking one argument. It will then return ;a function which remembers that argument (define make-echo-function (lambda (x) ;The local environment now consists of "x" ;Create a function which takes no parameters and return it (lambda () ;This function returns the x defined in the enclosing ;local environment x))) (define echo1 (make-echo-function 1)) (display (echo1)) ;should display 1 (newline) (define echo2 (make-echo-function 2)) (display (echo2)) ;should display 2 (newline) (display (echo1)) ;should display 1 (newline)
|
首先注意 echo1
和 echo2
都是 函数。现在 echo1
通常会返回 1,尽管它没有使用任何全局变量,也没有传递任何参数。这是因为当您创建这个函数时,这个局部环境已经保存了。在这种情况中,局部环境包括 x
。因此,它与 C 语言中的函数不同,每次调用 make-echo-function
时所创建的参数在 make-echo-function
函数结束之后依然还存在!只要从 make-echo-function
中返回的函数存在,这个局部环境也就存在。
实际上,当您定义 echo2
时,make-echo-function
有两个不同的局部环境都处于活动状态。虽然它们的变量名是相同的,但是变量中所保存的值却不相同。计算机知道使用哪个值,因为这个环境在使用 lambda
创建这个函数时就被绑定到这个函数上了。这种环境与函数的奇怪融合就称为 closure。
下面让我们来看一个稍微有点复杂的 closure。您将创建一个函数,它从一个给定的数字开始,然后每次调用时,都返回一个更大的数字。
清单 11. 用来展示函数生成函数的数字计数器程序 (define make-counter (lambda (curval) (lambda () (set! curval (+ curval 1)) curval))) (define my-counter (make-counter 0)) (display (my-counter)) ;writes 1 (newline) (display (my-counter)) ;writes 2 (newline) (define my-other-counter (make-counter 25)) (display (my-other-counter)) ;writes 26 (newline) (display (my-counter)) ;writes 3 (newline) (display (my-other-counter)) ;writes 27 (newline)
|
这种函数在用来创建那些维护从调用到调用的状态时非常有用。与函数参数一起使用,这是一种非常强大的编程技术。您可以将函数作为参数传递,这样功能就更加强大,因为 Scheme 的 closure 允许您使用函数传递各自的状态(局部环境)。在诸如 C 之类的编程语言中,在一个作为参数传递的函数中使用的任何状态都需要存在于一个局部变量中,或者需要显示地传递。
在一个过程中使用函数参数的地方是对程序员原先预想的范围进行扩充。但是如果您只能显示地传递在函数中使用的所有数据,那么您就与它的目的背道而驰了。(稍后我们会介绍一下这个问题的解决方案,但是即便是使用这个解决方案,也不如在 Scheme 中使用 closure 将一个局部环境附加到函数上有效)。
当您在一个环境中输入一个函数定义时,您实际上正在做的是为一个函数创建模板。每次执行 lambda
表达式时,都会使用这个模板创建一个新函数,它使用当前的局部变量来填充一些未知的变量。
回调函数可以让 closure 的使用非常便利。编写 API 的人中很少有人会知道您正在处理的所有数据类型。因此,他们并不清楚需要向您的回调函数中传递哪种数据。
使用 closure,您可以将想要的所有数据直接打包到一个回调函数中。让我们举一个例子,有一个 API 希望在构建一个按钮时使用一个没有参数的回调函数,每次按下按钮时都会调用这个函数。这看起来似乎有问题,特别是如果有多个按钮共享一个回调函数时更可能产生问题。如果这个 API 并不传递任何数据,您又如何知道按下的是哪个按钮呢?
使用 closure,可以在一个包含使用这个回调函数所需要的所有信息的环境中定义回调函数。这样接收这个调用和回调调用的 API 就不用知道任何事情:对于这个 API 来说就像是一个普通函数一样。
您也可以使用 closure 来创建一些函数,它们又可以创建特定版本的其他函数。让我们假设您有一个 filter-evens
函数,它接受一个列表,并返回该列表中的所有偶数。这个函数如下所示:
清单 12. 从一个列表中过滤偶数 (define filter-evens (lambda (the-list) (let ( ;This will hold the filtered elements (new-list '())) ;Cycle through the elements (for-each (lambda (entry) ;if it's even, add it to new-list (if (even? entry) (set! new-list (cons entry new-list)))) the-list) ;return new-list (note that this will be in ;reverse order that the items were in the ;original list) new-list))) (display (filter-evens '(1 2 3 4 5 6 7 8 9))) ;should display (8 6 4 2) (newline)
|
首先注意您定义并传递给 for-each
的函数是作为一个 closure 使用的。new-list
是在这个 enclosure 的环境中定义的。new-list
在每次调用 filter-evens
时的值都不同,但是为了构建这个列表,您需要在每次 for-each
循环中都使用相同的值。
过滤偶数是一个非常好的练习,不过如果您希望过滤其他东西--例如奇数,小于 20 的数字,甚至是过滤非数字列表(例如俄克拉荷马州的所有地址)--那么您可能需要重新编写这个算法,尽管每个过滤算法的实例大体上都是相同的。因此,根据前面讨论的标准,进行过滤选择的代码就很可能会作为一个函数参数使用。
您可以将实际的过滤过程编写成自己的函数。如果一个元素在列表中,这个函数就返回真,否则就返回假。然后它可以作为一个参数传递给这个函数,从而进行过滤操作。如下所示:
清单 13. 通用的过滤函数 (define filter (lambda (filter-func the-list) (let ( ;This will hold the filtered elements (new-list '())) ;Cycle through the elements (for-each (lambda (entry) ;if it matches the filter function ;add it to the result list (if (filter-func entry) (set! new-list (cons entry new-list)))) the-list) ;return new-list (note that this will be in ;reverse order that the items were in the ;original list) new-list))) (define number-list '(1 2 3 4 5 6 7 8 9)) (display (filter even? number-list)) ;should display (8 6 4 2) (newline) (display (filter odd? number-list)) ;should display (9 7 5 3 1) (newline) (display (filter (lambda (x) (< x 5)) number-list)) ;should display (4 3 2 1) (newline)
|
现在您有一个非常有用的函数了。然而,有时将一个过滤函数提前封装是很有用的,这样您就不用每次使用时都需要自己编写这个函数了。如果函数参数是一个很长的 lambda 表达式,这个问题就更加突出了。为了帮助创建新的专用过滤函数,您可以定义一个新函数,它可以生成新的过滤函数。让我们将这个函数称为 make-filter
(清单 14):
清单 14. 过滤生成函数 (define make-filter (lambda (comparison) (lambda (the-list) (filter comparison the-list)))) (define filter-evens (make-filter even?)) (define filter-odds (make-filter odd?)) (define filter-under20 (make-filter (lambda (x) (< x 20))))
|
虽然这些例子都非常简单,但是当您使用更复杂的过滤过程时,make-filter
就可以节省大量的时间,并减少错误的发生。
正如前面介绍的一样,在 C 编程语言中并不直接支持 closure。C 语言的函数没有与其定义时关联在一起的函数。虽然您可以使用静态变量,但是每次在静态变量中只能有一个值。而使用 closure,每个 closure 都有自己的附加的环境,允许程序员使用函数的多个实例,每个都有自己的局部环境。
其实,Scheme closure 是一个结构,它包含两个指针:一个指向函数本身,另外一个指向环境。因此,在 C 语言中您可以使用一个具有两个指针的结构来模拟 closure:一个是函数指针,另外一个是空指针。使用一个空指针,这样就无需担心环境是什么了,这样可以让程序员自由地进行选择。
函数指针是以一种非常通用的方法进行声明的,目的是为了支持大量的函数而不用进行修改。closure 结构如下所示:
清单 15. C 语言中 Closure 的定义 typedef void * (*generic_function)(void *, ...); typedef struct { generic_function function; void *environment; } closure;
|
generic_function
类型用来在很多不同的情况中使用尽可能通用的接口。它还易于简化 closure 中使用的其他函数类型的转换。environment 是一个空指针,同样也是为了简化类型转换的使用。创建和使用环境的程序需要知道这种布局。closure
结构只用来保存指针。虽然某些 closure 的 API 定义了创建并使用 closure 的完整系统(请参阅 参考资料 部分的链接),但是一个基本的实现需要这些即可。
让我们来看一下一个 C 版本的计数器程序,从而了解这些是如何一起工作的:
清单 16. C 语言的 计数器 closure #include <stdio.h> #include <stdlib.h> /* Closure definitions */ typedef void *(*generic_function)(void *p, ...); typedef struct { generic_function function; void *environment; } closure; int nextval(void *environment); /* This is the function that creates the closure */ closure make_counter(int startval) { closure c; /* The data is very simple. You are just storing an int, so all you need is a pointer to an int. */ int *value = malloc(sizeof(int)); *value = startval; /* Setup the closure */ c.function = (generic_function)nextval; c.environment = value; /* Return the closure */ return c; } /* This is the function that is used for the closure */ int nextval(void *environment) { /* convert the environment data back into the form used * by your functions */ int *value = environment; /* Increment */ (*value)++; /* Return the result */ return (*value); } int main() { /* Create the two closures */ closure my_counter = make_counter(2); closure my_other_counter = make_counter(3); /* Run the closures */ printf("The next value is %d\n", ((generic_function)my_counter.function) (my_counter.environment)) printf("The next value is %d\n", ((generic_function)my_other_counter.function) (my_other_counter.environment)); printf("The next value is %d\n", ((generic_function)my_counter.function) (my_counter.environment)); return 0; }
|
此处我们忽略了一些清理工作,但是如果您使用了“Inside Memory Management”一文中介绍的垃圾搜集器(请参阅 参考资料),这并不是什么问题。
很多 API 的执行 closure 都与此处介绍的不同。它们并没有定义一个包含函数指针和一个环境指针的 closure 结构,每次需要使用 closure 时,都分别通过函数调用传递函数指针和环境指针。不管采用哪种方法,当所传递的环境变得复杂时,在 C 中伪装 closure 会变得非常难以管理。 Scheme 语言可以为您解决所有的问题,因此有什么理由不利用它呢?
接下来,让我们看一下 Scheme 中 closure 与面向对象语言中对象的关系。
回页首
函数和面向对象编程
虽然不是非常直观,但是在 Scheme 的 closure 与面向对象语言中的对象之间的确存在一种直接的关系。回想一下 计数器 函数。您有哪些内容?您有一个函数,它创建了一组逻辑变量,然后返回一个单函数(一个 closure),他对这些变量进行操作(因为环境的关系)。让我们看一下现在您已经具有的面向对象编程的东西:
- 创建函数的函数的操作非常像是一个构造函数。
- 在构造函数的环境中定义的局部变量非常类似于对象的实例/成员变量。
- 返回函数非常类似于成员函数。
唯一欠缺的地方是它不能声明 多个 成员函数、析构函数以及更多的面向对象的语法。实际上,您可以将对象当作是一组对相同的局部变量定义的函数。或者,对于一种可能的实现,您可以将其称为是对相同的局部环境定义的一个函数 向量。
下面让我们来了解一下面向对象语言(例如 C++)中的 counter 函数 。
清单 17. 作为类重写的 counter 函数 class Counter { private: int value; public: Counter(int initial_value) { value = initial_value; } int nextValue() { value++; return value; } };
|
当然,正如前面介绍的一样,为了使用真正的面向对象编程,您需要对相同的 closure 定义一个函数向量。现在让我们就来做这件事。您将使用相同的 counter 函数 ,并添加一个 setValue()
方法将当前值设置为您所选择的值。
清单 18. 作为对象编写的 Counter 函数 (define make-counter (lambda (value) (vector (lambda () (set! value (+ value 1)) value) (lambda (new-value) (set! value new-value) value)))) (define nextValue (lambda (obj) (vector-ref obj 0))) (define setValue (lambda (obj) (vector-ref obj 1))) (define my-counter (make-counter 3)) (display ((nextValue my-counter))) ;displays 4 (newline) ((setValue my-counter) 25) ;now my-counter's value is 25 (display ((nextValue my-counter))) ;displays 26 (newline)
|
正如您在 make-counter
函数中可以看到的一样,为了获得一个对局部环境定义的函数向量,您只需要定义一个向量,其中每个成员都是一个在相同环境中定义的函数。然而,在引用向量中的函数时存在一些困难,因为这个向量是通过位置进行引用的。记住每个函数在向量中的偏移量实在是一件痛苦的事,尤其是您在有多个类时更是如此。例如,调用 ((vector-ref my-counter 0))
非常难以理解。因此,您定义了一些帮助函数来查找这些索引。
在这个程序中,在方法调用附近似乎多了一组圆括号。之所以包括这组圆括号是因为帮助函数只会查找这个函数--它们并不会真正调用这个函数。nextValue
只会查找这个对象的 nextvalue 函数。在进行查找时,这个函数仍然会被调用。第二组圆括号真正进行调用。在这种实现中,这两次单独的函数调用让您可以更清楚地看到这是一个两步的过程。
下面是经过重新编写的使用单步函数调用来调用该对象方法的程序。
清单 19. 作为对象编写的 counter 函数,包括了查找/调用步骤 (define make-counter (lambda (value) (vector (lambda () (set! value (+ value 1)) value) (lambda (new-value) (set! value new-value) value)))) (define nextValue (lambda (obj) ((vector-ref obj 0)))) (define setValue (lambda (obj value) ((vector-ref obj 1) value))) (define my-counter (make-counter 3)) (display (nextValue my-counter)) ;displays 4 (newline) (setValue my-counter 25) ;now my-counter's value is 25 (display (nextValue my-counter)) ;displays 26 (newline)
|
注意这种机制也允许使用单一继承。因为您查找函数的方法是基于数组的索引进行的,因此继承取决于兼容的类和子类中在相同的位置具有相关的函数。如果您正在使用多继承,那么这些函数每个都需要具有相同的函数结构。多继承(即使只使用 Java 风格的接口),只要可能,也需要不同的函数查找机制,更难以实现。
回页首
结束语
那么,我们既然已经有一些很好的面向对象语言和 Scheme 的面向对象的扩展了,为什么还要自己费劲地定义对象呢?实际上,您并不需要这样做;然而,我希望说明对象和 closure 之间的基本等价关系。实际上,对于一个程序员来说,在学习 Scheme 时,使用 closure 构建自己的系统几乎是一条必经之路。
closure 与对象基本上是等价的,为什么还要同时使用它们呢?
- 对于小型的系统来说,closure 通常比对象更容易处理。为一个对象创建类的代码量比创建一个局部变量的匿名函数所需要的代码多。
- 然而,当您需要定义多个函数(可能多于 3、4 个)而它们会前后使用一个局部环境时,对象通常更加适用。
现在您已经了解了这两个系统是如何工作的,如果您使用的语言缺少了某一项,就可以使用现在已经具有的功能来模拟没有的功能。
参考资料
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文。
- Teach Yourself Scheme in Fixnum Days 是一份很好的 Scheme 语言在线教程。
- 函数指针教程 站点中有很多在 C/C++ 中使用函数指针的极好讨论。
- 即使您已经掌握了现代编程的技术,复习 How to Design Programs 一书中有关计算机科学的知识也许会开阔您的眼界,在该书中可以学到功能强大的函数和函数编程的知识。
- Essentials of Programming Languages 一书的第 5 章介绍了 4 种面向对象的 Scheme 的不同实现,同时还讨论了各自的优缺点。
- SRFI 1 中包含了用来处理列表的全部函数,其中很多都使用一些参数来扩展自己的处理过程(SRFI 就像是 Scheme 的 RFC)。
- Gtk+ 2.0 API 具有 完整的 closure API,这值得一看。
- 内存管理内幕(developerWorks,2004 年 11 月)详细介绍了内存管理是如何工作的,如何手工管理内存,如何使用引用计数或内存池来半手工地管理内存,以及如何使用垃圾搜集器自动管理内存。
- 通过有效处理列表更好地实现编程(developerWorks,2005 年 1 月)着重介绍了单向链表及其处理,并简要介绍了 Scheme。
- Java 语言中的函数编程(developerWorks,2004 年 7 月)展示了在 Java 语言中如何使用诸如 closures 和高阶函数之类的函数编程结构来编写高度结构化、模块化的代码。
- 可爱的 Python: Python 中的函数编程,第 1 部分(developerWorks,2001 年 3 月)简要介绍了函数编程的一些基本概念,并介绍了在 Python 中如何实现函数编程技术。
- 可爱的 Python: Python 中的函数编程,第 2 部分(developerWorks,2001 年 4 月)介绍了 Python 中的 closure。
- 可爱的 Python: 在 Python 中进行函数编程,第 3 部分 (developerWorks,2001 年 6 月)介绍了 Curry(得名于逻辑学家 Haskell Curry,它允许函数的返回值是函数,但是所返回的函数“范围有限”)以及其他高阶函数。
- 在 developerWorks Linux 专区 中可以找到更多为 Linux 开发人员准备的资源。
- 通过 developerWorks blogs 来参与 developerWorks 社区。
- 在 Developer Bookstore 的 Linux 部分购买 Linux 的打折书籍。
- 定购免费的 SEK for Linux,一共有两张 DVD,其中包含了 IBM 在 Linux 平台上的最新使用软件,包括 DB2?、Lotus?、Rational?、Tivoli? 以及 WebSphere?。
- 在您的下一个 Linux 开发项目中使用 IBM 试用软件,这些软件可以从 developerWorks 上直接下载。
关于作者
Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书简要介绍了使用 Linux 汇编语言进行编程的知识。他是 New Media Worx 的首席开发人员,负责为客户端开发 Web、视频、信息站和桌面应用程序。您可以通过 johnnyb@eskimo.com 与 Jonathan 联系。