Python 闭包中有什么?习惯 OCaml 的人有什么注意事项?

What is in a Python closure and what are the caveats for people used to OCaml?

这是对 an old answer to a question about the necessity of functools.partial 的一种跟进:虽然这个答案非常清楚地解释了这种现象及其基本原因,但我仍然有一些不清楚的地方。

回顾一下,以下 Python 代码

myfuns = [lambda arg: str(arg) + str(clo) for clo in range(4)]
try :
    clo
except NameError :
    print("there is no clo")
for arg in range(4) :
    print(myfuns[arg](arg), end=", ")

给出03, 13, 23, 33, ,而类似的OCaml代码

let myfuns = Array.map (fun clo -> fun arg -> (string_of_int arg) ^ (string_of_int clo)) [|0;1;2;3|];;
(* there is obviously no clo variable here *)
for arg = 0 to 3 do
  print_string (myfuns.(arg) arg); print_string ", "
done;;

给出 00, 11, 22, 33, .

我理解这与应用于 lambda arg: str(arg) + str(clo) 及其对应者 fun arg -> (string_of_int arg) ^ (string_of_int clo) 的不同闭包概念有关。

在 OCaml 中,闭包在创建闭包时将标识符 clo 映射到外部作用域中的变量 clo 的值。在 Python 中,闭包以某种方式包含变量 clo 本身,这说明它受到 for 生成器引起的增量的影响。

这是正确的吗?

这是怎么做到的? clo变量在全局范围内不存在,我的try/except证明了这一点构造。通常,我会假设生成器的变量是它的局部变量,因此不会存在。那么,clo 又在哪里? This answer 给出了关于 __closure__ 的见解,但我仍然不完全理解它在生成过程中如何设法引用 clo 变量本身。

另外,除了这种奇怪的行为(对于习惯静态绑定语言的人),还有其他应该注意的注意事项吗?

区别在于python有变量而ocaml有绑定和柯里化。

Python:
myfuns = [lambda arg: str(arg) + str(clo) for clo in range(4)]

for 循环创建一个变量 clo 并在每次迭代中为其分配值 0、1、2、3。 lambda 绑定变量,以便稍后调用 str(clo)。但是由于循环最后将 3 分配给 clo 所有 lambda 都附加相同的字符串。

Ocaml:
let myfuns = Array.map (fun clo -> fun arg -> (string_of_int arg) ^ (string_of_int clo)) [|0;1;2;3|];;

这里用数组[|0;1;2;3|]调用Array.map。这将依次评估 fun clo -> ... 绑定 clo 到数组中的每个值。每次绑定都会不同,所以 string_of_int clo 结果也会不同。

虽然这不是唯一的区别,但此部分评估也在 python 中挽救了局面。如果你这样写你的代码:

Python:
def make_lambda(clo):
    return lambda arg: str(arg) + str(clo)
myfuns = [make_lambda(clo) for clo in range(4)]

make_lambda 的计算导致 lambda 中的 clo 绑定到 make_lambda 参数的值,而不是 for 循环中的变量。

另一个修复是显式绑定 lambda 中的值:

myfuns = [lambda arg, clo=clo: str(arg) + str(clo) for clo in range(4)]

当 Python 创建闭包时,将所有自由变量收集到 cells 的元组中。由于每个单元格都是可变的,并且 Python 将对单元格的引用传递到闭包中,因此您将在循环中看到归纳变量的最后一个值。让我们看看底层,这是我们的函数,i 在我们的 lambda 表达式中自由出现,

def make_closures():
    return [lambda x: str(x) + str(i) for i in range(4)]

这里是这个函数的disassembly

  2           0 BUILD_LIST               0
              3 LOAD_GLOBAL              0 (range)
              6 LOAD_CONST               1 (4)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                21 (to 37)
             16 STORE_DEREF              0 (i)
             19 LOAD_CLOSURE             0 (i)
             22 BUILD_TUPLE              1
             25 LOAD_CONST               2 (<code object <lambda>)
             28 MAKE_CLOSURE             0
             31 LIST_APPEND              2
             34 JUMP_ABSOLUTE           13
        >>   37 RETURN_VALUE        

我们可以看到 16 上的 STORE_DEREF 从堆栈顶部 (TOS) 获取一个正常的整数值,并将其与 STORE_DEREF 一起存储在一个单元格中。接下来的三个命令准备堆栈上的闭包结构,最后 MAKE_CLOSURE 将所有内容打包到闭包中,它表示为单元格的元组(在我们的例子中是 1 元组),

 >>> fs = make_closures()
 >>> fs[0].__closure__
 (<cell at 0x7ff688624f30: int object at 0xf72128>,)

所以它是一个元组,单元格包含一个 int,

 >>> fs[0].__closure__[0]
 <cell at 0x7ff688624f30: int object at 0xf72128>

 >>> type(fs[0].__closure__[0])
 cell

这里理解点的关键是自由变量被所有闭包共享,

>>> fs[0].__closure__
(<cell at 0x7f1d63f08b40: int object at 0xf16128>,)

>>> fs[1].__closure__
(<cell at 0x7f1d63f08b40: int object at 0xf16128>,)

由于每个单元格都是对封闭函数作用域中局部变量的引用,实际上,我们可以在 make_closures 函数的 cellvars 属性中找到 i 变量,

>>> make_closures.func_code.co_cellvars
('i',)

因此,我们有一点?整数值通过引用传递并变得可变的令人惊讶的效果。 Python 中的主要惊喜是变量的打包方式以及 for 循环没有自己的作用域。

公平地说,如果您手动创建引用并将其捕获在闭包中,则可以在 OCaml 中获得相同的结果。例如,

let make_closures () =
  let arg = ref 0 in
  let fs = Array.init 4 (fun _ -> fun _ -> assert false) in
  for i = 0 to 3 do
    fs.(i) <- (fun x -> string_of_int x ^ string_of_int !arg);
    incr arg
  done;
  fs

所以

let fs = make_closures ()
fs.(1) 1;;
- : string = "14"

历史参考资料

OCaml 和 Python 都受到 Lisp 的影响,并且都暗示了实现闭包的相同技术。令人惊讶的是,结果不同,但不是由于对词法作用域或闭包环境的不同解释,而是由于两种语言的不同对象(数据)模型。

OCaml 数据模型不仅更易于理解,而且由严格的类型系统定义得很好。 Python,由于其动态结构,在对象的解释及其表示方面留下了很大的自由度。因此,在 Python 中,他们决定将变量绑定在闭包的词法上下文中 mutable by default (even if they are integers). See also the PEP-227 以获得更多上下文。

您已经有几个很好的答案,但要关注本质,不同之处在于 Python 做出的两个设计选择:

  1. 所有变量绑定都是可变的,并在闭包中捕获。
  2. for comprehensions 不会为每次迭代绑定不同的变量,而是将新值重新分配给同一个变量。

两种设计选择都不是必需的,尤其是后者。例如,在 OCaml 中,for 循环的变量是 not 可变的,而是每次迭代的新绑定。更有趣的是,在 JavaScript、for (let x of ...) ... 中, 会使 x 可变(除非你使用 const 代替),但它仍然是每次迭代分开。这修复了 JavaScript 的旧 for (var x in ...) 的行为,它与 Python 有相同的问题,并且因导致闭包的细微错误而臭名昭著。