Python面向对象编程07-可调用对象与装饰器

可调用对象

可调用对象的概念

在 Python 中,将表达式 ( ) 应用于一个对象称为调用(call)对象。例如,函数是一个可调用对象,它可以被一对括号调用:

>>> def func(): ... return 'hello' >>> func <function func at 0x000001C56E18BBF8> >>> func() 'hello'

但 Python 中的可调用对象不只有函数。一个 lambda 表达式也是一个可调用对象,它也可以被调用:

>>> add = lambda x, y: x + y >>> add <function <lambda> at 0x0000020D959D2E18> >>> add(2, 3) 5

除此之外,一个类也能被调用,类调用的结果是返回一个构造的实例:

>>> class Type: ... ... >>> Type() <__main__.Type object at 0x0000015BFE72D400>

之所以它们能够被调用,这是因为它们实现了 .__call__() 方法。实现了 .__call__() 方法的对象是可调用对象,对该对象应用 () 表达式等价于应用对象的 .__call__() 方法。

该方法是一个实例方法,除了第一个参数 self 表示被调用的对象之外,其余参数都代表需要调用实例时需要传入的参数。也就是说,考虑以下类:

class TestCall:
    def __call__(self, *args):
        print(f'method called with arguments {args}')

那么,可以通过以下语句实例化该类,并使用圆括号调用该实例:

>>> test = TestCall() >>> test(1, 'a', [10]) method called with arguments (1, 'a', [10])

可调用对象相比直接定义函数,更适合处理一些有状态的调用行为,即使用两组相同参数的调用可能会得到不同的结果。

介绍闭包时曾经编写了一个求累积平均值的函数,历史结果将会影响新传入值的累积平均值。然而使用闭包编写的函数,需要通过局部变量和 nonlocal 声明来保存每一组数据的历史信息。这种情况就很适合使用可调用对象实现,可以采用实例属性代替局部变量保存信息:

class CumulativeAverage:
    def __init__(self):
        self.history_count = 0
        self.history_sum = 0
    def __call__(self, *new_data):
        self.history_count += len(new_data)
        self.history_sum += sum(new_data)
        return self.history_sum / self.history_count

判断可调用对象

Python 有一个内置函数 callable() ,用于判断一个对象是否是可调用对象:如果是,则返回 True ,否则返回 False

>>> callable('abc'.upper) True >>> callable([1, 2]) False >>> callable(set) True

该函数的本质就是判断对象中是否含有 .__call__() 方法。例如,可以使用该函数,查看内置的所有函数和类(它们都是可调用对象):

>>> [name for name, value in __builtins__.__dict__.items() if callable(value)] ['__loader__', '__build_class__', '__import__', 'abs', 'all', 'any', 'ascii', 'bin', 'callable', ..., 'staticmethod', 'str', 'super', 'tuple', 'type', 'zip', 'BaseException', 'Exception', 'TypeError', ...'TimeoutError', 'open', 'quit', 'exit', 'copyright', 'credits', 'license', 'help']

结果有点多,因为异常相关的类就占了一大半。

类与装饰器

类作为装饰器

既然可调用对象可以处理闭包的问题,就不能不提起装饰器。回顾一下,一个装饰器的结构大致如下所示:

def decorator(func):
    def wrapper(*args, **kwargs):
        do_something(func, *args, **kwargs)
        return func(*args, **kwargs)
    return wrapper

@decorator 作用于 func 函数的定义时,它实际上是在执行 func = decorator(func)decorator 函数作为一个装饰器,它接受一个函数 func 作为输入,将其包装为一个新的函数 wrapper 返回。当 func 函数被调用时,实际上是调用了 wrapper 函数:在使用者看来,wrapper 函数与原始的 func 函数的参数和返回值用法都一致,但 wrapper 函数内部还执行了一些额外的操作,就像原来的函数被“包装”了一样。

既然装饰器只影响函数的调用行为,而函数、类和自定义的对象都可以是可调用对象,那么类应该也可以作为装饰器。下面从调用角度分析一下将类作为装饰器会是什么结果:

假设 Decorator 是一个类,首先调用装饰器 @Decorate 的实质是调用 func = Decorator(func)Decorator(func) 是实例化一个类,因此 func 经过装饰后,变成了一个类的实例。而 func 装饰后不能改变它的调用行为,获得的 Decorator() 实例也必须可以被调用才行。因此,Decorator 对象还应该是一个可调用对象,需要实现 .__call__() 方法,并且调用时的实质是执行被装饰函数。

也就是说,这个装饰器需要实现以下几点:

  1. 包含 .__init__() 方法,并且该方法只有一个代表传入被装饰函数的参数
  2. 包含 .__call__() 方法,并且该方法的实质就是在执行被装饰函数

以下通过类实现了一个装饰器,用于追踪函数的调用情况:

class Trace:
    def __init__(self, func):
        self.func = func
    def __call__(self, *args, **kwargs):
        result = None
        try:
            result = self.func(*args, **kwargs)
        except Exception as e:
            result = e
        print(f'{self.func.__name__}{args, kwargs!r} -> {result!r}')
        return result

以下是一个使用示例:

@Trace
def calc(x, y, z):
    return x + y / z

calc(1, 3, 2)
calc(1, z=0, y=3)
$ python decorator.py calc((1, 3, 2), {}) -> 2.5 calc((1,), {'z': 0, 'y': 3}) -> ZeroDivisionError('division by zero')

通过类实现的装饰器相比通过函数实现的装饰器,同样更适合处理一些具有状态的装饰任务,这个时候就不用处理变量的作用域问题。除此之外,因为类作为装饰器会将函数变为一个可调用对象,可以为该对象编写其它方法提供更复杂的功能。例如,内置的 property 其实就是一个类,所以被装饰的对象就可以使用 .setter.deleter 接口处理该属性的写与删除的逻辑。

以下通过类实现了一个简单的缓存装饰器,它可以将相同参数下的第一次运行结果保存起来,随后每一次调用被装饰函数时都返回相同结果,避免重复计算的开销:

class Cache:
    def __init__(self, func):
        self.func = func
        self.cache = {}
    def __call__(self, *args, **kwargs):
        try:
            params = (args, frozenset(kwargs.items()))
            return self.cache[params]
        except KeyError:
            result = self.func(*args, **kwargs)
            self.cache[params] = result
            return result
        except TypeError:  # unhashable params
            return self.func(*args, **kwargs)

下面给出了一个使用示例,读者可以自行运行该代码并观察输出变化:

from time import sleep

@Cache
def huge_add(a, b):
    sleep(2)
    return a + b

huge_add(1, 2)  # wait for a moment and print result
huge_add(1, 2)  # immediately print result
huge_add(a='hello', b=' world')  # wait for a moment and print result
huge_add(b=' world', a='hello')  # immediately print result
huge_add('hello', b=' world')  # wait for a moment and print result
huge_add([1, 2], [2, 3])  # wait for a moment and print result
huge_add([1, 2], [2, 3])  # wait for a moment and print result

如果将函数对象也看作一个类,那么实现一个自己的类作为装饰器可扩展性便更强,可以在一个空白的类模板上实现自己的处理逻辑。

不过以上实现的装饰器还有一个小问题:使用类实现的装饰器会将函数变为一个类的实例,这意味着它们的性质变得完全不同了;如果给被装饰的函数添加文档:

@Cache
def huge_add(a, b):
    """Perform complex addition operations"""
    ...

如果在控制台检查该函数的文档,会发现经过装饰后,函数的文档被装饰器类的文档覆盖了;不仅如此,函数的变成其它实例后,__name__ 等属性也都丢失了:

>>> help(huge_add) Help on Cache in module __main__ object: class Cache(builtins.object) | Cache(func) | | Methods defined here: ... >>> huge_add.__name__ Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'Cache' object has no attribute '__name__'

对于函数形式的装饰器,处理方法是使用 functools 模块提供的 @wraps 装饰器将原函数的信息迁移到被装饰函数上;但通过类实现的装饰器却无法这样处理,因为 wraps 是用来处理函数。

实际上,functools 模块还提供了另一个函数 update_wrapper(wrapper, wrapped) ,它可以将 wrapped 函数的信息更新到 wrapper 对象上。装饰器类就可以在初始化方法内这样更新自身的信息:

from functools import update_wrapper

class Cache:
    def __init__(self, func):
        self.func = func
        update_wrapper(self, self.func)
        ...

现在装饰后的对象就具有 .__name__ 属性,也可以正确表示它的帮助信息了:

>>> huge_add.__name__ 'huge_add' >>> help(huge_add) Help on Cache in module __main__: huge_add = <__main__.Cache object> Perform complex addition operations

实际上,@wraps 装饰器就是 update_wrapper() 函数的一个包装版本。但即便如此,update_wrapper 也只是更新了一些基本信息,并不能使装饰结果和被装饰函数完全一样。例如,装饰过的函数不再能使用 FunctionType 做类型判断了。

带参数的装饰器类

装饰器经常会带参数。在使用函数处理带参数的装饰器时,需要通过三层嵌套函数完成装饰,其中最外层的函数负责处理参数。接下来从实现角度分析带参数的装饰器类应该如何实现。

首先,带参数的装饰器 @Decorator(...) 装饰实质是 func = Decorator(...)(func) 。由于调用运算符自左向右结合,首先 Decorator(...) 生成了一个实例 deco ,装饰变成了 deco(func) ,本质是在调用该实例。调用过程中,被装饰函数作为参数传入,又将其返回结果作为装饰后的函数使用。

根据以上分析,带参数的装饰器实现要点有:

  1. 实现了 .__init__() 方法,用于接受装饰时的参数
  2. 实现了 .__call__() 方法,实质上就是一个普通的装饰器

以下实现了一个 @Retry 装饰器,它可以使得被装饰的函数在执行失败时自动重试。@Retry 装饰器接收两个参数 maxnuminterval ,分别指定重试的最大次数和重试的间隔:

class Retry:
    def __init__(self, maxnum=5, interval=1.0):
        self.maxnum = maxnum
        self.interval = interval
    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            retries = 0
            while retries < self.maxnum:
                try:
                    result = func(*args, **kwargs)
                    return result
                except Exception as e:
                    retries += 1
                    print(f"Exception caught: {e}. Retrying ({retries}/{self.maxnum})...")
                    sleep(self.interval)
            raise RuntimeError(f"Failed after {self.maxnum} retries")
        return wrapper

以下是该装饰器的一个使用示例:

from random import random

@Retry(maxnum=3, interval=0.5)
def send_message():
    if random() < 0.8:
        raise ValueError("Network failure")
    return "Success"

status = send_message()
print("Status:", status)
$ python decorator.py Exception caught: Network failure. Retrying (1/3)... Exception caught: Network failure. Retrying (2/3)... Status: Success

装饰类的装饰器

实际上,Python 中的装饰器不仅可以装饰函数,还可以装饰一个类。也就是说,装饰器还可以这样使用:

@decorator
class SomeType:
    ...

装饰类的装饰器和装饰函数的装饰器本质是一样的:假设 decorator 是一个装饰器,那么装饰该类的实质也是 Type = decorator(Type) 。这也就意味着如果使用函数作为装饰器,则类在被装饰完成之后变成一个普通的函数了。但是在调用装饰完成的函数时,它返回的结果实际上是这个类构造的实例。换句话说,函数作为装饰器,实际上是装饰了类的构造方法。

因此,装饰类的装饰器处理方式有一些不同:装饰类的装饰器一般用于处理这个类属性和方法相关的问题(例如为该类添加上一些规范的方法,或者为某些特定方法做装饰);并且它在处理完后直接返回该类,避免改变这个类的基本属性。

以下实现了一个 @trace 装饰器,它会为该类的每个公有方法添加上追踪信息:

def trace(cls):
    def traced_func(func):
        if hasattr(func, 'tracing'):  # Only decorate once
            return func
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            try:
                result = func(*args, **kwargs)
            except Exception as e:
                result = e
            print(f'{func.__name__}{args, kwargs!r} -> {result!r}')
            return result
        setattr(wrapper, 'tracing', True)
        return wrapper
    for attr_name in dir(cls):
        attr = getattr(cls, attr_name)
        if not attr_name.startswith('_') and callable(attr):
            setattr(cls, attr_name, traced_func(attr))
    return cls

实际上这个函数可以看作一个嵌套的装饰器:它本身用于装饰一个类,而它内部的函数用于装饰方法。从以上示例也可以看出类和函数被装饰的特点:因为不能直接修改函数的代码,所以装饰函数的装饰器只能重新提供一个函数提供额外的代码;而类的属性和方法本身就可以访问或修改,所以装饰类的装饰器就可以直接改动类,而无需定义一个新的类。

一个更复杂的情况涉及到装饰类的装饰器还带有参数的时候。在有了这么多装饰器的处理经验后,处理这种问题就很简单了。例如,如果要给以上实现的装饰器加一个参数指定结果输出到哪个文件中,那么装饰器的实现只要再包裹一层函数接收这个参数即可:

def trace_to(fp):
    def trace(cls):
        def traced_func(func):
            ...
            def wrapper(*args, **kwargs):
                ...
                fp.write(f'{func.__name__}{args, kwargs!r} -> {result!r}\n')
            return wrapper
        ...
        return cls
    return trace

这个装饰器可以说是目前看到的最复杂的函数了,它是一个整整嵌套了四层的嵌套函数,并且第一层函数的参数直到第四层才被用到。在定义了一个如此复杂装饰器的同时,这个装饰器也非常强大,它可以在不重写方法的同时,批量为类的方法添加上额外的功能:

log_file = open('trace.log', 'a', encoding='utf8')

@trace_to(log_file)
class TracedList(list):
    pass

ls = TracedList(range(5))
ls.append(10)
ls.extend(ls.copy())
ls.pop()
ls.sort(key=lambda e: abs(e-5), reverse=True)
$ python decorator.py $ cat trace.log append(([0, 1, 2, 3, 4, 10], 10), {}) -> None copy(([0, 1, 2, 3, 4, 10],), {}) -> [0, 1, 2, 3, 4, 10] extend(([0, 1, 2, 3, 4, 10, 0, 1, 2, 3, 4, 10], [0, 1, 2, 3, 4, 10]), {}) -> None pop(([0, 1, 2, 3, 4, 10, 0, 1, 2, 3, 4],), {}) -> 10 sort(([0, 10, 0, 1, 1, 2, 2, 3, 3, 4, 4],), {'key': <function <lambda> at 0x0000015FCBCAB8B0>, 'reverse': True}) -> None

一般情况下不用定义如此复杂的装饰器,直接对函数使用装饰器也会让代码的可读性更高。不过在下一节中,会看到 functools 模块提供的一个装饰类的装饰器,以及它是如何简化类的实现的。

京ICP备2021034974号
contact me by hello@frozencandles.fun