Python 很多内置的 API 都会接收一个函数作为参数,这些函数通常被称为钩子函数。通过回调钩子函数内的代码,即可定制 API 的行为。
比如,list
类型的 sort
方法接受可选的 key
参数,用以指定每个索引位置上的值之间应该如何排序:
>> students = ['alex', 'bob', 'jana.wang', 'mia.li']
>> students.sort(key=lambda x: len(x))
>> students
['bob', 'alex', 'mia.li', 'jana.wang']
在 Python 中对于上述这样简单的 API ,通常我们会直接给他传入函数,而不是先定义某个类,然后再传入该类的实例。因为以函数作为挂钩,可以很容易地描述出该挂钩的功能,而且比定义一个类要简单。
Python 中的函数和方法作为一等公民,可以像一级对象那样引用,因此可以直接放在表达式里面。
一. 无状态的钩子函数
在 Python 中,我们会发现很多挂钩都是无状态的函数,这些函数有明确的参数及返回值。比如下面定义的 log_missing
钩子会在字典里找不到带查询的键时打印一条提示语,并返回 0 作为该键所对应的值:
import collections
def log_missing():
print('key added')
return 0
current = {'a':1, 'b':2, 'c':3}
increments = [('a', 2), ('c', 3), ('d', 4), ('e', 5)]
result = collections.defaultdict(log_missing, current)
这里返回的 result
是 defaultdict
类的实例:
>> result
defaultdict(<function __main__.log_missing()>, {'a': 1, 'b': 2, 'c': 3})
这种数据结构允许调用者提供一个函数,以后再查询本字典时,如果里面没有待查询的键,那就用这个函数为该键创建新值:
>> dict(result)
{'a': 1, 'b': 2, 'c': 3}
>> for word, num in increments:
... result[word] += num
...
key added
key added
>> dict(result)
{'a': 3, 'b': 2, 'c': 6, 'd': 4, 'e': 5}
以函数作为参数的 API 不仅易于构建,也更易测试,因为它能把附带的效果与确定的行为分隔开。例如,现在要给 defaultdict
传入一个产生默认值的挂钩,并令其记录该字典一共遇到了多少个缺失的键。下面,我们就来实现上述需求。
二. 带状态的闭包
一种实现方式是使用带状态的闭包:
def increment_with_report(current, increments):
added_count = 0
def missing():
nonlocal added_count
added_count += 1
return 0
result = collections.defaultdict(missing, current)
for word, num in increments:
result[word] += num
return result, added_count
运行结果:
>> current = {'a':1, 'b':2, 'c':3}
>> increments = [('a', 2), ('c', 3), ('d', 4), ('e', 5)]
>> result, count = increment_with_report(current, increments)
>> dict(result)
{'a': 3, 'b': 2, 'c': 6, 'd': 4, 'e': 5}
>> count
2
尽管 defaultdict
并不知道 missing
挂钩里保存了状态,但是运行上面这个函数之后,依然会产生预期的结果,并且 missing
还实现了附带的效果 - 统计缺失值的个数。
这就是令接口接受简单函数的又一个好处,把状态隐藏到闭包里面,稍后我们就可以为闭包函数添加新的功能。
三. 定义实现 __call__
方法的类
带状态的闭包函数如果篇幅很长,很复杂就会非常晦涩难懂。这个时候,我们可以编写小型的类,把需要追踪的状态封装起来:
class ReportMissing:
def __init__(self):
self.added_count = 0
def missing(self):
self.added_count += 1
return 0
运行结果:
>> counter = ReportMissing()
>> current = {'a':1, 'b':2, 'c':3}
>> increments = [('a', 2), ('c', 3), ('d', 4), ('e', 5)]
>> result = collections.defaultdict(counter.missing, current)
>> dict(result)
{'a': 1, 'b': 2, 'c': 3}
>> for word, num in increments:
... result[word] += num
...
>> dict(result)
{'a': 3, 'b': 2, 'c': 6, 'd': 4, 'e': 5}
>> counter.added_count
2
使用上述的辅助类来改写带状态的闭包,仍然存在一个问题:单看 ReportMissing
这个类,我们仍然不太理解这个类的意图, ReportMissing
实例由谁来构建?missing
方法由谁来调用?该类以后是否需要添加新的公共方法?这些问题,都需要我们阅读完 defaultdict
调用部分的代码才能明白。
通过名为 __call__
的特殊方法,可以使类的实例能够像普通的 Python 函数那样得到调用。如果要用函数来保存状态,那就应该定义新的类,并令其实现 __call__
方法,而不是定义带状态的闭包。
下面,我们就来定义一个实现了 __call__
特殊方法的类:
class ReportMissing:
def __init__(self):
self.added_count = 0
def __call__(self):
self.added_count += 1
return 0
现在 ReportMissing
类的实例就变得可调用了:
>> counter = ReportMissing()
>> assert callable(counter)
>>
运行结果:
>> current = {'a':1, 'b':2, 'c':3}
>> increments = [('a', 2), ('c', 3), ('d', 4), ('e', 5)]
>> result = collections.defaultdict(counter, current)
>> dict(result)
{'a': 1, 'b': 2, 'c': 3}
>> for word, num in increments:
... result[word] += num
...
>> dict(result)
{'a': 3, 'b': 2, 'c': 6, 'd': 4, 'e': 5}
>> counter.added_count
2
现在 ReportMissing
类就变很清晰,__call__
方法表明该类的实例会像函数那样,在适当的时候充当某个 API 的参数,并强烈地暗示了该类的用途,它告诉我们,这个类的功能就相当于一个带有状态的闭包。