红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 编程语言
  3. Python
  4. 正文

Python学习笔记24:函数装饰器进阶

2021年4月21日 936点热度 0人点赞 0条评论

Python学习笔记24:函数装饰器进阶

函数装饰器,也称为函数修饰器或函数修饰符。

之前在Python学习笔记11:函数修饰符和Python学习笔记12:函数修饰符的应用中我们有所讨论,但学习的并不深入。

在本文中我们就一些函数装饰器的进阶知识进行探讨。

方便起见,这里使用《Fluent Python》中的称呼,统一称为函数装饰器。

装饰器基本概念

在之前的文章中我们讨论过,装饰器就是一种设计模式,其目的是为了给一个功能附加上其它功能。具体到Python的函数装饰器,顾名思义,就是给指定函数附加上其它功能,让其“改头换面”。

那么解释器是何时开始执行函数装饰器对目标函数进行“装饰”的呢?

何时执行装饰器

这里用一个注册装饰器进行说明。

注册装饰器

registed = []
def register(func):
    registed.append(func)
    print(str(func),"is registed")
    return func
​
@register
def test1():
    print('this is test1 function')
​
@register
def test2():
    print('this is test2 function')
​
print("main function begin")
print(registed)
test1()
test2()
# <function test1 at 0x000002196EF73A60> is registed
# <function test2 at 0x000002196EF73B80> is registed
# main function begin
# [<function test1 at 0x000002196EF73A60>, <function test2 at 0x000002196EF73B80>]
# this is test1 function
# this is test2 function

我们可以看到,Python解释器调用函数装饰器对目标函数处理是在脚本的程序执行之前,理所应当地,也在被装饰的目标函数被调用之前。

事实上,在函数装饰器后紧跟着的目标函数完成定义后,Python解释器就会执行装饰器。

这里的“注册装饰器”极为简单,对目标函数没有做任何修改直接返回,和我们之前说过的装饰器模式定义不符。事实上,装饰器不一定会附加功能后返回新的函数,虽然大多数装饰器都是那样的功能,但也会有这里展示的这种"简化装饰器"。

其实我们完全可以不用@符号,不依赖Python解释器,手动完成调用装饰器处理目标函数的工作。

registed = []
def register(func):
    registed.append(func)
    print(str(func),"is registed")
    return func
​
def test1():
    print('this is test1 function')
test1 = register(test1)
​
def test2():
    print('this is test2 function')
test2 = register(test2)
​
print("main function begin")
print(registed)
test1()
test2()
# <function test1 at 0x000002196EF73A60> is registed
# <function test2 at 0x000002196EF73B80> is registed
# main function begin
# [<function test1 at 0x000002196EF73A60>, <function test2 at 0x000002196EF73B80>]
# this is test1 function
# this is test2 function

结果完全一致。

事实上@register(test1)和test1=register(test1)的效果是完全相同的,这也正式Python解释器的工作。

现在是不是对函数装饰器有了更深一层的认识?

使用装饰器改写“策略模式”

在Python学习笔记23:Python设计模式中我们介绍了如何用Python的方式实现策略模式,其中提到最佳策略的Python实现。

在那篇文章中我们使用了内省的方式来实现“自动注册”策略,除此之外,我们还可以用刚介绍过的装饰器来实现。

新建一个模块promo_register.py,实现一个简单的注册装饰器。

from .Order import Order
promotions = []
​
​
def regist_promo(func):
    promotions.append(func)
    return func

在促销策略模块promotion_func.py中使用@符号进行注册,并实现一个最佳策略方法:

@regist_promo
def large_order_promo(order: Order):
    if len(order.cart.items) >= 10:
        return order.total()*0.07
    else:
        return 0
​
def best_promo(order: Order):
    if len(promotions) == 0:
        return 0
    return max(promo(order) for promo in promotions)

这里有个坑,我一开始是把best_promo和注册装饰器写在一个模块里的,导致测试程序不需要导入promotion_func.py,自然也就不会把促销策略注册到promotions变量内,怎么试也不会使用促销。要避免这种情况出现,就要把最佳策略写在promotion_func.py中。

这里是不能在promo_register.py中加入import .promotion_func来避免此问题的,因为会构成循环引用。我有点怀念PHP的require_once了,怎么循环引用都没事。

测试代码相关修改这里就不做演示了,我把修改后的整体工程代码上传到百度云:

链接: https://pan.baidu.com/s/1qLv8dlYAL_6dokbBqIwG4Q

提取码: irfj

在继续深入了解函数装饰器前,我们先要讨论一个概念:闭包。

闭包

我记得Java中也有闭包的概念,简单地说就是一个当前作用域范围之外的变量在这个作用域中的可见性。

老实说我在PHP中很少细究这其中的差异,毕竟很少写像Java中的匿名类。

全局变量与局部变量

我们先看下面的例子:

numbers = []
​
​
def per(num: int) -> float:
    numbers.append(num)
    return sum(numbers)/len(numbers)
​
print(per(1))
print(per(5))
print(per(10))
# 1.0
# 3.0
# 5.333333333333333

per函数接受一个int值,将其加入全局变量numbers后再求numbers中的所有元素的平均值并返回。

看起来运行的不错,但这里面有一个细节性的问题:我们每次调用per都需要便利numbers后重新计算平均值,其实大可不必,就目前的功能来说,完全可以不用保存历史数据,我们只要保留已经输入的数字个数和数字之和就行了,对于新数字,只要累加后除以个数即可。

total = 0
nums = 0
​
def per(num: int) -> float:
    total += num
    nums += 1
    return total/nums
​
print(per(1))
print(per(5))
print(per(10))
# Traceback (most recent call last):
#   File "d:\workspace\python\test\test.py", line 9, in <module>
#     print(per(1))
#   File "d:\workspace\python\test\test.py", line 5, in per
#     total += num
# UnboundLocalError: local variable 'total' referenced before assignment

运行出错,错误提示我们在使用本地变量total前没有初始化。

total不是全局变量吗?

原来Python解释器对于全局变量访问有个细节上的判定,如果在函数内以非赋值的方式访问全局变量,比如之前的numbers.append(),这是完全没有问题的。但是一旦我们对其进行赋值操作,比如这里的total+=num,解释器就会认为total是个局部变量,而非全局变量,自然也就会出现报错。

像这里,我们需要在函数内对全局变量进行赋值操作的情况,我们只要简单实用global关键字进行声明即可,这是Python3的新特性,Python2中会有所不同,但这和我们的Python3无关。

total = 0
nums = 0
​
def per(num: int) -> float:
    global total,nums
    total += num
    nums += 1
    return total/nums
​
print(per(1))
print(per(5))
print(per(10))
# 1.0
# 3.0
# 5.333333333333333

这是全局变量和局部变量的问题,如果这个问题放在“函数的函数”中,情况将更加复杂。

自由变量

def getPer():
    numbers = []
    def per(num: int) -> float:
        numbers.append(num)
        return sum(numbers)/len(numbers)
    return per
per = getPer()
print(per(1))
print(per(5))
print(per(10))
# 1.0
# 3.0
# 5.333333333333333

我们用getPer来包装一下per函数,起一个类似装饰器的作用,这时候numbers是getPer函数的局部变量而非全局变量。但我们依然可以在getPer的内部函数per中调用numbers.append。即使在调用的时候外部环境事实上已经释放了也不受影响。

这里的numbers变量在Python中称为“自由变量”。

在Python解释器处理这种情况的时候,一旦人为嵌套函数使用的是一个外部函数的自由变量,就会把其引用加入当前的嵌套函数属性中,这样外部函数被释放并不会影响到自由变量的使用。

事实上我们可以使用代码查看函数的自由变量:

print(per.__code__.co_varnames)
print(per.__code__.co_freevars)
# ('num',)
# ('numbers',)

和全局变量类似,自由变量也必须在嵌套函数中进行“非赋值”的方式使用,一旦进行赋值操作,解释器就会认为是局部变量而非自由变量。

同样的,Python3提供一个关键字nonlocal用于声明变量是自由变量,声明后就可以进行赋值操作。

nonlocal

def getPer():
    total = 0
    nums = 0
​
    def per(num: int) -> float:
        nonlocal total, nums
        total += num
        nums += 1
        return total/nums
    return per
​
​
per = getPer()
print(per(1))
print(per(5))
print(per(10))
# 1.0
# 3.0
# 5.333333333333333
print(per.__code__.co_varnames)
print(per.__code__.co_freevars)
# ('num',)
# ('nums', 'total')

标准库中的装饰器

Python标准库提供了很多有用的装饰器,这里挑几种进行介绍。

为了接下来便于说明,这里实现一个简单的函数装饰器,其功能是对目标函数进行运行时间统计和参数返回值展示。

新建模块clock.py:

import time
def clock(func):
    def clocked(*args, **kwArgs):
        start = time.time()
        result = func(*args, **kwArgs)
        end = time.time()
        times = end - start
        argStr = ''
        if len(args)>0:
            argStr += ','.join(repr(arg) for arg in args)
        if len(kwArgs)>0:
            argStr += ','.join("%s:%s" % (key, repr(value))
                               for key, value in kwArgs.items())
        print("[%0.8fs] %s(%s)->%s" %
              (times, func.__name__, argStr, str(result)))
        return result
    return clocked

我们通过一个斐波那契函数进行测试:

from clock import clock
​
​
@clock
def fibonacci(n: int):
    if n <= 0:
        return 0
    elif n <= 2:
        return 1
    else:
        return fibonacci(n-1)+fibonacci(n-2)
​
print(fibonacci(2))
print(fibonacci(5))
# [0.00000000s] fibonacci(2)->1
# 1
# [0.00000000s] fibonacci(2)->1
# [0.00000000s] fibonacci(1)->1
# [0.00000000s] fibonacci(3)->2
# [0.00000000s] fibonacci(2)->1
# [0.00100803s] fibonacci(4)->3
# [0.00000000s] fibonacci(2)->1
# [0.00000000s] fibonacci(1)->1
# [0.00098610s] fibonacci(3)->2
# [0.00199413s] fibonacci(5)->5
# 5

奇妙的是通过输出我们可以观察到斐波那契函数进行的递归调用过程。

可以看到,在斐波那契函数进行递归调用的时候,有很多重复调用,比如在求fibonacci(5)的时候fibonacci(2)调用了三次。

Python在functools模块中提供了一个有用的函数修饰器lur_cache,可以对这种情况进行优化。

wraps

我们在Python学习笔记12:函数修饰符的应用中提到过wraps,说wraps是为了“告诉”Python解释器这是一个函数装饰器,但没有具体说明。

我们现在来看一下,如果不使用wraps会有什么问题。

# from clock import clockWithParam
from clock import clock
from functools import lru_cache
​
@lru_cache()
@clock
def fibonacci(n: int):
    if n <= 0:
        return 0
    elif n <= 2:
        return 1
    else:
        return fibonacci(n-1)+fibonacci(n-2)
​
# print(fibonacci(2))
# print(fibonacci(5))
print(fibonacci.__name__)
# clocked

可以看到,fibonacci函数的名称已经改变,这在很多时候看起来是很怪异的。

如果我们在装饰器中使用了wraps:

import time
from functools import wraps
​
def clock(func):
    @wraps(func)
    def clocked(*args, **kwArgs):
        start = time.time()
        result = func(*args, **kwArgs)
        end = time.time()
        times = end - start
        argStr = ''
        if len(args) > 0:
            argStr += ','.join(repr(arg) for arg in args)
        if len(kwArgs) > 0:
            argStr += ','.join("%s:%s" % (key, repr(value))
                               for key, value in kwArgs.items())
        print("[%0.8fs] %s(%s)->%s" %
              (times, func.__name__, argStr, str(result)))
        return result
    return clocked

再次执行测试程序我们就能发现被装饰后的fibonacci函数名称依然是fibonacci。

wraps装饰器的作用就是在装饰器用内嵌函数替换掉目标函数后,将目标函数的信息重新赋予内嵌函数,让内嵌函数表现地像目标函数。

lur_cache

其实在计算机的世界里缓存机制普遍存在,从CPU缓存到内存缓存,再到操作系统的分页文件,都是缓存思想的体现。

而lur_cache可以将高频函数调用的结果缓存起来,出现重复调用的时候就无需再调用,直接获取已有结果。

from clock import clock
from functools import lru_cache
​
@lru_cache()
@clock
def fibonacci(n: int):
    if n <= 0:
        return 0
    elif n <= 2:
        return 1
    else:
        return fibonacci(n-1)+fibonacci(n-2)
​
print(fibonacci(2))
print(fibonacci(5))
# [0.00000000s] fibonacci(2)->1
# 1
# [0.00000000s] fibonacci(1)->1
# [0.00214124s] fibonacci(3)->2
# [0.00316000s] fibonacci(4)->3
# [0.00515580s] fibonacci(5)->5
# 5

可以看到,斐波那契函数递归调用次数大大减少。

此外,我们还注意到lur_cache与clock的使用方式不同,其后还跟着(),这是因为它是一个带参数的函数修饰器。

事实上我们在Python学习笔记6:Web应用中就见过带参数的函数修饰符,比如使用Flask框架的这段代码:

from flask import Flask
web = Flask(__name__)
​
​
@web.route('/')
def hellow():
    return "hellow world"
​
​
web.run(debug=True)

其中@web.route('/')就是一个带参数的函数修饰符。

我们说回到lur_cache,它包含两个参数:

image-20210421191921995

maxsize是指定缓存大小,typed是指定是否进行类型检查,比如如果设置为true,fibonacci(1)和fibonacci(1.0)将会被视为两个不同的函数调用,将不会使用缓存机制。

带参数的函数装饰器实质上是一个函数装饰器的工厂方法,这点在稍后我们会解释。

需要强调的是,lur_cache实现缓存的方式是利用字典结构,将函数签名进行哈希后映射到返回值,所以可以进行快速检查是否存在已有缓存,同样的,类字典结构面临的散列问题lur_cache同样存在,所以使用lur_cache实现缓存的目标函数参数必须是可散列的。

关于散列问题可以查看Python学习笔记20:字典与集合。

单分派泛函数

我们在C++、Java或其它常见的强类型语言中经常会见到函数重载,通过不同的类型参数的函数签名,我们可以实现调用不同的函数。但是Python或者PHP这种弱类型语言是没有这种特性的,如果想实现类似的功能,就需要写if/ifel/else。

import numbers
import inspect
​
​
def showEverything(obj):
    if isinstance(obj, numbers.Integral):
        print('this is a integer:%s' % obj)
    elif inspect.isfunction(obj):
        sig = inspect.signature(obj)
        argStr = ','.join(name for name, param in sig.parameters.items())
        # result = obj()
        print('this is a function: %s(%s)' % (obj.__name__, argStr))
    elif isinstance(obj, str):
        print('this is a string:%s' % obj)
    else:
        print('this is a object:%r' % obj)
​
​
showEverything(1)
showEverything('hellow wolrd!')
showEverything(len)

除此以外,在Python中我们还可以通过单分派泛函数来实现,听起来有点绕口,但其实质就是一个以实现类似函数重载功能、进行“分发”工作的函数修饰器。

import numbers
import inspect
from functools import singledispatch
​
​
@singledispatch
def showEverything(obj):
    print('this is a object:%r' % obj)
​
​
@showEverything.register(numbers.Integral)
def _(intVar: int):
    print('this is a integer:%s' % intVar)
​
​
@showEverything.register(str)
def _(strVar: str):
    print('this is a string:%s' % strVar)
​
​
showEverything(1)
showEverything('hellow wolrd!')
showEverything(len)

在这段代码中,我们使用numbers.Integral而非int说明类型,是因为numbers.Integal是抽象基类,比int更具可扩展性,同样适用于其他从Integal派生的类。

此外,之所以会出现def _(strVar: str):这种奇怪的函数定义,是因为此函数是通过“注册”的方式绑定到showEverything函数进行执行,其本身并不会直接被调用,名称当然也无关紧要。

叠放装饰器

使用装饰器函数的时候是可以多个一起用的,这就是所谓的“叠放”。

这很好理解,如同设计模式中的装饰品模式,装饰器这一模式本来就是可以进行“俄罗斯套娃”式地应用,可以灵活包裹不同层次的装饰器来实现不同的目的。

但是使用的时候需要仔细思考包裹的次序,不同的先后次序会造成不同的效果。

参数化装饰器

就像前边说过的那样,装饰器是可以携带参数的,但参数化的装饰器相比无参数的装饰器更为复杂。

这里我们通过两个示例进行说明。

参数化注册器

我们对之前介绍过的注册器适配器进行参数化,添加一个参数来决定是否注册。

registed = []
​
​
def register(reaRegist=True):
    def registerDecorator(func):
        if reaRegist:
            registed.append(func)
            print(str(func), "is registed")
        return func
    return registerDecorator
​
​
@register()
def test1():
    print('this is test1 function')
​
​
@register(False)
def test2():
    print('this is test2 function')
​
​
print("main function begin")
print(registed)
test1()
test2()
# <function test1 at 0x000001CB6F143B80> is registed
# main function begin
# [<function test1 at 0x000001CB6F143B80>]
# this is test1 function
# this is test2 function

可以看到,通过指定参数,test2并没有被真正注册。

就像之前所说的,事实上现在的register函数并非真正的函数装饰器,而是一个函数装饰器工厂,它生产函数装饰器。@register()会将其产生的函数装饰器应用于目标函数。

我们来具体看一下,其实下面两种调用是等效的:

@register
def test1():
    print('this is test1 function')
def test1():
    print('this is test1 function')
register()(test1)

现在应该就很清晰了。

我们来看一个更复杂的例子:参数化clock装饰器。

参数化clock装饰器

相比于只是简单返回目标函数的注册装饰器,我们之前使用过的clock装饰器无疑更加复杂,我们来看一下参数化的clock装饰器怎么实现。

import time
​
​
def clockWithParam(fmt: str = "[{times:0.8f}s] {name}({argStr})->{result}"):
    def clockDecorator(func):
        def clocked(*args, **kwArgs):
            start = time.time()
            result = func(*args, **kwArgs)
            end = time.time()
            times = end - start
            argStr = ''
            if len(args) > 0:
                argStr += ','.join(repr(arg) for arg in args)
            if len(kwArgs) > 0:
                argStr += ','.join("%s:%s" % (key, repr(value))
                                   for key, value in kwArgs.items())
            name = func.__name__
            print(fmt.format(**locals()))
            return result
        return clocked
    return clockDecorator

我们把格式化字符串设置为参数。

测试代码只需要稍稍修改:

from clock import clockWithParam
from functools import lru_cache
​
@lru_cache()
@clockWithParam('{name}({argStr}) [{times:0.8f}]')
def fibonacci(n: int):
    if n <= 0:
        return 0
    elif n <= 2:
        return 1
    else:
        return fibonacci(n-1)+fibonacci(n-2)
​
print(fibonacci(2))
print(fibonacci(5))
# fibonacci(2) [0.00000000]
# 1
# fibonacci(1) [0.00000000]
# fibonacci(3) [0.00000000]
# fibonacci(4) [0.00099969]
# fibonacci(5) [0.00099969]
# 5

我们通过简单传递一个新的格式化参数就可以实现改变输出的样式。

老实说这样嵌套的函数装饰器可读性的确不高。

好了,装饰器的全部进阶内容讨论完毕。

老实说对这一篇我并不是很满意,很多时候都卡文,表述也相当拗口。但这部分内容的确也艰涩难懂,水平有限。

谢谢阅读。

老规矩,附上思维导图:

image-20210421213236656

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: 函数修饰符 函数装饰器 闭包
最后更新:2021年4月21日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号