Python装饰器:套层壳我变得更强了!
Python装饰器:套层壳我变得更强了
昨天阅读了《Python Tricks: The Book》的第三章“Effective Functions”,这一章节介绍了Python函数的灵活用法,包括lambda函数、装饰器、不定长参数*args和**kwargs等,书中关于闭包的介绍让我回想起了《你不知道的JavaScript-上卷》中的相关内容。本文主要记录自己在学习Python闭包和装饰器过程中的一些心得体会,部分内容直接摘抄自参考资料。
关于作用域和闭包可以聊点什么?
什么是作用域
作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。换句话说,作用域是根据名称查找变量的一套规则。
作用域的以下两点规则需要特别注意:
-
“遮蔽效应”:作用域查找会在找到第一个匹配的标识符时停止,嵌套作用域内部的标识符会遮蔽外部的标识符;
-
提升:无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理,可以形象地认为变量和函数声明从它们在代码中出现的位置被“移动”到了所在作用域的顶部。
下面通过一个例子进行说明:
level = 3
def upgrade():
"""在当前等级的基础上提升一级"""
level += 1
def cprint():
print('当前等级:' + '*' * level)
upgrade() # UnboundLocalError: local variable 'level' referenced before assignment
cprint() # 当前等级:***
print(xyz) # NameError: name 'xyz' is not defined
为什么同样是引用全局变量“level”,执行函数“upgrade”触发了“UnboundLocalError”异常,而执行函数“cprint”就不会呢?这是因为在代码编译的过程中,函数“upgrade”的赋值表达式“level += 1”会被解析为“level = level + 1”,这涉及变量声明和变量赋值两个过程。首先是变量声明,“level”会被声明为局部变量(全局作用域里面的“level”被遮盖了),并且它的声明会被提升到函数作用域的顶部;其次是变量赋值,Python解释器会从函数作用域中查询“level”,并计算表达式“level + 1”的结果,由于此时“level”虽然被声明了,但是还没有被赋值(绑定?),计算失败,触发了“UnboundLocalError”异常。
“UnboundLocalError”异常和“NameError”异常的触发条件是不同的:
-
UnboundLocalError: Raised when a reference is made to a local variable in a function or method, but no value has been bound to that variable.
-
NameError: Raised when a local or global name is not found.
从官方文档给出的描述中可以看到,“UnboundLocalError”异常是在变量被声明了(在作用域中找到了)但是还没有绑定值的时候触发,而“NameError”异常是在作用域中找不到变量的时候触发,两者是有比较明显的区别的。
通过为函数“upgrade”中的变量“level”加上global声明可以规避“UnboundLocalError”异常:
level = 3
def upgrade():
"""在当前等级的基础上提升一级"""
global level # global声明将“level”标记为全局变量
level += 1
upgrade() # 太棒了,没有触发异常!
print(level) # 4
global声明将“level”标记为全局变量,在代码编译过程中不会再声明“level”为函数作用域里面的局部变量了。nonlocal声明具有相似的功能,但使用的场景与global不同,由于篇幅限制,这里不再展开说明。
什么是闭包
A closure remembers the values from its enclosing lexical scope even when the program flow is no longer in that scope.
当函数可以记住并访问所在的词法作用域(定义函数时所在的作用域),即使函数是在词法作用域之外执行,这时就产生了闭包。
通过计算移动平均值的例子说明Python闭包:
def make_averager():
"""工厂函数"""
series = []
def averager(new_value):
"""移动平均值计算器"""
series.append(new_value) # series是外部作用域中的变量
total = sum(series)
return total / len(series)
return averager # 返回内部定义的函数averager
averager = make_averager()
averager(10) # 10
averager(20) # 15
averager(30) # 20
可以看到函数“averager”的定义体中引用了工厂函数“make_averager”的词法作用域中的局部变量“series”,当“averager”被当作对象返回并且在全局作用域中被调用,它仍然能够访问“series”的值,据此计算移动平均值。这就是闭包。
Python在函数的“__code__”属性中保存了词法作用域中的局部变量和自由变量(free variable,“series”就是自由变量)的名称,在函数的“__closure__”属性中保存了自由变量的值:
averager.__code__.co_varnames # ('new_value', 'total')
averager.__code__.co_freevars # ('series',)
averager.__closure__ # (<cell at 0x000002135DE72FD8: list object at 0x000002135D589488>,)
averager.__closure__[0].cell_contents # [10, 20, 30]
装饰器:套层壳我变得更强了
装饰器常用于把被装饰的函数(或可调用的对象)替换成其他函数,它的输入参数是一个函数,输出结果也是一个函数。装饰器是实现横切关注点(cross-cutting concerns)的绝佳方案,使用场景包括数据校验(用户登录了吗?用户有权限访问数据吗?)、缓存(functools.lru_cache)、日志打印等。
def uppercase(func):
def wrapper():
original_result = func() # 引用了uppercase函数作用域中的变量func
modified_result = original_result.upper()
return modified_result
return wrapper
def make_greeting_words():
"""来段问候语"""
return 'Hello, World!'
greet = uppercase(make_greeting_words) # 用uppercase装饰make_greeting_words
greet() # 'HELLO, WORLD!',好耶,单词变成大写的了!
greet.__name__ # 'wrapper'
greet.__doc__ # None
观察以上例子可以发现:
- 装饰器的输入是一个函数,输出也是一个函数;
- 被装饰的函数的一些元信息(原始函数名、文档字符串)被覆盖了;
- 装饰器基于闭包。
Python提供了通过@decorator_name的方式使用装饰器的语法糖。此外,通过使用functools.wraps(func),被装饰的函数的元信息能够得以保留,这有助于代码的调试:
import functools
def uppercase(func):
@functools.wraps(func)
def wrapper():
original_result = func() # 引用了uppercase函数作用域中的变量func
modified_result = original_result.upper()
return modified_result
return wrapper
@uppercase
def make_greeting_words():
"""来段问候语"""
return 'Hello, World!'
make_greeting_words() # 'HELLO, WORLD!'
make_greeting_words.__name__ # 'make_greeting_words'
make_greeting_words.__doc__ # '来段问候语'
带参数的装饰器:
import functools
def cache(func):
"""memorization装饰器,用于提高递归效率"""
known = dict()
@functools.wraps(func)
def wrapper(*args):
if args not in known:
known[args] = func(*args)
return known[args]
return wrapper
@cache
def fibonacci(n):
"""计算Fibonacci数列的第n项"""
assert n >= 0, 'n必须大于等于0'
return n if n in {0, 1} else fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(5) # 5
fibonacci(50) # 12586269025
参考资料
- Python Tricks: The Book
- 《你不知道的JavaScript-上卷》第一部分“作用域和闭包”
- 《流畅的Python》第7章“函数装饰器和闭包”
- Python UnboundLocalError和NameError错误根源解析
- Built-in Exceptions
- 《精通Python设计模式》第5章“修饰器模式”