[python 进阶] 第7章 函数装饰器和闭包


函数装饰器用于在源码中“标记”函数,以某种方式增强函数的行为。这是一项强大的功能,但是若想掌握,必须理解闭包。
nonlocal 是新近出现的保留关键字,在 Python 3.0 中引入。
除了在装饰器中有用处之外,闭包还是 回调式异步编程函数式编程风格的基础。
本章的最终目标是解释清楚函数装饰器的工作原理,包括最简单的 注册装饰器和较复杂的 参数化装饰器。但是,在实现这一目标之前,我们要讨论下述话题:

  • Python 如何计算装饰器句法
  • Python 如何判断变量是不是局部的
  • 闭包存在的原因和工作原理
  • nonlocal 能解决什么问题
    掌握这些基础知识后,我们可以进一步探讨装饰器:
  • 实现行为良好的装饰器
  • 标准库中有用的装饰器
  • 实现一个参数化装饰器

7.1 装饰器基础知识

装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。 装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。
假如有个名为 decorate 的装饰器:

@decorate
def target():
print('running target()')

上述代码的效果与下述写法一样:

def target():
print('running target()')
target = decorate(target)

两种写法的最终结果一样:上述两个代码片段执行完毕后得到的 target 不一定是原来那
个 target 函数,而是 decorate(target) 返回的函数。

为了确认被装饰的函数会被替换,请看示例 7-1 中的控制台会话。
示例 7-1 装饰器通常把函数替换成另一个函数

>>> def deco(func):
... def inner():
... print('running inner()')
... return inner 
...
>>> @deco
... def target(): 
... print('running target()')
...
>>> target() 
running inner()
>>> target 
<function deco.<locals>.inner at 0x10063b598>

严格来说,装饰器只是语法糖。如前所示,装饰器可以像常规的可调用对象那样调用,其
参数是另一个函数。有时,这样做更方便,尤其是做元编程(在运行时改变程序的行
为)时。
综上,装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器
在加载模块时立即执行。

7.2 Python何时执行装饰器

装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。这通常是在导入时
(即 Python 加载模块时)

registry = []
def register(func):
    print('running register(%s)' % func)
    registry.append(func)
    return func
@register
def f1():
    print('running f1()')
@register
def f2():
    print('running f2()')
def f3():
    print('running f3()')
def main():
    print('running main()')
    print('registry ->', registry)  # 发现registry这个数组并不是空
    f1()
    f2()
    f3()
if __name__=='__main__':
    main()

输出后是什么样子呢?

running register(<function f1 at 0x7ff079e400d0>)
running register(<function f2 at 0x7ff06c9e37b8>)
running main()
registry -> [<function f1 at 0x7ff079e400d0>, <function f2 at 0x7ff06c9e37b8>]
running f1()
running f2()
running f3()

注意:在调用f1()和f2()时,输出的是 runnint f1()和running f2()。
上面的例子主要是强调:函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 程序员所说的导入时和运行时之间的区别。
考虑到装饰器在真实代码中的常用方式,示例 7-2 有两个不寻常的地方。装饰器函数与被装饰的函数在同一个模块中定义。实际情况是,装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。
register 装饰器返回的函数与通过参数传入的相同。实际上,大多数装饰器会在内部定义一个函数,然后将其返回。

虽然上示例中的 register 装饰器原封不动地返回被装饰的函数,但是这种技术并非没有用处。很多 Python Web 框架使用这样的装饰器把函数添加到某种中央注册处,例如把URL模式映射到生成 HTTP 响应的函数上的注册处。这种注册装饰器可能会也可能不会修改被装饰的函数。

7.3 使用装饰器改进“策略”

使用注册装饰器可以改进之前的第六章中的电商促销折扣示例。
回顾一下,示例 6-6 的主要问题是,定义体中有函数的名称,但是 best_promo 用来判断哪个折扣幅度最大的 promos 列表中也有函数名称。这种重复是个问题,因为新增策略函数后可能会忘记把它添加到 promos 列表中,导致 best_promo 忽略新策略,而且不报错,为系统引入了不易察觉的缺陷。以下这个例子使用注册装饰器解决了这个问题。

  • promos 列表中的值使用 promotion 装饰器

      from collections import namedtuple
      Customer = namedtuple('Customer', 'name fidelity')
      class LineItem:
          def __init__(self, product, quantity, price):
              self.product = product
              self.quantity = quantity
              self.price = price
          def total(self):
              return self.price * self.quantity
      
      promos =[]
      def promotion(promo_func):
          promos.append(promo_func)
          return promo_func
      
      @promotion
      def fidelity(order):
          """为积分为1000或者以上的顾客提供5%折扣"""
          return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0
      
      @promotion
      def bulk_item(order):
          """单个商品为20个或者以上时提供10%折扣"""
          discount = 0
          for item in order.cart:
              if item.quantity >= 20:
                  discount += item.total() * 0.01
          return discount
      @promotion
      def large_order(order):
          """订单中的不同商品达到10个以上时提供7%折扣"""
          distinct_items = {item.product for item in order.cart}
          if len(distinct_items) >= 10:
              return order.total() * 0.07
          return 0
      
      def best_promo(order):
          """选择可用的最佳折扣"""
          return max(promo(order) for promo in promos)
      
      class Order: #上下文
          def __init__(self, customer, cart, promotion=None):
              self.customer = customer
              self.cart = list(cart)
              self.promotion = promotion
      
          def total(self):
              if not hasattr(self, '__total'):
                  self.__total = sum(item.total() for item in self.cart)
              return self.__total
      
          def due(self):
              if self.promotion is None:
                  discount = 0
              else:
                  discount = self.promotion(self)
              return self.total() - discount
          def __repr__(self):
              fmt = '<Order total: {:.2f} due: {:.2f}>'
              return fmt.format(self.total(), self.due())
              
      	if __name__ == '__main__':
      	    joe = Customer('John Doe', 0)
      	    ann = Customer('Ann Smith', 1000)
      	    cart = [LineItem('banana', 4, 0.5),
      	            LineItem('apple', 10, 1.5),
      	            LineItem('watermellon', 5, 5.0)]
      	    print(Order(joe, cart, fidelity))   # <Order total: 42.00 due: 42.00>
      	    print(Order(ann, cart, fidelity))   #<Order total: 42.00 due: 39.90>
    

与 6.1 节给出的方案相比,这个方案有几个优点。

  • 促销策略函数无需使用特殊的名称(即不用以 _promo 结尾)。
  • @promotion 装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:只需把装饰器注释掉。
  • 促销折扣策略可以在其他模块中定义,在系统中的任何地方都行,只要使用@promotion 装饰即可。

不过,多数装饰器会修改被装饰的函数。通常,它们会定义一个内部函数,然后将其返
回,替换被装饰的函数。使用内部函数的代码几乎都要靠闭包才能正确运作。

7.4 变量作用域(global)

在示例 7-4 中,我们定义并测试了一个函数,它读取两个变量的值:一个是局部变量 a,
是函数的参数;另一个是变量 b,这个函数没有定义它。

示例 7-4 一个函数,读取一个局部变量和一个全局变量

>>> def f1(a):
... print(a)
... print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f1
NameError: global name 'b' is not defined

出现错误并不奇怪。 在示例 7-4 中,如果先给全局变量 b 赋值,然后再调用 f,那就不
会出错。

示例 7-5 b 是局部变量,因为在函数的定义体中给它赋值了

>>> b = 6
>>> def f2(a):
... print(a)
... print(b)
... b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment

注意,首先输出了 3,这表明 print(a) 语句执行了。但是第二个语句 print(b) 执行不了。一开始我很吃惊,我觉得会打印 6,因为有个全局变量 b,而且是在 print(b) 之后为局部变量 b 赋值的。
可事实是,Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了。生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。后面调用 f2(3)时, f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现b 没有绑定值。
这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。这比 JavaScript 的行为好多了,JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量(使用 var),可能会在不知情的情况下获取全局变量。
如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:

>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
3
6
>>> b
9
>>> f3(3)
3
9
>>> b = 30
>>> b
30
>>>

了解 Python 的变量作用域之后,下一节可以讨论闭包了。如果好奇示例 7-4 和示例 7-5 中的两个函数生成的字节码有什么区别,可以下面的备注:

备注 -比较字节码(暂略)

7.5 闭包

在博客圈,人们有时会把闭包和匿名函数弄混。这是有历史原因的:在函数内部定义函数不常见,直到开始使用匿名函数才会这样做。而且,只有涉及嵌套函数时才有闭包问题。
因此,很多人是同时知道这两个概念的。
其实,闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。
这个概念难以掌握,最好通过示例理解。
假如有个名为 avg 的函数,它的作用是计算不断增加的系列值的均值;例如,整个历史中某个商品的平均收盘价。每天都会增加新价格,因此平均值要考虑至目前为止所有的价格。

示例 7-8 average_oo.py:计算移动平均值

class Averager():
    def __init__(self):
        self.series=[]
    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total/len(self.series)
avg=Averager()
print(avg(10)) #10.0
print(avg(11)) #10.5
print(avg(12)) #11.0

Averager 的实例是可调用对象

示例 7-9 是函数式实现,使用高阶函数 make_averager。

示例 7-9 average.py:计算移动平均值的高阶函数

def make_averager():
	series = []
	def averager(new_value):
		series.append(new_value)
		total = sum(series)
		return total/len(series)
return averager

调用 make_averager 时,返回一个 averager 函数对象。每次调用 averager 时,它会
把参数添加到系列值中,然后计算当前平均值,如示例 7-10 所示。
示例 7-10 测试示例 7-9

>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

注意,这两个示例有共通之处:调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。在示例 7-8 中,avg 是 Averager 的实例;在示例 7-9 中是内部函数 averager。不管怎样,我们都只需调用 avg(n),把 n 放入系列值中,然后重新计算均值。
Averager 类的实例 avg 在哪里存储历史值很明显:self.series 实例属性。但是第二个示例中的 avg 函数在哪里寻找 series 呢?
注意,series 是 make_averager 函数的局部变量,因为那个函数的定义体中初始化了series:series = []。可是,调用 avg(10) 时,make_averager 函数已经返回了,而它的本地作用域也一去不复返了。
在 averager 函数中,series 是自由变量(free variable)。这是一个技术术语,指未在本地作用域中绑定的变量。

在这里插入图片描述
averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定。
综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。
注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

7.6 nonlocal声明

前面实现 make_averager 函数的方法效率不高。在示例 7-9 中,我们把所有值存储在历史数列中,然后在每次调用 averager 时使用 sum 求和。更好的实现方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。
示例 7-13 中的实现有缺陷,只是为了阐明观点。你能看出缺陷在哪儿吗?

示例 7-13 计算移动平均值的高阶函数,不保存所有历史值,但有缺陷
def make_averager():
count = 0
total = 0
	def averager(new_value):
		count += 1
		total += new_value
	return total / count
return averager

Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为 nonlocal 声明的变量赋予新值,闭包中保存的绑定会更新。

示例 7-14 计算移动平均值,不保存所有历史(使用 nonlocal 修正)
def make_averager():
count = 0
total = 0
	def averager(new_value):
		nonlocal count, total
		count += 1
		total += new_value
	return total / count
return averager

global和nonlocal的区别

global 表示将变量声明为全局变量
nonlocal 表示将变量声明为外层变量(外层函数的局部变量,而且不能是全局变量)。

    1. global关键字用来在函数或其他局部作用域中使用全局变量。但是如果不修改全局变量也可以不使用global关键字。
    1. 声明全局变量,如果在局部要对全局变量修改,需要在局部也要先声明该全局变量。

         gcount = 0
         	def global_test():
         	    global gcount
         	    gcount +=1
         	    print (gcount)
         	global_test()
      
  • 3.在局部如果不声明全局变量,并且不修改全局变量。则可以正常使用全局变量:

      			gcount = 0
      			def global_test():
      			    print(gcount)
      			global_test()
    
    1. nonlocal关键字用来在函数或其他作用域中使用外层(非全局)变量

         def make_counter(): 
             count = 0 
             def counter(): 
                 nonlocal count 
                 count += 1 
                 return count 
             return counter 
         
         def make_counter_test(): 
           mc = make_counter() 
           print(mc())
           print(mc())
           print(mc())
         
         make_counter_test()
      

7.7 实现一个简单的装饰器

定义了一个装饰器,它会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用的结果打印出来。
import time

def clock(func):
    def clocked(*args):
        t0=time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter()
        name = func.__name__
        arg_str = ','.join(repr(arg) for arg in args)
        print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))
        return result
    return clocked

@clock
def snooze(seconds):
    time.sleep(seconds)
@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)
if __name__ == '__main__':
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))

这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数,而且(通常)返回被装饰的函数本该返回的值,同时还会做些额外操作。

将上述例子改进一下:

7.8 标准库中的装饰器

Python 内置了三个用于装饰方法的函数:property、classmethod 和
staticmethod。另一个常见的装饰器是 functools.wraps,它的作用是协助构建行为良好的装饰器。我
们在示例 7-17 中用过。标准库中最值得关注的两个装饰器是 lru_cache 和全新的
singledispatch(Python 3.4 新增)。这两个装饰器都在 functools 模块中定义。

7.8.1 使用functools.lru_cache做备忘

示例 7-18 生成第 n 个斐波纳契数,递归方式非常

import time

def clock(func):
    def clocked(*args):
        print(*args)
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter()
        name = func.__name__
        arg_str = ','.join(repr(arg) for arg in args)
        print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))
        return result
    return clocked

@clock
def fibonacci(n):
    return n if n < 2 else fibonacci(n-2)+fibonacci(n-1)

if __name__ == '__main__':
    print(fibonacci(6))

示例 7-19 使用缓存实现,速度

import time
import functools
def clock(func):
    def clocked(*args):
        print(*args)
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter()
        name = func.__name__
        arg_str = ','.join(repr(arg) for arg in args)
        print('[%0.8fs] %s(%s)-> %r' % (elapsed, name, arg_str, result))
        return result
    return clocked

@functools.lru_cache() #注意,必须像常规函数那样调用 lru_cache。这一行中有一对括号:@functools.lru_cache()。这么做的原因是,lru_cache 可以接受配置参数
@clock
def fibonacci(n):
    return n if n < 2 else fibonacci(n-2)+fibonacci(n-1)

if __name__ == '__main__':
    print(fibonacci(6))

补充 @functools.lru_cache()可以配置参数

7.8.2 单分派泛函数( @functools.singledispatch)

7.9 叠放装饰器

示例 7-19 演示了叠放装饰器的方式:@lru_cache 应用到 @clock 装饰 fibonacci 得到的结果上。在示例 7-21 中,模块中最后一个函数应用了两个 @htmlize.register 装饰器。
把 @d1 和 @d2 两个装饰器按顺序应用到 f 函数上,作用相当于 f = d1(d2(f))。
也就是说,下述代码:

@d1
@d2
def f():
	print('f')

等同于:

def f():
	print('f')
	f = d1(d2(f))

除了叠放装饰器之外,本章还用到了几个接受参数的装饰器,

7.10 参数化装饰器

解析源码中的装饰器时,Python 把被装饰的函数作为第一个参数传给装饰器函数。那怎么让装饰器接受其他参数呢?答案是:创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。

registry = []
def register(func):
	print('running register(%s)' % func)
	registry.append(func)
	return func
@register
def f1():
	print('running f1()')
print('running main')
print('registry ->',registry)
f1()

7.10.1 一个参数化的注册装饰器

为了便于启用或禁用 register 执行的函数注册功能,我们为它提供一个可选的 active参数,设为 False 时,不注册被装饰的函数。实现方式参见下面这个例子。从概念上看,这个新的 register 函数不是装饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,这才是应用到目标函数上的装饰器。

registry = []
def register(active=True):
	def decorate(func):
		print('running register(active=%s)->decorate(%s)'%(active, func))
		if active:
			registry.add(func)
		else:
			registry.discard(func)
		return func #decorate 是装饰器,必须返回一个函数
	return decorate # register 是装饰器工厂函数,因此返回 decorate
@register(active=True)
def f1():
	print('running f1()')
@register() #即使不传入参数,register 也必须作为函数调用(@register()),即要返回真正的装饰器 decorate。
def f2():
	print('running f2()')
def f3():
	print('running f3()')

如果不使用 @ 句法,那就要像常规函数那样使用 register;若想把 f 添加到 registry中,则装饰 f 函数的句法是 register()(f);不想添加(或把它删除)的话,句法是register(active=False)(f)。

参数化装饰器的原理相当复杂,我们刚刚讨论的那个比大多数都简单。参数化装饰器通常会把被装饰的函数替换掉,而且结构上需要多一层嵌套。

7.10.2 参数化clock装饰器

本节再次探讨 clock 装饰器,为它添加一个功能:让用户传入一个格式字符串,控制被装饰函数的输出。
import time
DEFAULT_FMT=’[{elapsed:0.8f}s] {name}({args})-> {result}’

def clock(fmt=DEFAULT_FMT):
	def decorate(func):
		def clocked(*_args):
			t0 = time.time()
			_result= func(*_args)
			elapsed=time.time-t0
			name = func._name__
			args = ','.join(repr(arg) for arg in _args)
			result = repr(_result)
			print(fmt.format(**locals()))
			return _result
		return clocked
	return decorate
if __name__ == '__main__':
	@clock
	def snooze(seconds):
		time.sleep(seconds)
	for i in range(3):
		snooze(.123)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值