可调用对象
可调用对象的概念
在 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__()
方法,并且调用时的实质是执行被装饰函数。
也就是说,这个装饰器需要实现以下几点:
- 包含
.__init__()
方法,并且该方法只有一个代表传入被装饰函数的参数
- 包含
.__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)
,本质是在调用该实例。调用过程中,被装饰函数作为参数传入,又将其返回结果作为装饰后的函数使用。
根据以上分析,带参数的装饰器实现要点有:
- 实现了
.__init__()
方法,用于接受装饰时的参数
- 实现了
.__call__()
方法,实质上就是一个普通的装饰器
以下实现了一个 @Retry
装饰器,它可以使得被装饰的函数在执行失败时自动重试。@Retry
装饰器接收两个参数 maxnum
和 interval
,分别指定重试的最大次数和重试的间隔:
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
模块提供的一个装饰类的装饰器,以及它是如何简化类的实现的。