摘要:在Python循环中定义函数时涉及到的作用域和闭包等问题。
文章说明
文章作者:鴻塵
参考内容:
- 为什么在具有不同值的循环中定义的lambdas都返回相同的结果?
- Python经典的大坑问题:lambda和循环作用域问题
- Python Lambda in a loop
- What do lambda function closures capture?
文章链接:https://hwame.top/20201020/python-lambda-in-a-for-loop.html
1.背景
最近有同学问我一个使用scipy
进行参数优化的问题,目标函数:
由于涉及到较多的参数和约束条件,因此考虑使用for
循环,程序主要代码如下:
1 | from scipy.optimize import minimize |
对于约束函数的生成,由于每个 $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 | func = [] |
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的作用域由
def
、class
、lambda
等语句产生,而if
、try
、for
等语句并不会产生新的作用域。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 makei
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
3func = []
for myx in range(4):
func.append(lambda tmp=myx: tmp**2)
②上述方法①等价的写法:1
2
3
4
5func = []
for myx in range(4):
def myfunc(tmp=myx):
return tmp**2
func.append(myfunc)
方案二:使内部函数成为闭包
①创建一个外部函数包裹内部函数,使内部函数成为闭包,1
2
3
4
5
6
7func = []
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
8def 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
6def makefunc(param):
return lambda: param**2
func = []
for myx in range(4):
func.append(makefunc(myx))
④同样,可以使用嵌套的lambda
函数简化上述方法①的代码,这可能看起来不太直观:1
2
3func = []
for myx in range(4):
func.append((lambda myx: lambda: myx**2)(myx))
方案三:使用标准库函数partial
Python的functools
模块提供了「偏函数partial
」,其返回一个新的「partial
对象」(参考这里),它会被「冻结了」一部分函数参数和/或关键字的部分函数应用所使用,从而得到一个具有简化签名的新对象。官方文档如图所示:
其他参考资料:
方案三代码:1
2
3
4from 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
14nums = [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
8def 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
当然,也可以使用上文提到的另外的方法。