函数指针、闭包和Lambda

89

我现在正在学习函数指针。当我阅读K&R关于这个主题的章节时,第一件让我印象深刻的是,“嘿,这有点像闭包。”但我知道这种假设在根本上是错误的,经过在线搜索后,我并没有找到真正比较这两者的分析。

那么为什么C风格的函数指针与闭包或lambda表达式有根本性的不同呢?据我所知,这与函数指针仍然指向已定义(命名)的函数有关,而不是匿名定义函数的实践。

为什么在第二种情况下将一个函数传递给另一个函数被视为更强大,其中它是未命名的,而在第一种情况下只是普通的、日常的函数被传递了呢?

请告诉我我为什么错误地如此密切地将这两者进行比较。

12个回答

109

一个lambda(或闭包)封装了函数指针和变量。这就是为什么在C#中你可以这样做:

int lessThan = 100;
Func<int, bool> lessThanTest = delegate(int i) {
   return i < lessThan;
};

我在那里使用了一个匿名委托作为闭包(它的语法比lambda更清晰,更接近于C),它将lessThan(一个堆栈变量)捕获到闭包中。当计算闭包时,lessThan(其堆栈帧可能已被销毁)将继续被引用。如果我改变lessThan,那么我就改变了比较:

int lessThan = 100;
Func<int, bool> lessThanTest = delegate(int i) {
   return i < lessThan;
};

lessThanTest(99); // returns true
lessThan = 10;
lessThanTest(99); // returns false
在C语言中,这样做是不合法的:

BOOL (*lessThanTest)(int);
int lessThan = 100;

lessThanTest = &LessThan;

BOOL LessThan(int i) {
   return i < lessThan; // compile error - lessThan is not in scope
}

虽然我可以定义一个接受两个参数的函数指针:

int lessThan = 100;
BOOL (*lessThanTest)(int, int);

lessThanTest = &LessThan;
lessThanTest(99, lessThan); // returns true
lessThan = 10;
lessThanTest(100, lessThan); // returns false

BOOL LessThan(int i, int lessThan) {
   return i < lessThan;
}

但是,现在我评估它时必须传递2个参数。如果我希望将这个函数指针传递给另一个没有lessThan作用域的函数,我要么必须通过将其传递给链中的每个函数来手动保持它的活性,要么通过将其提升为全局来保持它的活性。

虽然大多数支持闭包的主流语言使用匿名函数,但并非必须如此。你可以有没有匿名函数的闭包,也可以有没有闭包的匿名函数。

总结:闭包是函数指针和捕获变量的组合。


当您编写此代码时,可能使用的是较旧版本的C语言或者没有记得前向声明该函数。但是,当我测试此代码时,并未观察到您提到的相同行为。http://ideone.com/JsDVBK - smac89
1
@smac89 - 你将 lessThan 变量设为全局变量了 - 我明确提到过这是一种替代方案。 - Mark Brackett
你的第一个C代码片段里,int lessThan 是全局变量,不是吗?那么为什么会是非法的呢? - Will Ness

48
作为一个写过有“真正”的闭包和没有闭包语言的编译器的人,我不同意上面某些答案。 Lisp、Scheme、ML或Haskell闭包不会动态创建新函数,而是使用新的自由变量重新使用现有函数。自由变量的集合通常被编程语言理论家称为环境

闭包只是一个包含函数和环境的聚合体。在新泽西标准ML编译器中,我们将其表示为记录;一个字段包含指向代码的指针,另一个字段包含自由变量的值。编译器通过分配一个包含指向相同代码的指针的新记录,并为自由变量赋予不同的值,动态创建一个新闭包(而不是函数)

您可以在C中模拟所有这些内容,但这真的很麻烦。常用的两种技术:

  1. 传递函数(代码)的指针和单独的自由变量指针,以便将闭包拆分成两个C变量。

  2. 传递结构的指针,其中结构包含自由变量的值以及指向代码的指针。

技术#1在您尝试在C中模拟某种多态性并且不想公开环境类型时是理想的——使用void*指针表示环境。有关示例,请查看Dave Hanson的C接口和实现。更接近原生代码编译器的函数语言的实现,还类似于另一种熟悉的技术……具有虚拟成员函数的C++对象。这些实现几乎相同。

这个观察结果为Henry Baker带来了一个俏皮话:

Algol/Fortran界的人们多年来一直抱怨不了解函数闭包在未来高效编程中可能有什么用处。然后,“面向对象编程”革命发生了,现在每个人都使用函数闭包编程,只是他们仍然拒绝将其称为函数闭包。


3
+1 对于解释和引用OOP是真正的闭包——重复使用现有函数,但使用新的自由变量——函数(方法)需要环境(一个结构指针,指向对象实例数据,这仅仅是新状态)来操作。 - legends2k

8
在C语言中,无法定义内联函数,因此无法真正创建闭包。你所做的只是传递对某个预定义方法的引用。在支持匿名方法/闭包的语言中,方法的定义更加灵活。
简单来说,函数指针没有与之关联的作用域(除非您计算全局作用域),而闭包包括定义它们的方法的作用域。使用lambda表达式,您可以编写一个编写方法的方法。闭包允许您将“一些参数绑定到函数并获得低阶函数作为结果”(摘自Thomas的评论)。在C语言中无法实现这一点。
编辑:添加一个示例(我将使用Actionscript-ish语法,因为我现在想到了它):
假设您有一个接受另一个方法作为其参数的方法,但在调用该方法时不提供任何参数传递给它?比如说,某个方法会在运行传递给它的方法之前造成延迟(愚蠢的例子,但我想保持简单)。
function runLater(f:Function):Void {
  sleep(100);
  f();
}

现在假设您想使用runLater()来延迟处理对象的某些操作:
function objectProcessor(o:Object):Void {
  /* Do something cool with the object! */
}

function process(o:Object):Void {
  runLater(function() { objectProcessor(o); });
}

您传递给process()的函数不再是预先定义的静态函数。它是动态生成的,可以包含对在定义该方法时范围内的变量的引用。因此,它可以访问'o'和'objectProcessor',即使它们不在全局范围内。

希望这样说得清楚了。


我根据你的评论做了一些修改。由于对这些术语的具体细节仍不是100%清楚,因此我只是直接引用了你的话。 :) - Herms
匿名函数的内联能力是(大多数?)主流编程语言的实现细节 - 这不是闭包的要求。 - Mark Brackett

7

闭包 = 逻辑 + 环境。

比如,考虑这个 C# 3 方法:

public Person FindPerson(IEnumerable<Person> people, string name)
{
    return people.Where(person => person.Name == name);
}
lambda表达式不仅封装了逻辑(“比较名称”),还包括参数(即局部变量)“name”在内的环境。
有关更多信息,请参阅我的闭包文章,其中介绍了C# 1、2和3,展示了闭包如何使事情更加容易。 article on closures

请考虑将 void 替换为 IEnumerable<Person>。 - Amy B
1
@David B:谢谢,搞定了。 @edg:我认为这不仅仅是状态的问题,因为它是可变状态。换句话说,如果你执行一个改变局部变量的闭包(在方法内部),那么这个局部变量也会改变。“环境”对我来说似乎更能传达这个意思,但它有点模糊。 - Jon Skeet
我很感激你的回答,但是对我来说这并没有解决问题,看起来people只是一个对象,而你在调用它的方法。也许只是因为我不熟悉C#。 - None
是的,它在调用它的一个方法 - 但它传递的参数是闭包。 - Jon Skeet

4
在C语言中,函数指针可以作为参数传递给其他函数并从其他函数中返回,但函数只存在于顶层:您不能嵌套定义函数。想象一下,如果C支持嵌套函数并能够访问外部函数的变量,同时仍然能够在调用栈中上下发送函数指针,需要做些什么。(要理解这个解释,您应该知道如何在C和大多数类似的语言中实现函数调用的基础知识,请浏览维基百科上的调用堆栈条目。)
指向嵌套函数的指针是哪种对象?它不能仅仅是代码的地址,因为如果您调用它,它如何访问外部函数的变量?(请记住,由于递归,可能会同时激活几个不同的外部函数调用。)这被称为funarg问题,有两个子问题:向下funargs问题和向上funargs问题。
下行funargs问题,即将函数指针“向下传递”作为您调用的函数的参数,实际上与C并不不兼容,并且GCC支持嵌套函数作为下行funargs。在GCC中,当您创建指向嵌套函数的指针时,您实际上获得一个指向跳板的指针,这是一个动态构建的代码片段,它设置了静态链接指针,然后调用真正的函数,该函数使用静态链接指针访问外部函数的变量。
上行funargs问题更为困难。GCC不会阻止您在外部函数不再活动(在调用堆栈上没有记录)后存在一个跳板指针,然后静态链接指针可能会指向垃圾。激活记录无法在堆栈上分配。通常的解决方案是在堆上分配它们,并让表示嵌套函数的函数对象只指向外部函数的激活记录。这样的对象称为闭包。然后语言通常必须支持垃圾回收,以便在没有更多指向它们的指针时可以释放记录。
Lambda(匿名函数)实际上是一个单独的问题,但通常允许您在运行时定义匿名函数的语言也会让您将它们作为函数值返回,因此它们最终成为闭包。

3

一个lambda是一个匿名、动态定义的函数。在C语言中是不能使用它的......至于闭包(或两者的组合),典型的Lisp示例可能看起来像这样:

(defun get-counter (n-start +-number)
     "Returns a function that returns a number incremented
      by +-number every time it is called"
    (lambda () (setf n-start (+ +-number n-start))))

用C语言的术语来说,你可以这样说:匿名函数捕获了get-counter的词法环境(栈),并在内部进行修改,如下例所示:

[1]> (defun get-counter (n-start +-number)
         "Returns a function that returns a number incremented
          by +-number every time it is called"
        (lambda () (setf n-start (+ +-number n-start))))
GET-COUNTER
[2]> (defvar x (get-counter 2 3))
X
[3]> (funcall x)
5
[4]> (funcall x)
8
[5]> (funcall x)
11
[6]> (funcall x)
14
[7]> (funcall x)
17
[8]> (funcall x)
20
[9]> 

2
在GCC中,可以使用以下宏来模拟lambda函数:
#define lambda(l_ret_type, l_arguments, l_body)       \
({                                                    \
    l_ret_type l_anonymous_functions_name l_arguments \
    l_body                                            \
    &l_anonymous_functions_name;                      \
})

以下为来源示例:

qsort (array, sizeof (array) / sizeof (array[0]), sizeof (array[0]),
     lambda (int, (const void *a, const void *b),
             {
               dump ();
               printf ("Comparison %d: %d and %d\n",
                       ++ comparison, *(const int *) a, *(const int *) b);
               return *(const int *) a - *(const int *) b;
             }));

使用这种技术会使您的应用程序无法与其他编译器配合使用,而且显然是"未定义"的行为,所以效果可能因人而异。


2

闭包会捕获所在的环境中的自由变量。即使周围的代码可能不再活动,该环境仍将存在。

在Common Lisp中的一个示例,在此示例中,MAKE-ADDER返回一个新的闭包。

CL-USER 53 > (defun make-adder (start delta) (lambda () (incf start delta)))
MAKE-ADDER

CL-USER 54 > (compile *)
MAKE-ADDER
NIL
NIL

使用上述函数:

使用以上的函数:

CL-USER 55 > (let ((adder1 (make-adder 0 10))
                   (adder2 (make-adder 17 20)))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder1))
               (print (funcall adder2))
               (print (funcall adder2))
               (print (funcall adder2))
               (print (funcall adder1))
               (print (funcall adder1))
               (describe adder1)
               (describe adder2)
               (values))

10 
20 
30 
40 
37 
57 
77 
50 
60 
#<Closure 1 subfunction of MAKE-ADDER 4060001ED4> is a CLOSURE
Function         #<Function 1 subfunction of MAKE-ADDER 4060001CAC>
Environment      #(60 10)
#<Closure 1 subfunction of MAKE-ADDER 4060001EFC> is a CLOSURE
Function         #<Function 1 subfunction of MAKE-ADDER 4060001CAC>
Environment      #(77 20)

请注意,DESCRIBE函数显示两个闭包函数对象相同,但环境不同。
Common Lisp将闭包和纯函数对象(没有环境的函数)都视为函数,可以使用FUNCALL以相同的方式调用它们。

2
闭包意味着函数定义点的某些变量与函数逻辑绑定在一起,就像能够即时声明迷你对象一样。但是,在C和闭包中存在一个重要问题,即分配在堆栈上的变量将在离开当前作用域时被销毁,而不管闭包是否指向它们。这会导致人们因疏忽返回指向局部变量的指针而出现的错误。闭包基本上意味着所有相关变量都是在堆上的ref-counted或garbage-collected项。我不确定所有语言中的lambda都是闭包,有时我认为lambda只是本地定义的匿名函数,没有绑定变量(Python pre 2.1?)。

1
主要区别在于C语言中没有词法作用域。函数指针只是指向一段代码块的指针。它引用的任何非栈变量都是全局、静态或类似的变量。
相比之下,闭包具有自己的状态形式,即“外部变量”或“上值”。使用词法作用域,它们可以是私有的或共享的。您可以使用相同的函数代码创建许多具有不同变量实例的闭包。
一些闭包可以共享一些变量,因此可以成为对象的接口(在OOP意义上)。要在C中实现这个,您必须将结构与函数指针表相关联(这就是C++使用类vtable所做的事情)。
简而言之,闭包是函数指针加上一些状态。它是一种更高级的构造。

2
WTF?C语言绝对有词法作用域。 - Luís Oliveira
1
它具有“静态作用域”。据我理解,词法作用域是一项更复杂的功能,旨在维护具有动态创建函数的语言的类似语义,然后调用闭包。 - Javier

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接