从头捋一捋Python装饰器怎么来的

376 阅读9分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

通俗的理解装饰器

在开发工作中,装饰器的应用场景非常多,比如开发中常见的staticmethodclassmethod还有wraps

装饰器的概念理解起来并不难,把装饰器这三个字拆开来看,指的是一种工具,在Python中函数就是一种工具,装饰就更加通俗了,平常不管是家里还是办公室总会有一些花花草草或者小摆件放在房间里装饰,所以装饰的意思就是为其他事物添加额外的东西进行点缀。

因此装饰器就是通过定义一个功能,这个功能就是为其他功能添加一些额外的东西,并且这个东西可以非常方便的添加进去或者移除,就像一个人带的帽子,可以随时戴上也可以非常方便的摘下来。

为啥要用装饰器

简单举个例子,登录这个功能大家都不陌生,就拿某宝为例,想要将心仪的商品加入购物车你需要先登录,想要直接下单你得先登录,想看看以前买了啥你还得先登录,加购物车、下单和查看订单这属于三个不同的功能,但是这三个不同的功能都需要登录这一个前提,直接写代码?那你就需要重复写三次判定是否登录的代码...更别提还有其他功能了比如评论呀、付定金呀等等...这么多功能里面都需要验证是否登录,如果老板发现了重复了N次的代码老脸往哪搁!

所以为了咱的工作稳定,就需要提出一种解决方案,我们都知道为了解决一个功能被多次使用又防止代码重复的问题,出现了函数,而装饰器是为了解决其他功能需要一些额外的功能同时还要解决代码重复的问题。

装饰器本身的目的就是在不修改被装饰对象源代码和调用方式的前提下为被装饰对象添加一些新的功能,就像咱们出门戴帽子一样,帽子就是一个装饰器,而我们人或者说小脑袋就是被装饰对象。

如何实现装饰器

这里就以一个计算函数运行时间的功能为例来推导如何实现装饰器,其实非常简单,下面我们就来一步一步的进行推导吧。

首先准备好一个函数,这个函数就是后面被装饰的对象,即被计算运行时间的函数。

import time


def index(x: int, y: int) -> tuple[int, int]:
    time.sleep(1)
    return x, y   

最简单的计算函数运行的方式就是在原函数的基础上增加start_timeend_time然后相减就可以计算出函数运行时间。如下述代码:

import time


def index(x: int, y: int) -> tuple[int, int]:
    start_time = time.time()
    time.sleep(1)
    end_time = time.time()
    run_time = end_time - start_time
    print(f'运行时间是{run_time}s')
    return x, y

上述这种解决方式就是我们开头说的,代码重复啦~ 如果有多个函数都需要计算运行时间,就需要多次写重复代码,这不就是相当于把帽子焊死在脑袋上了?分分钟被老板嫌弃就毕业了o(╯□╰)o,所以还需要优化。

根据上面的方案,咱可以再想一个办法,把计算函数运行时间的代码单独给拎出来不就行了,所以代码如下:

import time


def index(x: int, y: int) -> tuple[int, int]:
    time.sleep(1)
    return x, y


start_time = time.time()
index(1, 2)
end_time = time.time()
run_time = end_time - start_time
print(f'运行时间是{run_time}s')

这个方案怎么说呢?虽然解决了帽子焊死在了脑袋上的问题,但是本质上还是没有解决代码重复,但是解决代码重复咱们可以使用函数呀,所以上面单独拎出来的代码咱可以用函数给他封装封装。所以代码就变成下面这个样子:

import time


def index(x: int, y: int) -> tuple[int, int]:
    time.sleep(1)
    return x, y


def wrapper():
    start_time = time.time()
    index(1, 2)
    end_time = time.time()
    run_time = end_time - start_time
    print(f'运行时间是{run_time}s')

上面这种方式目前只能计算一个函数的运行时间,但是我们可以通过给函数传递参数的方式来解决,这样就解决了代码冗余的问题,但是有一个非常重要的问题就是函数的调用方式变了o(╯□╰)o,我本来是想调用index函数同时计算这个函数的运行时间,但是现在只能通过调用wrapper函数才行,很明显不符合装饰器的原则了,而且index函数还需要参数,参数问题如何解决呢?

首先先来解决函数的参数问题,后面再来解决函数的调用方式。根据上面那种方式发现,调用wrapper函数会间接调用index函数,所以我们可以在定义wrapper函数的时候,定义和index相同的形参就可以实现通过wrapper函数简介给index函数传参了,这样index函数的参数就活了,代码如下:

import time


def index(x: int, y: int) -> tuple[int, int]:
    time.sleep(1)
    return x, y


def wrapper(x: int, y: int):
    start_time = time.time()
    index(x, y)
    end_time = time.time()
    run_time = end_time - start_time
    print(f'运行时间是{run_time}s')

上述方案首先不考虑调用方式的问题,虽然将函数的参数写活了,但是有一个严重的缺陷就是因为调用wrapper函数就会间接调用index函数,所以在给wrapper函数定义形参的时候必须按照index的形参的格式,如果index的形参个数发生变化,那么在定义wrapper的形参也必须跟着变。所以就需要让wrapper函数的形参不受到index函数形参的影响,因此可以在定义wrapper函数的时候,将wrapper函数的形参定义为可变长度参数,可以用来接收任何符合语法规则的实参,只要在传实参的时候和index保持一致即可,而定义阶段不管index的形参如何变化,wrapper都可以接收所有形式的实参,不需要改变wrapper形参。代码如下:

import time


def index(x: int, y: int) -> tuple[int, int]:
    time.sleep(1)
    return x, y


def wrapper(*args, **kwargs):
    start_time = time.time()
    index(*args, **kwargs)
    end_time = time.time()
    run_time = end_time - start_time
    print(f'运行时间是{run_time}s')

在不考虑调用方式的情况下,已经解决了代码冗余、参数固定的问题,现在需要解决的问题就是目前wrapper函数只能计算index这一个函数的运行时间,因此就需要将被计算运行时间的函数写活,想到的第一种解决方案就是直接在wrapper函数中增加一个参数用来传递函数,代码如下:

def wrapper(func,*args,**kwargs):  
    start = time.time()
    func(*args,**kwargs)  
    end = time.time()
    print(end - start)

上述这种方式解决了wrapper函数只能计算一个函数运行时间的问题,此时我们还有最后一个问题没有解决就是如何在不改变函数调用方式的前提下为函数增加计算运行时间的功能,很明显上述方案无法解决。

既然不能通过wrapper函数增加形参解决,就通过闭包函数的方式来给wrapper函数传参,我们知道闭包函数有一个重要的功能就是可以给内层函数传递参数,所以代码如下:

def outer(func):
    # func = index
    def wrapper(*args,**kwargs):  #args= (1,) kwargs={'y':2}
        start = time.time()
        res = func(*args,**kwargs)  # 1,y=2
        end = time.time()
        print(end - start)
        return res  # 返回被装饰函数的返回值
    return wrapper

此时我们就来调用一下outer函数来分析一下这个函数调用之后会发生什么:

outer(index)
'''
将index函数作为参数传递给outer函数,然后返回wrapper函数的内存地址,所以我们可以使用一个变量去接收wrapper函数的内存地址。
'''

wrapper = outer(index)
'''
此时wrapper变量指向的是wrapper这个函数的内存地址,因此wrapper这个变量是可以加括号调用的
'''

wrapper()
'''
运行wrapper函数就运行了index函数并且计算了运行时间
'''

通过上述分析发现调用方式还是变了呀,通过闭包函数也没有解决调用方式的问题呀,咱们先别急,outer(index)返回的是wrapper函数的内存地址,这个内存地址既然可以赋值给wrapper这个变量,当然也可以赋值给index这个变量,所有在定义outer函数时返回wrapper就是这个原因。所以上述分析过程可以改成:

index = outer(index)
index()

至此就完成了在不修改index的源代码也不修改调用方式的前提下给index增加了新的功能 此时的index指向的内存地址已经不是原来的index指向的内存地址,而是wrapper函数的内存地址,只是将函数名改为了index。

当然为了方便装饰器的使用,Python为我们提供了语法糖,能够让我们更加方便的去使用装饰器,这个语法糖就是@装饰器名称,就像我们戴帽子一样,非常方便的使用。

装饰器的基本原则就是偷梁换柱,就是将wrapper函数做的和原函数一模一样才行:不改变源代码不改变调用方式,但是增加了其他的功能,上述总结的无参装饰器模板,有一点点的小瑕疵。具体体现在

当打印被装饰过的函数的函数名或者被装饰函数有说明文档的时候,如果打印出来就会暴露调用的函数并不是原函数了

import time

def timer(func):
    def wrapper(*args,**kwargs):
        start_time = time.time()
        print('hello')
        time.sleep(2)
        end = time.time()
        print(end-start_time)
        res = func(*args,**kwargs)


        return res
    return wrapper

@timer
def index(x,y):
    '''
    doc:我是index
    :param x:
    :param y:
    :return:
    '''
    print(x,y)

index(1,2)

print(index.__name__) # wrapper
print(index.__doc__) # None


'''
原函数有说明文档,被装饰后没有了
原函数的函数名是index,只要已打印函数的名字属性,就会暴露不是原函数的事实
'''
  • 解决方式
- 方式一
# 手动将原函数的属性赋值给wrapper函数
# 1、函数wrapper.__name__ = 原函数.__name__
# 2、函数wrapper.__doc__ = 原函数.__doc__
# wrapper.__name__ = func.__name__
# wrapper.__doc__ = func.__doc__
缺点:每个函数的属性有很多,难道真的要一条一条的进行修改吗?
- 方式二:wraps
wraps:本质也是一个装饰器,会将原函数的属性全部赋值给wrapper函数


from functools import wraps
def outer(func):
	# 将被装饰函数当做参数传给wraps
	@wrapper(func)
	def wrapper(*args,**kwargs)
		res = func(*args,**kwargs)
		return res
	return wrapper

因此最后总结一下装饰器的使用模板如下:

from functools import wraps

# 装饰函数
def outer(func):
    @wrapper(func)
	def wrapper(*args,**kwargs):
		res = func(*args,**kwargs)
		return res
	return wrapper


@outer  # 使用语法糖对被装饰对象进行装饰
def index():
    # 被装饰函数
    pass

本文正在参加「金石计划 . 瓜分6万现金大奖」