让Python中的委托工作起来

使用 delegates 装饰器和 GetAttr 修复委托问题
作者

Jeremy Howard

发布日期

2019年8月6日

委托问题

让我们来看看所有程序员都面临过的一个问题;我称之为委托问题。为了解释清楚,我将使用一个例子。下面是一个你在内容管理系统中可能看到的类示例
class WebPage:
    def __init__(self, title, category="General", date=None, author="Jeremy"):
        self.title,self.category,self.author = title,category,author
        self.date = date or datetime.now()
        ...
然后,你想要为特定类型的页面添加一个子类,比如产品页面。它应该包含 WebPage 的所有细节,再加上一些额外内容。一种方法是使用继承,像这样
class ProductPage(WebPage):
    def __init__(self, title, price, cost, category="General", date=None, author="Jeremy"):
        super().__init__(title, category=category, date=date, author=author)
        ...

但现在我们违反了不要重复自己(DRY)的原则。我们重复了参数名列表和默认值。所以之后,我们可能决定将默认作者改为“Rachel”,于是我们在 WebPage.__init__ 中修改了定义。但我们忘记在 ProductPage 中做同样的事情,结果就出现了 bug 🐛!(在编写 fastai 深度学习库时,我曾多次以这种方式制造 bug,有时它们非常难以追踪,因为深度学习超参数的差异可能导致非常微妙且难以测试或检测的问题。)

为了避免这种情况,也许我们可以改写成这样
class ProductPage(WebPage):
    def __init__(self, title, price, cost, **kwargs):
        super().__init__(title, **kwargs)
        ...

这种方法的关键是使用 **kwargs。在Python中,参数列表中的 **kwargs 意味着“将所有额外的关键字参数放入一个名为 kwarg 的字典中”。而参数调用列表中的 **kwargs 意味着“将 kwargs 字典中的所有键/值对作为命名参数插入到这里”。这种方法在许多流行的库中都有使用,例如 matplotlib,其中主要的 plot 函数的签名就是 plot(*args, **kwargs)plot 文档写道:“kwargs 是 Line2D 的属性”,然后列出了这些属性。

不只是 Python 使用这种方法。例如,在R 语言中,等同于 **args 的简单写法是 ...(一个省略号)。R 文档解释道:“另一个常见需求是允许一个函数将其参数设置传递给另一个函数。例如,许多图形函数使用 par() 函数,而像 plot() 这样的函数允许用户将图形参数传递给 par() 来控制图形输出。这可以通过在函数中包含一个额外的参数,即字面上的 ‘…’ 来实现,这个参数随后可以被传递下去。

有关在 Python 中使用 **kwargs 的更多详细信息,你可以在 Google 上找到许多不错的教程,比如这个**kwargs 解决方案看起来工作得相当好
p = ProductPage('Soap', 15.0, 10.50, category='Bathroom', author="Sylvain")
p.author
'Sylvain'

然而,这使得我们的 API 变得很难用,因为现在我们用来编辑 Python 代码的环境(本文中的示例假设我们使用 Jupyter Notebook)不知道有哪些参数可用,所以像参数名的 Tab 补全和签名弹出列表之类的功能将无法工作 😢。此外,如果我们使用自动工具生成 API 文档(例如 fastai 的 show_docSphinx),我们的文档将不会包含完整的参数列表,我们需要手动添加关于这些委托参数的信息(即本例中的 categorydateauthor)。事实上,我们在 matplotlib 的 plot 文档中已经看到了这一点。

另一个替代方案是避免继承,转而使用组合,像这样
class ProductPage:
    def __init__(self, page, price, cost):
        self.page,self.price,self.cost = page,price,cost
        ...

p = ProductPage(WebPage('Soap', category='Bathroom', author="Sylvain"), 15.0, 10.50)
p.page.author
'Sylvain'

然而,这带来了一个新问题,那就是最基本的属性现在隐藏在 p.page 下面,这对类的使用者来说不是一个好的体验(而且与我们的继承版本相比,构造函数现在也显得相当笨拙)。

使用委托继承解决问题

我最近想到的解决方案是创建一个装饰器,像这样使用
@delegates()
class ProductPage(WebPage):
    def __init__(self, title, price, cost, **kwargs):
        super().__init__(title, **kwargs)
        ...
……这看起来与我们之前使用 kwargs 的继承版本完全一样,但有一个关键区别
print(inspect.signature(ProductPage))
(title, price, cost, category='General', date=None, author='Jeremy')

事实证明,我称之为委托继承的这种方法解决了我们所有的问题;在 Jupyter 中,如果我在实例化 ProductPage 时按下标准的“显示参数”键 Shift-Tab,我可以看到完整的参数列表,包括来自 WebPage 的参数。按下 Tab 键会显示一个包含 WebPage 参数的补全列表。此外,文档工具也能看到完整、正确的签名,包括 WebPage 的参数详情。

要装饰委托函数而不是类 __init__,我们使用几乎相同的语法。唯一的区别是,我们现在需要传递我们要委托给的函数
def create_web_page(title, category="General", date=None, author="Jeremy"):
    ...

@delegates(create_web_page)
def create_product_page(title, price, cost, **kwargs):
    ...

print(inspect.signature(create_product_page))
(title, price, cost, category='General', date=None, author='Jeremy')

我真的无法夸大这个小小的装饰器对我的编程实践有多么重要。在 fastai 的早期版本中,我们经常使用 kwargs 进行委托,因为我们想确保代码尽可能简单易写(否则我容易犯很多错误!)。我们不仅用它将 __init__ 委托给父类,也用于标准函数,类似于 matplotlib 的 plot 函数中的用法。然而,随着 fastai 越来越受欢迎,我听到了越来越多类似的反馈:“我喜欢 fastai 的一切,除了讨厌处理 kwargs!” 我完全理解他们的感受;事实上,处理 R API 中的 ... 和 Python API 中的 kwargs 对我来说也是一个常见痛点。但结果我却把这种痛苦施加给了我的用户!😯

当然,我不是第一个处理这个问题的人。Python 中关键字参数的使用与滥用是一篇深思熟虑的文章,其结论是:“所以这是可读性与可扩展性的权衡。我倾向于支持可读性胜过可扩展性,我在这里也会这么做:看在你信奉的任何神灵的份上,请谨慎使用 **kwargs 并在使用时做好文档记录。” 这也是我们在 fastai 中最终做的事情。去年,Sylvain 花了一个愉快的 (!) 下午,尽可能地移除了所有的 kwargs,并将其替换为显式的参数列表。当然,现在偶尔也会因为我们中的某个人忘记更新所有函数中的参数默认值而产生 bug……

但现在这些都已成为过去。我们可以再次使用 **kwargs,并且借助于 DRY 原则,我们的代码更简单、更可靠,同时也为开发者带来了极好的体验。🎈 delegates() 的基本功能代码量很少(源代码在文章底部)。

使用委托组合解决问题

作为另一种解决方案,我们再来看看组合方法
class ProductPage:
    def __init__(self, page, price, cost): self.page,self.price,self.cost = page,price,cost

page = WebPage('Soap', category='Bathroom', author="Sylvain")
p = ProductPage(page, 15.0, 10.50)
p.page.author
'Sylvain'
我们如何才能直接写 p.author,而不是 p.page.author 呢?事实证明,Python 对此有一个很好的解决方案:只需重写 __getattr__,它会在请求未知属性时自动调用
class ProductPage:
    def __init__(self, page, price, cost): self.page,self.price,self.cost = page,price,cost
    def __getattr__(self, k): return getattr(self.page,k)

p = ProductPage(page, 15.0, 10.50)
p.author
'Sylvain'

这是一个好的开始。但我们还有几个问题。首先是,我们又失去了 Tab 补全功能…… 但我们可以修复它!Python 会调用 __dir__ 来确定对象提供了哪些属性,因此我们可以重写它,同时列出 self.page 中的属性。

第二个问题是,我们通常想要控制哪些属性被转发到组合对象。转发所有东西可能会导致意想不到的 bug。因此,我们应该考虑提供一个转发属性的列表,并在 __getattr____dir__ 中使用它。

我创建了一个简单的基类 GetAttr,它解决了这两个问题。你只需将对象中的 default 属性设置为你想委托到的属性,其他一切都是自动的!你还可以选择性地将 _xtra 属性设置为一个字符串列表,其中包含你希望转发的属性名称(它默认为 default 中的所有属性,除了那些名称以下划线开头的)。
class ProductPage(GetAttr):
    def __init__(self, page, price, cost):
        self.page,self.price,self.cost = page,price,cost
        self.default = page

p = ProductPage(page, 15.0, 10.50)
p.author
'Sylvain'
下面是你在 Tab 补全中会看到的属性
[o for o in dir(p) if not o.startswith('_')]
['author', 'category', 'cost', 'date', 'default', 'page', 'price', 'title']

所以现在我们有两种非常好的处理委托的方式;选择哪种取决于你正在解决的问题的具体情况。如果你将在几个不同的地方使用组合对象,那么组合方法可能是最好的。如果你是为现有类添加一些功能,委托继承可能会为你的类使用者带来更清晰的 API。

请参阅本文末尾的 GetAttrdelegates 源代码。

让委托为你所用

现在你的工具箱里有了这个工具,你打算如何使用它呢?

我最近开始在我的许多类和函数中使用它。我的大多数类都是基于其他类的功能构建的,无论是我自己的还是来自其他模块的,所以我经常使用组合或继承。这样做的时候,我通常喜欢将原始类的全部功能也暴露出来。通过使用 GetAttrdelegates,我无需在可维护性、可读性和可用性之间做出任何妥协!

如果你尝试了这个方法,无论是否觉得有帮助,我都乐意听取你的反馈。我也很想了解其他人是如何解决委托问题的。联系我的最好方式是在 Twitter 上提到我,我的账号是 @jeremyphoward

关于编码风格的简要说明

PEP 8 展示了“构成 Python 主发行版中标准库的 Python 代码的编码约定”。它们也被广泛用于许多其他 Python 项目。我不会在数据科学工作或更普遍的教学中使用 PEP 8,因为其目标和上下文与 Python 标准库的目标和上下文截然不同(而且 PEP 8 的第一点就是“愚蠢的一致性是小家子气的怪物”)。通常我的代码倾向于遵循为数据科学和教学设计的fastai 风格指南。因此请

  • 如果你在遵循 PEP 8 的项目工作,请勿遵循本文中的编码约定
  • 不要抱怨我的代码不使用 PEP 8。

源代码

这是 delegates() 函数;复制它并在某个地方使用即可…… 我认为没必要为此创建一个 pip 包
def delegates(to=None, keep=False):
    "Decorator: replace `**kwargs` in signature with params from `to`"
    def _f(f):
        if to is None: to_f,from_f = f.__base__.__init__,f.__init__
        else:          to_f,from_f = to,f
        sig = inspect.signature(from_f)
        sigd = dict(sig.parameters)
        k = sigd.pop('kwargs')
        s2 = {k:v for k,v in inspect.signature(to_f).parameters.items()
              if v.default != inspect.Parameter.empty and k not in sigd}
        sigd.update(s2)
        if keep: sigd['kwargs'] = k
        from_f.__signature__ = sig.replace(parameters=sigd.values())
        return f
    return _f
这是 GetAttr。正如你所见,它很简单!
def custom_dir(c, add): return dir(type(c)) + list(c.__dict__.keys()) + add

class GetAttr:
    "Base class for attr accesses in `self._xtra` passed down to `self.default`"
    @property
    def _xtra(self): return [o for o in dir(self.default) if not o.startswith('_')]
    def __getattr__(self,k):
        if k in self._xtra: return getattr(self.default, k)
        raise AttributeError(k)
    def __dir__(self): return custom_dir(self, self._xtra)