Blog

Python 中的抽象基类(ABC)和 Protocol

鸭子类型要到调用时才报错,那时离犯错的地方已经很远。抽象基类和 Protocol 都能解决这个问题,只是方向正好相反。

Python 不会检查对象是否真有你要调用的方法。这就是鸭子类型。Python 代码之所以很容易拼装在一起,靠的就是它;拼写错误之所以会在莫名其妙的地方冒出来,也是因为它。

鸭子类型的痛处

class JsonExporter:
    def export(self, rows): return f"json:{len(rows)}"
class CsvExporter:
    def export(self, rows): return f"csv:{len(rows)}"
class Broken:
    def exprot(self, rows): return "typo"     # misspelled

def run(exporter, rows): return exporter.export(rows)
print(run(JsonExporter(), [1,2]), run(CsvExporter(), [1,2]))
try:
    run(Broken(), [1,2])
except AttributeError as err:
    print(type(err).__name__ + ':', err)

输出:

json:2 csv:2
AttributeError: 'Broken' object has no attribute 'export'

两个正常的类没有共同的基类也能工作,鸭子类型在这里物有所值。Broken 失败了,但失败发生在 run 内部、调用的那一刻,可能是在生产环境里,而且回溯信息(traceback)指向的是调用方,而不是那个拼错的类。

有两种工具能解决这个问题,从相反的两端入手。

抽象基类:类预先作出承诺

from abc import ABC, abstractmethod
class Exporter(ABC):
    @abstractmethod
    def export(self, rows): ...
    def describe(self): return f"{type(self).__name__} exporter"

class GoodExporter(Exporter):
    def export(self, rows): return f"good:{len(rows)}"

print(GoodExporter().describe(), GoodExporter().export([1,2]))

class BadExporter(Exporter):
    pass
try:
    BadExporter()
except TypeError as err:
    print(type(err).__name__ + ':', str(err)[:80])

输出:

GoodExporter exporter good:2
TypeError: Can't instantiate abstract class BadExporter without an implementation for abstr

失败提前到了实例化的时候,早得多,也清楚得多。抽象基类还能附带共享的行为,比如这里的 describe,Protocol 做不到这一点。

代价是:类必须继承你的抽象基类。你又回到了继承关系,第 6 篇讲过的那些问题都会跟着回来。

Protocol:调用方说明自己需要什么

from typing import Protocol, runtime_checkable

@runtime_checkable
class SupportsExport(Protocol):
    def export(self, rows) -> str: ...

print('JsonExporter matches :', isinstance(JsonExporter(), SupportsExport))
print('Broken matches       :', isinstance(Broken(), SupportsExport))
print('JsonExporter subclasses SupportsExport?', issubclass(JsonExporter, SupportsExport))

输出:

JsonExporter matches : True
Broken matches       : False
JsonExporter subclasses SupportsExport? True

JsonExporter 从没听说过 SupportsExport,却照样匹配,因为它有对的方法。这就是结构化类型:形状就是契约。类型检查器会在代码运行之前检查这一点;加上 @runtime_checkable,还能用 isinstance

注意,运行时检查只确认方法存在,不检查签名。真正干活的是静态检查器。

起决定作用的区别

class ThirdParty:            # imagine this is in a library
    def export(self, rows): return f"vendor:{len(rows)}"

print('Protocol accepts it :', isinstance(ThirdParty(), SupportsExport))
print('ABC accepts it      :', isinstance(ThirdParty(), Exporter))

输出:

Protocol accepts it : True
ABC accepts it      : False

你没法让第三方的类继承你的抽象基类。但你完全可以写一个它本来就满足的 Protocol。

取舍就这么简单:

  • 抽象基类:所有实现都归你管,而且除了要求接口,你还想共享行为。
  • Protocol:实现来自别处,或者你只是想描述函数需要什么,而不要求任何人继承任何东西。

要点

  • 鸭子类型在调用处才失败;两种工具都能让失败提前。
  • 抽象基类在实例化时强制检查,能带共享代码,但要求继承。
  • Protocol 是结构化的:形状对得上的都能匹配,包括你控制不了的代码。
  • @runtime_checkable 只检查方法名是否存在,签名交给类型检查器。

函数参数优先用 Protocol:它说明你需要什么,却不限制谁来提供。当你在构建一组归自己管、而且确实共享行为的类时,再用抽象基类。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。