在Python中使用装饰器的技巧

本文深入探讨Python装饰器的使用技巧,包括最佳实践如类装饰器和wrapt模块的应用,以及常见错误如签名丢失和作用域问题的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

在Python当中使用装饰器的场景非常的多,比如说一些异常的捕捉日志的记录特定功能的复用权限验证等等。那么我们在这些场景下使用装饰器会遇到哪些坑呢?

装饰器(Decorator) 是 Python 里的一种特殊工具,它为我们提供了一种在函数外部修改函数的灵活能力。它有点像一顶画着独一无二 @ 符号的神奇帽子,只要将它戴在函数头顶上,就能悄无声息的改变函数本身的行为。

你可能已经和装饰器打过不少交道了。在做面向对象编程时,我们就经常会用到 @staticmethod@classmethod 两个内置装饰器。此外,如果你接触过 click 模块,就更不会对装饰器感到陌生。click 最为人所称道的参数定义接口 @click.option(...) 就是利用装饰器实现的。

除了用装饰器,我们也经常需要自己写一些装饰器。在这篇文章里,我将从 最佳实践常见错误 两个方面,来与你分享有关装饰器的一些小知识。

最佳实践

尝试用类来实现装饰器

绝大多数装饰器都是基于函数和 闭包 实现的,但这并非制造装饰器的唯一方式。事实上,Python 对某个对象是否能通过装饰器( @decorator)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象

# 使用callable可以检测某个对象是否可被调用  
def angel():
	pass

print(type(angel))  # 输出:function
print(callable(angel))  # 输出:True  

函数自然是“可被调用”的对象。但除了函数外,我们也可以让任何一个类(class)变得“可被调用”(callable)。办法很简单,只要自定义类的 __call__ 魔法方法即可

class Angel:
	def __call__(self):
		print('yao, yao, check it now! ---> by __call__.')

# 创建实例对象
angel = Angel()
# 判断这个对象是否可被调用
print(callable(angel))  # 输出:True
# 调用这个对象
angel()  # 输出:yao, yao, check it now! ---> by __call__.

基于这样的特性,我们可以很方便的使用类来实现装饰器。

下面这段代码,会定义一个名为 @delay(duration) 的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的 duration 秒。同时,我们也希望为用户提供无需等待马上执行的 eager_call 接口。

import time
import functools

class DelayFunc:
	def __init__(self, duration, func):
		self.duration = duration
		self.func = func
	
	def __call__(self, *args, **kwargs):
		print(f'Wait for {self.duration} seconds...')
		time.sleep(self.duration)
		return self.func(*args, **kwargs)
	
	def eager_call(self, *args, **kwargs):
		print('Call without delay.')
		return self.func(*args, **kwargs)

def delay(duration):
	'''
	装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行
	'''
	# 构造一个装饰器,在这里使用functools.partial帮助构造DelayFunc的实例,避免定义额外的函数
	return functools.partial(DelayFunc, duration)

# 使用装饰器完成一些任务  
@delay(duration=2)
def add(a, b):
	return a + b

# 延迟调用
add(1, 2)  # 结果先输出Wait for 2 seconds...,延迟2秒后再输出:3
# 紧急调用
add.eager_call(1, 2)  # 结果先输出Call without delay.,然后立即输出:3

@delay(duration) 就是一个基于类来实现的装饰器。当然,如果你非常熟悉 Python 里的函数和闭包,上面的 delay 装饰器其实也完全可以只用函数来实现。所以,为什么我们要用类来做这件事呢?

与纯函数相比,我觉得使用类实现的装饰器在特定场景下有几个优势:

  • 实现有状态的装饰器时,操作类属性比操作闭包内变量更符合直觉、不易出错;

  • 实现为函数扩充接口的装饰器时,使用类包装函数,比直接为函数对象追加属性更易于维护;

  • 更容易实现一个同时兼容装饰器与上下文管理器协议的对象(参考 unitest.mock.patch)。

使用wrapt模块编写更扁平的装饰器

在写装饰器的过程中,你有没有碰到过什么不爽的事情?不管你有没有,反正我有。我经常在写代码的时候,被下面两件事情搞得特别难受:

  1. 实现带参数的装饰器时,层层嵌套的函数代码特别难写、难读;

  2. 因为函数和类方法的不同,为前者写的装饰器经常没法直接套用在后者上。

比如,在下面的例子里,我实现了一个生成随机数并注入成为函数参数的装饰器:

import random

def number_provider(min_num, max_num):
	'''
	装饰器:随机生成一个在 [min_num, max_num] 范围的整数,追加为函数的第一个位置参数
	'''
	def wrapper(func):
		def decorated(*args, **kwargs):
			num =  random.randint(min_num, max_num)
			return func(num, *args, **kwargs)
		return decorated
	return wrapper

# 装饰器的使用  
@number_provider(1, 100)
def print_random_number(num):
	print(num)

# 调用函数,不用传入任何参数
print_random_number()  # 结果为1-100内的一个随机数,每次运行的结果都不一样

@number_provider 装饰器功能看上去很不错,但它有着我在前面提到的两个问题:嵌套层级深、无法在类方法上使用。如果直接用它去装饰类方法,会出现下面的情况:

class Rand_num:
	@number_provider(1, 100)
	def print_random_number(self, num):
		print(num)

# 调用
Rand_num().print_random_number()
# 结果输出为:<__main__.Rand_num object at 0x000002143E839198>  

Rand_num 类实例中的 print_random_number 方法将会输出类实例 self ,而不是我们期望的随机数 num

之所以会出现这个结果,是因为类方法(method)和函数(function)二者在工作机制上有着细微不同。如果要修复这个问题, number_provider 装饰器在修改类方法的位置参数时,必须要聪明的跳过藏在 *args 里面的类实例 self 变量,才能正确的将 num 作为第一个参数注入。

这时,就应该是 wrapt 模块闪亮登场的时候了。 wrapt 模块是一个专门帮助你编写装饰器的工具库。利用它,我们可以非常方便的改造 number_provider 装饰器,完美解决“嵌套层级深”和“无法通用”两个问题:

# 使用wrapt重新实现装饰器number_provider
import wrapt

def number_provider(min_num, max_num):
	@wrapt.decorator
	def wrapper(wrapped, instance, args, kwargs):
		# 参数含义:
		#			        
		# - wrapped:被装饰的函数或类方法  
		# - instance:
		#   - 如果被装饰者为普通类方法,该值为类实例
		#   - 如果被装饰者为 classmethod 类方法,该值为类
		#   - 如果被装饰者为类/函数/静态方法,该值为 None        
		#      
		# - args:调用时的位置参数(注意没有 * 符号)   
		# - kwargs:调用时的关键字参数(注意没有 ** 符号)  
		#
		num = random.randint(min_num, max_num)
		# 无需关注 wrapped 是类方法或普通函数,直接在头部追加参数
		args = (num, ) + args
		return wrapped(*args, **kwargs)
	return wrapper

# 再次调用我们上面的类方法
Rand_num().print_random_number()
# 结果输出为1-100之间的一个随机数,每次运行的结果都不一样

使用 wrapt 模块编写的装饰器,相比原来拥有下面这些优势:

  • 嵌套层级少:使用 @wrapt.decorator 可以将两层嵌套减少为一层;

  • 更简单:处理位置与关键字参数时,可以忽略类实例等特殊情况;

  • 更灵活:针对 instance 值进行条件判断后,更容易让装饰器变得通用。

常见错误

“装饰器"并不是"装饰器模式”

“设计模式”是一个在计算机世界里鼎鼎大名的词。假如你是一名 Java 程序员,而你一点设计模式都不懂,那么我打赌你在找工作的面试过程一定会度过的相当艰难。

但写 Python 时,我们极少谈起“设计模式”。虽然 Python 也是一门支持面向对象的编程语言,但它的 鸭子类型 设计以及出色的 动态特性 决定了大部分设计模式对我们来说并不是必需品。所以,很多 Python 程序员在工作很长一段时间后,可能并没有真正应用过几种设计模式。

不过 “装饰器模式(Decorator Pattern)” 是个例外。因为 Python 的“装饰器”和“装饰器模式”有着一模一样的名字,我不止一次听到有人把它们俩当成一回事,认为使用“装饰器”就是在实践“装饰器模式”。但事实上,它们是两个完全不同的东西

“装饰器模式”是一个完全基于“面向对象”衍生出的编程手法。它拥有几个关键组成:一个统一的接口定义、若干个遵循该接口的类、类与类之间一层一层的包装。最终由它们共同形成一种“装饰”的效果。

而 Python 里的“装饰器”和“面向对象”没有任何直接联系,它完全可以只是发生在函数和函数间的把戏。事实上,“装饰器”并没有提供某种无法替代的功能,它仅仅就是一颗“语法糖”而已。下面这段使用了装饰器的代码:

@log_time
@cache_result
def angel():
	pass

完全等同于下面的代码:

def angel():
	pass
	
log_time(cache_result(angel))

装饰器最大的功劳,在于让我们在某些特定场景时,可以写出更符合直觉、易于阅读的代码。它只是一颗“糖”,并不是某个面向对象领域的复杂编程模式。

Hint: 在 Python 官网上有一个实现装饰器模式的例子,你可读读这个例子来更好的了解它。

用functools.wraps()来装饰内层函数

下面是一个简单的装饰器,专门用来打印函数调用耗时:

def timer(wrapped):
	'''装饰器,记录并打印函数运行耗时'''
	def wrapper(*args, **kwargs):
		tim = time.time()
		ret = wrapped(*args, **kwargs)
		print('Execution takes {} seconds.'.format(time.time() - tim))
		return ret
	return wrapper

@timer
def for_break(n):
	'''休息一小会儿'''
	time.sleep(n)

# 调用
for_break(3)
# 结果输出为:Execution takes 3.000194787979126 seconds.  

timer 装饰器虽然没有错误,但是使用它装饰函数后,函数的原始签名就会被破坏。也就是说你再也没办法正确拿到 for_break 函数的名称、文档内容了,所有签名都会变成内层函数 wrapper 的值

print(for_break.__name__)
# 结果输出为:'wrapper' 
print(for_break.__doc__)
# 结果输出为:None  

这虽然只是个小问题,但在某些时候也可能会导致难以察觉的 bug。幸运的是,标准库 functools 为它提供了解决方案,你只需要在定义装饰器时,用另外一个装饰器再装饰一下内层 wrapper 函数就行。

def timer(wrapped):
	'''装饰器,记录并打印函数运行耗时'''
	# 将wrapped的签名赋值到wrapper上
	@functools.wraps(wrapped)
	def wrapper(*args, **kwargs):
		tim = time.time()
		ret = wrapped(*args, **kwargs)
		print('Execution takes {} seconds.'.format(time.time() - tim))
		return ret
	return wrapper

这样处理后, timer 装饰器就不会影响它所装饰的函数了:

print(for_break.__name__)
# 结果输出为: 'for_break'  
print(for_break.__doc__)
# 结果输出为:'休息一会儿'   
修改外层变量时使用nonlocal

装饰器是对函数对象的一个高级应用。在编写装饰器的过程中,你会经常碰到内层函数需要修改外层函数变量的情况。就像下面这个装饰器一样:

def counter(wrapped):
	'''记录并打印函数的调用次数'''
	count = 0
	@functools.wraps(wrapped)
	def wrapper(*args, **kwargs):
		count += 1
		print(f'Count: {count}')
		return wrapped(*args, **kwargs)
	return wrapper

@counter
def angel():
	pass

# 调用
angel()

为了统计函数调用次数,我们需要在 wrapper 函数内部修改外层函数定义的 count 变量的值。但是,上面这段代码是有问题的,在执行它时解释器会报错:

UnboundLocalError: local variable 'count' referenced before assignment  

这个错误是由 counterwrapper 函数互相嵌套的作用域引起的。

当解释器执行到 count+=1 时,并不知道 count 是一个在外层作用域定义的变量,它把 count 当做一个局部变量,并在当前作用域内查找。最终却没有找到有关 count 变量的任何定义,然后抛出错误。

为了解决这个问题,我们需要通过 nonlocal 关键字告诉解释器:“count 变量并不属于当前的 local 作用域,去外面找找吧”,之前的错误就可以得到解决。

def counter(wrapped):
	'''记录并打印函数的调用次数'''
	count = 0
	@functools.wraps(wrapped)
	def wrapper(*args, **kwargs):
		nonlocal count
		count += 1
		print(f'Count: {count}')
		return wrapped(*args, **kwargs)
	return wrapper  

我们再次调用被装饰函数,即可产生出我们当初设计的那种效果:

angel()  
# 结果输出为:Count: 1  

Hint:如果要了解更多有关 nonlocal 关键字的历史,可以查阅 PEP-3104

总结

在这篇文章里,我与你分享了有关装饰器的一些小技巧与知识。

要点总结:

  • 一切 callable 的对象都可以被用来实现装饰器;

  • 混合使用函数与类,可以更好的实现装饰器;

  • wrapt 模块很有用,用它可以帮助我们用更简单的代码写出复杂装饰器;

  • “装饰器”只是语法糖,它不是“装饰器模式”;

  • 装饰器会改变函数的原始签名,你需要 functools.wraps

  • 在内层函数修改外层函数的变量时,需要使用 nonlocal 关键字。

注:本文转自 Python工匠 系列文章,仅供学习交流使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值