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不同,由于篇幅限制,这里不再展开说明。

什么是闭包

当函数可以记住并访问所在的词法作用域(定义函数时所在的作用域),即使函数是在词法作用域之外执行,这时就产生了闭包。

通过计算移动平均值的例子说明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

观察以上例子可以发现:

  1. 装饰器的输入是一个函数,输出也是一个函数;
  2. 被装饰的函数的一些元信息(原始函数名、文档字符串)被覆盖了;
  3. 装饰器基于闭包。

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

参考资料

  1. Python Tricks: The Book
  2. 《你不知道的JavaScript-上卷》第一部分“作用域和闭包”
  3. 《流畅的Python》第7章“函数装饰器和闭包”
  4. Python UnboundLocalError和NameError错误根源解析
  5. Built-in Exceptions
  6. 《精通Python设计模式》第5章“修饰器模式”
04-20 11:24