对值和上下文的闭包

3

我正在考虑不同的闭包实现方式,并且想知道不同风格的优点。似乎有两种选择,即基于执行上下文或值进行封闭。例如,基于上下文的实现如下:

a = 1
def f():
  return a
f() # returns 1
a = 2
f() # returns 2

另外,我们可以通过闭包来获取值,代码如下:

a = 1
def f():
  return a
f() # returns 1
a = 2
f() # returns 1

是否有语言实现第二个?优缺点是什么?


也许我说错了(如果有误请纠正),但第二种方法是在JavaScript中,不使用作用域限定符(例如,在块末尾添加...(variable))。 - om-nom-nom
2
一个区别是,从人们期望的数量来看,我们似乎在所有支持它的语言中都很糟糕地解释了前者。@om-nom-nom:我非常确定JS做前者,否则作为闭包的get/set方法将无法工作。 - user395760
6个回答

2

在这种情况下,我认为问题不在于上下文与值的关系,而在于您是将变量作为引用单元还是变量所包含的值进行闭合。

如果您真正意味着上下文,那么您是指动态范围和词法范围。请参阅this维基百科文章,以进行深入比较。

大多数语言实现词法范围(或尝试实现)。一些语言确实实现了动态范围:特别是旧版Lisp,例如emacs的ELisp。大多数具有闭包的语言(例如Scheme、Haskell、ML等)在词法范围内关闭值。动态范围通常被认为是一个坏主意,因为它更难以理解(这是“幽灵般的远距离作用”)。

请注意,即使在词法范围的语言中,如果您将引用单元闭合,则可以获得类似于第一个示例的行为。这就是为什么Scheme和JavaScript闭包的行为像它们一样(因为变量是引用单元)。


两者都是词法作用域。问题在于闭包是针对变量还是值。 - Tristan
你说得对,“执行上下文”的部分让我有些困惑。我编辑了我的帖子以澄清这一点。 - Asumu Takikawa

1
在大多数具有闭包和可变变量的语言中,闭包捕获位置而不是值(即第一种行为)。例如Scheme、Python和Javascript。
为了安全地执行此操作,在许多情况下,语言必须堆分配被闭包捕获的可变变量。这通常是通过一个编译器传递来实现的,将实际上被改变的变量转换为明确分配的可变单元,之后编译器可以忘记这个问题。
为了避免隐式堆分配,Java要求(需要?)内部类捕获的变量声明为final(即不可变)。其他语言,如ML和Haskell,完全避免了这个问题,因为变量总是不可变的。在C++中,按引用捕获可能是不安全的,正如Jon在他的答案中指出的那样。

1

各种编程语言都有这两种方式中的一种或两种。

主要区别在于当您对变量进行赋值时会发生什么。因此,正如其他人指出的那样,在变量是不可变的语言中

在按值捕获的语言中,一个问题是如何处理对该变量的赋值。由于它是按值捕获的

正如其他人指出的那样,很多语言没有明确的语法来处理按值捕获和按引用捕获,包括:Python,Ruby,JavaScript,Scheme,Perl,Go,Smalltalk等。 正如其他人指出的那样,ML语言(SML,OCaml)和Haskell可以说是按值捕获,因为它们的变量是不可变的,所以两者之间没有真正的区别,而按值捕获更简单。 正如其他人指出的那样,Java要求捕获的变量必须是final,基本上是为了按值捕获,否则在相同的作用域中有两个单独的可变副本就会产生混淆;但当它们是final时,它们无法被修改,因此拥有一个副本和多个副本之间没有区别。 C++11允许您选择按值还是按引用捕获。将要捕获的变量列在方括号中。带有&的变量按引用捕获;否则,它是按值捕获。仅使用=表示按值捕获所有未列出的变量;仅使用&表示按引用捕获所有未列出的变量。在按引用捕获变量时,必须小心不要捕获超出范围的变量。有趣的是(与Java不同),可以通过在匿名函数上使用mutable修饰符使其成为可变的变量,并按值捕获该变量。 同样地,PHP允许您在声明变量时选择捕获方式。使用&表示按引用捕获;否则按值捕获。 Apple开发工具中的块(用于C、C++和Objective-C语言;在Mac OS X 10.6+和iOS 4+中可用)也允许您进行选择。当您首次创建一个块时,它可以访问按引用捕获的捕获变量;但是,如果捕获局部变量,这样的块将不被允许离开作用域(例如返回)。必须复制一个块才能使其离开作用域;当复制块时,捕获的变量按值捕获。还可以在声明该变量时使用__block修饰符,指示要在复制后由块按引用捕获该局部变量。这可能会将其分配到堆上。

1

C++ lambdas 可以通过值显式捕获:

int a = 1;
auto f1 = [a]() -> int { return a; }
f1() == 1;
a = 2;
f1() == 1;

或者通过引用:

a = 1;
auto f2 = [&a]() -> int { return a; }
f2() == 1;
a = 2;
f2() == 2;

你也可以隐式地捕获任何一种方式:

auto f1 = [=]() -> int { return a; }
auto f2 = [&]() -> int { return a; }

优点是您可以控制复制或引用哪些变量以及是否复制或引用。潜在的缺点是您必须注意生命周期问题,因为C++引用是非拥有的:如果a超出范围,则调用f1仍然有效,但调用f2是未定义的。如果自然而然且您不介意开销,您可以始终捕获一个shared_ptr<T>(具有共享所有权的指针)。

因此,对于不可变值:

  • 按值捕获会强制进行复制。按引用捕获则不会。

  • 按值捕获没有所有权问题。按引用捕获则有。

对于可变值,您当然必须按引用捕获。这里有一个类似于std::partial_sum()的人为示例:

int sum = 0;
auto f = [&sum](int i) -> int { sum += i; return sum; }

vector<int> input{1, 2, 3, 4, 5};
vector<int> output;
transform(begin(input), end(input), back_inserter(output), f);

sum == 15;
output == vector{1, 3, 6, 10, 15};

当然,在C++中通过引用进行捕获是愚蠢的,因为捕获指针就足够了,不是吗? - Yttrill
@Yttrill:不完全是这样。Lambda表达式无法捕获临时变量,因此要通过指针来捕获“int x;”,您需要编写int* y = &x;,然后按值捕获y。Lambda表达式需要显式地解引用y,这很丑陋,并且具有相同的生命周期问题:x仍然可能超出范围。因此,我认为使用引用更有意义。 - Jon Purdy

1

闭包应该像第一种情况一样运作,但有些语言提供了第二种情况。

Smalltalk 按照第一种情况工作。假设一个类定义了方法 mtest

m
| counter c |  "temporary vars"
counter = 0.
c = [ counter = counter + 1. counter ]. 
^ c. "returns the closure"

test
| c | "temporary vars"
c = self m. "obtain a closure that increments a counter"
c value. "return 1"
c value. " returns 2"

要思考闭包,就必须考虑堆栈。如果闭包 c 在方法m中定义,并且关闭临时变量 counter ,则 m 的堆栈帧直到垃圾回收闭包后才能被删除。闭包是第一类对象,因此您不知道何时不再引用它。
但是,许多闭包不会关闭任何临时变量,或者关闭在定义闭包后不再修改的临时变量。在后一种情况下,可以将闭包定义时临时变量的值复制到闭包中,以便它们不需要对 m 的堆栈帧进行引用。
在上述闭包 c 的情况下,闭包可以复制 counter 的值。这是Java通过强制关闭的临时变量为final来实现的。
如果方法m
m
| counter c |  "temporary vars"
counter = 0.
c = [ counter = counter + 1. counter ].
counter = 1. 
^ c. "returns the closure"

我猜这会破坏优化,因为counter在闭包创建后被改变了。

至少我是这样理解闭包的。


我认为你没有抓住重点,因为问题有点淡化了基本区别。问题被重新构造成在闭包形成或执行的时间使用哪个值 -- 在OP的帖子中,这对于f()的两种情况是相同的。 OP的问题实际上问错了问题:是否使用定义时的值,而不是闭包形成和执行时的值。在一个仅仅声明函数而不执行函数定义的语言中,显然这是无意义的。 - Yttrill
@Ytrill 对,这并不是非常关注问题,但对于OP可能仍然有趣。我猜有三种类型的闭包:(1)在创建时复制值的闭包(没有真正的闭包)(2)那些使用词法作用域关闭的闭包(常见情况)(3)那些使用动态作用域关闭的闭包(难以理解)。 - ewernli

1

Felix实际上提供了相当复杂的语义,有时候是违反直觉的。闭包通过指向上下文帧的指针来捕获上下文..在形成闭包的时候。因此,您会期望捕获的变量始终反映出在执行闭包时变量的当前值。

但事实并非如此,因为优化器可能会用其值替换变量,特别是如果“变量”声明为:

val x = 1;

它被视为不可变的值,这样的替换被认为是安全的。即使该值作为参数传递也是如此!例如:

fun f(x:int) () => x;
val y = 1;
val fy = f y;  // closure formed
println$ fy();

很可能我们已经将fy定义为if:

val fy = fun () => 1;

已经被编写。在这种情况下,变量可能是相同的:

var z = 1;
val fz = f z;
z = 2;
println$ fz (); // prints 1 .. maybe

通过将x替换为闭包形成时z的值,但也可以通过将x替换为变量名z来打印2。

在Felix中,应用哪种优化是不确定的,这是故意的:它允许编译器自由选择(它认为最好的优化)。

如果您想强制解释,可以使用以下参数参数:

fun f(var x:int) () => x; // 强制急切求值,将参数复制到参数 fun f( x: unit -> int ) => x(); // 强制延迟求值

对于原始问题,您可以通过简单地使用指针来强制进行延迟解释:

var x = 1;
fun f()=> *&x;

强制使用急切解释是没有意义的。如果您想这样做:

var x = 1;
val y = x;
var x = 2;
fun f() => y; // prints 1

我必须说,我对这些语义并不满意,但目前情况就是这样,并且似乎相当合乎逻辑。更令人困扰的是:

var g : unit -> int;

for var i = 0 upto 10 do
   val x = i;
   fun f()() => x;
   if i == 3 do
     g = f();
   done
done

for循环是平的,没有堆栈帧。 这里的“x”是一个值,但它不是不可变的! 如果你能预测出g()打印的值,那么你比我做得好(而我设计了这种语言:))

不幸的是,通过这些语义获得的优化是强制性的:我们不想最终性能像Haskell一样糟糕(无意冒犯)。

故事的寓意是:“如果您的代码取决于OP问题的答案,那就自负其责吧!编写语义确定的代码,如果您需要的话。”


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