循环中的lambda函数产生的作用域及闭包问题

温馨提示:点击页面下方以展开或折叠目录

摘要:在Python循环中定义函数时涉及到的作用域和闭包等问题。

文章说明
文章作者:鴻塵
参考内容:

文章链接:https://hwame.top/20201020/python-lambda-in-a-for-loop.html

1.背景

最近有同学问我一个使用scipy进行参数优化的问题,目标函数:

由于涉及到较多的参数和约束条件,因此考虑使用for循环,程序主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from scipy.optimize import minimize
import numpy as np

def fun(args):
# 目标函数及系数
a, b, c, d = args
return lambda x: (a + x[0]) / (b + x[1]) - c * x[0] + d * x[2]

def cons():
# 约束条件constraints,暂时略过
cons = [fun1, fun2, fun3, fun4, fun5, fun6]
return cons

if __name__ == "__main__":
args = (2, 1, 3, 4) # 定义常量值a, b, c, d
cons = cons() # 生成约束
x0 = np.asarray((0.5, 0.5, 0.5)) # 设置初始猜测值

# 开始优化
res = minimize(fun(args), x0, method='SLSQP', constraints=cons)

print(res.fun) # 优化结果,即最优目标函数值
print(res.success) # 优化状态,即是否成功
print(res.x) # 优化结果,即x的最优解

对于约束函数的生成,由于每个 $x$ 分量都有一个上界和下界,范围为$[0.1, 0.9]$,所以约束条件会随分量的增多呈线性增长,因此写死是不现实的,考虑用for循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 直接把所有情况写死
def cons():
cons = ({'type': 'ineq', 'fun': lambda x: x[0] - 0.1},
{'type': 'ineq', 'fun': lambda x: -x[0] + 0.9},
{'type': 'ineq', 'fun': lambda x: x[1] - 0.1},
{'type': 'ineq', 'fun': lambda x: -x[1] + 0.9},
{'type': 'ineq', 'fun': lambda x: x[2] - 0.1},
{'type': 'ineq', 'fun': lambda x: -x[2] + 0.9})
return cons

# 借助for循环
def cons():
cons = []
for i in range(3):
b = {'type': 'ineq', 'fun': lambda x: x[i] - 0.1}
c = {'type': 'ineq', 'fun': lambda x: 0.9 - x[i]}
cons.append(b)
cons.append(c)
return cons

2.问题描述

显然,使用循环的方式便于程序的拓展,而且也省时,但是会出一个问题:生成的cons约束函数只有最后一个生效,且所有函数都相同

这个问题我曾经遇到过,第一反应就确定了问题出在循环上。尽管当时解决了却并没有完全弄懂它,以致于再次遇到还是一脸懵逼,因此查阅了详细的资料,在此记录下来。

3.原因分析

3.1.问题引入

借助官方文档中一个简单的例子来说明。

1
2
3
4
5
6
7
8
func = []
for myx in range(4):
func.append(lambda: myx**2)

# 调用函数
func[0](), func[1](), func[2](), func[3]()
# 预期结果:(0, 1, 4, 9)
# 实际结果:(9, 9, 9, 9)

3.2.原因解释

来自官方文档的解释:

发生这种情况是因为myx不是lambdas的内部变量,而是在外部作用域中定义,并且在调用lambda时访问它——而不是在定义它时。
当循环结束时,myx的值是3,所以所有的函数现在返回3**2,即9。你可以通过更改myx的值来验证这一点,即使退出了循环,列表中的函数仍然发生了变化且是所有并查看lambdas的结果如何变化:

1
2
3
myx = 8
func[1]()
# 输出:64,且func[0](), func[2](), func[3]()皆输出64

3.3.作用域和闭包

来自Max Shawabkeh的解释,原文链接

Scoping in Python is dynamic and lexical. A closure will always remember the name and scope of the variable, not the object it’s pointing to. Since all the functions in your example are created in the same scope and use the same variable name, they always refer to the same variable.

翻译:Python的作用域是动态且词汇丰富的。闭包将始终记住变量的名称和范围,而不是其指向的对象。由于示例中的所有函数都是在同一作用域中创建的,并且使用相同的变量名称,因此它们始终引用相同的变量。


来自Claudiu的解释:

Python has static scoping, not dynamic scoping.. it’s just all variables are references, so when you set a variable to a new object, the variable itself (the reference) has the same location, but it points to something else.

翻译:Python具有静态作用域,而不是动态作用域。它只是所有变量都是引用,因此当您将变量设置为新对象时,变量本身(引用)具有相同的位置,但指向其他对象。


来自欢喜明的解释,原文链接

函数在定义的时候,并没有分配内存空间用来保存任何变量的值,只有在执行的时候才会分配空间保存变量的值。

Python的作用域由defclasslambda等语句产生,而iftryfor等语句并不会产生新的作用域。

Python变量名引用的查找顺序为:

  • 本地作用域(Local)
  • 外围作用域,即当前作用域是一个内嵌作用域(Enclosing Locals);
  • 全局/模块作用域(Global),即模块内的作用域,其作用范围是单一文件内;
  • 内置作用域(Built-in)

来自千山飞雪的解释,原文链接

在一个内部函数中,对外部作用域的变量进行引用,(并且一般外部函数的返回值为内部函数),那么内部函数就被认为是闭包。

闭包无法修改外部函数的局部变量;python循环中不包含域的概念。


来自Walter Mundt的解释,原文链接

The corollary is that the outer function can change i but the inner function cannot (since that would make i a local instead of a closure based on Python’s syntactic rules).

翻译:结论是外部函数可以更改i,而内部函数则不能(因为这将使i成为局部变量,而不是基于Python语法规则的闭包)。

3.4.解决方案

以上文所举的一个简单的例子来说明。

1
2
3
4
# 一个简单的例子
func = []
for myx in range(4):
func.append(lambda: myx**2)

方案一:带默认值的参数

使用具有默认值的参数来强制捕获变量,即官方文档所谓之「将值保存在lambdas的局部变量中」

1
2
3
func = []
for myx in range(4):
func.append(lambda tmp=myx: tmp**2)

上述方法①等价的写法:
1
2
3
4
5
func = []
for myx in range(4):
def myfunc(tmp=myx):
return tmp**2
func.append(myfunc)

方案二:使内部函数成为闭包

创建一个外部函数包裹内部函数,使内部函数成为闭包,

1
2
3
4
5
6
7
func = []
for myx in range(4):
def makefunc(myx):
def myfunc():
return myx**2
return myfunc
func.append(makefunc(myx))

上述方法①可以简化为使用单个的makefunc()函数:
1
2
3
4
5
6
7
8
def makefunc(myx):
def myfunc():
return myx**2
return myfunc

func = []
for myx in range(4):
func.append(makefunc(myx))

当内部函数较为简单时,可以使用lambda简化代码:
1
2
3
4
5
6
def makefunc(param):
return lambda: param**2

func = []
for myx in range(4):
func.append(makefunc(myx))

同样,可以使用嵌套lambda函数简化上述方法①的代码,这可能看起来不太直观:
1
2
3
func = []
for myx in range(4):
func.append((lambda myx: lambda: myx**2)(myx))

方案三:使用标准库函数partial

Python的functools模块提供了「偏函数partial」,其返回一个新的「partial对象」(参考这里),它会被「冻结了」一部分函数参数和/或关键字的部分函数应用所使用,从而得到一个具有简化签名的新对象。官方文档如图所示:
偏函数functools.partial官方文档

其他参考资料:

方案三代码:

1
2
3
4
from functools import partial
func = []
for myx in range(4):
func.append(partial(lambda param: param**2, myx))

Georgy认为,lambda可以使用functools.partial来代替,这是一种语法更简单的方法。以下是一个便于理解的例子,原文链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
nums = [1, 2, 3]

lambdas = [lambda: print(i) for i in nums]
binding = [lambda i=i: print(i) for i in nums]
partials = [partial(print, i) for i in nums]

for function in lambdas:
function() # 输出:3 3 3

for function in binding:
function() # 输出:1 2 3

for function in partials:
function() # 输出:1 2 3

3.5.说明

  • 上述各例中的函数调用时是不需要传参的,这对于需要传参的函数没什么区别。
  • 本文以循环中的lambda函数引入作用域及闭包问题,但需注意的是这种行为并不是lambda所特有的,也适用于常规函数

上述各种方案运行结果如下:

方案名 描述 运行结果
方案一① 带默认值的参数,
使用lambda
方案一①结果
方案一② 带默认值的参数,
不使用lambda
方案一②结果
方案二① 使内部函数成为闭包,
函数置于循环中
方案二①结果
方案二② 使内部函数成为闭包,
使用单个的makefunc()函数
方案二②结果
方案二③ 使内部函数成为闭包,
使用lambda的单个函数
方案二③结果
方案二④ 使内部函数成为闭包,
使用嵌套的lambda的函数
方案二④结果
方案三 使用标准库函数
functools.partial
方案三结果

4.实际问题的解决办法

至此,解决方案很简单了,只需要绑定外部变量到lambda函数中即可,就像在函数中设置默认值一样def myfunc(x, tmp=i)

1
2
3
4
5
6
7
8
def cons():
cons = []
for i in range(3):
b = {'type': 'ineq', 'fun': lambda x, tmp=i: x[tmp] - 0.1}
c = {'type': 'ineq', 'fun': lambda x, tmp=i: 0.9 - x[tmp]}
cons.append(b)
cons.append(c)
return cons

当然,也可以使用上文提到的另外的方法。