Python 类 – 以函数作为参数的 API

时间:2021-7-4 作者:qvyue

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)

这里返回的 resultdefaultdict 类的实例:

>> result
defaultdict(, {'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 的参数,并强烈地暗示了该类的用途,它告诉我们,这个类的功能就相当于一个带有状态的闭包。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。