利用元类优化装饰器接口的方案(Python)

目录

一. 经典方案的局限性

二. 预备元类方案

1. 元类方案的可行性

2. 函数的__kwdefaults__属性

 三. 使用元类

四. 完整代码清单


我突然想到这个问题,是因为在研究基于类的一类装饰器时,觉得它们的接口有些不自由。

像下面这样基于实例替换原理的装饰器:

from functools import update_wrapper
import time

class Delay:
    def __init__(self, func, *, times: int=1) -> None:
        self.func = func
        self.times = times
        update_wrapper(self, func)
    def __call__(self, *args, **kwargs):
        print(f'休息{self.times}秒先')
        time.sleep(self.times)
        return self.func(*args, **kwargs)

它的作用是在调用函数之前停止几秒,比如:

@Delay
def hi(name: str) -> None:
    print(f'你好呀,{name}!')

hi('奶龙')

但是,这样的调用方式只能使用默认值。(๑•́ ₃ •̀๑)

我在想,要是它能够像下面这样支持3种调用方式该多好啊: 

@Delay
def hi(name: str) -> None:
    print(f'你好呀,{name}!')
@Delay(times=2)
def hello(name: str) -> None:
    print(f'你好呀,{name}!')
@Delay()
def nihao(name: str) -> None:
    print(f'你好呀,{name}!')

hi('奶龙')
hello('贝利亚')
nihao('毒液')

一. 经典方案的局限性

对这类装饰器实现可选参数,比较经典和简单的方案是使用partial:

from functools import update_wrapper, partial
import time

class Delay:
    def __init__(self, func, *, times: int=1) -> None:
        self.func = func
        self.times = times
        update_wrapper(self, func)
    def __call__(self, *args, **kwargs):
        print(f'休息{self.times}秒先')
        time.sleep(self.times)
        return self.func(*args, **kwargs)
    
def delay(*, times: int=1): #(1)
    return partial(Delay, times=times)

@delay(times=2) #(2)
def hi(name: str) -> None:
    print(f'你好呀,{name}!')

hi('奶龙')

(1):新定义的delay把接收到的times先传给Delay类,返回一个偏类。 

(2):在需要传入参数时,使用偏类工厂函数delay而不是Delay。

这样做虽然简单又好用,但是满足不了我“3种接口一次满足”的愿望——毕竟都换人了(。ŏ_ŏ)

而且,装饰器有几个可选关键字参数,这个偏类工厂函数就必须也定义几个参数,扩展性不够强。

二. 预备元类方案

1. 元类方案的可行性

我分析了自己的需求,直接使用@Delay其实是在使用

func = Delay(func)。

而如果我想让@Delay(times=2)这种写法成功,就是想让

func = Delay(times=2)(func)

这种调用合法。

那么,就必须让Delay(times=2)这种本该创建实例的语句改为创建类,且让类中__init__方法的times参数得到相应修改。但是,像Delay(func)这样的调用方式仍然需要创建实例,这就显得很复杂。

想要这么大幅度地干涉实例创建,我感觉这一任务非元类莫属了——重写元类的__call__方法,让它干涉实例的创建过程。判断装饰器的使用方式,并决定是应该创建实例还是创建新类。

在看这个元类的实现方法之前,我们先说一说预备知识。

2. 函数的__kwdefaults__属性

函数的仅限关键字参数默认值存储在它的属性__kwdefaults__中,这个属性是一个字典:

def f(*, x=1, y=2):pass

print(f.__kwdefaults__)

 如果我想让元类根据传入的值修改__init__方法的参数,就需要操作Delay.__init__.__kwdefaults__属性。

 三. 使用元类

下面我们先看效果,通过指定这个元类,可以让装饰器实现3种接口一次满足,无论它有几个可选关键字参数:

# 使用元类MetaDecorator
class Delay(metaclass=MetaDecorator):
    def __init__(self, func, *, times: int=1) -> None:
        self.func = func
        self.times = times
        update_wrapper(self, func)
    def __call__(self, *args, **kwargs):
        print(f'休息{self.times}秒先')
        time.sleep(self.times)
        return self.func(*args, **kwargs)

# 方式一:不带括号,使用默认值
@Delay
def hi(name: str) -> None:
    print(f'你好呀,{name}!')
# 方式二:传入参数
@Delay(times=2)
def hello(name: str) -> None:
    print(f'你好呀,{name}!')
# 方式三:带括号,但是使用默认值
@Delay()
def nihao(name: str) -> None:
    print(f'你好呀,{name}!')

hi('奶龙')
hello('贝利亚')
nihao('毒液')

现在我们看元类的代码:

class MetaDecorator(type):
    """
    一个元类,用在实例替换型装饰器上时,\n
    可以让它灵活地支持带参数和不带参数两种使用方式。\n
    装饰器的其它参数必须全是可选关键字参数。
    """
    def __call__(cls, func=None, **kwargs): #(1)
        if func is not None: #(2)
            return super().__call__(func, **kwargs)
        else: #(3)
            class NewDecorator(cls): #(4)
                def __init__(self, func):
                    merged_kwargs = {
                        **getattr(cls.__init__, '__kwdefaults__', {}), 
                        **kwargs
                    } #(5)
                    super().__init__(func, **merged_kwargs) #(6)
            return NewDecorator 

(1):在元类中重写__call__方法,这个方法干涉实例的创建。第一个参数是元类的实例——类cls;其次有一个位置参数留给func,默认值是None;然后,接受任意关键字参数,绑定像times=2这样的所有传入值。

(2):如果func不是None这个默认值,说明在创建实例时传入了函数。也就是有func = Delay(func)这样的操作,这说明装饰器是像@Delay这样用的。这时直接委托超类type的__call__方法创建实例并返回。

(3):否则,说明是在采用@Delay(times=2)这样的用法,应该让Delay(times=2)返回一个参数被修改过后的新类。

(4):创建一个新类NewDecorator,继承cls,并改写它的__init__方法来更改参数。

(5):这个“融合字典”将cls.__init__.__kwdefaults__和传入的参数kwargs融合,如果有相同的键,后者的值会覆盖前者的值,达到修改__init__的参数的目的。

(6):委托超类cls的__init__方法,传入的关键字参数来自“融合字典”。最后,返回这个动态创建的类。

有个小细节需要注意:我选择动态创建新类而不是直接修改cls的属性,因为这种属性修改是永久的。假设我们使用过@Delay(times=2),那么从此每当使用@Delay,times的默认值就会变成2,而不是最初的1。为了避免这种情况,我选择动态创建一个新类。

好了,本文的讨论到此为止。

最后呢,放一下完整的代码清单吧。本人实力不强,如果你检查出这个元类有Bug,请不吝赐教!这样鄙人也能学到新东西(* ̄▽ ̄)~*

四. 完整代码清单

from functools import update_wrapper
import time

class MetaDecorator(type):
    """
    一个元类,用在实例替换型装饰器上时,\n
    可以让它灵活地支持带参数和不带参数两种使用方式。\n
    装饰器的其它参数必须全是可选关键字参数。
    """
    def __call__(cls, func=None, **kwargs):
        # 若func不是None,说明是@Decorator的使用方式
        if func is not None:
            return super().__call__(func, **kwargs)
        # 否则是@Decorator(某些关键字参数)的使用方式
        else:
            class NewDecorator(cls):
                def __init__(self, func):
                    merged_kwargs = {
                        **getattr(cls.__init__, '__kwdefaults__', {}), 
                        **kwargs
                    }
                    super().__init__(func, **merged_kwargs)
            return NewDecorator
class Delay(metaclass=MetaDecorator):
    def __init__(self, func, *, times: int=1) -> None:
        self.func = func
        self.times = times
        update_wrapper(self, func)
    def __call__(self, *args, **kwargs):
        print(f'休息{self.times}秒先')
        time.sleep(self.times)
        return self.func(*args, **kwargs)

@Delay
def hi(name: str) -> None:
    print(f'你好呀,{name}!')
@Delay(times=2)
def hello(name: str) -> None:
    print(f'你好呀,{name}!')
@Delay()
def nihao(name: str) -> None:
    print(f'你好呀,{name}!')

hi('奶龙')
hello('贝利亚')
nihao('毒液')

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值