30.5.2. 让简单的接口接受函数,而不是类的实例

Python有许多内置的API,都允许我们传入某个函数来定制它的行为。这种函数可以叫作挂钩(hook),API在执行过程中,会回调(call back)这些挂钩函数。

例如,list类型的sort方法就带有可选的key参数,如果明确指定了这个参数,那么它就会按照你提供的挂钩函数来决定列表中每个元素的先后顺序。

下面的代码把内置的len函数当成挂钩传给key参数,让sort方法根据长度排列这些名字。

names = ["Socrates", "Archimedes", "Plato", "Aristotle"]
names.sort(key=len)
print(names)
'''
['Plato', 'Socrates', 'Aristotle', 'Archimedes']
'''

在其他编程语言中,挂钩可能会用抽象类(abstract class)来定义。但在Python中,许多挂钩都是无状态的函数(stateless function),带有明确的参数与返回值。

挂钩用函数来描述,要比定义成类更简单。用作挂钩的函数与别的函数一样,都是Python里的头等(first-class)对象,也就是说,这些函数与方法可以像Python中其他值那样传递与引用。

例如,我们要定制defaultdict类的行为。

这种defaultdict数据结构允许调用者提供一个函数,用来在键名缺失的情况下,创建与这个键相对应的值。

只要字典发现调用者想要访问的键不存在,就会触发这个函数,以返回应该与键相关联的默认值。下面定义一个log_missing函数作为键名缺失时的挂钩,该函数总是会把这种键的默认值设为0。

#!/usr/bin/env python
# -*- coding:utf8 -*-

def log_missing():
    print("Key added")
    return 0


from collections import defaultdict

current = {'green': 12, 'blue': 3}
increments = [
    ('red', 5),
    ('blue', 17),
    ('orange', 9),
]

result = defaultdict(log_missing, current)
print("Before:", dict(result))

for key, amount in increments:
    # print(key, amount)
    result[key] += amount
print("After:", dict(result))

'''
Before: {'green': 12, 'blue': 3}
Key added
Key added
After: {'green': 12, 'blue': 20, 'red': 5, 'orange': 9}
'''

下面这段代码通过定制的defaultdict字典,把increments列表里面描述的增量添加到current这个普通字典所提供的初始量上面,但字典里一开始没有’red’和’orange’这两个键,因此log_missing这个挂钩函数会触发两次,每次它都会打印’Key added’信息。

from collections import defaultdict

current = {'green': 12, 'blue': 3}
increments = [
    ('red', 5),
    ('blue', 17),
    ('orange', 9),
]


def increment_with_report(current, increments):
    added_count = 0

    def missing():
        nonlocal added_count  # 有状态的闭包
        added_count += 1
        return 0

    result = defaultdict(missing, current)
    for key, amount in increments:
        result[key] += amount
    return result, added_count


result, count = increment_with_report(current, increments)
print(dict(result), count)
assert count == 2

运行这个辅助函数处理前面的数据,可以得到预期的结果。统计键名缺失次数所用的added_count状态是由missing挂钩维护的,这体现了把简单函数传给接口的另一好处,也就是方便稍后添加新的功能,因为我们可以把实现这项功能所用的状态隐藏在这个简单的闭包里面。

与无状态的闭包函数相比,用有状态的闭包作为挂钩写出来的代码会难懂一些。为了让代码更清晰,可以专门定义一个小类,把原本由闭包所维护的状态给封装起来。

class CountMissing:
    def __init__(self):
        self.added = 0

    def missing(self):
        self.added += 1
        return 0

在Python中,方法与函数都是头等的对象,因此可以直接通过对象引用它所属的CountMissing类里的missing方法,并把这个方法传给defaultdict充当挂钩,让字典可以用这个挂钩制作默认值。在Python中,这种通过对象实例而引用的方法,很容易就能通过参数传给API当挂钩函数使用。

class CountMissing:
    def __init__(self):
        self.added = 0

    def missing(self):
        self.added += 1
        return 0


counter = CountMissing()
result = defaultdict(counter.missing, current)
for key, amount in increments:
    result[key] += amount

print(dict(result), counter.added)
assert counter.added == 2

把有状态的闭包所具备的行为,改用辅助类来实现,要比前面的increment_with_report函数更清晰。但如果单看这个类,可能没办法立刻了解它的意图。CountMissing对象应该由谁构造?missing方法应该由谁调用?这个类将来还会不会再增加public方法?这些疑惑都必须在看到defaultdict的用法之后才能解开。

为了让这个类的意义更加明确,可以给它定义名为__call__的特殊方法。这会让这个类的对象能够像函数那样得到调用。同时,也让内置的callable函数能够针对这种实例返回True值,用以表示这个实例与普通的函数或方法类似,都是可调用的。凡是能够像这样(在后面加一对括号来)执行的对象,都叫作callable。

class BetterCountMissing:
    def __init__(self):
        self.added = 0

    def __call__(self, *args, **kwargs):
        self.added += 1
        return 0


counter = BetterCountMissing()
result = defaultdict(counter, current)  # 依赖于__call__
for key, amount in increments:
    result[key] += amount

print(dict(result), counter.added)
assert counter.added == 2

上面这段代码要比CountMissing更清晰,因为它里面有__call__方法,这说明这个类的实例可像普通的函数那样使用(例如可以传给API当挂钩)。即便是初次看到这段代码,也能明白这个类的主要目标。因为你应该会注意到那个比较显眼的__call__方法。它强烈暗示着这个类可以像有状态的闭包那样使用。总之,最大的优势在于,defaultdict仍然不需要关注__call__方法触发之后究竟会做什么。它只知道自己可以用这样一个挂钩,来给缺失的键制作默认值。Python很容易就能设计这种把挂钩函数当参数来用的接口,面对这种接口,调用者可以采用最适合自己的,把符合接口要求的东西传进去。

要点:

  • 如果想设计简单的Python接口,让组件之间能够通过接口交互,那么可以考虑让接口接受挂钩函数,而不一定非得定义新类,并要求使用者传入这种类的实例。

  • Python的函数与方法都是头等对象,这意味着它们可以像其他类型那样,用在表达式里。某个类如果定义了__call__特殊方法,那么它的实例就可以像普通的Python函数那样调用。如果想用函数来维护状态,那么可以考虑定义一个带有__call__方法的新类,而不要用有状态的闭包去实现。